diff --git a/.env.example b/.env.example index f3b84b4b..c43ad5d8 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ -PUBLIC_GITHUB_TOKEN=your_github_token +KV_REST_API_TOKEN=your_kv_token +KV_REST_API_URL="https://your.api/url" +GITHUB_TOKEN=your_github_token +PUBLIC_POSTHOG_TOKEN=your_posthog_token diff --git a/README.md b/README.md index e69de3af..7305388d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # svelte-changelog -[svelte-changelog.vercel.app](https://svelte-changelog.vercel.app/) +[svelte-changelog.dev](https://svelte-changelog.dev) Made with SvelteKit, TailwindCSS & shadcn-svelte. @@ -13,36 +13,19 @@ Made with SvelteKit, TailwindCSS & shadcn-svelte. - Dynamically computed badges to indicate whether a package is the Latest, a Major version, a Prerelease, or a Maintenance version - Hover popups at multiple places across the site - "What's new" banner to keep users updated about the latest changes to the website -- Authenticate with GitHub to bypass rate limits and get access to more features -- Optional use of a GitHub token to avoid rate limiting in dev mode in a `.env` file (see `.env.example`) +- ...and more! ## How does it work? -The site makes requests to the GitHub API on the client side to get the latest releases for all the packages. -As such, the data is fresh from GitHub every time you refresh the page. +The site makes requests to the GitHub API on the server side to get the latest releases for all the packages. +It smartly caches the data, frequently invalidating it to always be up to date while avoiding hitting GitHub as +much as possible. Some computations are made to generate the badges, but everything else is a simple cosmetic wrapper around GitHub releases. **No data alteration is performed by the site other than for styling and rendering purposes**. -### What is that "Log in with GitHub" button? - -With the growing amount of features and supported packages, the site went from requesting the GitHub API -very few times to request it a lot. -As such, the rate limit of the GitHub API was quickly reached, and the site became hard to browse. - -To solve this issue, I [initially implemented](https://github.com/WarningImHack3r/svelte-changelog/commit/f28218cbf3d57d509e771520e8c02a610dab4b95) a way to input a GitHub token in the website settings. -This became [the next week](https://github.com/WarningImHack3r/svelte-changelog/pull/27) a full-fledged authentication system with GitHub OAuth, which is what you see today. - -**By logging in with GitHub, you can browse the site (almost) without any rate limit issues**. -You will also get access to more features, such as the ability to see the details of a pull request -or issue directly on the site. - -The site does not store any data about you. The only thing the login system does is store the token given -by the GitHub authentication process in the browser's local storage to use it for the GitHub API requests. - -Logging in is entirely optional but highly recommended. You can remove the token from the website at any time -by clicking the "Log out" button in your avatar dropdown. +For more info, visit the [v2 release blog](https://svelte-changelog.dev/blog/v2). ## Missing a package? @@ -57,15 +40,15 @@ If you think I missed a package, you can either open an issue or directly contri ### How to contribute -Fork the repo, edit the `/src/routes/+layout.ts` file, and open a PR. +Fork the repo, edit the `/src/lib/repositories.ts` file, and open a PR. **If the repo is not in the `sveltejs` GitHub organization, please open an issue instead.** The code architecture is made to be as flexible as possible, here's how it works: ```typescript -const repos: Record = { - svelte: ..., - kit: ..., +export const repos = { + svelte: {/* ... */}, + kit: {/* ... */}, others: { name: "Other", repos: [ @@ -76,7 +59,7 @@ const repos: Record = { changesMode: "releases", // Optional line, the way to get the changes; either "releases" or "changelog", defaults to "releases" repoName: "your-repo", // The name of the repo on GitHub, as it appears in the URL: https://github.com/sveltejs/your-repo dataFilter: ({ tag_name }) => true, // Optional line, return false to exclude a version from its tag name - versionFromTag: tag => "...", // Return the version from the tag name; must be a valid semver + metadataFromTag: tag => ["package-name", "2.4.3"], // Return the package name and version from the tag name; the version must be a valid semver without any leading "v" changelogContentsReplacer: contents => contents, // Optional line, replace the contents of the changelog file before parsing it; only used if `changesMode` is "changelog" } ] diff --git a/eslint.config.js b/eslint.config.js index be6a9835..bb3ac24a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,6 +30,11 @@ export default tseslint.config( } } }, + { + rules: { + "@typescript-eslint/no-unused-vars": ["error", { ignoreRestSiblings: true }] + } + }, { ignores: ["build/", ".svelte-kit/", "dist/", "src/lib/components/ui/", "src/lib/utils.[jt]s"] } diff --git a/package.json b/package.json index 1b10d377..a9ad4870 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,12 @@ }, "devDependencies": { "@eslint/js": "^9.22.0", + "@fontsource/dm-serif-display": "^5.2.5", + "@fontsource/pretendard": "^5.2.5", "@lucide/svelte": "^0.483.0", "@neoconfetti/svelte": "^2.2.2", "@octokit/graphql-schema": "^15.26.0", + "@prgm/sveltekit-progress-bar": "^3.0.2", "@shikijs/langs": "^3.2.1", "@shikijs/rehype": "^3.2.1", "@shikijs/themes": "^3.2.1", @@ -28,9 +31,10 @@ "@tailwindcss/vite": "^4.0.14", "@total-typescript/ts-reset": "^0.6.1", "@types/eslint-config-prettier": "^6.11.3", + "@types/node": "^22.13.13", "@types/semver": "^7.5.8", + "@upstash/redis": "^1.34.6", "@vercel/speed-insights": "^1.2.0", - "arctic": "^3.5.0", "bits-ui": "^1.3.13", "clsx": "^2.1.1", "eslint": "^9.22.0", @@ -39,6 +43,7 @@ "globals": "^16.0.0", "mode-watcher": "^0.5.1", "octokit": "^4.1.2", + "posthog-js": "^1.235.4", "prettier": "^3.5.3", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", @@ -49,9 +54,7 @@ "svelte-check": "^4.1.5", "svelte-exmarkdown": "^4.0.3", "svelte-meta-tags": "^4.2.0", - "svelte-persisted-store": "^0.12.0", "svelte-sonner": "^0.3.28", - "sveltekit-search-params": "^3.0.0", "tailwind-merge": "^3.0.2", "tailwind-variants": "^1.0.0", "tailwindcss": "^4.0.14", @@ -65,6 +68,7 @@ "pnpm": { "onlyBuiltDependencies": [ "@vercel/speed-insights", + "core-js", "esbuild" ] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02c49fac..f82426e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@eslint/js': specifier: ^9.22.0 version: 9.22.0 + '@fontsource/dm-serif-display': + specifier: ^5.2.5 + version: 5.2.5 + '@fontsource/pretendard': + specifier: ^5.2.5 + version: 5.2.5 '@lucide/svelte': specifier: ^0.483.0 version: 0.483.0(svelte@5.23.2) @@ -20,6 +26,9 @@ importers: '@octokit/graphql-schema': specifier: ^15.26.0 version: 15.26.0 + '@prgm/sveltekit-progress-bar': + specifier: ^3.0.2 + version: 3.0.2(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2) '@shikijs/langs': specifier: ^3.2.1 version: 3.2.1 @@ -31,34 +40,37 @@ importers: version: 3.2.1 '@sveltejs/adapter-vercel': specifier: ^5.6.3 - version: 5.6.3(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(rollup@4.34.6) + version: 5.6.3(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(rollup@4.34.6) '@sveltejs/kit': specifier: ^2.20.1 - version: 2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + version: 2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) '@sveltejs/vite-plugin-svelte': specifier: ^5.0.3 - version: 5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + version: 5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.16(tailwindcss@4.0.14) '@tailwindcss/vite': specifier: ^4.0.14 - version: 4.0.14(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + version: 4.0.14(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) '@total-typescript/ts-reset': specifier: ^0.6.1 version: 0.6.1 '@types/eslint-config-prettier': specifier: ^6.11.3 version: 6.11.3 + '@types/node': + specifier: ^22.13.13 + version: 22.13.13 '@types/semver': specifier: ^7.5.8 version: 7.5.8 + '@upstash/redis': + specifier: ^1.34.6 + version: 1.34.6 '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.2.0(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2) - arctic: - specifier: ^3.5.0 - version: 3.5.0 + version: 1.2.0(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2) bits-ui: specifier: ^1.3.13 version: 1.3.13(svelte@5.23.2) @@ -83,6 +95,9 @@ importers: octokit: specifier: ^4.1.2 version: 4.1.2 + posthog-js: + specifier: ^1.235.4 + version: 1.235.4 prettier: specifier: ^3.5.3 version: 3.5.3 @@ -113,15 +128,9 @@ importers: svelte-meta-tags: specifier: ^4.2.0 version: 4.2.0(svelte@5.23.2) - svelte-persisted-store: - specifier: ^0.12.0 - version: 0.12.0(svelte@5.23.2) svelte-sonner: specifier: ^0.3.28 version: 0.3.28(svelte@5.23.2) - sveltekit-search-params: - specifier: ^3.0.0 - version: 3.0.0(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) tailwind-merge: specifier: ^3.0.2 version: 3.0.2 @@ -145,10 +154,10 @@ importers: version: 8.26.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2) vite: specifier: ^6.2.2 - version: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) + version: 6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) vite-plugin-lucide-preprocess: specifier: ^1.3.0 - version: 1.3.0(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + version: 1.3.0(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) packages: @@ -503,6 +512,12 @@ packages: '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@fontsource/dm-serif-display@5.2.5': + resolution: {integrity: sha512-yhhRKyfXgRPJLwD1+BDAVAERTlYegqSV+l7+FTj7R5lq1G3etXcSR9dg+Age1p6kD2YWM108MUQhikSLlIzyDw==} + + '@fontsource/pretendard@5.2.5': + resolution: {integrity: sha512-UAj3l+Exfx6Sq5hySbhTQhNzkZnhzBxQdHySmjo2lifXyXpMAHv7GD7hAQTLgZfBd1WixJJkmynfJbOp+TAckg==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -689,24 +704,6 @@ packages: resolution: {integrity: sha512-vk0jnc5k0/mLMUI4IA9LfSYkLs3OHtfa7B3h4aRG6to912V3wIG8lS/wKwatwYxRkAug4oE8is0ERRI8pzoYTw==} engines: {node: '>= 18'} - '@oslojs/asn1@1.0.0': - resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} - - '@oslojs/binary@1.0.0': - resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} - - '@oslojs/crypto@1.0.1': - resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} - - '@oslojs/encoding@0.4.1': - resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} - - '@oslojs/encoding@1.1.0': - resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} - - '@oslojs/jwt@0.2.0': - resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -714,6 +711,13 @@ packages: '@polka/url@1.0.0-next.25': resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} + '@prgm/sveltekit-progress-bar@3.0.2': + resolution: {integrity: sha512-RilKiPC2Lt/ahC1qmqvL5B8YJPmpwcz/PVebhOmGA7VRcU97xvbn83428r3pVfRNVlxKjHm9NatbIgQim/ccJA==} + engines: {node: ^20.5.0 || >=22.2.0} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + svelte: ^5.0.0 + '@rollup/pluginutils@5.1.4': resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==} engines: {node: '>=14.0.0'} @@ -861,14 +865,6 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 vite: ^5.0.3 || ^6.0.0 - '@sveltejs/vite-plugin-svelte-inspector@2.1.0': - resolution: {integrity: sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==} - engines: {node: ^18.0.0 || >=20} - peerDependencies: - '@sveltejs/vite-plugin-svelte': ^3.0.0 - svelte: ^4.0.0 || ^5.0.0-next.0 - vite: ^5.0.0 - '@sveltejs/vite-plugin-svelte-inspector@4.0.1': resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22} @@ -877,13 +873,6 @@ packages: svelte: ^5.0.0 vite: ^6.0.0 - '@sveltejs/vite-plugin-svelte@3.1.2': - resolution: {integrity: sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==} - engines: {node: ^18.0.0 || >=20} - peerDependencies: - svelte: ^4.0.0 || ^5.0.0-next.0 - vite: ^5.0.0 - '@sveltejs/vite-plugin-svelte@5.0.3': resolution: {integrity: sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22} @@ -1007,6 +996,9 @@ packages: '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + '@types/node@22.13.13': + resolution: {integrity: sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==} + '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -1063,6 +1055,9 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + '@upstash/redis@1.34.6': + resolution: {integrity: sha512-/ic+NszsXyIl2P8aExL7xETxgmzyhn6txG4senGDgd524bypGEJs1TMzLDeOV+PFVuacc10wZVJLIj6aRZZNaw==} + '@vercel/nft@0.29.2': resolution: {integrity: sha512-A/Si4mrTkQqJ6EXJKv5EYCDQ3NL6nJXxG8VGXePsaiQigsomHYQC9xSpX8qGk7AEZk4b1ssbYIqJ0ISQQ7bfcA==} engines: {node: '>=18'} @@ -1133,9 +1128,6 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - arctic@3.5.0: - resolution: {integrity: sha512-SR2BnMinzA5X4qsMR5wFMqELeKfMKNAThpRJyuPFJpCZIqvAMW0VbVSacycgIBV56XQLCLtUMwdohKEMaR2y1g==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1234,10 +1226,16 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + core-js@3.41.0: + resolution: {integrity: sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1417,6 +1415,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1975,6 +1976,20 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.235.4: + resolution: {integrity: sha512-CcAQpw7oaIoOwyaeqNZoKjciIMygrjgn6+cBSWFQcbo7aEmiO2666BZHZH/GBFmz0g2/w5abSpO7UntAj/69dw==} + peerDependencies: + '@rrweb/types': 2.0.0-alpha.17 + rrweb-snapshot: 2.0.0-alpha.17 + peerDependenciesMeta: + '@rrweb/types': + optional: true + rrweb-snapshot: + optional: true + + preact@10.26.5: + resolution: {integrity: sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2208,23 +2223,11 @@ packages: peerDependencies: svelte: ^5.1.3 - svelte-hmr@0.16.0: - resolution: {integrity: sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==} - engines: {node: ^12.20 || ^14.13.1 || >= 16} - peerDependencies: - svelte: ^3.19.0 || ^4.0.0 - svelte-meta-tags@4.2.0: resolution: {integrity: sha512-VFnbdN4CNLeGaiRK++mhHKm/otgId3qM0hY6fu6vhh+e+jGE9Xr3kSUjgKAEnquktskEjUEo7W0ek7EANsyTHw==} peerDependencies: svelte: ^5.0.0 - svelte-persisted-store@0.12.0: - resolution: {integrity: sha512-BdBQr2SGSJ+rDWH8/aEV5GthBJDapVP0GP3fuUCA7TjYG5ctcB+O9Mj9ZC0+Jo1oJMfZUd1y9H68NFRR5MyIJA==} - engines: {node: '>=0.14'} - peerDependencies: - svelte: ^3.48.0 || ^4 || ^5 - svelte-sonner@0.3.28: resolution: {integrity: sha512-K3AmlySeFifF/cKgsYNv5uXqMVNln0NBAacOYgmkQStLa/UoU0LhfAACU6Gr+YYC8bOCHdVmFNoKuDbMEsppJg==} peerDependencies: @@ -2240,12 +2243,6 @@ packages: resolution: {integrity: sha512-PHP1o0aYJNMatiZ+0nq1W/Z1W1/l5Z94B9nhMIo7gsuTBbxC454g4O5SQMjQpZBUZi5ANYUrXJOE4gPzcN/VQw==} engines: {node: '>=18'} - sveltekit-search-params@3.0.0: - resolution: {integrity: sha512-wq1Yo5zITev8ty9CWGmHgvAh+Xb3mCUewyUmvCdv6MJWi+/aZ4o79Y6SjuduDL0Cfd/KYHkqt4f/wQ4FtokSdw==} - peerDependencies: - '@sveltejs/kit': ^1.0.0 || ^2.0.0 - svelte: ^3.55.0 || ^4.0.0 || ^5.0.0 - tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} @@ -2318,6 +2315,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2402,14 +2402,6 @@ packages: yaml: optional: true - vitefu@0.2.5: - resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 - peerDependenciesMeta: - vite: - optional: true - vitefu@1.0.5: resolution: {integrity: sha512-h4Vflt9gxODPFNGPwp4zAMZRpZR7eslzwH2c5hn5kNZ5rhnKyRJ50U+yGCdc2IRaBs8O4haIgLNGrV5CrpMsCA==} peerDependencies: @@ -2421,6 +2413,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -2679,6 +2674,10 @@ snapshots: '@floating-ui/utils@0.2.8': {} + '@fontsource/dm-serif-display@5.2.5': {} + + '@fontsource/pretendard@5.2.5': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2911,30 +2910,16 @@ snapshots: '@octokit/request-error': 6.1.7 '@octokit/webhooks-methods': 5.1.1 - '@oslojs/asn1@1.0.0': - dependencies: - '@oslojs/binary': 1.0.0 - - '@oslojs/binary@1.0.0': {} - - '@oslojs/crypto@1.0.1': - dependencies: - '@oslojs/asn1': 1.0.0 - '@oslojs/binary': 1.0.0 - - '@oslojs/encoding@0.4.1': {} - - '@oslojs/encoding@1.1.0': {} - - '@oslojs/jwt@0.2.0': - dependencies: - '@oslojs/encoding': 0.4.1 - '@pkgjs/parseargs@0.11.0': optional: true '@polka/url@1.0.0-next.25': {} + '@prgm/sveltekit-progress-bar@3.0.2(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)': + dependencies: + '@sveltejs/kit': 2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + svelte: 5.23.2 + '@rollup/pluginutils@5.1.4(rollup@4.34.6)': dependencies: '@types/estree': 1.0.6 @@ -3046,9 +3031,9 @@ snapshots: dependencies: acorn: 8.14.0 - '@sveltejs/adapter-vercel@5.6.3(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(rollup@4.34.6)': + '@sveltejs/adapter-vercel@5.6.3(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(rollup@4.34.6)': dependencies: - '@sveltejs/kit': 2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + '@sveltejs/kit': 2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) '@vercel/nft': 0.29.2(rollup@4.34.6) esbuild: 0.24.2 transitivePeerDependencies: @@ -3056,9 +3041,9 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': + '@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) '@types/cookie': 0.6.0 cookie: 0.6.0 devalue: 5.1.1 @@ -3071,50 +3056,27 @@ snapshots: set-cookie-parser: 2.6.0 sirv: 3.0.0 svelte: 5.23.2 - vite: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) + vite: 6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) - '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) debug: 4.4.0 svelte: 5.23.2 - vite: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) + vite: 6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': + '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) - debug: 4.4.0 - svelte: 5.23.2 - vite: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) - transitivePeerDependencies: - - supports-color - - '@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': - dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) - debug: 4.4.0 - deepmerge: 4.3.1 - kleur: 4.1.5 - magic-string: 0.30.17 - svelte: 5.23.2 - svelte-hmr: 0.16.0(svelte@5.23.2) - vite: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) - vitefu: 0.2.5(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) - transitivePeerDependencies: - - supports-color - - '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': - dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) debug: 4.4.0 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.17 svelte: 5.23.2 - vite: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) - vitefu: 1.0.5(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + vite: 6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) + vitefu: 1.0.5(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) transitivePeerDependencies: - supports-color @@ -3183,13 +3145,13 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.0.14 - '@tailwindcss/vite@4.0.14(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': + '@tailwindcss/vite@4.0.14(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': dependencies: '@tailwindcss/node': 4.0.14 '@tailwindcss/oxide': 4.0.14 lightningcss: 1.29.2 tailwindcss: 4.0.14 - vite: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) + vite: 6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) '@total-typescript/ts-reset@0.6.1': {} @@ -3217,6 +3179,10 @@ snapshots: '@types/ms@0.7.34': {} + '@types/node@22.13.13': + dependencies: + undici-types: 6.20.0 + '@types/semver@7.5.8': {} '@types/unist@3.0.2': {} @@ -3300,6 +3266,10 @@ snapshots: '@ungap/structured-clone@1.2.0': {} + '@upstash/redis@1.34.6': + dependencies: + crypto-js: 4.2.0 + '@vercel/nft@0.29.2(rollup@4.34.6)': dependencies: '@mapbox/node-pre-gyp': 2.0.0 @@ -3319,9 +3289,9 @@ snapshots: - rollup - supports-color - '@vercel/speed-insights@1.2.0(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)': + '@vercel/speed-insights@1.2.0(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)': optionalDependencies: - '@sveltejs/kit': 2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) + '@sveltejs/kit': 2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) svelte: 5.23.2 abbrev@3.0.0: {} @@ -3355,12 +3325,6 @@ snapshots: ansi-styles@6.2.1: {} - arctic@3.5.0: - dependencies: - '@oslojs/crypto': 1.0.1 - '@oslojs/encoding': 1.1.0 - '@oslojs/jwt': 0.2.0 - argparse@2.0.1: {} aria-query@5.3.2: {} @@ -3442,12 +3406,16 @@ snapshots: cookie@0.6.0: {} + core-js@3.41.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + cssesc@3.0.0: {} debug@4.4.0: @@ -3677,6 +3645,8 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fflate@0.4.8: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4399,6 +4369,15 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.235.4: + dependencies: + core-js: 3.41.0 + fflate: 0.4.8 + preact: 10.26.5 + web-vitals: 4.2.4 + + preact@10.26.5: {} + prelude-ls@1.2.1: {} prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.23.2): @@ -4625,19 +4604,11 @@ snapshots: transitivePeerDependencies: - supports-color - svelte-hmr@0.16.0(svelte@5.23.2): - dependencies: - svelte: 5.23.2 - svelte-meta-tags@4.2.0(svelte@5.23.2): dependencies: schema-dts: 1.1.5 svelte: 5.23.2 - svelte-persisted-store@0.12.0(svelte@5.23.2): - dependencies: - svelte: 5.23.2 - svelte-sonner@0.3.28(svelte@5.23.2): dependencies: svelte: 5.23.2 @@ -4666,15 +4637,6 @@ snapshots: magic-string: 0.30.17 zimmerframe: 1.1.2 - sveltekit-search-params@3.0.0(@sveltejs/kit@2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)): - dependencies: - '@sveltejs/kit': 2.20.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) - '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@5.23.2)(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) - svelte: 5.23.2 - transitivePeerDependencies: - - supports-color - - vite - tabbable@6.2.0: {} tailwind-merge@3.0.2: {} @@ -4735,6 +4697,8 @@ snapshots: typescript@5.8.2: {} + undici-types@6.20.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.2 @@ -4794,31 +4758,30 @@ snapshots: unist-util-stringify-position: 4.0.0 vfile-message: 4.0.2 - vite-plugin-lucide-preprocess@1.3.0(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)): + vite-plugin-lucide-preprocess@1.3.0(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)): dependencies: - vite: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) + vite: 6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) - vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2): + vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2): dependencies: esbuild: 0.25.0 postcss: 8.5.3 rollup: 4.34.6 optionalDependencies: + '@types/node': 22.13.13 fsevents: 2.3.3 jiti: 2.4.2 lightningcss: 1.29.2 yaml: 2.4.2 - vitefu@0.2.5(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)): + vitefu@1.0.5(vite@6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)): optionalDependencies: - vite: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) - - vitefu@1.0.5(vite@6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)): - optionalDependencies: - vite: 6.2.2(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) + vite: 6.2.2(@types/node@22.13.13)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) web-namespaces@2.0.1: {} + web-vitals@4.2.4: {} + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: diff --git a/src/app.css b/src/app.css index f0b30846..9602f7b0 100644 --- a/src/app.css +++ b/src/app.css @@ -1,13 +1,19 @@ @import "tailwindcss" theme(static); -@plugin "@tailwindcss/typography"; @import "tw-animate-css"; +@import "./fonts.css"; + +@plugin "@tailwindcss/typography"; @variant dark (&:is(.dark *)); +/* shadcn static */ @theme static { --breakpoint-xs: 475px; --breakpoint-2xl: 1400px; +} +/* shadcn */ +@theme { --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); @@ -37,22 +43,22 @@ --color-ring: hsl(var(--ring)); /* - --color-sidebar: hsl(var(--sidebar-background)); - --color-sidebar-foreground: hsl(var(--sidebar-foreground)); - --color-sidebar-primary: hsl(var(--sidebar-primary)); - --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); - --color-sidebar-accent: hsl(var(--sidebar-accent)); - --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); - --color-sidebar-border: hsl(var(--sidebar-border)); - --color-sidebar-ring: hsl(var(--sidebar-ring)); - */ - + --color-sidebar: hsl(var(--sidebar-background)); + --color-sidebar-foreground: hsl(var(--sidebar-foreground)); + --color-sidebar-primary: hsl(var(--sidebar-primary)); + --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); + --color-sidebar-accent: hsl(var(--sidebar-accent)); + --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-ring: hsl(var(--sidebar-ring)); + */ --radius-xl: calc(var(--radius) + 4px); --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); } +/* Tailwind container utility */ @utility container { margin-inline: auto; padding-inline: 2rem; @@ -60,13 +66,14 @@ max-width: var(--breakpoint-2xl); } +/* shadcn theme */ @layer base { :root { - --background: 0 0% 100%; + --background: 17.14 100% 98.63%; --foreground: 20 14.3% 4.1%; - --card: 0 0% 100%; + --card: 17.14 100% 98.68%; --card-foreground: 20 14.3% 4.1%; - --popover: 0 0% 100%; + --popover: 17.14 100% 98.63%; --popover-foreground: 20 14.3% 4.1%; --primary: 24.6 95% 53.1%; --primary-foreground: 60 9.1% 97.8%; @@ -76,19 +83,20 @@ --muted-foreground: 25 5.3% 44.7%; --accent: 60 4.8% 95.9%; --accent-foreground: 24 9.8% 10%; - --destructive: 0 72.22% 50.59%; + --destructive: 0 84.2% 60.2%; --destructive-foreground: 60 9.1% 97.8%; --border: 20 5.9% 90%; --input: 20 5.9% 90%; --ring: 24.6 95% 53.1%; --radius: 0.5rem; } + .dark { - --background: 20 14.3% 4.1%; + --background: 20 32.17% 6.4%; --foreground: 60 9.1% 97.8%; - --card: 20 14.3% 4.1%; + --card: 20 33.72% 4.6%; --card-foreground: 60 9.1% 97.8%; - --popover: 20 14.3% 4.1%; + --popover: 20 31.41% 4.68%; --popover-foreground: 60 9.1% 97.8%; --primary: 20.5 90.2% 48.2%; --primary-foreground: 60 9.1% 97.8%; @@ -106,6 +114,23 @@ } } +/* custom themes */ +@theme { + --font-sans: "Pretendard", sans-serif; + --font-display: "DM Serif Display", serif; + + --animate-major-gradient: major-gradient 7s ease-in-out infinite; + @keyframes major-gradient { + from, + to { + background-position: 0 0; + } + 50% { + background-position: 100% 0; + } + } +} + @layer base { ::selection { @apply bg-primary text-primary-foreground; @@ -115,6 +140,11 @@ @apply border-border; } + h1, + h2 { + @apply font-display; + } + body { @apply flex min-h-screen flex-col bg-background text-foreground; } diff --git a/src/fonts.css b/src/fonts.css new file mode 100644 index 00000000..591c65d4 --- /dev/null +++ b/src/fonts.css @@ -0,0 +1,102 @@ +@font-face { + font-family: "DM Serif Display"; + font-style: normal; + font-display: auto; + font-weight: 400; + src: + url(@fontsource/dm-serif-display/files/dm-serif-display-latin-400-normal.woff2) format("woff2"), + url(@fontsource/dm-serif-display/files/dm-serif-display-latin-400-normal.woff) format("woff"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Pretendard"; + font-style: normal; + font-display: auto; + font-weight: 100; + src: + url(@fontsource/pretendard/files/pretendard-latin-100-normal.woff2) format("woff2"), + url(@fontsource/pretendard/files/pretendard-latin-100-normal.woff) format("woff"); +} + +@font-face { + font-family: "Pretendard"; + font-style: normal; + font-display: auto; + font-weight: 200; + src: + url(@fontsource/pretendard/files/pretendard-latin-200-normal.woff2) format("woff2"), + url(@fontsource/pretendard/files/pretendard-latin-200-normal.woff) format("woff"); +} + +@font-face { + font-family: "Pretendard"; + font-style: normal; + font-display: auto; + font-weight: 300; + src: + url(@fontsource/pretendard/files/pretendard-latin-300-normal.woff2) format("woff2"), + url(@fontsource/pretendard/files/pretendard-latin-300-normal.woff) format("woff"); +} + +@font-face { + font-family: "Pretendard"; + font-style: normal; + font-display: auto; + font-weight: 400; + src: + url(@fontsource/pretendard/files/pretendard-latin-400-normal.woff2) format("woff2"), + url(@fontsource/pretendard/files/pretendard-latin-400-normal.woff) format("woff"); +} + +@font-face { + font-family: "Pretendard"; + font-style: normal; + font-display: auto; + font-weight: 500; + src: + url(@fontsource/pretendard/files/pretendard-latin-500-normal.woff2) format("woff2"), + url(@fontsource/pretendard/files/pretendard-latin-500-normal.woff) format("woff"); +} + +@font-face { + font-family: "Pretendard"; + font-style: normal; + font-display: auto; + font-weight: 600; + src: + url(@fontsource/pretendard/files/pretendard-latin-600-normal.woff2) format("woff2"), + url(@fontsource/pretendard/files/pretendard-latin-600-normal.woff) format("woff"); +} + +@font-face { + font-family: "Pretendard"; + font-style: normal; + font-display: auto; + font-weight: 700; + src: + url(@fontsource/pretendard/files/pretendard-latin-700-normal.woff2) format("woff2"), + url(@fontsource/pretendard/files/pretendard-latin-700-normal.woff) format("woff"); +} + +@font-face { + font-family: "Pretendard"; + font-style: normal; + font-display: auto; + font-weight: 800; + src: + url(@fontsource/pretendard/files/pretendard-latin-800-normal.woff2) format("woff2"), + url(@fontsource/pretendard/files/pretendard-latin-800-normal.woff) format("woff"); +} + +@font-face { + font-family: "Pretendard"; + font-style: normal; + font-display: auto; + font-weight: 900; + src: + url(@fontsource/pretendard/files/pretendard-latin-900-normal.woff2) format("woff2"), + url(@fontsource/pretendard/files/pretendard-latin-900-normal.woff) format("woff"); +} diff --git a/src/hooks.client.ts b/src/hooks.client.ts new file mode 100644 index 00000000..44656ad2 --- /dev/null +++ b/src/hooks.client.ts @@ -0,0 +1,6 @@ +import posthog from "posthog-js"; + +export function handleError({ error, status }) { + if (status === 404) return; + posthog.captureException(error); +} diff --git a/src/lib/array.ts b/src/lib/array.ts new file mode 100644 index 00000000..3c874a21 --- /dev/null +++ b/src/lib/array.ts @@ -0,0 +1,18 @@ +/** + * A utility function to only keep unique items in + * an array, based on the uniqTransform parameter. + * + * @param arr the input array + * @param uniqTransform the transformation function + * to make items unique + * @returns the filtered array, containing only unique items + * + * @see {@link https://stackoverflow.com/a/70503699/12070367|Original implementation} + */ +export function uniq(arr: T[], uniqTransform: (item: T) => U) { + const track = new Set(); + return arr.filter(item => { + const value = uniqTransform(item); + return track.has(value) ? false : track.add(value); + }); +} diff --git a/src/lib/components/BlinkingBadge.svelte b/src/lib/components/BlinkingBadge.svelte index 75ba999c..6a9c768e 100644 --- a/src/lib/components/BlinkingBadge.svelte +++ b/src/lib/components/BlinkingBadge.svelte @@ -1,8 +1,6 @@ + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 00000000..da026644 --- /dev/null +++ b/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,16 @@ + + +

+ {@render children?.()} +

diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 00000000..6894149e --- /dev/null +++ b/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,16 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 00000000..1baa92cb --- /dev/null +++ b/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,16 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 00000000..f7d59c13 --- /dev/null +++ b/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,25 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte new file mode 100644 index 00000000..3e3a4ed7 --- /dev/null +++ b/src/lib/components/ui/card/card.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts new file mode 100644 index 00000000..0f9084d1 --- /dev/null +++ b/src/lib/components/ui/card/index.ts @@ -0,0 +1,22 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, +}; diff --git a/src/lib/components/ui/checkbox/checkbox.svelte b/src/lib/components/ui/checkbox/checkbox.svelte index e585f3cf..2395094c 100644 --- a/src/lib/components/ui/checkbox/checkbox.svelte +++ b/src/lib/components/ui/checkbox/checkbox.svelte @@ -1,6 +1,7 @@ - import { Tabs as TabsPrimitive } from "bits-ui"; - import { cn } from "$lib/utils.js"; - - let { - ref = $bindable(null), - class: className, - ...restProps - }: TabsPrimitive.ContentProps = $props(); - - - diff --git a/src/lib/components/ui/tabs/tabs-list.svelte b/src/lib/components/ui/tabs/tabs-list.svelte deleted file mode 100644 index f03e5fc7..00000000 --- a/src/lib/components/ui/tabs/tabs-list.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/src/lib/components/ui/tabs/tabs-trigger.svelte b/src/lib/components/ui/tabs/tabs-trigger.svelte deleted file mode 100644 index f1f5825e..00000000 --- a/src/lib/components/ui/tabs/tabs-trigger.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/src/lib/config.ts b/src/lib/config.ts deleted file mode 100644 index 6ef562d7..00000000 --- a/src/lib/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const PROD_URL = "https://svelte-changelog.vercel.app"; -export const FAVICON_URL = - "https://raw.githubusercontent.com/sveltejs/branding/master/svelte-logo.svg"; -export const FAVICON_PNG_URL = "https://svelte.dev/favicon.png"; diff --git a/src/lib/news/news.json b/src/lib/news/news.json index 0fc49f32..cc68c62d 100644 --- a/src/lib/news/news.json +++ b/src/lib/news/news.json @@ -20,6 +20,11 @@ "id": 3, "content": "New! Improved engine brings support for svelte-preprocess, rollup-plugin-svelte and prettier-plugin-svelte", "endDate": "2024-09-09" + }, + { + "id": 4, + "content": "Svelte Changelog v2 is here. [Read the announcement](/blog/v2).", + "endDate": "2025-05-01" } ] } diff --git a/src/lib/octokit.ts b/src/lib/octokit.ts deleted file mode 100644 index 6dbd70c5..00000000 --- a/src/lib/octokit.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { dev } from "$app/environment"; -import { env } from "$env/dynamic/public"; -import { Octokit } from "octokit"; -import { persisted } from "svelte-persisted-store"; -import { plainTextSerializer } from "./stores"; -import { tokenKey } from "./types"; - -let octokit: Octokit; - -/** - * Creates or returns a shared {@link Octokit} instance with the current user's access token. - * If the user is not logged in, the new instance will be created without authentication. - * In development mode, the instance will be created with the access token - * from the environment if it is available, overriding the user's access token. - */ -export function getOctokit() { - if (octokit) return octokit; - // TODO: Invert the condition to make the logged in token take precedence over the environment token - const hasTokenInDev = dev && !!env.PUBLIC_GITHUB_TOKEN; - octokit = new Octokit( - hasTokenInDev - ? { - auth: env.PUBLIC_GITHUB_TOKEN - } - : undefined - ); - if (hasTokenInDev) return octokit; - const unsubscribe = persisted(tokenKey, "", { - serializer: plainTextSerializer - }).subscribe(token => { - if (!token) return; - octokit.hook.wrap("request", async (request, options) => { - unsubscribe(); - options.headers.authorization = `token ${token}`; - - return request(options); - }); - }); - return octokit; -} diff --git a/src/lib/persisted.svelte.ts b/src/lib/persisted.svelte.ts new file mode 100644 index 00000000..425faf14 --- /dev/null +++ b/src/lib/persisted.svelte.ts @@ -0,0 +1,34 @@ +type Primitive = string | null | symbol | boolean | number | undefined | bigint; + +function isPrimitive(val: unknown): val is Primitive { + return val !== Object(val) || val === null; +} + +/** + * A `localStorage` wrapper, runes edition. + * + * @param key the `localStorage` key to use + * @param initial the initial value if it doesn't already exist in `localStorage` + * @returns the (one-way) rune, updating `localStorage` on change + * + * @see {@link https://x.com/puruvjdev/status/1787037268143689894/photo/1|Original idea} + */ +export function persisted(key: string, initial: T) { + const existing = localStorage.getItem(key); + + const primitive = isPrimitive(initial); + const parsedValue = existing ? (JSON.parse(existing) as T) : initial; + + const state = $state( + // @ts-expect-error type conflict between object version and raw version + primitive ? { value: parsedValue } : parsedValue + ); + + $effect.root(() => { + $effect(() => { + localStorage.setItem(key, JSON.stringify(primitive ? (state as { value: T }).value : state)); + }); + }); + + return state; +} diff --git a/src/lib/repositories.ts b/src/lib/repositories.ts new file mode 100644 index 00000000..2af61c48 --- /dev/null +++ b/src/lib/repositories.ts @@ -0,0 +1,167 @@ +import type { Category, Entries, Prettify, RepoInfo } from "$lib/types"; +import { uniq } from "$lib/array"; + +export const repos: Record = { + svelte: { + name: "Svelte", + repos: [ + { + repoName: "svelte", + metadataFromTag: splitByLastAt + } + ] + }, + kit: { + name: "SvelteKit", + repos: [ + { + repoName: "kit", + dataFilter: ({ tag_name }) => tag_name.includes("/kit@"), + metadataFromTag: splitByLastAt + } + ] + }, + others: { + name: "Other", + repos: [ + { + repoName: "kit", + dataFilter: ({ tag_name }) => !tag_name.includes("/kit@"), + metadataFromTag: splitByLastAt + }, + { + repoName: "cli", + metadataFromTag: splitByLastAt + }, + { + repoName: "vite-plugin-svelte", + metadataFromTag: splitByLastAt + }, + { + repoName: "eslint-plugin-svelte", + metadataFromTag(tag) { + if (tag.includes("@")) { + return splitByLastAt(tag); + } + return [this.repoName, tag.replace(/^v/, "")]; + } + }, + { + repoName: "eslint-config", + metadataFromTag(tag) { + return [this.repoName, tag.replace(/^v/, "")]; + } + }, + { + repoName: "svelte-eslint-parser", + metadataFromTag(tag) { + return [this.repoName, tag.replace(/^v/, "")]; + } + }, + { + repoName: "language-tools", + metadataFromTag: tag => { + const lastIndex = tag.lastIndexOf("-"); + return [tag.substring(0, lastIndex), tag.substring(lastIndex + 1)]; + } + }, + { + repoName: "acorn-typescript", + metadataFromTag(tag) { + return [this.repoName, tag.replace(/^v/, "")]; + } + }, + { + repoName: "svelte-devtools", + metadataFromTag(tag) { + return [this.repoName, tag.replace(/^v/, "")]; + } + }, + { + changesMode: "changelog", + repoName: "svelte-preprocess", + metadataFromTag(tag) { + return [this.repoName, tag.replace(/^v/, "")]; + }, + changelogContentsReplacer: file => file.replace(/^# \[/gm, "## [") + }, + { + changesMode: "changelog", + repoName: "rollup-plugin-svelte", + metadataFromTag(tag) { + return [this.repoName, tag.replace(/^v/, "")]; + } + }, + { + changesMode: "changelog", + repoName: "prettier-plugin-svelte", + metadataFromTag(tag) { + return [this.repoName, tag.replace(/^v/, "")]; + } + } + ] + } +}; + +/** + * A convenience helper to split a string into two parts + * from its last occurrence of the `@` symbol. + * + * @param s the input string + * @returns an array of length 2 with the two split elements + */ +function splitByLastAt(s: string): [string, string] { + const lastIndex = s.lastIndexOf("@"); + return [s.substring(0, lastIndex), s.substring(lastIndex + 1)]; +} + +/** + * Get all repositories as entries for ease of use + * and iteration. + * + * @example + * const [id, { name, repos }] = repositories; + */ +export const iterableRepos = Object.entries(repos) as unknown as Entries; + +/** + * A type storing all the repo information + * in a standard format + */ +export type Repository = Prettify< + { + category: { + slug: string; + name: string; + }; + owner: string; + } & RepoInfo +>; + +/** + * Get all the repositories in a standard format + */ +export const publicRepos: Repository[] = iterableRepos.flatMap(([slug, { name, repos }]) => + repos.map(repo => ({ + category: { + slug, + name + }, + owner: "sveltejs", + ...repo + })) +); + +/** + * Return a unique array of owner and name of + * the available repositories + */ +export const uniqueRepos = uniq( + iterableRepos.flatMap(([, { repos }]) => + repos.map(({ repoName }) => ({ + owner: "sveltejs", + name: repoName + })) + ), + ({ owner, name }) => `${owner}/${name}` +); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts deleted file mode 100644 index 83bad1a9..00000000 --- a/src/lib/server/auth.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { dev } from "$app/environment"; -import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from "$env/static/private"; -import { GitHub } from "arctic"; -import { PROD_URL } from "$lib/config"; - -export const github = new GitHub( - GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET, - `${dev ? "http://localhost:5173" : PROD_URL}/login/callback` -); diff --git a/src/lib/server/github-cache.ts b/src/lib/server/github-cache.ts new file mode 100644 index 00000000..d68b0408 --- /dev/null +++ b/src/lib/server/github-cache.ts @@ -0,0 +1,604 @@ +import { GITHUB_TOKEN, KV_REST_API_TOKEN, KV_REST_API_URL } from "$env/static/private"; +import { Redis } from "@upstash/redis"; +import { Octokit } from "octokit"; +import type { Repository as GQLRepository } from "@octokit/graphql-schema"; +import type { Repository } from "$lib/repositories"; +import type { Issues, Pulls } from "$lib/types"; +import parseChangelog from "$lib/changelog-parser"; + +/** + * A strict version of Extract. + * + * @see {@link https://github.com/sindresorhus/type-fest/issues/222#issuecomment-940597759|Original implementation} + */ +type ExtractStrict = U; + +export type GitHubRelease = Awaited< + ReturnType["rest"]["repos"]["listReleases"]> +>["data"][number]; + +type KeyType = "releases" | "descriptions" | "issue" | "pr"; + +export type ItemDetails = { + comments: Awaited>["data"]; +}; + +export type IssueDetails = ItemDetails & { + info: Awaited>["data"]; + linkedPrs: LinkedItem[]; +}; + +export type PullRequestDetails = ItemDetails & { + info: Awaited>["data"]; + commits: Awaited>["data"]; + files: Awaited>["data"]; + linkedIssues: LinkedItem[]; +}; + +export type LinkedItem = { + number: number; + title: string; + author?: { + avatarUrl: string; + login: string; + } | null; + createdAt: string; + body: string; +}; + +/** + * The maximum items amount to get per-page + * when fetching from GitHub API. + * Capped at 100. + * + * (Lowercased despite being a constant for + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#property_definitions|Shorthand property names} + * usage purposes) + * + * @see {@link https://docs.github.com/en/rest/releases/releases#list-releases|GitHub Docs} + */ +const per_page = 100; +/** + * The TTL of the cached releases, in seconds. + */ +const RELEASES_TTL = 60 * 15; // 15 min +/** + * The TTL of the full issue/pr details, in seconds. + */ +const FULL_DETAILS_TTL = 60 * 60 * 2; // 2 hours +/** + * The TTL of the cached descriptions, in seconds. + */ +const DESCRIPTIONS_TTL = 60 * 60 * 24 * 10; // 10 days + +/** + * A fetch layer to reach the GitHub API + * with an additional caching mechanism. + */ +export class GitHubCache { + readonly #redis: Redis; + readonly #octokit: Octokit; + + /** + * Creates a new {@link GitHubCache} with the required auth info. + * + * @param redisUrl the Redis cache URL + * @param redisToken the Redis cache token + * @param githubToken the GitHub token for uncached API requests + * @constructor + */ + constructor(redisUrl: string, redisToken: string, githubToken: string) { + this.#redis = new Redis({ + url: redisUrl, + token: redisToken + }); + + this.#octokit = new Octokit({ + auth: githubToken + }); + } + + /** + * Generates a Redis key from the passed info. + * + * @param owner the GitHub repository owner + * @param repo the GitHub repository name + * @param type the kind of cache to use + * @param args the optional additional values to append + * at the end of the key; every element will be interpolated + * in a string + * @returns the pure computed key + * @private + */ + #getRepoKey(owner: string, repo: string, type: KeyType, ...args: unknown[]) { + const strArgs = args.map(a => `:${a}`); + return `repo:${owner}/${repo}:${type}${strArgs}`; + } + + /** + * Get the item (issue or pr) with the given information. + * Return the appropriate value if the type is defined, or + * try to coerce it otherwise. + * + * @param owner the GitHub repository owner + * @param repo the GitHub repository name + * @param id the issue/pr number + * @param type the item to fetch + * @returns the matching or specified item, or `null` if not found + */ + async getItemDetails( + owner: string, + repo: string, + id: number, + type: ExtractStrict | undefined = undefined + ) { + // Known type we assume the existence of + switch (type) { + case "issue": + return await this.getIssueDetails(owner, repo, id); + case "pr": + return await this.getPullRequestDetails(owner, repo, id); + } + + // Unknown type, try to find or null otherwise + try { + return await this.getPullRequestDetails(owner, repo, id); + } catch (err: unknown) { + console.error(`Error trying to get PR details for ${owner}/${repo}: ${err}`); + } + + try { + // comes last because issues will also resolve for prs + return await this.getIssueDetails(owner, repo, id); + } catch (err: unknown) { + console.error(`Error trying to get issue details for ${owner}/${repo}: ${err}`); + } + + return null; + } + + /** + * Get the issue from the specified info. + * + * @param owner the GitHub repository owner + * @param repo the GitHub repository name + * @param id the issue number + * @returns the matching issue + * @throws Error if the issue is not found + */ + async getIssueDetails(owner: string, repo: string, id: number) { + const cacheKey = this.#getRepoKey(owner, repo, "issue", id); + + const cachedDetails = await this.#redis.json.get(cacheKey); + if (cachedDetails) { + console.log(`Cache hit for issue details for ${cacheKey}`); + return cachedDetails; + } + + console.log(`Cache miss for issue details for ${cacheKey}, fetching from the GitHub API`); + + const [{ data: info }, { data: comments }, linkedPrs] = await Promise.all([ + this.#octokit.rest.issues.get({ owner, repo, issue_number: id }), + this.#octokit.rest.issues.listComments({ owner, repo, issue_number: id }), + this.#getLinkedPullRequests(owner, repo, id) + ]); + + const details: IssueDetails = { info, comments, linkedPrs }; + + await this.#redis.json.set(cacheKey, "$", details); + await this.#redis.expire(cacheKey, FULL_DETAILS_TTL); + + return details; + } + + /** + * Get the pull request from the specified info. + * + * @param owner the GitHub repository owner + * @param repo the GitHub repository name + * @param id the PR number + * @returns the matching pull request + * @throws Error if the PR is not found + */ + async getPullRequestDetails(owner: string, repo: string, id: number) { + const cacheKey = this.#getRepoKey(owner, repo, "pr", id); + + const cachedDetails = await this.#redis.json.get(cacheKey); + if (cachedDetails) { + console.log(`Cache hit for PR details for ${cacheKey}`); + return cachedDetails; + } + + console.log(`Cache miss for PR details for ${id}, fetching from the GitHub API`); + + const [{ data: info }, { data: comments }, { data: commits }, { data: files }, linkedIssues] = + await Promise.all([ + this.#octokit.rest.pulls.get({ owner, repo, pull_number: id }), + this.#octokit.rest.issues.listComments({ owner, repo, issue_number: id }), + this.#octokit.rest.pulls.listCommits({ owner, repo, pull_number: id }), + this.#octokit.rest.pulls.listFiles({ owner, repo, pull_number: id }), + this.#getLinkedIssues(owner, repo, id) + ]); + + const details: PullRequestDetails = { info, comments, commits, files, linkedIssues }; + + // Cache the result + await this.#redis.json.set(cacheKey, "$", details); + await this.#redis.expire(cacheKey, FULL_DETAILS_TTL); + + return details; + } + + /** + * Get the pull requests linked to the given issue number. + * + * @param owner the GitHub repository owner + * @param repo the GitHub repository name + * @param issueNumber the issue number + * @returns the linked pull requests + * @private + */ + async #getLinkedPullRequests(owner: string, repo: string, issueNumber: number) { + const result = await this.#octokit.graphql<{ repository: GQLRepository }>( + ` + query($owner: String!, $repo: String!, $issueNumber: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $issueNumber) { + timelineItems(first: 100, itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) { + nodes { + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + author { + avatarUrl + login + } + createdAt + body + } + } + } + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + author { + avatarUrl + login + } + createdAt + body + } + } + } + } + } + } + } + } + `, + { + owner, + repo, + issueNumber + } + ); + + // Extract and deduplicate PRs + const linkedPRs = new Map(); + const timelineItems = result?.repository?.issue?.timelineItems?.nodes ?? []; + + for (const item of timelineItems) { + if (!item) continue; + if (!("subject" in item) && !("source" in item)) continue; + const pr = item.subject || item.source; + linkedPRs.set(pr.number, pr); + } + + return Array.from(linkedPRs.values()); + } + + /** + * Get the issues linked to the given PR number. + * + * @param owner the GitHub repository owner + * @param repo the GitHub repository name + * @param prNumber the PR number + * @returns the linked issues + * @private + */ + async #getLinkedIssues(owner: string, repo: string, prNumber: number) { + const result = await this.#octokit.graphql<{ repository: GQLRepository }>( + ` + query($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + closingIssuesReferences(first: 50) { + nodes { + number + title + author { + login + avatarUrl + } + createdAt + body + } + } + } + } + } + `, + { + owner, + repo, + prNumber + } + ); + + // Extract and deduplicate issues + const linkedIssues = new Map(); + + const closingIssues = result?.repository?.pullRequest?.closingIssuesReferences?.nodes ?? []; + for (const issue of closingIssues) { + if (!issue) continue; + linkedIssues.set(issue.number, issue); + } + + return Array.from(linkedIssues.values()); + } + + /** + * Get all the releases for a given repository + * + * @param repository the repository to get the releases for + * @returns the releases, either cached or fetched + */ + async getReleases(repository: Repository) { + const cacheKey = this.#getRepoKey(repository.owner, repository.repoName, "releases"); + + const cachedReleases = await this.#redis.json.get(cacheKey); + if (cachedReleases) { + console.log(`Cache hit for releases for ${cacheKey}`); + return cachedReleases; + } + + console.log(`Cache miss for releases for ${cacheKey}, fetching from GitHub API`); + + const releases = await this.#fetchReleases(repository); + + await this.#redis.json.set(cacheKey, "$", releases); + await this.#redis.expire(cacheKey, RELEASES_TTL); + + return releases; + } + + /** + * A utility method to fetch the releases based on the + * mode we want to use to get them + * + * @param repository the repository to fetch the releases for + * @returns the fetched releases + * @private + */ + async #fetchReleases(repository: Repository): Promise { + const { owner, repoName: repo, changesMode, changelogContentsReplacer } = repository; + if (changesMode === "releases" || !changesMode) { + const { data: releases } = await this.#octokit.rest.repos.listReleases({ + owner, + repo, + per_page + }); + return releases; + } + + // Changelog mode: we'll need to get the tags and re-build releases from them + + // 1. Fetch tags + const { data: tags } = await this.#octokit.rest.repos.listTags({ + owner, + repo, + per_page + }); + + // 2. Fetch changelog + const { data: changelogResult } = await this.#octokit.rest.repos.getContent({ + owner, + repo, + ref: + owner === "sveltejs" && + repo === "prettier-plugin-svelte" && // this repo is a bit of a mess + tags[0] && + repository.metadataFromTag(tags[0].name)[1].startsWith("3") + ? "version-3" // a temporary fix to get the changelog from the right branch while v4 isn't out yet + : undefined, + path: "CHANGELOG.md" + }); + + if (!("content" in changelogResult)) return []; // filter out empty or multiple results + const { content, encoding, type } = changelogResult; + if (type !== "file" || !content) return []; // filter out directories and empty files + const changelogFileContents = + encoding === "base64" ? Buffer.from(content, "base64").toString() : content; + // Actually parse the changelog file + const { versions } = await parseChangelog( + changelogContentsReplacer?.(changelogFileContents) ?? changelogFileContents + ); + + /** + * Returns a simili-hash for local ID creation purposes + * + * @param input the input string + * @returns a (hopefully unique) and pure hashcode + */ + function simpleHash(input: string) { + return Math.abs( + input.split("").reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) & 0xffffffff, 0) + ); + } + + // 3. Return the recreated releases + return await Promise.all( + tags.map( + async ( + { name: tag_name, commit: { sha }, zipball_url, tarball_url, node_id }, + tagIndex + ) => { + const { + data: { author, committer } + } = await this.#octokit.rest.git.getCommit({ owner, repo, commit_sha: sha }); + const [, cleanVersion] = repository.metadataFromTag(tag_name); + const changelogVersion = versions.find( + ({ version }) => !!version?.includes(cleanVersion) + ); + return { + url: "", + html_url: `https://github.com/${owner}/${repo}/releases/tag/${tag_name}`, + assets_url: "", + upload_url: "", + tarball_url, + zipball_url, + id: simpleHash(`${owner}/${repo}`) + tagIndex, + node_id, + tag_name, + target_commitish: "main", + name: `${repo}@${cleanVersion}`, + body: changelogVersion?.body ?? "_No changelog provided._", + draft: false, + prerelease: tag_name.includes("-"), + created_at: committer.date, + published_at: null, + author: { + name: author.name, + login: "", + email: author.email, + id: 0, + node_id: "", + avatar_url: "", + gravatar_id: null, + url: "", + html_url: "", + followers_url: "", + following_url: "", + gists_url: "", + starred_url: "", + subscriptions_url: "", + organizations_url: "", + repos_url: "", + events_url: "", + received_events_url: "", + type: "", + site_admin: false + }, + assets: [] + } satisfies GitHubRelease; + } + ) + ); + } + + /** + * Get a map that contains the descriptions + * of all the packages in the given repository. + * Irrelevant paths (e.g., tests) or empty descriptions + * are excluded. + * + * @param repository the repository to fetch the + * descriptions in + * @returns a map of paths to descriptions. + * @private + */ + async getDescriptions(repository: Repository) { + const cacheKey = this.#getRepoKey(repository.owner, repository.repoName, "descriptions"); + + const cachedDescriptions = await this.#redis.json.get<{ [key: string]: string }>(cacheKey); + if (cachedDescriptions) { + console.log(`Cache hit for descriptions for ${cacheKey}`); + return cachedDescriptions; + } + + console.log(`Cache miss for releases for ${cacheKey}, fetching from GitHub API`); + + const { owner, repoName: repo } = repository; + + const { data: allFiles } = await this.#octokit.rest.git.getTree({ + owner, + repo, + tree_sha: "HEAD", + recursive: "true" + }); + + const allPackageJson = allFiles.tree + .map(({ path }) => path) + .filter(path => path !== undefined) + .filter( + path => + !path.includes("/test/") && + !path.includes("/e2e-tests/") && + (path === "package.json" || path.endsWith("/package.json")) + ); + + const descriptions = new Map(); + for (const path of allPackageJson) { + const { data: packageJson } = await this.#octokit.rest.repos.getContent({ + owner, + repo, + path + }); + + if (!("content" in packageJson)) continue; // filter out empty or multiple results + const { content, encoding, type } = packageJson; + if (type !== "file" || !content) continue; // filter out directories and empty files + const packageFile = + encoding === "base64" ? Buffer.from(content, "base64").toString() : content; + + try { + const { description } = JSON.parse(packageFile) as { description: string }; + if (description) descriptions.set(path, description); + } catch { + // ignore + } + } + + await this.#redis.json.set(cacheKey, "$", Object.fromEntries(descriptions)); + await this.#redis.expire(cacheKey, DESCRIPTIONS_TTL); + + return Object.fromEntries(descriptions); + } + + /** + * Checks if releases are present in the cache for the + * given GitHub info + * + * @param owner the owner of the GitHub repository to check the + * existence in the cache for + * @param repo the name of the GitHub repository to check the + * existence in the cache for + * @param type the kind of cache to target + * @returns whether the repository is cached or not + */ + async exists(owner: string, repo: string, type: KeyType) { + const cacheKey = this.#getRepoKey(owner, repo, type); + const result = await this.#redis.exists(cacheKey); + return result === 1; + } + + /** + * Delete a repository from the cache + * + * @param owner the owner of the GitHub repository to remove + * from the cache + * @param repo the name of the GitHub repository to remove + * from the cache + * @param type the kind of cache to target + */ + async deleteEntry(owner: string, repo: string, type: KeyType) { + const cacheKey = this.#getRepoKey(owner, repo, type); + await this.#redis.del(cacheKey); + } +} + +export const gitHubCache = new GitHubCache(KV_REST_API_URL, KV_REST_API_TOKEN, GITHUB_TOKEN); diff --git a/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/graphql.config.yml b/src/lib/server/graphql.config.yml similarity index 52% rename from src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/graphql.config.yml rename to src/lib/server/graphql.config.yml index bf605b4e..451696a3 100644 --- a/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/graphql.config.yml +++ b/src/lib/server/graphql.config.yml @@ -1,4 +1,4 @@ schema: - https://api.github.com/graphql: headers: - Authorization: Bearer ${PUBLIC_GITHUB_TOKEN} + Authorization: Bearer ${GITHUB_TOKEN} diff --git a/src/lib/server/package-discoverer.ts b/src/lib/server/package-discoverer.ts new file mode 100644 index 00000000..d4881a44 --- /dev/null +++ b/src/lib/server/package-discoverer.ts @@ -0,0 +1,150 @@ +import type { Prettify } from "$lib/types"; +import { GitHubCache, gitHubCache } from "./github-cache"; +import { publicRepos, type Repository } from "$lib/repositories"; + +type Package = { + name: string; + description: string; +}; + +export type DiscoveredPackage = Prettify< + Repository & { + packages: Package[]; + } +>; + +export type CategorizedPackage = Prettify< + Pick & { + packages: (Omit & { pkg: Package })[]; + } +>; + +export class PackageDiscoverer { + readonly #cache: GitHubCache; + readonly #repos: Repository[] = []; + #packages: DiscoveredPackage[] = []; + + constructor(cache: GitHubCache, repos: Repository[]) { + this.#cache = cache; + this.#repos = repos; + } + + /** + * A processing-heavy function that discovers all the + * packages for the repos. + * Populates the result into packages. + */ + async discoverAll() { + this.#packages = await Promise.all( + this.#repos.map(async repo => { + const releases = await this.#cache.getReleases(repo); + const descriptions = await this.#cache.getDescriptions(repo); + const packages = [ + ...new Set( + releases + .filter(release => repo.dataFilter?.(release) ?? true) + .map(({ tag_name }) => { + const [name] = repo.metadataFromTag(tag_name); + return name; + }) + ) + ]; + console.log( + `Discovered ${packages.length} packages for ${repo.owner}/${repo.repoName}: ${packages.join(", ")}` + ); + return { + ...repo, + packages: packages.map(pkg => { + const ghName = this.#gitHubDirectoryFromName(pkg); + return { + name: pkg, + description: + descriptions[`packages/${ghName}/package.json`] ?? + descriptions[ + `packages/${ghName.substring(ghName.lastIndexOf("/") + 1)}/package.json` + ] ?? + descriptions["package.json"] ?? + "" + }; + }) + }; + }) + ); + } + + /** + * Returns the directory on GitHub from the name + * of the package. + * Useful to retrieve the correct `package.json` file. + * + * @param name the package name + * @returns the directory name in GitHub for that package + * @private + */ + #gitHubDirectoryFromName(name: string): string { + switch (name) { + case "extensions": + return "svelte-vscode"; + case "sv": + return "cli"; + case "svelte-migrate": + return "migrate"; + default: + return name; + } + } + + /** + * Returns the saved packages if they're not empty, + * otherwise calls {@link discoverAll} then returns the + * packages. + * + * @returns all the discovered packages per repo name + */ + async getOrDiscover() { + if (this.#packages.length) { + return this.#packages; + } + await this.discoverAll(); + return this.#packages; + } + + /** + * Returns all packages sorted by categories. + * Calls {@link getOrDiscover} under the hood. + * + * @returns all the discovered packages in a + * category-centric data structure + */ + async getOrDiscoverCategorized() { + return (await this.getOrDiscover()).reduce( + (acc, { category, ...rest }) => { + const formattedPackages = rest.packages.map(pkg => ({ + ...rest, + pkg + })); + + for (const [i, item] of acc.entries()) { + if (item.category.slug === category.slug) { + acc[i]?.packages.push(...formattedPackages); + return acc; + } + } + + // If the category doesn't exist in the accumulator, create it + acc.push({ + category, + packages: rest.packages.map(pkg => ({ + ...rest, + pkg + })) + }); + + return acc; + }, + [] + ); + } +} + +export const discoverer = new PackageDiscoverer(gitHubCache, publicRepos); diff --git a/src/lib/stores.ts b/src/lib/stores.ts deleted file mode 100644 index 87aa5c43..00000000 --- a/src/lib/stores.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getContext, setContext } from "svelte"; -import { writable } from "svelte/store"; -import type { Serializer } from "svelte-persisted-store"; -import type { Tab } from "./types"; - -export const plainTextSerializer: Serializer = { - parse: (text: string): string => { - return text; - }, - stringify: (object: string): string => { - return object; - } -}; - -const tabStateKey = Symbol("tabState"); - -export function initTabState() { - const tabState = writable("svelte"); - return setContext(tabStateKey, tabState); -} - -export function getTabState() { - return getContext>(tabStateKey); -} diff --git a/src/lib/types.ts b/src/lib/types.ts index 14b79953..55770fde 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,10 +1,15 @@ import type { Octokit } from "octokit"; +import type { GitHubRelease } from "$lib/server/github-cache"; + +export type Prettify = { + [K in keyof T]: T[K]; +} & {}; export type Entries = { [K in keyof T]: [K, T[K]]; }[keyof T][]; -export type Repo = { +export type RepoInfo = { /** * Mode to fetch the releases of the repo. * - `releases`: Fetches from the Releases page @@ -20,30 +25,29 @@ export type Repo = { * If it returns false, the release is filtered out. * * @param release The release to filter + * @returns whether we want to keep the release */ - dataFilter?: ( - release: Awaited< - ReturnType["rest"]["repos"]["listReleases"]> - >["data"][number] - ) => boolean; + dataFilter?: (release: GitHubRelease) => boolean; /** - * Extracts the version from the tag name. + * Extracts the package name and version from the tag name. * - * @param tag The tag name to extract the version from + * @param tag The tag name to extract the name and version from + * @returns an array with the package name, and the package version */ - versionFromTag: (tag: string) => string; + metadataFromTag: (tag: string) => [string, string]; /** * Replaces the contents of the changelog file. * Only used when `changesMode` is set to `changelog`. * By default, no replacement is performed. * * @param file The contents of the changelog file + * @returns the modified contents */ changelogContentsReplacer?: (file: string) => string; }; -export const availableTabs = ["svelte", "kit", "others"] as const; -export type Tab = (typeof availableTabs)[number]; +export const availableCategory = ["svelte", "kit", "others"] as const; +export type Category = (typeof availableCategory)[number]; -export const tokenKey = "token"; -export const oauthCookieKey = "github_oauth_state"; +export type Issues = InstanceType["rest"]["issues"]; +export type Pulls = InstanceType["rest"]["pulls"]; diff --git a/src/lib/util.ts b/src/lib/util.ts deleted file mode 100644 index 9221653e..00000000 --- a/src/lib/util.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { Entries } from "./types"; - -/** - * A `window.scrollTo` wrapper that scrolls to an element smoothly - * and returns a promise that resolves when the scrolling is done. - * Source: https://stackoverflow.com/a/55686711/12070367 - * - * @param offset The offset from the top of the element to scroll to - */ -export function scrollToAsync(offset = 0) { - const { promise, resolve } = Promise.withResolvers(); - - function onScroll() { - if (window.scrollY.toFixed() === offset.toFixed()) { - window.removeEventListener("scroll", onScroll); - resolve(); - } - } - - window.addEventListener("scroll", onScroll); - onScroll(); - window.scrollTo({ - top: offset, - behavior: "smooth" // TODO: replace this with auto (based on scroll-behavior) with reduced motion media query - }); - - return promise; -} - -/** - * Decodes a base64 string to a UTF-8 string - * Source: https://stackoverflow.com/a/64752311/12070367 - * @param base64 The base64 string to decode - */ -export function decodeBase64(base64: string) { - const text = atob(base64); - const length = text.length; - const bytes = new Uint8Array(length); - for (let i = 0; i < length; i++) { - bytes[i] = text.charCodeAt(i); - } - const decoder = new TextDecoder(); // default is utf-8 - return decoder.decode(bytes); -} - -/** - * Converts a date to a relative date string. - * e.g., "2 days ago", "3 hours ago", "1 minute ago" - * - * @param date The date to convert - */ -export function toRelativeDateString(date: Date) { - let dateDiff = new Date().getTime() - date.getTime(); - let relevantUnit: Intl.RelativeTimeFormatUnit; - switch (true) { - case dateDiff < 1000 * 60: - dateDiff /= 1000; - relevantUnit = "seconds"; - break; - case dateDiff < 1000 * 60 * 60: - dateDiff /= 1000 * 60; - relevantUnit = "minutes"; - break; - case dateDiff < 1000 * 60 * 60 * 24: - dateDiff /= 1000 * 60 * 60; - relevantUnit = "hours"; - break; - default: - dateDiff /= 1000 * 60 * 60 * 24; - relevantUnit = "days"; - break; - } - return new Intl.RelativeTimeFormat("en", { - style: "long" - }).format(-Math.ceil(dateDiff), relevantUnit); -} - -/** - * An `Object.entries` wrapper that retains the object's type. - * Source: https://stackoverflow.com/a/74823834/12070367 - * - * @param obj The object to get entries from - */ -export function typedEntries(obj: T) { - return Object.entries(obj) as Entries; -} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 00000000..b0c8a902 --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,19 @@ +import { discoverer } from "$lib/server/package-discoverer"; +import { uniq } from "$lib/array"; + +export async function load() { + const categorizedPackages = await discoverer.getOrDiscoverCategorized(); + + return { + // The displayable data, available to load from clients + displayablePackages: categorizedPackages.map(res => ({ + ...res, + packages: uniq( + res.packages + .map(({ dataFilter, metadataFromTag, changelogContentsReplacer, ...rest }) => rest) + .toSorted((a, b) => a.pkg.name.localeCompare(b.pkg.name)), + item => item.pkg.name + ) + })) + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4e5b729a..fa21435f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,91 +1,25 @@ - - - -
-

- {data.repos[currentTab].name} - Releases -

- -
- - {#each typedEntries(data.repos) as [id, { name }] (id)} - - - {name} - - - {/each} - -
- - {#if currentTab === "svelte"} - - {:else if currentTab === "kit"} - - {:else} - - {/if} - -
-
- - {#each typedEntries(data.repos) as [id, { name, repos: repoList }] (id)} - - - {#await fetchReleases(id)} -
-

- - Loading... -

- - - - -
- {:then releases} - - {@const _ = (() => { - // Cache the response - // TODO: restore with refactor - // cachedResponses[id] = releases; - - // Add tab to loaded tabs - // const toSet = new Set(loadedTabs); - // toSet.add(id); - // loadedTabs = [...toSet]; - - // Update the most recent date of a release of the list - const latestRelease = releases.sort( - (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - )[0]; - if (!latestRelease) return false; // boolean because cannot store void in a const - const storedDate = localStorage.getItem(`${id}MostRecentUpdate`); - const latestReleaseDate = new Date(latestRelease.created_at); - if (storedDate) { - const storedDateObj = new Date(storedDate.replace(/"/g, "")); - if (latestReleaseDate > storedDateObj) { - localStorage.setItem( - `${id}MostRecentUpdate`, - `"${latestReleaseDate.toISOString()}"` - ); - } - } else { - localStorage.setItem(`${id}MostRecentUpdate`, `"${latestReleaseDate.toISOString()}"`); - } - })()} - - {@const latestReleases = ( - id === "others" - ? repoList - .map(({ repoName, versionFromTag }) => { - const thisRepoReleases = releases.filter( - ({ prerelease, html_url }) => - !prerelease && - html_url.startsWith(`https://github.com/sveltejs/${repoName}`) - ); - const uniquePackages = new Set( - thisRepoReleases.map(({ tag_name }) => { - // Not exactly the package name, but the _generic_ part of the tag name - const pkgFromTag = tag_name.replace(versionFromTag(tag_name), ""); - return pkgFromTag ?? repoName; // workaround for eslint-config - }) - ); - return [...uniquePackages].map( - pkg => - thisRepoReleases - .filter(({ tag_name }) => - ["eslint-config"].includes(pkg) - ? true // workaround for eslint-config - : tag_name.includes(pkg) - ) - .sort((a, b) => - semver.rcompare(versionFromTag(a.tag_name), versionFromTag(b.tag_name)) - )[0] - ); - }) - .flat() - : repoList.map( - repo => - releases - .filter(({ prerelease }) => !prerelease) - .sort((a, b) => - semver.rcompare( - repo.versionFromTag(a.tag_name), - repo.versionFromTag(b.tag_name) - ) - )[0] - ) - ).filter(Boolean)} - - {@const earliestOfLatestMajors = ( - id === "others" - ? repoList - .map(repo => { - const thisRepoReleases = releases.filter( - ({ prerelease, html_url }) => - !prerelease && - html_url.startsWith(`https://github.com/sveltejs/${repo.repoName}`) - ); - const uniquePackages = new Set( - thisRepoReleases.map(({ tag_name }) => { - const pkgFromTag = tag_name.replace(repo.versionFromTag(tag_name), ""); - return pkgFromTag ? pkgFromTag : repo.repoName; - }) - ); - return [...uniquePackages].map( - pkg => - thisRepoReleases - .filter(({ tag_name }) => { - const isSamePackage = ["eslint-config"].includes(pkg) - ? true // workaround for eslint-config - : tag_name.includes(pkg); - if (!isSamePackage) return false; - const matchingLatestRelease = latestReleases.find( - ({ html_url, tag_name: latest_tag_name }) => - html_url.startsWith( - `https://github.com/sveltejs/${repo.repoName}` - ) && - latest_tag_name.replace( - repo.versionFromTag(latest_tag_name), - "" - ) === tag_name.replace(repo.versionFromTag(tag_name), "") - ); - if (!matchingLatestRelease) return false; - return ( - semver.major(repo.versionFromTag(tag_name)) === - semver.major(repo.versionFromTag(matchingLatestRelease.tag_name)) - ); - }) - .sort((a, b) => - semver.compare( - repo.versionFromTag(a.tag_name), - repo.versionFromTag(b.tag_name) - ) - )[0] - ); - }) - .flat() - : repoList.map( - repo => - releases - .filter( - ({ prerelease, tag_name }) => - !prerelease && - (latestReleases[0] - ? semver.major(repo.versionFromTag(latestReleases[0].tag_name)) === - semver.major(repo.versionFromTag(tag_name)) - : false) - ) - .sort((a, b) => - semver.compare( - repo.versionFromTag(a.tag_name), - repo.versionFromTag(b.tag_name) - ) - )[0] - ) - ).filter(Boolean)} - { - return ( - new Date(created_at).getTime() > new Date().getTime() - 1000 * 60 * 60 * 24 * 7 - ); - }) - .map(({ id }) => id.toString())} - > - {#each releases - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .filter(({ prerelease }) => { - // If not a prerelease, show the release anyway - if (!prerelease) return true; - // Filter out beta releases depending on the setting - switch (id) { - case "svelte": - return $displaySvelteBetaReleases; - case "kit": - return $displayKitBetaReleases; - case "others": - return $displayOtherBetaReleases; - } - }) as release, index (release.id)} - {@const releaseDate = new Date(release.created_at)} - {@const isOlderThanAWeek = - releaseDate.getTime() < new Date().getTime() - 1000 * 60 * 60 * 24 * 7} - {@const isMajorRelease = release.tag_name.includes(".0.0") && !release.prerelease} - {@const isLatestRelease = latestReleases.map(({ id }) => id).includes(release.id)} - {@const releaseRepo = repoList.find(({ repoName }) => - release.html_url.startsWith(`https://github.com/sveltejs/${repoName}`) - )} - {@const matchingLatestRelease = latestReleases.find(({ html_url, tag_name }) => - releaseRepo - ? html_url.startsWith(`https://github.com/sveltejs/${releaseRepo.repoName}`) && - tag_name.replace(releaseRepo.versionFromTag(tag_name), "") === - release.tag_name.replace(releaseRepo.versionFromTag(release.tag_name), "") - : false - )} - {@const matchingEarliestOfLatestMajor = earliestOfLatestMajors.find( - ({ html_url, tag_name }) => - releaseRepo - ? html_url.startsWith(`https://github.com/sveltejs/${releaseRepo.repoName}`) && - tag_name.replace(releaseRepo.versionFromTag(tag_name), "") === - release.tag_name.replace(releaseRepo.versionFromTag(release.tag_name), "") - : false - )} - {@const isMaintenanceRelease = - releaseRepo && matchingLatestRelease && matchingEarliestOfLatestMajor - ? !isMajorRelease && - semver.major(releaseRepo.versionFromTag(release.tag_name)) < - semver.major(releaseRepo.versionFromTag(matchingLatestRelease.tag_name)) && - releaseDate > new Date(matchingEarliestOfLatestMajor.created_at) - : false} - {@const releaseName = (() => { - if (!releaseRepo) return release.name; - const packageName = release.tag_name - .replace(releaseRepo.versionFromTag(release.tag_name), "") - .replace(/-$/, ""); - return release.name?.includes("@") - ? release.name - : `${ - /^[\d.]+/.test(release.tag_name.replace(/^v/, "")) - ? releaseRepo.repoName - : packageName - }@${releaseRepo.versionFromTag(release.tag_name)}`; - })()} - {@const releaseBody = (() => { - const body = release.body ?? ""; - if (!releaseRepo) return body; - // Add missing links to PRs in the release body - return body.replace( - /[^[][#\d, ]*?#(\d+)(#issuecomment-\d+)?[#\d, ]*?[^\]]/g, - // Match all `(#1234)` patterns, including `#issuecomment-` ones and multiple in one parenthesis - (match, prNumber, rest) => { - if (!rest) rest = ""; - const prUrl = `https://github.com/sveltejs/${releaseRepo.repoName}/pull/${prNumber}${rest}`; - // replaceception - return match.replace(`#${prNumber}${rest}`, `[#${prNumber}${rest}](${prUrl})`); - } - ); - })()} - - {#snippet badges()} - {#if isLatestRelease} - - - - - Latest - - - - {#if id === "others"} - This is a latest stable release - {:else} - This is the latest stable release of {name} - {/if} - - - - {/if} - {#if isMajorRelease} - - - - Major - - Major update (e.g.: 1.0.0, 2.0.0, 3.0.0...) - - - {:else if release.prerelease} - - - - - Prerelease - - - - This version is an alpha or a beta, unstable version{id === "others" - ? "" - : ` of ${name}`} - - - - {:else if isMaintenanceRelease} - - - - - Maintenance - - - - An update bringing bug fixes and minor improvements to an older major - version - - - - {/if} - {/snippet} - - - - -
- - {#key isLoadingDone} - {#if releaseDate > new Date(lastVisitDateString) && !visitedTabs.includes(id)} -
- - -
- {/if} - {/key} -
- {#if isMajorRelease && id !== "others"} - {@const newReleaseMajor = releaseRepo - ?.versionFromTag(release.tag_name) - ?.split(".")[0]} - - - - {#if index === 0 && currentTab === id} -
- {/if} - - {releaseName} - -
- - {#if newReleaseMajor} - {name} {newReleaseMajor} is available! - {:else} - A new major of {name} is available! - {/if} - -
-
- {:else} - - {releaseName} - {#if releaseRepo && id === "others"} - - {releaseRepo.repoName} - - {/if} - - {/if} -
- {@render badges()} -
-
- - - - - - {isOlderThanAWeek - ? releaseDate.toLocaleDateString("en") - : toRelativeDateString(releaseDate)} - - - {isOlderThanAWeek - ? toRelativeDateString(releaseDate) - : new Intl.DateTimeFormat("en", { - dateStyle: "medium", - timeStyle: "short" - }).format(releaseDate)} - - - - - -
-
- - -
- - - -
-
-
- {/each} -
- - - - {releases.length} results are shown. If you want to see more releases, - {#if id !== "others"} - please visit - - {name}'s releases page - . - {:else} - please visit each package's releases page. - {/if} - - - {:catch error} - -

{error.message}

- {/await} -
- {/each} -
-
- - diff --git a/src/routes/+page.ts b/src/routes/+page.ts deleted file mode 100644 index 1cb92e07..00000000 --- a/src/routes/+page.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { browser } from "$app/environment"; -import { redirect } from "@sveltejs/kit"; -import { tokenKey } from "$lib/types"; - -export function load({ url }) { - if (!browser) return; - - // Login redirect - const token = url.searchParams.get(tokenKey); - if (token) { - localStorage.setItem(tokenKey, token); - redirect(302, "/"); - } -} diff --git a/src/routes/[pullOrIssue=poi]/+page.server.ts b/src/routes/[pullOrIssue=poi]/+page.server.ts index 9e277ef1..e148867f 100644 --- a/src/routes/[pullOrIssue=poi]/+page.server.ts +++ b/src/routes/[pullOrIssue=poi]/+page.server.ts @@ -1,5 +1,5 @@ import { redirect } from "@sveltejs/kit"; export function load() { - redirect(302, "/"); + redirect(308, "/"); } diff --git a/src/routes/[pullOrIssue=poi]/[org]/+page.server.ts b/src/routes/[pullOrIssue=poi]/[org]/+page.server.ts index 9e277ef1..e148867f 100644 --- a/src/routes/[pullOrIssue=poi]/[org]/+page.server.ts +++ b/src/routes/[pullOrIssue=poi]/[org]/+page.server.ts @@ -1,5 +1,5 @@ import { redirect } from "@sveltejs/kit"; export function load() { - redirect(302, "/"); + redirect(308, "/"); } diff --git a/src/routes/[pullOrIssue=poi]/[org]/[repo]/+page.server.ts b/src/routes/[pullOrIssue=poi]/[org]/[repo]/+page.server.ts index 9e277ef1..e148867f 100644 --- a/src/routes/[pullOrIssue=poi]/[org]/[repo]/+page.server.ts +++ b/src/routes/[pullOrIssue=poi]/[org]/[repo]/+page.server.ts @@ -1,5 +1,5 @@ import { redirect } from "@sveltejs/kit"; export function load() { - redirect(302, "/"); + redirect(308, "/"); } diff --git a/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.server.ts b/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.server.ts new file mode 100644 index 00000000..25c1835a --- /dev/null +++ b/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.server.ts @@ -0,0 +1,27 @@ +import { error, redirect } from "@sveltejs/kit"; +import { gitHubCache } from "$lib/server/github-cache"; + +export async function load({ params }) { + const { pullOrIssue: type, org, repo, id } = params; + const numId = +id; // id is already validated by the route matcher + + const item = await gitHubCache.getItemDetails(org, repo, numId); + if (!item) { + error(404, `${type} #${id} doesn't exist in repo ${org}/${repo}`); + } + + const realType = "commits" in item ? "pull" : "issues"; + if (type !== realType) { + redirect(303, `/${realType}/${org}/${repo}/${id}`); + } + + return { + itemMetadata: { + org, + repo, + id: numId, + type: type === "issues" ? ("issue" as const) : ("pull" as const) + }, + item + }; +} diff --git a/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.svelte b/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.svelte index 5ceb1e8b..29124377 100644 --- a/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.svelte +++ b/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.svelte @@ -1,249 +1,25 @@ - - Detail of {data.org}/{data.repo}#{data.id} | Svelte Changelog - - -{#if info} - -{:else} - - - Loading info... - -{/if} + diff --git a/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.ts b/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.ts index 921bc950..4c281781 100644 --- a/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.ts +++ b/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.ts @@ -1,18 +1,10 @@ -import { error } from "@sveltejs/kit"; -import { getOctokit } from "$lib/octokit"; - -export async function load({ params }) { - const { pullOrIssue, org, repo, id } = params; - - const poiName = pullOrIssue === "pull" ? "pulls" : "issues"; - await getOctokit() - .request(`GET /repos/${org}/${repo}/${poiName}/${id}`) - .catch(() => error(404)); +import type { MetaTagsProps } from "svelte-meta-tags"; +export function load({ data }) { return { - pullOrIssue: pullOrIssue as "pull" | "issues", - org, - repo, - id: Number(id) + ...data, + pageMetaTags: Object.freeze({ + title: `Detail of ${data.itemMetadata.org}/${data.itemMetadata.repo}#${data.itemMetadata.id}` + }) satisfies MetaTagsProps }; } diff --git a/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/PageRenderer.svelte b/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/PageRenderer.svelte index 64e16080..8fc946dc 100644 --- a/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/PageRenderer.svelte +++ b/src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/PageRenderer.svelte @@ -19,7 +19,6 @@
@@ -129,7 +121,9 @@ {#if linkedEntities.length > 0}

- {type === "pull" ? "Closing issue" : "Development PR"}{linkedEntities.length > 1 ? "s" : ""} + {metadata.type === "pull" ? "Closing issue" : "Development PR"}{linkedEntities.length > 1 + ? "s" + : ""}

{#each linkedEntities as entity (entity.number)} @@ -184,10 +178,10 @@ {/if}

- {type === "pull" ? "Pull request" : "Issue"} + {metadata.type === "pull" ? "Pull request" : "Issue"}

- {#if type === "pull"} + {#if metadata.type === "pull"} {/if} - {#if type === "pull"} + {#if metadata.type === "pull"}
- {#each linkedEntities as closingIssue (closingIssue.id)} + {#each linkedEntities as closingIssue (closingIssue.number)} {/each}
+ +
+ + diff --git a/src/routes/package/[...package]/SidePanel.svelte b/src/routes/package/[...package]/SidePanel.svelte new file mode 100644 index 00000000..a0026fc4 --- /dev/null +++ b/src/routes/package/[...package]/SidePanel.svelte @@ -0,0 +1,117 @@ + + +
+ + + Packages + + See all + + + + +
    + {#each allPackages as { category, packages }, index (category)} + {#if index > 0} + + {/if} +
  • + {#if packages.length > 1} +

    {category.name}

    +
      + {#each packages as { pkg } (pkg.name)} +
    • + {#if page.url.pathname.endsWith(`/${pkg.name}`)} + {pkg.name} + {:else} + + {pkg.name} + + + {/if} +
    • + {/each} +
    + {:else} + {@const firstPackageName = packages[0]?.pkg.name ?? ""} + {#if page.url.pathname.endsWith(`/${firstPackageName}`)} +

    + {category.name} +

    + {:else} + + {category.name} + + + {/if} + {/if} +
  • + {/each} +
+
+
+
+ + +
+
diff --git a/src/routes/packages/+page.svelte b/src/routes/packages/+page.svelte new file mode 100644 index 00000000..3184a31a --- /dev/null +++ b/src/routes/packages/+page.svelte @@ -0,0 +1,41 @@ + + + diff --git a/src/routes/packages/+page.ts b/src/routes/packages/+page.ts new file mode 100644 index 00000000..61ff945d --- /dev/null +++ b/src/routes/packages/+page.ts @@ -0,0 +1,9 @@ +import type { MetaTagsProps } from "svelte-meta-tags"; + +export function load() { + return { + pageMetaTags: Object.freeze({ + title: "All Packages" + }) satisfies MetaTagsProps + }; +} diff --git a/svelte.config.js b/svelte.config.js index 9ac23496..b6f29476 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -6,7 +6,10 @@ const config = { preprocess: [vitePreprocess({})], kit: { - adapter: adapter() + adapter: adapter(), + paths: { + relative: false + } } }; diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..752fb2e7 --- /dev/null +++ b/vercel.json @@ -0,0 +1,12 @@ +{ + "rewrites": [ + { + "source": "/ingest/static/:path(.*)", + "destination": "https://eu-assets.i.posthog.com/static/:path*" + }, + { + "source": "/ingest/:path(.*)", + "destination": "https://eu.i.posthog.com/:path*" + } + ] +}