├── .env.example ├── .github ├── renovate.json └── workflows │ └── pr-checks.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── README.md ├── components.json ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── app.css ├── app.d.ts ├── app.html ├── fonts.css ├── hooks.client.ts ├── hooks.server.ts ├── lib │ ├── array.ts │ ├── changelog-parser.ts │ ├── components │ │ ├── AnimatedCollapsibleContent.svelte │ │ ├── GHBadge.svelte │ │ ├── MarkdownRenderer.svelte │ │ ├── ReactionToast.svelte │ │ ├── Reactions.svelte │ │ ├── ScreenSize.svelte │ │ ├── Step.svelte │ │ ├── Steps.svelte │ │ ├── renderers │ │ │ └── ListElementRenderer.svelte │ │ └── ui │ │ │ ├── accordion │ │ │ ├── accordion-content.svelte │ │ │ ├── accordion-item.svelte │ │ │ ├── accordion-trigger.svelte │ │ │ ├── accordion.svelte │ │ │ └── index.ts │ │ │ ├── alert │ │ │ ├── alert-description.svelte │ │ │ ├── alert-title.svelte │ │ │ ├── alert.svelte │ │ │ └── index.ts │ │ │ ├── avatar │ │ │ ├── avatar-fallback.svelte │ │ │ ├── avatar-image.svelte │ │ │ ├── avatar.svelte │ │ │ └── index.ts │ │ │ ├── badge │ │ │ ├── badge.svelte │ │ │ └── index.ts │ │ │ ├── button │ │ │ ├── button.svelte │ │ │ └── index.ts │ │ │ ├── card │ │ │ ├── card-action.svelte │ │ │ ├── card-content.svelte │ │ │ ├── card-description.svelte │ │ │ ├── card-footer.svelte │ │ │ ├── card-header.svelte │ │ │ ├── card-title.svelte │ │ │ ├── card.svelte │ │ │ └── index.ts │ │ │ ├── checkbox │ │ │ ├── checkbox.svelte │ │ │ └── index.ts │ │ │ ├── collapsible │ │ │ ├── collapsible-content.svelte │ │ │ ├── collapsible-trigger.svelte │ │ │ ├── collapsible.svelte │ │ │ └── index.ts │ │ │ ├── dialog │ │ │ ├── dialog-close.svelte │ │ │ ├── dialog-content.svelte │ │ │ ├── dialog-description.svelte │ │ │ ├── dialog-footer.svelte │ │ │ ├── dialog-header.svelte │ │ │ ├── dialog-overlay.svelte │ │ │ ├── dialog-title.svelte │ │ │ ├── dialog-trigger.svelte │ │ │ └── index.ts │ │ │ ├── dropdown-menu │ │ │ ├── dropdown-menu-checkbox-item.svelte │ │ │ ├── dropdown-menu-content.svelte │ │ │ ├── dropdown-menu-group-heading.svelte │ │ │ ├── dropdown-menu-group.svelte │ │ │ ├── dropdown-menu-item.svelte │ │ │ ├── dropdown-menu-label.svelte │ │ │ ├── dropdown-menu-radio-group.svelte │ │ │ ├── dropdown-menu-radio-item.svelte │ │ │ ├── dropdown-menu-separator.svelte │ │ │ ├── dropdown-menu-shortcut.svelte │ │ │ ├── dropdown-menu-sub-content.svelte │ │ │ ├── dropdown-menu-sub-trigger.svelte │ │ │ ├── dropdown-menu-trigger.svelte │ │ │ └── index.ts │ │ │ ├── label │ │ │ ├── index.ts │ │ │ └── label.svelte │ │ │ ├── separator │ │ │ ├── index.ts │ │ │ └── separator.svelte │ │ │ ├── sheet │ │ │ ├── index.ts │ │ │ ├── sheet-close.svelte │ │ │ ├── sheet-content.svelte │ │ │ ├── sheet-description.svelte │ │ │ ├── sheet-footer.svelte │ │ │ ├── sheet-header.svelte │ │ │ ├── sheet-overlay.svelte │ │ │ ├── sheet-title.svelte │ │ │ └── sheet-trigger.svelte │ │ │ ├── skeleton │ │ │ ├── index.ts │ │ │ └── skeleton.svelte │ │ │ ├── sonner │ │ │ ├── index.ts │ │ │ └── sonner.svelte │ │ │ └── tooltip │ │ │ ├── index.ts │ │ │ ├── tooltip-content.svelte │ │ │ └── tooltip-trigger.svelte │ ├── news │ │ ├── news.json │ │ └── news.schema.json │ ├── repositories.ts │ ├── server │ │ ├── cache-handler.ts │ │ ├── github-cache.ts │ │ ├── graphql.config.yml │ │ └── package-discoverer.ts │ ├── types.ts │ └── utils.ts ├── params │ ├── number.ts │ └── pid.ts ├── reset.d.ts └── routes │ ├── +error.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.server.ts │ ├── [pid=pid] │ ├── +page.server.ts │ └── [org] │ │ ├── +page.server.ts │ │ └── [repo] │ │ ├── +page.server.ts │ │ └── [id=number] │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── +page.ts │ │ ├── BottomCollapsible.svelte │ │ └── PageRenderer.svelte │ ├── all-package-releases.ts │ ├── devlog │ ├── +layout.svelte │ ├── +page.server.ts │ └── v2 │ │ ├── +page.svelte │ │ └── +page.ts │ ├── package │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.server.ts │ ├── SidePanel.svelte │ ├── [...package] │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── +page.ts │ │ ├── ReleaseCard.svelte │ │ ├── atom.xml │ │ │ └── +server.ts │ │ ├── rss.json │ │ │ └── +server.ts │ │ ├── rss.ts │ │ └── rss.xml │ │ │ └── +server.ts │ └── releases.ts │ ├── packages │ ├── +page.server.ts │ ├── +page.svelte │ └── +page.ts │ └── tracker │ ├── +page.server.ts │ └── [org] │ ├── +page.server.ts │ └── [repo] │ ├── +layout.svelte │ ├── +page.server.ts │ ├── +page.svelte │ ├── +page.ts │ └── RepoSidePanel.svelte ├── static └── github.svg ├── svelte.config.js ├── tsconfig.json ├── vercel.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | KV_REST_API_TOKEN=your_kv_token 2 | KV_REST_API_URL="https://your.api/url" 3 | GITHUB_TOKEN=your_github_token 4 | PUBLIC_POSTHOG_KEY=your_posthog_token 5 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", ":disableDependencyDashboard"], 4 | "labels": ["dependencies"], 5 | "rangeStrategy": "bump", 6 | "postUpdateOptions": ["pnpmDedupe"], 7 | "packageRules": [ 8 | { 9 | "matchManagers": ["github-actions", "npm"], 10 | "groupName": "{{manager}}", 11 | "addLabels": ["{{manager}}"] 12 | }, 13 | { 14 | "matchUpdateTypes": ["patch"], 15 | "matchCurrentVersion": "!/^0/", 16 | "automerge": true 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Checks 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - src/** 7 | - static/** 8 | - package.json 9 | - pnpm-lock.yaml 10 | - "*.config.*s" 11 | - tsconfig.json 12 | - .github/workflows/* 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | permissions-check: 20 | name: Permissions check 21 | runs-on: ubuntu-latest 22 | outputs: 23 | has-permissions: ${{ steps.check-output.outputs.has-permissions }} 24 | 25 | steps: 26 | - name: ❓ Has access to secrets? 27 | id: secrets-check 28 | continue-on-error: true 29 | uses: actions/checkout@v4 30 | with: 31 | repository: ${{ github.event.repository.full_name }} 32 | ref: ${{ github.head_ref }} 33 | token: ${{ secrets.WORKFLOW_PAT }} 34 | 35 | - name: 📤 Set output 36 | id: check-output 37 | if: always() 38 | run: echo "has-permissions=${{ steps.secrets-check.outcome == 'success' && 'true' || 'false' }}" >> $GITHUB_OUTPUT 39 | 40 | check-and-fix: 41 | name: Check and fix 42 | runs-on: ubuntu-latest 43 | needs: permissions-check 44 | permissions: 45 | contents: write 46 | 47 | steps: 48 | - name: 📂 Checkout 49 | uses: actions/checkout@v4 50 | with: 51 | repository: ${{ github.event.repository.full_name }} 52 | ref: ${{ github.head_ref }} 53 | token: ${{ needs.permissions-check.outputs.has-permissions == 'false' && github.token || secrets.WORKFLOW_PAT }} 54 | fetch-depth: ${{ github.event_name == 'push' && 2 || 1 }} 55 | 56 | - name: 📥 Install pnpm 57 | uses: pnpm/action-setup@v2 58 | with: 59 | version: latest 60 | 61 | - name: 🧭 Setup Node 62 | uses: actions/setup-node@v4 63 | with: 64 | node-version: latest 65 | cache: pnpm 66 | 67 | - name: 📥 Install NPM dependencies 68 | run: pnpm i --no-frozen-lockfile 69 | 70 | - name: 🔍 Detect file changes 71 | id: detect-changes-pnpm 72 | run: | 73 | if [[ $(git diff --name-only) =~ pnpm-lock.yaml ]]; then 74 | echo "changes_detected=true >> $GITHUB_OUTPUT" 75 | else 76 | echo "changes_detected=false >> $GITHUB_OUTPUT" 77 | fi 78 | 79 | - name: ❌ Exit if lock file is not updated 80 | if: needs.permissions-check.outputs.has-permissions == 'false' && steps.detect-changes-pnpm.outputs.changes_detected == 'true' 81 | run: exit 1 82 | 83 | - name: 📤 Commit updated lock file 84 | id: auto-commit-action-lock 85 | if: needs.permissions-check.outputs.has-permissions == 'true' 86 | uses: stefanzweifel/git-auto-commit-action@v5 87 | with: 88 | commit_message: Update lock file 89 | file_pattern: pnpm-lock.yaml 90 | 91 | - name: ❌ Exit if lock file has been committed 92 | if: needs.permissions-check.outputs.has-permissions == 'true' && steps.auto-commit-action-lock.outputs.changes_detected == 'true' 93 | run: exit 1 94 | 95 | - name: 🔍 Get modified files 96 | id: modified-files 97 | uses: tj-actions/changed-files@v46 98 | 99 | - name: 📤 Export results 100 | id: changed-files 101 | run: | 102 | code_changes=false 103 | config_changes=false 104 | deps_changes=false 105 | 106 | for file in ${{ steps.modified-files.outputs.all_changed_files }}; do 107 | if [[ $file =~ ^src/ || $file =~ ^static/ ]]; then 108 | echo "$file changes code" 109 | code_changes=true 110 | elif [[ $file = pnpm-lock.yaml ]]; then 111 | echo "$file changes dependencies" 112 | deps_changes=true 113 | elif [[ $file = *.config.*s || $file = tsconfig.json ]]; then 114 | echo "$file changes config" 115 | config_changes=true 116 | fi 117 | if [[ $code_changes == 'true' && $config_changes == 'true' ]]; then 118 | echo "Code and config changes detected, skipping further checks" 119 | break 120 | fi 121 | done 122 | 123 | echo "code_changes=$code_changes" >> $GITHUB_OUTPUT 124 | echo "config_changes=$config_changes" >> $GITHUB_OUTPUT 125 | echo "deps_changes=$deps_changes" >> $GITHUB_OUTPUT 126 | 127 | - name: ✨ Check Svelte format 128 | id: svelte-format 129 | if: steps.changed-files.outputs.code_changes == 'true' 130 | run: pnpm check:ci 131 | 132 | - name: ✨ Check style with Prettier & ESLint 133 | id: prettier-eslint 134 | if: steps.changed-files.outputs.code_changes == 'true' || steps.changed-files.outputs.config_changes == 'true' || steps.changed-files.outputs.deps_changes == 'true' 135 | run: pnpm lint 136 | 137 | - name: 🔧 Fix lint 138 | if: failure() && needs.permissions-check.outputs.has-permissions == 'true' && (steps.svelte-format.outcome == 'failure' || steps.prettier-eslint.outcome == 'failure') 139 | run: pnpm format && pnpm eslint --fix . 140 | 141 | - name: 📤 Commit lint fixes 142 | if: failure() && needs.permissions-check.outputs.has-permissions == 'true' && (steps.svelte-format.outcome == 'failure' || steps.prettier-eslint.outcome == 'failure') 143 | uses: stefanzweifel/git-auto-commit-action@v5 144 | with: 145 | commit_message: Fix lint 146 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /.idea 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /build 3 | /.svelte-kit 4 | /package 5 | /src/lib/components/ui 6 | /src/lib/utils.ts 7 | .env 8 | .env.* 9 | !.env.example 10 | 11 | # Ignore files for PNPM, NPM and YARN 12 | pnpm-lock.yaml 13 | package-lock.json 14 | yarn.lock 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "trailingComma": "none", 4 | "printWidth": 100, 5 | "arrowParens": "avoid", 6 | "plugins": [ 7 | "prettier-plugin-svelte", 8 | "@trivago/prettier-plugin-sort-imports", 9 | "prettier-plugin-tailwindcss" 10 | ], 11 | "overrides": [ 12 | { 13 | "files": "*.svelte", 14 | "options": { 15 | "parser": "svelte" 16 | } 17 | } 18 | ], 19 | "tailwindStylesheet": "./src/app.css", 20 | "importOrder": [ 21 | "^\\.\\./app.css$", 22 | "^svelte$", 23 | "^@sveltejs/kit.*$", 24 | "^svelte/.*$", 25 | "\\$(app|env)/.+$", 26 | "", 27 | "\\$lib/(?!components).+$", 28 | "\\$lib/components/ui/.+$", 29 | "\\$lib/components/.+$", 30 | "^\\.*/.+$" 31 | ], 32 | "importOrderSortSpecifiers": true 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svelte-changelog 2 | 3 | [svelte-changelog.dev](https://svelte-changelog.dev) 4 | 5 | Made with SvelteKit, TailwindCSS & shadcn-svelte. 6 | 7 | ## Features 8 | 9 | - Gorgeous UI from shadcn/ui 10 | - Includes _all_ NPM packages by the Svelte team that can be used in a project 11 | - Track the relevant repos' PRs, issues, and discussions 12 | - Stunning page embedding all details of a pull request/issue/discussion 13 | - RSS feeds for all packages 14 | - Dynamically computed badges to indicate whether a package is the Latest, a Major version, a Prerelease, or a Maintenance version 15 | - Sidebar with the number of unseen releases for each package 16 | - ...and more! 17 | 18 | ## How does it work? 19 | 20 | The site makes requests to the GitHub API on the server side to get the latest releases for all the packages. 21 | It smartly caches the data, frequently invalidating it to always be up to date while avoiding hitting GitHub as 22 | much as possible. 23 | 24 | Some computations are made to generate the badges, but everything else is a simple cosmetic 25 | wrapper around GitHub releases. 26 | **No data alteration is performed by the site other than for styling and rendering purposes**. 27 | 28 | For more info, visit the [v2 release post](https://svelte-changelog.dev/devlog/v2). 29 | 30 | ## Missing a package? 31 | 32 | If you think I missed a package, you can either open an issue or directly contribute. 33 | 34 | ### Package inclusion criteria 35 | 36 | - Must be by the Svelte team or their members 37 | - Must be on GitHub 38 | - Must _not_ be an internal package used only by the Svelte team 39 | - Must either have releases on GitHub or at least have tags and a `CHANGELOG.md` file at the root of the repository 40 | 41 | ### How to contribute 42 | 43 | Fork the repo, edit the [`src/lib/repositories.ts`](src/lib/repositories.ts) file, and open a PR. 44 | The site's code has been architectured to be as flexible as possible, here's how it works: 45 | 46 | ```typescript 47 | const repos = { 48 | svelte: {/* ... */}, 49 | kit: {/* ... */}, 50 | others: { 51 | name: "Other", 52 | repos: [ 53 | { 54 | ... 55 | }, 56 | { 57 | changesMode: "releases", // Optional line, the way to get the changes; either "releases" or "changelog", defaults to "releases" 58 | repoOwner: "your-owner", // Optional line, the name of the owner on GitHub, defaults to "sveltejs" 59 | repoName: "your-repo", // The name of the repo on GitHub, as it is shown in the URL: https://github.com/sveltejs/your-repo 60 | dataFilter: ({ tag_name }) => true, // Optional line, return false to exclude a version from its tag name 61 | metadataFromTag: tag => ["package-name", "2.4.3"], // Return the package name and version from the tag name 62 | changelogContentsReplacer: contents => contents, // Optional line, replace the contents of the changelog file before parsing it; only used if `changesMode` is "changelog" 63 | } 64 | ] 65 | } 66 | }; 67 | ``` 68 | 69 | And that's it! The site will automatically adapt to the new package(s). 70 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://www.shadcn-svelte.com/schema.json", 3 | "tailwind": { 4 | "css": "src/app.pcss", 5 | "baseColor": "slate" 6 | }, 7 | "aliases": { 8 | "components": "$lib/components", 9 | "utils": "$lib/utils", 10 | "ui": "$lib/components/ui", 11 | "hooks": "$lib/hooks", 12 | "lib": "$lib" 13 | }, 14 | "typescript": true, 15 | "registry": "https://www.shadcn-svelte.com/registry" 16 | } 17 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import prettierConfig from "eslint-config-prettier/flat"; 3 | import svelte from "eslint-plugin-svelte"; 4 | import globals from "globals"; 5 | import tseslint from "typescript-eslint"; 6 | import svelteConfig from "./svelte.config.js"; 7 | 8 | export default tseslint.config( 9 | eslint.configs.recommended, 10 | tseslint.configs.recommended, 11 | svelte.configs.recommended, 12 | prettierConfig, 13 | svelte.configs.prettier, 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.browser, 18 | ...globals.node 19 | } 20 | } 21 | }, 22 | { 23 | files: ["**/*.svelte", "**/*.svelte.js", "**/*.svelte.ts"], 24 | languageOptions: { 25 | parserOptions: { 26 | parser: tseslint.parser, 27 | extraFileExtensions: [".svelte"], 28 | projectService: true, 29 | svelteConfig 30 | } 31 | } 32 | }, 33 | { 34 | rules: { 35 | "svelte/no-unused-props": ["error", { allowUnusedNestedProperties: true }], 36 | "@typescript-eslint/no-unused-vars": ["error", { ignoreRestSiblings: true }] 37 | } 38 | }, 39 | { 40 | ignores: ["build/", ".svelte-kit/", "dist/", "src/lib/components/ui/", "src/lib/utils.[jt]s"] 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-changelog", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:ci": "svelte-kit sync && svelte-check --no-tsconfig", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "lint": "prettier --check . && eslint .", 13 | "format": "prettier --write .", 14 | "prepare": "svelte-kit sync" 15 | }, 16 | "devDependencies": { 17 | "@eslint/js": "^9.28.0", 18 | "@fontsource/dm-serif-display": "^5.2.6", 19 | "@fontsource/pretendard": "^5.2.5", 20 | "@internationalized/date": "^3.8.2", 21 | "@lucide/svelte": "^0.513.0", 22 | "@neoconfetti/svelte": "^2.2.2", 23 | "@octokit/graphql-schema": "^15.26.0", 24 | "@prgm/sveltekit-progress-bar": "^3.0.2", 25 | "@shikijs/langs": "^3.6.0", 26 | "@shikijs/rehype": "^3.6.0", 27 | "@shikijs/themes": "^3.6.0", 28 | "@shikijs/transformers": "^3.6.0", 29 | "@sveltejs/adapter-vercel": "^5.7.2", 30 | "@sveltejs/kit": "^2.21.2", 31 | "@sveltejs/vite-plugin-svelte": "^5.1.0", 32 | "@tailwindcss/typography": "^0.5.16", 33 | "@tailwindcss/vite": "^4.1.8", 34 | "@total-typescript/ts-reset": "^0.6.1", 35 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 36 | "@types/eslint-config-prettier": "^6.11.3", 37 | "@types/node": "^22.15.30", 38 | "@types/semver": "^7.7.0", 39 | "@upstash/redis": "^1.35.0", 40 | "@vercel/speed-insights": "^1.2.0", 41 | "bits-ui": "^2.5.0", 42 | "clsx": "^2.1.1", 43 | "eslint": "^9.28.0", 44 | "eslint-config-prettier": "^10.1.5", 45 | "eslint-plugin-svelte": "^3.9.1", 46 | "feed": "^5.1.0", 47 | "globals": "^16.2.0", 48 | "marked": "^15.0.12", 49 | "mode-watcher": "^1.0.7", 50 | "octokit": "^5.0.3", 51 | "posthog-js": "^1.249.4", 52 | "posthog-node": "^4.18.0", 53 | "prettier": "^3.5.3", 54 | "prettier-plugin-svelte": "^3.4.0", 55 | "prettier-plugin-tailwindcss": "^0.6.12", 56 | "rehype-raw": "^7.0.0", 57 | "remark-github": "^12.0.0", 58 | "runed": "^0.28.0", 59 | "semver": "^7.7.2", 60 | "shiki": "^3.6.0", 61 | "svelte": "^5.33.18", 62 | "svelte-check": "^4.2.1", 63 | "svelte-exmarkdown": "^5.0.1", 64 | "svelte-meta-tags": "^4.4.0", 65 | "svelte-sonner": "^1.0.4", 66 | "tailwind-merge": "^3.3.0", 67 | "tailwind-variants": "^1.0.0", 68 | "tailwindcss": "^4.1.8", 69 | "tslib": "^2.8.1", 70 | "tw-animate-css": "^1.3.4", 71 | "typescript": "^5.8.3", 72 | "typescript-eslint": "^8.33.1", 73 | "vite": "^6.3.5", 74 | "vite-plugin-devtools-json": "^0.1.1", 75 | "vite-plugin-lucide-preprocess": "^1.3.0" 76 | }, 77 | "pnpm": { 78 | "onlyBuiltDependencies": [ 79 | "@tailwindcss/oxide", 80 | "@vercel/speed-insights", 81 | "core-js", 82 | "esbuild" 83 | ] 84 | }, 85 | "type": "module", 86 | "packageManager": "pnpm@10.11.1" 87 | } 88 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" theme(static); 2 | @import "tw-animate-css"; 3 | @import "./fonts.css"; 4 | 5 | @plugin "@tailwindcss/typography"; 6 | 7 | @variant dark (&:is(.dark *)); 8 | 9 | /* shadcn static */ 10 | @theme static { 11 | --breakpoint-xs: 475px; 12 | --breakpoint-2xl: 1400px; 13 | } 14 | 15 | /* shadcn */ 16 | @theme { 17 | --color-background: hsl(var(--background)); 18 | --color-foreground: hsl(var(--foreground)); 19 | 20 | --color-card: hsl(var(--card)); 21 | --color-card-foreground: hsl(var(--card-foreground)); 22 | 23 | --color-popover: hsl(var(--popover)); 24 | --color-popover-foreground: hsl(var(--popover-foreground)); 25 | 26 | --color-primary: hsl(var(--primary)); 27 | --color-primary-foreground: hsl(var(--primary-foreground)); 28 | 29 | --color-secondary: hsl(var(--secondary)); 30 | --color-secondary-foreground: hsl(var(--secondary-foreground)); 31 | 32 | --color-muted: hsl(var(--muted)); 33 | --color-muted-foreground: hsl(var(--muted-foreground)); 34 | 35 | --color-accent: hsl(var(--accent)); 36 | --color-accent-foreground: hsl(var(--accent-foreground)); 37 | 38 | --color-destructive: hsl(var(--destructive)); 39 | --color-destructive-foreground: hsl(var(--destructive-foreground)); 40 | 41 | --color-border: hsl(var(--border)); 42 | --color-input: hsl(var(--input)); 43 | --color-ring: hsl(var(--ring)); 44 | 45 | /* 46 | --color-sidebar: hsl(var(--sidebar-background)); 47 | --color-sidebar-foreground: hsl(var(--sidebar-foreground)); 48 | --color-sidebar-primary: hsl(var(--sidebar-primary)); 49 | --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); 50 | --color-sidebar-accent: hsl(var(--sidebar-accent)); 51 | --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); 52 | --color-sidebar-border: hsl(var(--sidebar-border)); 53 | --color-sidebar-ring: hsl(var(--sidebar-ring)); 54 | */ 55 | --radius-xl: calc(var(--radius) + 4px); 56 | --radius-lg: var(--radius); 57 | --radius-md: calc(var(--radius) - 2px); 58 | --radius-sm: calc(var(--radius) - 4px); 59 | } 60 | 61 | /* Tailwind container utility */ 62 | @utility container { 63 | margin-inline: auto; 64 | padding-inline: 2rem; 65 | /* Cancels the undocumented default breakpoint-driven max-width */ 66 | max-width: var(--breakpoint-2xl); 67 | } 68 | 69 | /* shadcn theme */ 70 | @layer base { 71 | :root { 72 | --background: 17.14 100% 98.63%; 73 | --foreground: 20 14.3% 4.1%; 74 | --card: 17.14 100% 98.68%; 75 | --card-foreground: 20 14.3% 4.1%; 76 | --popover: 17.14 100% 98.63%; 77 | --popover-foreground: 20 14.3% 4.1%; 78 | --primary: 24.6 95% 53.1%; 79 | --primary-foreground: 60 9.1% 97.8%; 80 | --secondary: 60 4.8% 95.9%; 81 | --secondary-foreground: 24 9.8% 10%; 82 | --muted: 60 4.8% 95.9%; 83 | --muted-foreground: 25 5.3% 44.7%; 84 | --accent: 60 4.8% 95.9%; 85 | --accent-foreground: 24 9.8% 10%; 86 | --destructive: 0 84.2% 60.2%; 87 | --destructive-foreground: 60 9.1% 97.8%; 88 | --border: 20 5.9% 90%; 89 | --input: 20 5.9% 90%; 90 | --ring: 24.6 95% 53.1%; 91 | --radius: 0.5rem; 92 | } 93 | 94 | .dark { 95 | --background: 20 32.17% 6.4%; 96 | --foreground: 60 9.1% 97.8%; 97 | --card: 20 33.72% 4.6%; 98 | --card-foreground: 60 9.1% 97.8%; 99 | --popover: 20 31.41% 4.68%; 100 | --popover-foreground: 60 9.1% 97.8%; 101 | --primary: 20.5 90.2% 48.2%; 102 | --primary-foreground: 60 9.1% 97.8%; 103 | --secondary: 12 6.5% 15.1%; 104 | --secondary-foreground: 60 9.1% 97.8%; 105 | --muted: 12 6.5% 15.1%; 106 | --muted-foreground: 24 5.4% 63.9%; 107 | --accent: 12 6.5% 15.1%; 108 | --accent-foreground: 60 9.1% 97.8%; 109 | --destructive: 0 72.2% 50.6%; 110 | --destructive-foreground: 60 9.1% 97.8%; 111 | --border: 12 6.5% 15.1%; 112 | --input: 12 6.5% 15.1%; 113 | --ring: 20.5 90.2% 48.2%; 114 | } 115 | } 116 | 117 | /* custom themes */ 118 | @theme { 119 | --font-sans: "Pretendard", sans-serif; 120 | --font-display: "DM Serif Display", serif; 121 | 122 | --animate-major-gradient: major-gradient 7s ease-in-out infinite; 123 | @keyframes major-gradient { 124 | from, 125 | to { 126 | background-position: 0 0; 127 | } 128 | 50% { 129 | background-position: 100% 0; 130 | } 131 | } 132 | } 133 | 134 | @layer base { 135 | ::selection { 136 | @apply bg-primary text-primary-foreground; 137 | } 138 | 139 | * { 140 | @apply border-border; 141 | } 142 | 143 | h1, 144 | h2 { 145 | @apply font-display; 146 | } 147 | 148 | body { 149 | @apply flex min-h-screen flex-col bg-background text-foreground; 150 | } 151 | 152 | /* I like pointer cursors on buttons */ 153 | button:not(:disabled), 154 | [role="button"]:not(:disabled) { 155 | cursor: pointer; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | import type { PostHog } from "posthog-node"; 4 | 5 | declare global { 6 | namespace App { 7 | // interface Error {} 8 | interface Locals { 9 | posthog: PostHog; 10 | } 11 | // interface PageData {} 12 | // interface PageState {} 13 | // interface Platform {} 14 | } 15 | } 16 | 17 | export {}; 18 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | %sveltekit.head% 11 | 12 | 13 |
%sveltekit.body%
14 | 15 | 16 | -------------------------------------------------------------------------------- /src/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "DM Serif Display"; 3 | font-style: normal; 4 | font-display: auto; 5 | font-weight: 400; 6 | src: 7 | url(@fontsource/dm-serif-display/files/dm-serif-display-latin-400-normal.woff2) format("woff2"), 8 | url(@fontsource/dm-serif-display/files/dm-serif-display-latin-400-normal.woff) format("woff"); 9 | unicode-range: 10 | U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, 11 | U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 12 | } 13 | 14 | @font-face { 15 | font-family: "Pretendard"; 16 | font-style: normal; 17 | font-display: auto; 18 | font-weight: 100; 19 | src: 20 | url(@fontsource/pretendard/files/pretendard-latin-100-normal.woff2) format("woff2"), 21 | url(@fontsource/pretendard/files/pretendard-latin-100-normal.woff) format("woff"); 22 | } 23 | 24 | @font-face { 25 | font-family: "Pretendard"; 26 | font-style: normal; 27 | font-display: auto; 28 | font-weight: 200; 29 | src: 30 | url(@fontsource/pretendard/files/pretendard-latin-200-normal.woff2) format("woff2"), 31 | url(@fontsource/pretendard/files/pretendard-latin-200-normal.woff) format("woff"); 32 | } 33 | 34 | @font-face { 35 | font-family: "Pretendard"; 36 | font-style: normal; 37 | font-display: auto; 38 | font-weight: 300; 39 | src: 40 | url(@fontsource/pretendard/files/pretendard-latin-300-normal.woff2) format("woff2"), 41 | url(@fontsource/pretendard/files/pretendard-latin-300-normal.woff) format("woff"); 42 | } 43 | 44 | @font-face { 45 | font-family: "Pretendard"; 46 | font-style: normal; 47 | font-display: auto; 48 | font-weight: 400; 49 | src: 50 | url(@fontsource/pretendard/files/pretendard-latin-400-normal.woff2) format("woff2"), 51 | url(@fontsource/pretendard/files/pretendard-latin-400-normal.woff) format("woff"); 52 | } 53 | 54 | @font-face { 55 | font-family: "Pretendard"; 56 | font-style: normal; 57 | font-display: auto; 58 | font-weight: 500; 59 | src: 60 | url(@fontsource/pretendard/files/pretendard-latin-500-normal.woff2) format("woff2"), 61 | url(@fontsource/pretendard/files/pretendard-latin-500-normal.woff) format("woff"); 62 | } 63 | 64 | @font-face { 65 | font-family: "Pretendard"; 66 | font-style: normal; 67 | font-display: auto; 68 | font-weight: 600; 69 | src: 70 | url(@fontsource/pretendard/files/pretendard-latin-600-normal.woff2) format("woff2"), 71 | url(@fontsource/pretendard/files/pretendard-latin-600-normal.woff) format("woff"); 72 | } 73 | 74 | @font-face { 75 | font-family: "Pretendard"; 76 | font-style: normal; 77 | font-display: auto; 78 | font-weight: 700; 79 | src: 80 | url(@fontsource/pretendard/files/pretendard-latin-700-normal.woff2) format("woff2"), 81 | url(@fontsource/pretendard/files/pretendard-latin-700-normal.woff) format("woff"); 82 | } 83 | 84 | @font-face { 85 | font-family: "Pretendard"; 86 | font-style: normal; 87 | font-display: auto; 88 | font-weight: 800; 89 | src: 90 | url(@fontsource/pretendard/files/pretendard-latin-800-normal.woff2) format("woff2"), 91 | url(@fontsource/pretendard/files/pretendard-latin-800-normal.woff) format("woff"); 92 | } 93 | 94 | @font-face { 95 | font-family: "Pretendard"; 96 | font-style: normal; 97 | font-display: auto; 98 | font-weight: 900; 99 | src: 100 | url(@fontsource/pretendard/files/pretendard-latin-900-normal.woff2) format("woff2"), 101 | url(@fontsource/pretendard/files/pretendard-latin-900-normal.woff) format("woff"); 102 | } 103 | -------------------------------------------------------------------------------- /src/hooks.client.ts: -------------------------------------------------------------------------------- 1 | import posthog from "posthog-js"; 2 | 3 | export function handleError({ error, status, event }) { 4 | if (status === 404) return; 5 | posthog.captureException(error, event); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | import { dev } from "$app/environment"; 3 | import { PUBLIC_POSTHOG_KEY } from "$env/static/public"; 4 | import { PostHog } from "posthog-node"; 5 | 6 | const client = new PostHog(PUBLIC_POSTHOG_KEY, { 7 | host: "https://eu.i.posthog.com", 8 | disabled: dev 9 | }); 10 | 11 | export async function handleError({ error, status, event }) { 12 | if (status !== 404) { 13 | client.captureException(error, undefined, event); 14 | await client.shutdown(); 15 | } 16 | } 17 | 18 | export async function handle({ event, resolve }) { 19 | if (event.url.pathname === "/blog" || event.url.pathname.startsWith("/blog/")) { 20 | redirect(307, event.url.pathname.replace(/^\/blog/, "/devlog")); 21 | } 22 | 23 | event.locals.posthog = client; 24 | return await resolve(event); 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A utility function to only keep unique items in 3 | * an array, based on the uniqTransform parameter. 4 | * 5 | * @param arr the input array 6 | * @param uniqTransform the transformation function 7 | * to make items unique 8 | * @returns the filtered array, containing only unique items 9 | * 10 | * @see {@link https://stackoverflow.com/a/70503699/12070367|Original implementation} 11 | */ 12 | export function uniq(arr: T[], uniqTransform: (item: T) => U) { 13 | const track = new Set(); 14 | return arr.filter(item => { 15 | const value = uniqTransform(item); 16 | return track.has(value) ? false : track.add(value); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/changelog-parser.ts: -------------------------------------------------------------------------------- 1 | // Gently stolen from https://github.com/fl-client/changelog-parser as this “fixed” version 2 | // is not available on npm. 3 | // Removed the `filePath` option as I don't need it, bring in the types and fix the linting errors. 4 | 5 | const EOL = "\n"; 6 | 7 | // patterns 8 | const semver = /\[?v?([\w.-]+\.[\w.-]+[a-zA-Z0-9])]?/; 9 | const date = /.* \(?(\d\d?\d?\d?[-/.]\d\d?[-/.]\d\d?\d?\d?)\)?.*/; 10 | const subhead = /^###/; 11 | const listItem = /^[*-]/; 12 | 13 | type Changelog = { 14 | title: string; 15 | description: string; 16 | versions: { 17 | version: string | null; 18 | title: string; 19 | date: string | null; 20 | body: string; 21 | parsed: Record; 22 | }[]; 23 | }; 24 | 25 | type PrivateVersion = Changelog["versions"][number] & { 26 | _private?: { 27 | activeSubhead: string | null; 28 | }; 29 | }; 30 | 31 | type ProcessingData = { 32 | log: Changelog; 33 | current: PrivateVersion | null; 34 | }; 35 | 36 | /** 37 | * Changelog parser. 38 | * 39 | * @param text changelog text 40 | * @param callback optional callback 41 | * @returns parsed changelog object 42 | */ 43 | export default function parseChangelog( 44 | text: string, 45 | callback?: (error: string | null, result?: Changelog) => void 46 | ): Promise { 47 | const changelog = parse(text); 48 | 49 | if (typeof callback === "function") { 50 | changelog.then(log => callback(null, log)).catch((err: string) => callback(err)); 51 | } 52 | 53 | // otherwise, invoke callback 54 | return changelog; 55 | } 56 | 57 | /** 58 | * Internal parsing logic. 59 | * 60 | * @param text the changelog text 61 | * @returns the parsed changelog object 62 | */ 63 | function parse(text: string): Promise { 64 | let data: ReturnType = { 65 | log: { 66 | title: "", 67 | description: "", 68 | versions: [] 69 | }, 70 | current: null 71 | }; 72 | 73 | return new Promise(resolve => { 74 | function done() { 75 | // push last version into log 76 | if (data.current) { 77 | pushCurrent(data); 78 | } 79 | 80 | // clean up description 81 | data.log.description = clean(data.log.description); 82 | 83 | resolve(data.log); 84 | } 85 | 86 | if (text) { 87 | text.split(/\r\n?|\n/gm).forEach(line => (data = handleLine(line, data))); 88 | done(); 89 | } 90 | }); 91 | } 92 | 93 | /** 94 | * Handles each line and mutates the data object (bound to `this`) as needed. 95 | * 96 | * @param line line from the changelog file 97 | * @param data the current processing data 98 | */ 99 | function handleLine(line: string, data: ProcessingData): ProcessingData { 100 | // skip line if it's a link label 101 | if (RegExp(/^\[[^[\]]*] *?:/).exec(line)) return data; 102 | 103 | // set the title if it's there 104 | if (!data.log.title && RegExp(/^# ?[^#]/).exec(line)) { 105 | data.log.title = line.substring(1).trim(); 106 | return data; 107 | } 108 | 109 | // new version found! 110 | if (RegExp(/^##? ?[^#]/).exec(line)) { 111 | if (data.current?.title) pushCurrent(data); 112 | 113 | data.current = versionFactory(); 114 | 115 | if (semver.exec(line)) data.current.version = semver.exec(line)?.at(1) ?? null; 116 | 117 | data.current.title = line.substring(2).trim(); 118 | 119 | if (data.current.title && date.exec(data.current.title)) 120 | data.current.date = date.exec(data.current.title)?.at(1) ?? null; 121 | 122 | return data; 123 | } 124 | 125 | // deal with body or description content 126 | if (data.current) { 127 | data.current.body += line + EOL; 128 | 129 | // handle case where current line is a 'subhead': 130 | // - 'handleize' subhead. 131 | // - add subhead to 'parsed' data if not already present. 132 | if (subhead.exec(line)) { 133 | const key = line.replace("###", "").trim(); 134 | 135 | if (!data.current.parsed[key]) { 136 | data.current.parsed[key] = []; 137 | data.current._private ||= { activeSubhead: key }; 138 | } 139 | } 140 | 141 | // handle case where current line is a 'list item': 142 | if (listItem.exec(line)) { 143 | // add line to 'catch all' array 144 | data.current.parsed._?.push(line); 145 | 146 | // add line to 'active subhead' if applicable (eg. 'Added', 'Changed', etc.) 147 | if (data.current._private?.activeSubhead) { 148 | data.current.parsed[data.current._private.activeSubhead]?.push(line); 149 | } 150 | } 151 | } else { 152 | data.log.description = (data.log.description || "") + line + EOL; 153 | } 154 | 155 | return data; 156 | } 157 | 158 | function versionFactory(): PrivateVersion { 159 | return { 160 | version: null, 161 | title: "", 162 | date: null, 163 | body: "", 164 | parsed: { 165 | _: [] 166 | }, 167 | _private: { 168 | activeSubhead: null 169 | } 170 | }; 171 | } 172 | 173 | function pushCurrent(data: ProcessingData) { 174 | if (!data.current) return; 175 | // remove private properties 176 | delete data.current._private; 177 | 178 | data.current.body = clean(data.current.body); 179 | data.log.versions.push(data.current); 180 | } 181 | 182 | function clean(str: string) { 183 | if (!str) return ""; 184 | 185 | return ( 186 | str 187 | // trim 188 | .trim() 189 | // remove leading newlines 190 | .replace(new RegExp("[" + EOL + "]*"), "") 191 | // remove trailing newlines 192 | .replace(new RegExp("[" + EOL + "]*$"), "") 193 | ); 194 | } 195 | -------------------------------------------------------------------------------- /src/lib/components/AnimatedCollapsibleContent.svelte: -------------------------------------------------------------------------------- 1 | 6 | 23 | 24 | 25 | {#snippet child({ props, open })} 26 | {#if open} 27 |
28 | {@render children?.()} 29 |
30 | {/if} 31 | {/snippet} 32 |
33 | -------------------------------------------------------------------------------- /src/lib/components/GHBadge.svelte: -------------------------------------------------------------------------------- 1 | 114 | 115 | {#if mode === "regular"} 116 |
117 | {#if icon} 118 | {@const SvelteComponent = icon} 119 | 120 | {/if} 121 | {label} 122 |
123 | {:else if icon} 124 | {@const Component = icon} 125 | 126 | {/if} 127 | -------------------------------------------------------------------------------- /src/lib/components/MarkdownRenderer.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 46 | 48 | posthog.captureException(e, { 49 | caughtInBoundary: true, 50 | boundaryLocation: "MarkdownRenderer", 51 | md 52 | })} 53 | > 54 | {#snippet failed(error, reset)} 55 | {#if inline} 56 | 57 | {:else} 58 | {@const message = 59 | error instanceof Error 60 | ? `${error.name}: ${error.message}`.trim() 61 | : typeof error === "object" && error !== null 62 | ? JSON.stringify(error).trim() 63 | : `${error}`} 64 |
67 | An error occurred while rendering this Markdown content: 68 |
{message}
70 | 71 | It's now rendered with a minimal look to avoid further errors. Please 75 | report this issue 76 | if it's not already known. 77 | 78 | 81 |
82 | 83 | {/if} 84 | {/snippet} 85 | 86 | 95 |
96 |
97 | 98 | 104 | -------------------------------------------------------------------------------- /src/lib/components/ReactionToast.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |

Sorry, you can't react to stuff here!

12 |
13 |

Click here to open this content on GitHub

14 | 20 |
21 | -------------------------------------------------------------------------------- /src/lib/components/Reactions.svelte: -------------------------------------------------------------------------------- 1 | 42 | 43 | {#if reactions} 44 | {@const reactionEntries = Object.entries(reactions) 45 | .filter(([k, v]) => !["url", "total_count"].includes(k) && !!v) 46 | .toSorted(([a], [b]) => sortedEmojis.indexOf(a) - sortedEmojis.indexOf(b)) as Entries< 47 | Record 48 | >} 49 |
50 | {#each reactionEntries as [key, value] (key)} 51 | 55 | toast(ReactionToast, { 56 | duration: 5_000, 57 | componentProps: { href: reactionItemUrl } 58 | })} 59 | > 60 | {reactionsEmojis[key]} 61 | {value} 62 | 63 | {/each} 64 |
65 | {/if} 66 | -------------------------------------------------------------------------------- /src/lib/components/ScreenSize.svelte: -------------------------------------------------------------------------------- 1 | 65 | 66 |
69 |
70 | 71 | {width.toLocaleString()} x {height.toLocaleString()} 72 | 73 | {#if matchingScreen} 74 |
75 | 82 | {:else} 83 |
84 | {/if} 85 |
86 | {#if showAllScreens} 87 |
88 | {#each screens as screen (screen.name)} 89 |
98 | {screen.name.toUpperCase()} 99 | {screen.size.toLocaleString()}px 100 |
101 | {/each} 102 |
103 | {/if} 104 |
105 | -------------------------------------------------------------------------------- /src/lib/components/Step.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 | 19 | {#if icon} 20 | {@const Component = icon} 21 | 22 | {:else} 23 | 24 | {/if} 25 | 26 | {@render children?.()} 27 |
28 | -------------------------------------------------------------------------------- /src/lib/components/Steps.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
.step:not(:first-child)]:mt-8", className)} 16 | {...rest} 17 | > 18 | {@render children?.()} 19 |
20 | -------------------------------------------------------------------------------- /src/lib/components/renderers/ListElementRenderer.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 |
  • 54 | {@render children?.()} 55 | {#if allLinks.length > 0} 56 | 64 | {/if} 65 |
  • 66 | -------------------------------------------------------------------------------- /src/lib/components/ui/accordion/accordion-content.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 |
    20 | {@render children?.()} 21 |
    22 |
    23 | -------------------------------------------------------------------------------- /src/lib/components/ui/accordion/accordion-item.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/accordion/accordion-trigger.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | svg]:rotate-180", 23 | className 24 | )} 25 | {...restProps} 26 | > 27 | {@render children?.()} 28 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/lib/components/ui/accordion/accordion.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /src/lib/components/ui/accordion/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./accordion.svelte"; 2 | import Content from "./accordion-content.svelte"; 3 | import Item from "./accordion-item.svelte"; 4 | import Trigger from "./accordion-trigger.svelte"; 5 | 6 | export { 7 | Root, 8 | Content, 9 | Item, 10 | Trigger, 11 | // 12 | Root as Accordion, 13 | Content as AccordionContent, 14 | Item as AccordionItem, 15 | Trigger as AccordionTrigger, 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/components/ui/alert/alert-description.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    22 | {@render children?.()} 23 |
    24 | -------------------------------------------------------------------------------- /src/lib/components/ui/alert/alert-title.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /src/lib/components/ui/alert/alert.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 35 | 36 | 45 | -------------------------------------------------------------------------------- /src/lib/components/ui/alert/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./alert.svelte"; 2 | import Description from "./alert-description.svelte"; 3 | import Title from "./alert-title.svelte"; 4 | export { alertVariants, type AlertVariant } from "./alert.svelte"; 5 | 6 | export { 7 | Root, 8 | Description, 9 | Title, 10 | // 11 | Root as Alert, 12 | Description as AlertDescription, 13 | Title as AlertTitle, 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/components/ui/avatar/avatar-fallback.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/avatar/avatar-image.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/avatar/avatar.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/avatar/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./avatar.svelte"; 2 | import Image from "./avatar-image.svelte"; 3 | import Fallback from "./avatar-fallback.svelte"; 4 | 5 | export { 6 | Root, 7 | Image, 8 | Fallback, 9 | // 10 | Root as Avatar, 11 | Image as AvatarImage, 12 | Fallback as AvatarFallback, 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/badge/badge.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | 41 | 49 | {@render children?.()} 50 | 51 | -------------------------------------------------------------------------------- /src/lib/components/ui/badge/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Badge } from "./badge.svelte"; 2 | export { badgeVariants, type BadgeVariant } from "./badge.svelte"; 3 | -------------------------------------------------------------------------------- /src/lib/components/ui/button/button.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 55 | 56 | {#if href} 57 | 67 | {@render children?.()} 68 | 69 | {:else} 70 | 80 | {/if} 81 | -------------------------------------------------------------------------------- /src/lib/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import Root, { 2 | type ButtonProps, 3 | type ButtonSize, 4 | type ButtonVariant, 5 | buttonVariants, 6 | } from "./button.svelte"; 7 | 8 | export { 9 | Root, 10 | type ButtonProps as Props, 11 | // 12 | Root as Button, 13 | buttonVariants, 14 | type ButtonProps, 15 | type ButtonSize, 16 | type ButtonVariant, 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-action.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-content.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    14 | {@render children?.()} 15 |
    16 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-description.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |

    19 | {@render children?.()} 20 |

    21 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-footer.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-header.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    22 | {@render children?.()} 23 |
    24 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card-title.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/card.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    22 | {@render children?.()} 23 |
    24 | -------------------------------------------------------------------------------- /src/lib/components/ui/card/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./card.svelte"; 2 | import Content from "./card-content.svelte"; 3 | import Description from "./card-description.svelte"; 4 | import Footer from "./card-footer.svelte"; 5 | import Header from "./card-header.svelte"; 6 | import Title from "./card-title.svelte"; 7 | import Action from "./card-action.svelte"; 8 | 9 | export { 10 | Root, 11 | Content, 12 | Description, 13 | Footer, 14 | Header, 15 | Title, 16 | Action, 17 | // 18 | Root as Card, 19 | Content as CardContent, 20 | Description as CardDescription, 21 | Footer as CardFooter, 22 | Header as CardHeader, 23 | Title as CardTitle, 24 | Action as CardAction, 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/components/ui/checkbox/checkbox.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 27 | {#snippet children({ checked, indeterminate })} 28 |
    29 | {#if checked} 30 | 31 | {:else if indeterminate} 32 | 33 | {/if} 34 |
    35 | {/snippet} 36 |
    37 | -------------------------------------------------------------------------------- /src/lib/components/ui/checkbox/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./checkbox.svelte"; 2 | export { 3 | Root, 4 | // 5 | Root as Checkbox, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/components/ui/collapsible/collapsible-content.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/collapsible/collapsible-trigger.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/collapsible/collapsible.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/lib/components/ui/collapsible/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./collapsible.svelte"; 2 | import Trigger from "./collapsible-trigger.svelte"; 3 | import Content from "./collapsible-content.svelte"; 4 | 5 | export { 6 | Root, 7 | Content, 8 | Trigger, 9 | // 10 | Root as Collapsible, 11 | Content as CollapsibleContent, 12 | Trigger as CollapsibleTrigger, 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-close.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-content.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 33 | {@render children?.()} 34 | {#if showCloseButton} 35 | 38 | 39 | Close 40 | 41 | {/if} 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-description.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-footer.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-header.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-overlay.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-title.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-trigger.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- 1 | import { Dialog as DialogPrimitive } from "bits-ui"; 2 | 3 | import Title from "./dialog-title.svelte"; 4 | import Footer from "./dialog-footer.svelte"; 5 | import Header from "./dialog-header.svelte"; 6 | import Overlay from "./dialog-overlay.svelte"; 7 | import Content from "./dialog-content.svelte"; 8 | import Description from "./dialog-description.svelte"; 9 | import Trigger from "./dialog-trigger.svelte"; 10 | import Close from "./dialog-close.svelte"; 11 | 12 | const Root = DialogPrimitive.Root; 13 | const Portal = DialogPrimitive.Portal; 14 | 15 | export { 16 | Root, 17 | Title, 18 | Portal, 19 | Footer, 20 | Header, 21 | Trigger, 22 | Overlay, 23 | Content, 24 | Description, 25 | Close, 26 | // 27 | Root as Dialog, 28 | Title as DialogTitle, 29 | Portal as DialogPortal, 30 | Footer as DialogFooter, 31 | Header as DialogHeader, 32 | Trigger as DialogTrigger, 33 | Overlay as DialogOverlay, 34 | Content as DialogContent, 35 | Description as DialogDescription, 36 | Close as DialogClose, 37 | }; 38 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 31 | {#snippet children({ checked, indeterminate })} 32 | 33 | {#if indeterminate} 34 | 35 | {:else} 36 | 37 | {/if} 38 | 39 | {@render childrenProp?.()} 40 | {/snippet} 41 | 42 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 27 | 28 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 23 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 28 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
    23 | {@render children?.()} 24 |
    25 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | {#snippet children({ checked })} 24 | 25 | {#if checked} 26 | 27 | {/if} 28 | 29 | {@render childrenProp?.({ checked })} 30 | {/snippet} 31 | 32 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | {@render children?.()} 20 | 21 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | {@render children?.()} 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; 2 | import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; 3 | import Content from "./dropdown-menu-content.svelte"; 4 | import Group from "./dropdown-menu-group.svelte"; 5 | import Item from "./dropdown-menu-item.svelte"; 6 | import Label from "./dropdown-menu-label.svelte"; 7 | import RadioGroup from "./dropdown-menu-radio-group.svelte"; 8 | import RadioItem from "./dropdown-menu-radio-item.svelte"; 9 | import Separator from "./dropdown-menu-separator.svelte"; 10 | import Shortcut from "./dropdown-menu-shortcut.svelte"; 11 | import Trigger from "./dropdown-menu-trigger.svelte"; 12 | import SubContent from "./dropdown-menu-sub-content.svelte"; 13 | import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; 14 | import GroupHeading from "./dropdown-menu-group-heading.svelte"; 15 | const Sub = DropdownMenuPrimitive.Sub; 16 | const Root = DropdownMenuPrimitive.Root; 17 | 18 | export { 19 | CheckboxItem, 20 | Content, 21 | Root as DropdownMenu, 22 | CheckboxItem as DropdownMenuCheckboxItem, 23 | Content as DropdownMenuContent, 24 | Group as DropdownMenuGroup, 25 | Item as DropdownMenuItem, 26 | Label as DropdownMenuLabel, 27 | RadioGroup as DropdownMenuRadioGroup, 28 | RadioItem as DropdownMenuRadioItem, 29 | Separator as DropdownMenuSeparator, 30 | Shortcut as DropdownMenuShortcut, 31 | Sub as DropdownMenuSub, 32 | SubContent as DropdownMenuSubContent, 33 | SubTrigger as DropdownMenuSubTrigger, 34 | Trigger as DropdownMenuTrigger, 35 | GroupHeading as DropdownMenuGroupHeading, 36 | Group, 37 | GroupHeading, 38 | Item, 39 | Label, 40 | RadioGroup, 41 | RadioItem, 42 | Root, 43 | Separator, 44 | Shortcut, 45 | Sub, 46 | SubContent, 47 | SubTrigger, 48 | Trigger, 49 | }; 50 | -------------------------------------------------------------------------------- /src/lib/components/ui/label/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./label.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Label, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/label/label.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | -------------------------------------------------------------------------------- /src/lib/components/ui/separator/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./separator.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Separator, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/separator/separator.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/index.ts: -------------------------------------------------------------------------------- 1 | import { Dialog as SheetPrimitive } from "bits-ui"; 2 | import Trigger from "./sheet-trigger.svelte"; 3 | import Close from "./sheet-close.svelte"; 4 | import Overlay from "./sheet-overlay.svelte"; 5 | import Content from "./sheet-content.svelte"; 6 | import Header from "./sheet-header.svelte"; 7 | import Footer from "./sheet-footer.svelte"; 8 | import Title from "./sheet-title.svelte"; 9 | import Description from "./sheet-description.svelte"; 10 | 11 | const Root = SheetPrimitive.Root; 12 | const Portal = SheetPrimitive.Portal; 13 | 14 | export { 15 | Root, 16 | Close, 17 | Trigger, 18 | Portal, 19 | Overlay, 20 | Content, 21 | Header, 22 | Footer, 23 | Title, 24 | Description, 25 | // 26 | Root as Sheet, 27 | Close as SheetClose, 28 | Trigger as SheetTrigger, 29 | Portal as SheetPortal, 30 | Overlay as SheetOverlay, 31 | Content as SheetContent, 32 | Header as SheetHeader, 33 | Footer as SheetFooter, 34 | Title as SheetTitle, 35 | Description as SheetDescription, 36 | }; 37 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-close.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-content.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 41 | 42 | 43 | 44 | 50 | {@render children?.()} 51 | 54 | 55 | Close 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-description.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-footer.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-header.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-overlay.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-title.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/sheet/sheet-trigger.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/skeleton/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./skeleton.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Skeleton, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/skeleton/skeleton.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
    18 | -------------------------------------------------------------------------------- /src/lib/components/ui/sonner/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Toaster } from "./sonner.svelte"; 2 | -------------------------------------------------------------------------------- /src/lib/components/ui/sonner/sonner.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/tooltip/index.ts: -------------------------------------------------------------------------------- 1 | import { Tooltip as TooltipPrimitive } from "bits-ui"; 2 | import Trigger from "./tooltip-trigger.svelte"; 3 | import Content from "./tooltip-content.svelte"; 4 | 5 | const Root = TooltipPrimitive.Root; 6 | const Provider = TooltipPrimitive.Provider; 7 | const Portal = TooltipPrimitive.Portal; 8 | 9 | export { 10 | Root, 11 | Trigger, 12 | Content, 13 | Provider, 14 | Portal, 15 | // 16 | Root as Tooltip, 17 | Content as TooltipContent, 18 | Trigger as TooltipTrigger, 19 | Provider as TooltipProvider, 20 | Portal as TooltipPortal, 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/components/ui/tooltip/tooltip-content.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 30 | {@render children?.()} 31 | 32 | {#snippet child({ props })} 33 |
    44 | {/snippet} 45 |
    46 |
    47 |
    48 | -------------------------------------------------------------------------------- /src/lib/components/ui/tooltip/tooltip-trigger.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/news/news.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "news.schema.json", 3 | "news": [ 4 | { 5 | "id": 0, 6 | "content": "New: This banner and PR/issues details are now available!", 7 | "endDate": "2024-08-01" 8 | }, 9 | { 10 | "id": 1, 11 | "content": "You can now authenticate with your own GitHub token! Check the settings in the top-right corner of the page.", 12 | "endDate": "2024-08-25" 13 | }, 14 | { 15 | "id": 2, 16 | "content": "Manual token authentication is no more: you can now use the new, shiny \"Login with GitHub\" button!", 17 | "endDate": "2024-09-09" 18 | }, 19 | { 20 | "id": 3, 21 | "content": "New! Improved engine brings support for svelte-preprocess, rollup-plugin-svelte and prettier-plugin-svelte", 22 | "endDate": "2024-09-09" 23 | }, 24 | { 25 | "id": 4, 26 | "content": "Svelte Changelog v2 is here. [Read the announcement](/devlog/v2).", 27 | "endDate": "2025-05-01" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/news/news.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "title": "News", 4 | "description": "News schema", 5 | "type": "object", 6 | "properties": { 7 | "news": { 8 | "type": "array", 9 | "description": "A list of news", 10 | "items": { 11 | "type": "object", 12 | "properties": { 13 | "id": { 14 | "type": "number", 15 | "description": "The unique identifier of the news" 16 | }, 17 | "content": { 18 | "type": "string", 19 | "description": "The text content of the news", 20 | "minLength": 1 21 | }, 22 | "endDate": { 23 | "type": "string", 24 | "description": "The maximum date and time the news will be displayed until", 25 | "format": "date", 26 | "minLength": 1 27 | } 28 | }, 29 | "required": ["content", "endDate"], 30 | "additionalProperties": false 31 | } 32 | } 33 | }, 34 | "required": ["news"] 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/repositories.ts: -------------------------------------------------------------------------------- 1 | import { uniq } from "$lib/array"; 2 | import type { Category, Entries, Prettify, RepoInfo, WithRequired } from "$lib/types"; 3 | 4 | const repos: Record = { 5 | svelte: { 6 | name: "Svelte", 7 | repos: [ 8 | { 9 | repoName: "svelte", 10 | metadataFromTag: splitByLastAt 11 | } 12 | ] 13 | }, 14 | kit: { 15 | name: "SvelteKit", 16 | repos: [ 17 | { 18 | repoName: "kit", 19 | dataFilter: ({ tag_name }) => tag_name.includes("/kit@"), 20 | metadataFromTag: splitByLastAt 21 | } 22 | ] 23 | }, 24 | others: { 25 | name: "Other", 26 | repos: [ 27 | { 28 | repoName: "kit", 29 | dataFilter: ({ tag_name }) => !tag_name.includes("/kit@"), 30 | metadataFromTag: splitByLastAt 31 | }, 32 | { 33 | repoName: "cli", 34 | metadataFromTag: splitByLastAt 35 | }, 36 | { 37 | repoName: "vite-plugin-svelte", 38 | metadataFromTag: splitByLastAt 39 | }, 40 | { 41 | repoName: "eslint-plugin-svelte", 42 | metadataFromTag(tag) { 43 | if (tag.includes("@")) { 44 | return splitByLastAt(tag); 45 | } 46 | return [this.repoName, tag.replace(/^v/, "")]; 47 | } 48 | }, 49 | { 50 | repoName: "eslint-config", 51 | metadataFromTag(tag) { 52 | return [this.repoName, tag.replace(/^v/, "")]; 53 | } 54 | }, 55 | { 56 | repoName: "svelte-eslint-parser", 57 | metadataFromTag(tag) { 58 | return [this.repoName, tag.replace(/^v/, "")]; 59 | } 60 | }, 61 | { 62 | repoName: "language-tools", 63 | metadataFromTag: tag => { 64 | const lastIndex = tag.lastIndexOf("-"); 65 | return [tag.substring(0, lastIndex), tag.substring(lastIndex + 1)]; 66 | } 67 | }, 68 | { 69 | repoName: "acorn-typescript", 70 | metadataFromTag(tag) { 71 | return [this.repoName, tag.replace(/^v/, "")]; 72 | } 73 | }, 74 | { 75 | repoName: "svelte-devtools", 76 | metadataFromTag(tag) { 77 | return [this.repoName, tag.replace(/^v/, "")]; 78 | } 79 | }, 80 | { 81 | changesMode: "changelog", 82 | repoName: "svelte-preprocess", 83 | metadataFromTag(tag) { 84 | return [this.repoName, tag.replace(/^v/, "")]; 85 | }, 86 | changelogContentsReplacer: file => file.replace(/^# \[/gm, "## [") 87 | }, 88 | { 89 | changesMode: "changelog", 90 | repoName: "rollup-plugin-svelte", 91 | metadataFromTag(tag) { 92 | return [this.repoName, tag.replace(/^v/, "")]; 93 | } 94 | }, 95 | { 96 | changesMode: "changelog", 97 | repoName: "prettier-plugin-svelte", 98 | metadataFromTag(tag) { 99 | return [this.repoName, tag.replace(/^v/, "")]; 100 | } 101 | } 102 | ] 103 | } 104 | }; 105 | 106 | /** 107 | * A convenience helper to split a string into two parts 108 | * from its last occurrence of the `@` symbol. 109 | * 110 | * @param s the input string 111 | * @returns an array of length 2 with the two split elements 112 | */ 113 | function splitByLastAt(s: string): [string, string] { 114 | const lastIndex = s.lastIndexOf("@"); 115 | return [s.substring(0, lastIndex), s.substring(lastIndex + 1)]; 116 | } 117 | 118 | /** 119 | * Get all repositories as entries for ease of use 120 | * and iteration. 121 | * 122 | * @example 123 | * const [id, { name, repos }] = repositories; 124 | */ 125 | export const iterableRepos = Object.entries(repos) as unknown as Entries; 126 | 127 | /** 128 | * A type storing all the repo information 129 | * in a standard format 130 | */ 131 | export type Repository = Prettify< 132 | { 133 | category: { 134 | slug: string; 135 | name: string; 136 | }; 137 | } & WithRequired 138 | >; 139 | 140 | /** 141 | * The repository owner of all the repositories in 142 | * {@link RepoInfo repos} if the {@link RepoInfo.repoOwner|repoOwner} 143 | * property is not set. 144 | */ 145 | const DEFAULT_OWNER = "sveltejs"; 146 | 147 | /** 148 | * Get all the repositories in a standard format 149 | */ 150 | export const publicRepos: Repository[] = iterableRepos.flatMap(([slug, { name, repos }]) => 151 | repos.map(repo => ({ 152 | category: { 153 | slug, 154 | name 155 | }, 156 | ...repo, 157 | repoOwner: repo.repoOwner || DEFAULT_OWNER 158 | })) 159 | ); 160 | 161 | /** 162 | * Return a unique array of owner and name of 163 | * the available repositories 164 | */ 165 | export const uniqueRepos = uniq( 166 | iterableRepos.flatMap(([, { repos }]) => 167 | repos.map(({ repoOwner, repoName }) => ({ 168 | owner: repoOwner || DEFAULT_OWNER, 169 | name: repoName 170 | })) 171 | ), 172 | ({ owner, name }) => `${owner}/${name}` 173 | ); 174 | -------------------------------------------------------------------------------- /src/lib/server/cache-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Redis } from "@upstash/redis"; 2 | 3 | export type RedisJson = Parameters["json"]["set"]>[2]; 4 | 5 | export class CacheHandler { 6 | readonly #redis: Redis; 7 | readonly #memoryCache: Map; 8 | readonly #isDev: boolean; 9 | 10 | /** 11 | * Initialize the cache handler 12 | * 13 | * @param redis the Redis instance 14 | * @param isDev whether we're in dev or prod 15 | */ 16 | constructor(redis: Redis, isDev: boolean) { 17 | this.#redis = redis; 18 | this.#memoryCache = new Map(); 19 | this.#isDev = isDev; 20 | } 21 | 22 | /** 23 | * Get a value from the cache 24 | * 25 | * @param key the key to get the result for 26 | * @returns the cached value, or `null` if not present 27 | */ 28 | async get(key: string) { 29 | if (this.#isDev) { 30 | console.log(`Retrieving ${key} from in-memory cache`); 31 | // In dev mode, use memory cache only 32 | const entry = this.#memoryCache.get(key); 33 | 34 | if (!entry) { 35 | console.log("Nothing to retrieve"); 36 | return null; 37 | } 38 | 39 | // Check if entry is expired 40 | if (entry.expiresAt && entry.expiresAt < Date.now()) { 41 | console.log("Value expired, purging and returning null"); 42 | this.#memoryCache.delete(key); 43 | return null; 44 | } 45 | 46 | console.log("Returning found value from in-memory cache"); 47 | return entry.value as T; 48 | } 49 | 50 | // Production: get TTL for Redis key first 51 | let ttl: number; 52 | try { 53 | ttl = await this.#redis.ttl(key); 54 | } catch (error) { 55 | console.error("Redis TTL error:", error); 56 | return null; 57 | } 58 | 59 | // Check memory cache first 60 | const entry = this.#memoryCache.get(key); 61 | if (entry) { 62 | if (ttl < 0) { 63 | // No expiration (-1) or key doesn't exist (-2) 64 | return entry.value as T; 65 | } 66 | 67 | // Validate TTL matches what we have in memory 68 | const remainingTime = entry.expiresAt 69 | ? Math.ceil((entry.expiresAt - Date.now()) / 1000) 70 | : null; 71 | // Allow a 1-second difference 72 | if (remainingTime === null || Math.abs(remainingTime - ttl) <= 1) { 73 | return entry.value as T; 74 | } 75 | 76 | // TTL mismatch — purge memory cache 77 | this.#memoryCache.delete(key); 78 | } 79 | 80 | // If we reach here, either: 81 | // 1. Nothing in memory cache 82 | // 2. Memory cache was invalid 83 | // Try Redis 84 | try { 85 | const value = await this.#redis.json.get(key); 86 | if (value !== null) { 87 | // Store in the memory cache with proper expiration 88 | const expiresAt = ttl > 0 ? Date.now() + ttl * 1000 : null; 89 | this.#memoryCache.set(key, { value, expiresAt }); 90 | } 91 | return value; 92 | } catch (error) { 93 | console.error("Redis get error:", error); 94 | return null; 95 | } 96 | } 97 | 98 | /** 99 | * Set a value in the cache 100 | * 101 | * @param key the key to store the value for 102 | * @param value the value to store 103 | * @param ttlSeconds the optional TTL to set for expiration 104 | */ 105 | async set(key: string, value: T, ttlSeconds?: number) { 106 | if (this.#isDev) { 107 | console.log(`Setting value for ${key} in memory cache`); 108 | // In dev mode, use memory cache only 109 | const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null; 110 | if (expiresAt) { 111 | console.log(`Defining cache for ${key}, expires at ${new Date(expiresAt)}`); 112 | } else console.log(`No cache set for ${key}`); 113 | this.#memoryCache.set(key, { value, expiresAt }); 114 | } else { 115 | // In production, use both Redis and memory cache 116 | try { 117 | await this.#redis.json.set(key, "$", value); 118 | if (ttlSeconds) await this.#redis.expire(key, ttlSeconds); 119 | // Mirror in the memory cache 120 | const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null; 121 | this.#memoryCache.set(key, { value, expiresAt }); 122 | } catch (error) { 123 | console.error("Redis set error:", error); 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/lib/server/graphql.config.yml: -------------------------------------------------------------------------------- 1 | schema: 2 | - https://api.github.com/graphql: 3 | headers: 4 | Authorization: Bearer ${GITHUB_TOKEN} 5 | -------------------------------------------------------------------------------- /src/lib/server/package-discoverer.ts: -------------------------------------------------------------------------------- 1 | import { type Repository, publicRepos } from "$lib/repositories"; 2 | import type { Prettify } from "$lib/types"; 3 | import { GitHubCache, githubCache } from "./github-cache"; 4 | 5 | type Package = { 6 | name: string; 7 | description: string; 8 | deprecated?: string; 9 | }; 10 | 11 | export type DiscoveredPackage = Prettify< 12 | Repository & { 13 | packages: Package[]; 14 | } 15 | >; 16 | 17 | export type CategorizedPackage = Prettify< 18 | Pick & { 19 | packages: (Omit & { pkg: Package })[]; 20 | } 21 | >; 22 | 23 | export class PackageDiscoverer { 24 | readonly #cache: GitHubCache; 25 | readonly #repos: Repository[] = []; 26 | #packages: DiscoveredPackage[] = []; 27 | 28 | constructor(cache: GitHubCache, repos: Repository[]) { 29 | this.#cache = cache; 30 | this.#repos = repos; 31 | } 32 | 33 | /** 34 | * A processing-heavy function that discovers all the 35 | * packages for the repos. 36 | * Populates the result into packages. 37 | */ 38 | async discoverAll() { 39 | this.#packages = await Promise.all( 40 | this.#repos.map(async repo => { 41 | const releases = await this.#cache.getReleases(repo); 42 | const descriptions = await this.#cache.getDescriptions(repo.repoOwner, repo.repoName); 43 | const packages = [ 44 | ...new Set( 45 | releases 46 | .filter(release => repo.dataFilter?.(release) ?? true) 47 | .map(({ tag_name }) => { 48 | const [name] = repo.metadataFromTag(tag_name); 49 | return name; 50 | }) 51 | ) 52 | ]; 53 | console.log( 54 | `Discovered ${packages.length} packages for ${repo.repoOwner}/${repo.repoName}: ${packages.join(", ")}` 55 | ); 56 | return { 57 | ...repo, 58 | packages: await Promise.all( 59 | packages.map(async (pkg): Promise => { 60 | const ghName = this.#gitHubDirectoryFromName(pkg); 61 | const deprecated = (await this.#cache.getPackageDeprecation(pkg)).value || undefined; 62 | return { 63 | name: pkg, 64 | description: deprecated 65 | ? "" // descriptions of deprecated packages are often wrong as their code might be deleted, 66 | : // thus falling back to a higher hierarchy description, often a mismatch 67 | (descriptions[`packages/${ghName}/package.json`] ?? 68 | descriptions[ 69 | `packages/${ghName.substring(ghName.lastIndexOf("/") + 1)}/package.json` 70 | ] ?? 71 | descriptions["package.json"] ?? 72 | ""), 73 | deprecated 74 | }; 75 | }) 76 | ) 77 | }; 78 | }) 79 | ); 80 | } 81 | 82 | /** 83 | * Returns the directory on GitHub from the name 84 | * of the package. 85 | * Useful to retrieve the correct `package.json` file. 86 | * 87 | * @param name the package name 88 | * @returns the directory name in GitHub for that package 89 | * @private 90 | */ 91 | #gitHubDirectoryFromName(name: string): string { 92 | switch (name) { 93 | case "extensions": 94 | return "svelte-vscode"; 95 | case "sv": 96 | return "cli"; 97 | case "svelte-migrate": 98 | return "migrate"; 99 | default: 100 | return name; 101 | } 102 | } 103 | 104 | /** 105 | * Returns the saved packages if they're not empty, 106 | * otherwise calls {@link discoverAll} then returns the 107 | * packages. 108 | * 109 | * @returns all the discovered packages per repo name 110 | */ 111 | async getOrDiscover() { 112 | if (this.#packages.length) { 113 | return this.#packages; 114 | } 115 | await this.discoverAll(); 116 | return this.#packages; 117 | } 118 | 119 | /** 120 | * Returns all packages sorted by categories. 121 | * Calls {@link getOrDiscover} under the hood. 122 | * 123 | * @returns all the discovered packages in a 124 | * category-centric data structure 125 | */ 126 | async getOrDiscoverCategorized() { 127 | return (await this.getOrDiscover()).reduce( 128 | (acc, { category, ...rest }) => { 129 | const formattedPackages = rest.packages.map(pkg => ({ 130 | ...rest, 131 | pkg 132 | })); 133 | 134 | for (const [i, item] of acc.entries()) { 135 | if (item.category.slug === category.slug) { 136 | acc[i]?.packages.push(...formattedPackages); 137 | return acc; 138 | } 139 | } 140 | 141 | // If the category doesn't exist in the accumulator, create it 142 | acc.push({ 143 | category, 144 | packages: rest.packages.map(pkg => ({ 145 | ...rest, 146 | pkg 147 | })) 148 | }); 149 | 150 | return acc; 151 | }, 152 | [] 153 | ); 154 | } 155 | } 156 | 157 | export const discoverer = new PackageDiscoverer(githubCache, publicRepos); 158 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { Octokit } from "octokit"; 2 | import type { GitHubRelease } from "$lib/server/github-cache"; 3 | 4 | export type Prettify = { 5 | [K in keyof T]: T[K]; 6 | } & {}; 7 | 8 | export type Entries = { 9 | [K in keyof T]: [K, T[K]]; 10 | }[keyof T][]; 11 | 12 | export type WithRequired = T & { [P in K]-?: T[P] }; 13 | 14 | export type RepoInfo = { 15 | /** 16 | * Mode to fetch the releases of the repo. 17 | * - `releases`: Fetches from the Releases page 18 | * - `changelog`: Fetches the changelog of the repo, if available 19 | * 20 | * @default "releases" 21 | */ 22 | changesMode?: "releases" | "changelog"; 23 | /** 24 | * Repository organization/owner on GitHub 25 | * 26 | * @default "sveltejs" 27 | */ 28 | repoOwner?: string; 29 | /** 30 | * Repository name on GitHub 31 | */ 32 | repoName: string; 33 | /** 34 | * Filter function to apply to the releases of the repo. 35 | * If it returns false, the release is filtered out. 36 | * 37 | * @param release The release to filter 38 | * @returns whether we want to keep the release 39 | */ 40 | dataFilter?: (release: GitHubRelease) => boolean; 41 | /** 42 | * Extracts the package name and version from the tag name. 43 | * 44 | * @param tag The tag name to extract the name and version from 45 | * @returns an array with the package name, and the package version 46 | */ 47 | metadataFromTag: (tag: string) => [string, string]; 48 | /** 49 | * Replaces the contents of the changelog file. 50 | * Only used when `changesMode` is set to `changelog`. 51 | * By default, no replacement is performed. 52 | * 53 | * @param file The contents of the changelog file 54 | * @returns the modified contents 55 | */ 56 | changelogContentsReplacer?: (file: string) => string; 57 | }; 58 | 59 | export const availableCategory = ["svelte", "kit", "others"] as const; 60 | export type Category = (typeof availableCategory)[number]; 61 | 62 | export type Issues = InstanceType["rest"]["issues"]; 63 | export type Pulls = InstanceType["rest"]["pulls"]; 64 | 65 | /** 66 | * The slug name for all the packages 67 | */ 68 | export const ALL_SLUG = "all"; 69 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | export type WithoutChild = T extends { child?: any } ? Omit : T; 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | export type WithoutChildren = T extends { children?: any } ? Omit : T; 12 | export type WithoutChildrenOrChild = WithoutChildren>; 13 | export type WithElementRef = T & { ref?: U | null }; 14 | -------------------------------------------------------------------------------- /src/params/number.ts: -------------------------------------------------------------------------------- 1 | export function match(value: string) { 2 | return /^\d+$/.test(value); 3 | } 4 | -------------------------------------------------------------------------------- /src/params/pid.ts: -------------------------------------------------------------------------------- 1 | export function match(param: string) { 2 | return param === "pull" || param === "issues" || param === "discussions"; 3 | } 4 | -------------------------------------------------------------------------------- /src/reset.d.ts: -------------------------------------------------------------------------------- 1 | import "@total-typescript/ts-reset"; 2 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | Error {page.status} | Svelte Changelog 7 | 8 | 9 |
    10 |

    {page.status}

    11 | {#if page.error} 12 |

    {page.error.message}

    13 | {/if} 14 |
    15 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { uniq } from "$lib/array"; 2 | import { discoverer } from "$lib/server/package-discoverer"; 3 | 4 | export async function load() { 5 | const categorizedPackages = await discoverer.getOrDiscoverCategorized(); 6 | 7 | return { 8 | // The displayable data, available to load from clients 9 | displayablePackages: categorizedPackages.map(res => ({ 10 | ...res, 11 | packages: uniq( 12 | res.packages 13 | .map(({ dataFilter, metadataFromTag, changelogContentsReplacer, ...rest }) => rest) 14 | .toSorted((a, b) => a.pkg.name.localeCompare(b.pkg.name)), 15 | item => item.pkg.name 16 | ) 17 | })) 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 96 | 97 | {#if dev} 98 | 99 | {/if} 100 | 101 | 102 | 103 | 104 | 105 |
    = 25 110 | } 111 | ]} 112 | > 113 |
    121 |
    122 | 123 | 124 | Svelte 129 | {#if !page.route.id?.startsWith("/devlog")} 130 | 134 | {/if} 135 | 136 | {#if page.route.id?.startsWith("/devlog")} 137 |
    138 | Blog 139 | {/if} 140 | 141 | 142 | {#if !page.route.id?.startsWith("/devlog")} 143 | 158 | {/if} 159 | 160 | 161 |
    162 | 214 |
    215 |
    216 |
    217 | {#if newsToDisplay} 218 | 219 | 220 | 244 | {/if} 245 |
    246 | 247 |
    248 | {@render children?.()} 249 |
    250 | 251 | 269 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | import { browser, dev } from "$app/environment"; 2 | import { PUBLIC_POSTHOG_KEY } from "$env/static/public"; 3 | import { injectSpeedInsights } from "@vercel/speed-insights/sveltekit"; 4 | import posthog from "posthog-js"; 5 | import type { MetaTagsProps } from "svelte-meta-tags"; 6 | 7 | injectSpeedInsights(); 8 | 9 | const siteName = "Svelte Changelog"; 10 | 11 | export function load({ url, data }) { 12 | if (browser && !dev) { 13 | posthog.init(PUBLIC_POSTHOG_KEY, { 14 | api_host: `${url.origin}/ingest`, 15 | ui_host: "https://eu.posthog.com", 16 | person_profiles: "always" 17 | }); 18 | } 19 | 20 | return { 21 | ...data, 22 | siteName, 23 | baseMetaTags: Object.freeze({ 24 | title: "Loading...", 25 | titleTemplate: `%s | ${siteName}`, 26 | description: "A nice UI to stay up-to-date with Svelte releases", 27 | canonical: new URL(url.pathname, url.origin).href, 28 | openGraph: { 29 | type: "website", 30 | url: new URL(url.pathname, url.origin).href, 31 | locale: "en_US", 32 | siteName, 33 | images: [ 34 | { 35 | url: "https://svelte.dev/favicon.png", 36 | width: 128, 37 | height: 128, 38 | alt: "Svelte logo" 39 | } 40 | ] 41 | }, 42 | twitter: { 43 | creator: "@probably_coding", 44 | cardType: "summary" as const, 45 | description: "A nice UI to stay up-to-date with Svelte releases" 46 | }, 47 | keywords: ["svelte", "changelog", "svelte changelog", "sveltekit"], 48 | additionalRobotsProps: { 49 | noarchive: true 50 | } 51 | }) satisfies MetaTagsProps 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export async function load({ parent }) { 4 | const { displayablePackages } = await parent(); 5 | const firstCategory = displayablePackages[0]; 6 | if (!firstCategory) redirect(307, "/packages"); 7 | const firstPackage = firstCategory.packages[0]; 8 | if (!firstPackage) redirect(307, "/packages"); 9 | redirect(307, `/package/${firstPackage.pkg.name}`); 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/[pid=pid]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export function load() { 4 | redirect(308, "/"); 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/[pid=pid]/[org]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export function load() { 4 | redirect(308, "/"); 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/[pid=pid]/[org]/[repo]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export function load() { 4 | redirect(308, "/"); 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/[pid=pid]/[org]/[repo]/[id=number]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error, redirect } from "@sveltejs/kit"; 2 | import { githubCache } from "$lib/server/github-cache"; 3 | 4 | export async function load({ params }) { 5 | const { pid: type, org, repo, id } = params; 6 | const numId = +id; // id is already validated by the route matcher 7 | 8 | const item = await githubCache.getItemDetails(org, repo, numId); 9 | if (!item) { 10 | error(404, `${type} #${id} doesn't exist in repo ${org}/${repo}`); 11 | } 12 | 13 | const realType = "commits" in item ? "pull" : "category" in item.info ? "discussions" : "issues"; 14 | if (type !== realType) { 15 | redirect(307, `/${realType}/${org}/${repo}/${id}`); 16 | } 17 | 18 | return { 19 | itemMetadata: { 20 | org, 21 | repo, 22 | id: numId, 23 | type: 24 | type === "issues" 25 | ? ("issue" as const) 26 | : type === "discussions" 27 | ? ("discussion" as const) 28 | : ("pull" as const) 29 | }, 30 | item 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/routes/[pid=pid]/[org]/[repo]/[id=number]/+page.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /src/routes/[pid=pid]/[org]/[repo]/[id=number]/+page.ts: -------------------------------------------------------------------------------- 1 | import type { MetaTagsProps } from "svelte-meta-tags"; 2 | 3 | export function load({ data }) { 4 | return { 5 | ...data, 6 | pageMetaTags: Object.freeze({ 7 | title: `Detail of ${data.itemMetadata.org}/${data.itemMetadata.repo}#${data.itemMetadata.id}` 8 | }) satisfies MetaTagsProps 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/[pid=pid]/[org]/[repo]/[id=number]/BottomCollapsible.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
    26 | 27 | 28 | 31 | {#if icon} 32 | {@const SvelteComponent = icon} 33 | 34 | {/if} 35 |
    36 | {label} 37 | {#if secondaryLabel} 38 | {secondaryLabel} 39 | {/if} 40 |
    41 |
    42 | 43 | {@render children?.()} 44 | 45 |
    46 |
    47 |
    48 | -------------------------------------------------------------------------------- /src/routes/all-package-releases.ts: -------------------------------------------------------------------------------- 1 | import type { PostHog } from "posthog-node"; 2 | import { discoverer } from "$lib/server/package-discoverer"; 3 | import { getPackageReleases } from "./package/releases"; 4 | 5 | /** 6 | * Get all the repositories and releases for all the 7 | * known packages. 8 | * 9 | * @param allPackages all the known packages 10 | * @param posthog the optional PostHog instance 11 | * @returns a map of package names to their awaitable result 12 | */ 13 | export function getAllPackagesReleases( 14 | allPackages: Awaited>, 15 | posthog?: PostHog 16 | ) { 17 | const packages = allPackages.flatMap(({ packages }) => packages); 18 | 19 | return packages.reduce>>( 20 | (acc, { pkg: { name } }) => { 21 | if (acc[name]) 22 | console.warn( 23 | `Duplicate package "${name}" while aggregating packages releases; this should not happen!` 24 | ); 25 | acc[name] = getPackageReleases(name, allPackages, posthog); 26 | return acc; 27 | }, 28 | {} 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/routes/devlog/+layout.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
    10 |

    11 | {page.route.id?.substring(page.route.id?.lastIndexOf("/") + 1) ?? 0} 12 |

    13 | {@render children()} 14 |
    15 | -------------------------------------------------------------------------------- /src/routes/devlog/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export function load() { 4 | redirect(307, "/devlog/v2"); 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/devlog/v2/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
    6 | {new Intl.DateTimeFormat("en", { 7 | dateStyle: "long" 8 | }).format(new Date("2025-04-11"))} • Antoine Lethimonnier 9 |
    10 | 11 |

    12 | Svelte Changelog v2 is here! 13 | With a new, faster backend, a new domain and a design refresh, here's a quick tour of what's changed 14 | in this new iteration. 15 |

    16 | 17 |

    The road to today

    18 | 19 |

    20 | More than a year ago now, I started this project after watching 23 | This Week in Svelte 24 | , because I saw a need in the "old way" of browsing the releases: directly and manually 25 | through GitHub. This site was born in an afternoon.
    Since then, it has become 26 | 27 | my most starred GitHub repository 28 | , and has been featured every week in TWIS. I can't be more grateful for all this. 29 |

    30 | 31 |

    32 | Since then, it's got a few evolutions like 35 | the details page 36 | , a 37 | syntax highlighter, 38 | and 39 | a login mechanism. It 40 | came as I thought about it, but also from live feedbacks (and bugs) reported from TWIS. 41 |

    42 | 43 |

    44 | But as new features and notable migrations were introduced, I started realizing the mess of a 45 | codebase it was starting to become. Paolo (TWIS host) himself also 46 | said the old "Others" tab was unreadable (and I thank him for his feedback!). 47 |

    48 | 49 |

    50 | All of this indicated it was 51 | time for a change 52 | . So, with a bit of prototyping and help from 53 | Thomas (Melt UI 54 | creator) and 3 weeks of work in the middle of moving to a new flat, here we are. 55 |

    56 | 57 |

    What's new

    58 | 59 |

    60 | As you may guess now, my goals were the following: a codebase easier to maintain, a clearer and 61 | cleaner website, packed of course with long-awaited features. 62 |

    63 | 64 |

    65 | More than an upgrade, Svelte Changelog v2 features a brand new paradigm. No more 66 | client auth, no more forever-spinning loadings: it now just works, and it works fast. 67 |

    68 | 69 |

    70 | I rewrote almost everything from scratch, moving from a bring-your-own-token client-only paradigm 71 | to a brand new server-side data fetching layer. 72 |
    73 | Under the hood, I now use a dedicated Redis instance, hosted on 74 | Upstash. Thanks to a granular 75 | cache TTL, data fetched with my token on the server is automatically purged and re-fetched 76 | periodically. 77 |

    78 | 79 |

    80 | The UI also got a design refresh, vibing more with 81 | the new Svelte site 82 | . Alongside new fonts and updated colors, the site now uses modernized components, still based 83 | on 84 | shadcn/ui. 85 |
    86 | The routing has also been revamped, now featuring a page per package as well as a 87 | sidebar for a better packages overview and an eased navigation. 88 |

    89 | 90 |

    All that now lives under a brand new domain, {page.url.host}.

    91 | 92 |

    93 | This version truly is the beginning of a new era for this site, unlocking many possibilities 94 | whilst being way more pleasant to browse. 95 |

    96 | 97 |

    The roadmap

    98 | 99 |

    This refresh sets the bar for a ton of other improvements I'd like to bring:

    100 | 101 |
      102 |
    • 103 | A global search based on TypeSense, 104 | allowing to search for packages or even release details 105 |
    • 106 |
    • 107 | More filters to allow you to customize even more what's shown on your releases 108 | page (cc Thomas!) 109 |
    • 110 |
    • 111 | Infinite loading to be able to load as many releases as desired, leveraging the 112 | new data fetching layer 113 |
    • 114 |
    • A dashboard, using graphs to show stats about releases and more
    • 115 |
    • Even more frequent updates thanks to a revised notification system
    • 116 |
    • 117 | and even more! Check the 118 | open issues 119 | for sneak peeks ;) 120 |
    • 121 |
    122 | 123 |

    Thank you

    124 | 125 |

    126 | I really hope you'll like everything I put into this release! 127 |
    128 | As always, I'm open to feedbacks and contributions, visit 129 | the repo! 130 |

    131 | 132 |

    133 | Many thanks to Paolo for your key role in TWIS and the community, 134 | Thomas for your help and kindness, the whole Svelte Community 135 | for being so great, the Svelte team for bringing us such amazing pieces of 136 | software, and thank you to everyone supporting me and enjoying this site and my projects! 137 |

    138 | 139 |
    ~ Antoine
    140 | -------------------------------------------------------------------------------- /src/routes/devlog/v2/+page.ts: -------------------------------------------------------------------------------- 1 | import type { MetaTagsProps } from "svelte-meta-tags"; 2 | 3 | export function load() { 4 | return { 5 | pageMetaTags: Object.freeze({ 6 | title: "v2 • Devlog", 7 | description: "The development blog of Svelte Changelog", 8 | twitter: { 9 | description: "The development blog of Svelte Changelog" 10 | } 11 | }) satisfies MetaTagsProps 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/package/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { discoverer } from "$lib/server/package-discoverer"; 2 | import { getAllPackagesReleases } from "../all-package-releases"; 3 | 4 | /** 5 | * The goal of this load function is to serve any `[...package]` 6 | * page by handing it a bunch of promises, so it can await the one 7 | * it needs. The other ones are for the sidebar badges, so the page 8 | * doesn't have to re-run the data loading every time we switch from 9 | * a package to another. 10 | */ 11 | export async function load({ locals }) { 12 | // 1. Get all the packages 13 | const categorizedPackages = await discoverer.getOrDiscoverCategorized(); 14 | 15 | // 2. Use them to get a map of packages to promises of releases 16 | const allReleases = getAllPackagesReleases(categorizedPackages, locals.posthog); 17 | 18 | // 3. Send all that down to the page's load function 19 | return { allReleases }; 20 | } 21 | -------------------------------------------------------------------------------- /src/routes/package/+layout.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
    21 |
    22 | {@render children()} 23 |
    24 | 25 | 26 | 27 | {#snippet child({ props })} 28 | 39 | {/snippet} 40 | 41 | 42 | 43 | Packages 44 | 45 | 52 | 53 | 54 | 55 |
    66 | -------------------------------------------------------------------------------- /src/routes/package/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export function load() { 4 | redirect(307, "/packages"); 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/package/SidePanel.svelte: -------------------------------------------------------------------------------- 1 | 109 | 110 | {#snippet newBadge(count: number)} 111 | {#if count > 0} 112 | {count} new 113 | {/if} 114 | {/snippet} 115 | 116 |
    126 | 132 | {#if !headless} 133 | 134 | Packages 135 | 139 | See all 140 | 141 | 142 | 143 | {/if} 144 | 145 | 229 | 230 | 231 | {#if headless} 232 | 233 | {/if} 234 |
    240 | 246 | 254 |
    255 |
    256 | -------------------------------------------------------------------------------- /src/routes/package/[...package]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error } from "@sveltejs/kit"; 2 | import { discoverer } from "$lib/server/package-discoverer"; 3 | import { ALL_SLUG } from "$lib/types"; 4 | import { getAllPackagesReleases, getPackageReleases } from "../releases"; 5 | 6 | export async function load({ params, locals }) { 7 | const { package: slugPackage } = params; 8 | // 1. Get all the discovered packages 9 | const categorizedPackages = await discoverer.getOrDiscoverCategorized(); 10 | 11 | // 1.5. Return a set for all the packages 12 | if (slugPackage.toLowerCase() === ALL_SLUG) { 13 | return { 14 | currentPackage: { 15 | category: { 16 | slug: ALL_SLUG, 17 | name: "All" 18 | }, 19 | repoOwner: "", 20 | repoName: "", 21 | pkg: { 22 | name: "All packages", 23 | description: "All the packages of this site." 24 | } 25 | } satisfies NonNullable>>["releasesRepo"], 26 | releases: await getAllPackagesReleases(categorizedPackages, locals.posthog) 27 | }; 28 | } 29 | 30 | // 2. Get the releases and package info 31 | const packageReleases = await getPackageReleases( 32 | slugPackage, 33 | categorizedPackages, 34 | locals.posthog 35 | ); 36 | if (!packageReleases) error(404); 37 | 38 | // 3. Return the data 39 | const { releasesRepo: currentPackage, releases } = packageReleases; 40 | return { currentPackage, releases }; 41 | } 42 | -------------------------------------------------------------------------------- /src/routes/package/[...package]/+page.svelte: -------------------------------------------------------------------------------- 1 | 84 | 85 | {#snippet loading()} 86 |
    87 |
    88 | 89 | 90 |
    91 |
    92 |

    95 | 96 | {loadingSentences[Math.floor(Math.random() * loadingSentences.length)]}... 97 |

    98 | 99 | 100 | 101 | 102 |
    103 |
    104 | {/snippet} 105 | 106 | {#if navigating.to} 107 | {@render loading()} 108 | {:else} 109 | 110 | {#await Promise.resolve()} 111 | {@render loading()} 112 | {:then} 113 |
    114 |
    115 |

    119 | 120 | {@html data.currentPackage.pkg.name.replace(/\//g, "/")} 121 |

    122 |
    123 | {#if data.currentPackage.repoOwner && data.currentPackage.repoName} 124 |

    125 | 131 | 135 | {data.currentPackage.repoOwner} 136 | / 140 | {data.currentPackage.repoName} 141 | 142 | 143 |

    144 |
    181 | {#if data.currentPackage.pkg.description} 182 |

    186 | {data.currentPackage.pkg.description} 187 |

    188 | {/if} 189 |
    190 | (expandableReleases = openValues)} 194 | class="w-full space-y-2" 195 | > 196 | {#if data.currentPackage.pkg.deprecated} 197 | 198 | 199 | Deprecated 200 | 201 | 206 | {#snippet a({ style, children, class: className, title, href, hidden, type })} 207 | 208 | {@render children?.()} 209 | 210 | {/snippet} 211 | 212 | 213 | 214 | {/if} 215 | {#each displayableReleases as release, index (release.id)} 216 | {@const semVersion = semver.coerce(release.cleanVersion)} 217 | {@const isMajorRelease = 218 | !release.prerelease && 219 | semVersion?.minor === 0 && 220 | semVersion?.patch === 0 && 221 | !semVersion?.prerelease.length} 222 | {@const releaseDate = new Date(release.published_at ?? release.created_at)} 223 | {@const isLatest = release.id === latestRelease?.id} 224 | {@const isMaintenance = earliestOfLatestMajor 225 | ? !isMajorRelease && 226 | /* `semVersion` and `latestRelease` can't be undefined here */ 227 | semVersion!.major < semver.major(latestRelease!.cleanVersion) && 228 | releaseDate > 229 | new Date(earliestOfLatestMajor.published_at ?? earliestOfLatestMajor.created_at) 230 | : false} 231 | 238 | {/each} 239 | 240 |
    241 | {/await} 242 | {/if} 243 | -------------------------------------------------------------------------------- /src/routes/package/[...package]/+page.ts: -------------------------------------------------------------------------------- 1 | import type { MetaTagsProps } from "svelte-meta-tags"; 2 | 3 | export function load({ data }) { 4 | return { 5 | ...data, 6 | pageMetaTags: Object.freeze({ 7 | title: data.currentPackage.pkg.name 8 | }) satisfies MetaTagsProps 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/package/[...package]/atom.xml/+server.ts: -------------------------------------------------------------------------------- 1 | import { type RequestHandler, text } from "@sveltejs/kit"; 2 | import { rssHandler } from "../rss"; 3 | 4 | export const GET: RequestHandler = rssHandler(feed => 5 | text(feed.atom1(), { 6 | headers: { 7 | "Cache-Control": "max-age=0, s-max-age=600", 8 | "Content-Type": "application/xml" 9 | } 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /src/routes/package/[...package]/rss.json/+server.ts: -------------------------------------------------------------------------------- 1 | import { type RequestHandler, text } from "@sveltejs/kit"; 2 | import { rssHandler } from "../rss"; 3 | 4 | export const GET: RequestHandler = rssHandler(feed => 5 | text(feed.json1(), { 6 | headers: { 7 | "Cache-Control": "max-age=0, s-max-age=600", 8 | "Content-Type": "application/json" 9 | } 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /src/routes/package/[...package]/rss.ts: -------------------------------------------------------------------------------- 1 | import { type RequestHandler, error } from "@sveltejs/kit"; 2 | import { Feed } from "feed"; 3 | import { marked } from "marked"; 4 | import { discoverer } from "$lib/server/package-discoverer"; 5 | import { ALL_SLUG } from "$lib/types"; 6 | import { getAllPackagesReleases, getPackageReleases } from "../releases"; 7 | 8 | /** 9 | * Get the base info to build an RSS feed upon 10 | * @param url the page URL; must be an RSS feed 11 | * @param title the feed title 12 | * @param mode whether it is a feed for a single or all the packages 13 | * @return a new {@link Feed} object 14 | */ 15 | function getBaseFeed(url: URL, title: string, mode: "all" | "single" = "single") { 16 | const feed = new Feed({ 17 | copyright: "Antoine Lethimonnier & GitHub Inc.", 18 | description: `The releases feed for ${mode === "single" ? title : "all the packages"}, brought by Svelte Changelog.`, 19 | favicon: "https://raw.githubusercontent.com/sveltejs/branding/master/svelte-logo.svg", 20 | feedLinks: { 21 | xml: url.toString().replace(/[A-z\d]+\.[A-z\d]+$/, "rss.xml"), 22 | json: url.toString().replace(/[A-z\d]+\.[A-z\d]+$/, "rss.json"), 23 | atom: url.toString().replace(/[A-z\d]+\.[A-z\d]+$/, "atom.xml") 24 | }, 25 | id: url.toString(), 26 | language: "en", 27 | link: url.toString(), 28 | title 29 | }); 30 | feed.addCategory("Technology"); 31 | feed.addContributor({ 32 | name: "Antoine Lethimonnier", 33 | link: "https://github.com/WarningImHack3r" 34 | }); 35 | return feed; 36 | } 37 | 38 | /** 39 | * Convert a raw Markdown into a basic HTML structure 40 | * @param md the Markdown text 41 | * @return the HTML conversion 42 | */ 43 | function mdToHtml(md: string | null | undefined) { 44 | if (!md) return undefined; 45 | // we'll assume GH content doesn't need to be sanitized *wink wink* 46 | return marked(md) as string; // can only be a Promise if the `async` option is set to true, not the case here 47 | } 48 | 49 | /** 50 | * A SvelteKit request handler utility to create an RSS feed for packages 51 | * @param response the handler converting the final feed object into a response 52 | * @return the response gotten from the callback parameter 53 | */ 54 | export function rssHandler(response: (feed: Feed) => Response): RequestHandler { 55 | return async ({ params, url, locals }) => { 56 | const { package: slugPackage } = params; 57 | if (!slugPackage) error(400); 58 | 59 | // 1. Get all the discovered packages 60 | const categorizedPackages = await discoverer.getOrDiscoverCategorized(); 61 | 62 | // 2. Get the releases and package info 63 | let packageName: string; 64 | let releases: NonNullable>>["releases"]; 65 | if (slugPackage.toLowerCase() === ALL_SLUG) { 66 | // All releases 67 | packageName = "All"; 68 | releases = await getAllPackagesReleases(categorizedPackages, locals.posthog); 69 | } else { 70 | // This package releases 71 | const packageReleases = await getPackageReleases( 72 | slugPackage, 73 | categorizedPackages, 74 | locals.posthog 75 | ); 76 | if (!packageReleases) error(404); 77 | packageName = packageReleases.releasesRepo.pkg.name; 78 | releases = packageReleases.releases; 79 | } 80 | 81 | const feed = getBaseFeed( 82 | url, 83 | `${packageName} releases`, 84 | packageName.toLowerCase() === ALL_SLUG ? "all" : "single" 85 | ); 86 | for (const release of releases) { 87 | feed.addItem({ 88 | author: [ 89 | { 90 | name: release.author.name ?? release.author.login ?? undefined, 91 | link: release.author.html_url, 92 | email: release.author.email ?? undefined 93 | } 94 | ], 95 | content: release.body_html ?? mdToHtml(release.body), 96 | date: new Date(release.published_at ?? release.created_at), 97 | description: `${release.cleanName} ${release.cleanVersion} release`, 98 | id: release.id.toString(), 99 | link: release.html_url, 100 | published: release.published_at ? new Date(release.published_at) : undefined, 101 | title: `${release.cleanName}@${release.cleanVersion}` 102 | }); 103 | } 104 | 105 | return response(feed); 106 | }; 107 | } 108 | -------------------------------------------------------------------------------- /src/routes/package/[...package]/rss.xml/+server.ts: -------------------------------------------------------------------------------- 1 | import { type RequestHandler, text } from "@sveltejs/kit"; 2 | import { rssHandler } from "../rss"; 3 | 4 | export const GET: RequestHandler = rssHandler(feed => 5 | text(feed.rss2(), { 6 | headers: { 7 | "Cache-Control": "max-age=0, s-max-age=600", 8 | "Content-Type": "application/xml" 9 | } 10 | }) 11 | ); 12 | -------------------------------------------------------------------------------- /src/routes/package/releases.ts: -------------------------------------------------------------------------------- 1 | import type { PostHog } from "posthog-node"; 2 | import semver from "semver"; 3 | import type { Repository } from "$lib/repositories"; 4 | import { type GitHubRelease, githubCache } from "$lib/server/github-cache"; 5 | import type { discoverer } from "$lib/server/package-discoverer"; 6 | import type { Prettify } from "$lib/types"; 7 | 8 | /** 9 | * Get all the releases for a single package. 10 | * 11 | * @param packageName the package to get the releases for 12 | * @param allPackages all the known packages 13 | * @param posthog the optional PostHog instance 14 | * @returns the package's repository alongside its releases, or 15 | * undefined if not found 16 | */ 17 | export async function getPackageReleases( 18 | packageName: string, 19 | allPackages: Awaited>, 20 | posthog?: PostHog 21 | ) { 22 | let currentPackage: 23 | | Prettify< 24 | Omit & 25 | Pick<(typeof allPackages)[number]["packages"][number], "pkg"> 26 | > 27 | | undefined = undefined; 28 | const foundVersions = new Set(); 29 | const releases: ({ cleanName: string; cleanVersion: string } & GitHubRelease)[] = []; 30 | 31 | // Discover releases 32 | console.log("Starting loading releases..."); 33 | for (const { category, packages } of allPackages) { 34 | for (const { pkg, ...repo } of packages) { 35 | if (pkg.name.localeCompare(packageName, undefined, { sensitivity: "base" }) !== 0) continue; 36 | 37 | // 1. Get releases 38 | const cachedReleases = await githubCache.getReleases({ ...repo, category }); 39 | console.log( 40 | `${cachedReleases.length} releases found for repo ${repo.repoOwner}/${repo.repoName}` 41 | ); 42 | 43 | // 2. Filter out invalid ones 44 | const validReleases = cachedReleases 45 | .map(release => { 46 | if (!release.tag_name && release.name) return { ...release, tag_name: release.name }; // Mitigate (?) some obscure empty tags scenarios 47 | return release; 48 | }) 49 | .filter(release => { 50 | if (!release.tag_name) { 51 | posthog?.captureException(new Error("Release with null tag_name"), undefined, { 52 | packageName, 53 | repoOwner: repo.repoOwner, 54 | repoName: repo.repoName, 55 | ...release 56 | }); 57 | console.warn(`Empty release tag name: ${JSON.stringify(release)}`); 58 | return false; 59 | } 60 | const [name] = repo.metadataFromTag(release.tag_name); 61 | return ( 62 | (repo.dataFilter?.(release) ?? true) && 63 | pkg.name.localeCompare(name, undefined, { sensitivity: "base" }) === 0 64 | ); 65 | }) 66 | .sort((a, b) => { 67 | const [, firstVersion] = repo.metadataFromTag(a.tag_name); 68 | const [, secondVersion] = repo.metadataFromTag(b.tag_name); 69 | return semver.rcompare(firstVersion, secondVersion); 70 | }); 71 | console.log("Final filtered count:", validReleases.length); 72 | 73 | // 3. For each release, check if it is already found, searching by versions 74 | const { dataFilter, metadataFromTag, changelogContentsReplacer, ...rest } = repo; 75 | for (const release of validReleases) { 76 | const [cleanName, cleanVersion] = repo.metadataFromTag(release.tag_name); 77 | console.log(`Release ${release.tag_name}, extracted version: ${cleanVersion}`); 78 | if (foundVersions.has(cleanVersion)) continue; 79 | 80 | // If not, add its version to the set and itself to the final version 81 | const currentNewestVersion = [...foundVersions].sort(semver.rcompare)[0]; 82 | console.log("Current newest version", currentNewestVersion); 83 | foundVersions.add(cleanVersion); 84 | releases.push({ cleanName, cleanVersion, ...release }); 85 | 86 | // If it is newer than the newest we got, set this repo as the "final repo" 87 | if (!currentNewestVersion || semver.gt(cleanVersion, currentNewestVersion)) { 88 | console.log( 89 | `Current newest version "${currentNewestVersion}" doesn't exist or is lesser than ${cleanVersion}, setting ${repo.repoOwner}/${repo.repoName} as final repo` 90 | ); 91 | currentPackage = { 92 | category, 93 | pkg, 94 | ...rest 95 | }; 96 | } 97 | } 98 | console.log("Done"); 99 | } 100 | } 101 | 102 | return currentPackage 103 | ? { 104 | releasesRepo: currentPackage, 105 | releases: releases.toSorted( 106 | (a, b) => 107 | new Date(b.published_at ?? b.created_at).getTime() - 108 | new Date(a.published_at ?? a.created_at).getTime() 109 | ) 110 | } 111 | : undefined; 112 | } 113 | 114 | /** 115 | * Get all the releases from all the packages. 116 | * 117 | * @param allPackages all the known packages 118 | * @param posthog the optional PostHog instance 119 | * @return a list of all the package releases 120 | */ 121 | export async function getAllPackagesReleases( 122 | allPackages: Awaited>, 123 | posthog?: PostHog 124 | ) { 125 | const packages = allPackages.flatMap(({ packages }) => packages); 126 | 127 | const awaitedResult = await Promise.all( 128 | packages.map(async ({ pkg }) => getPackageReleases(pkg.name, allPackages, posthog)) 129 | ); 130 | 131 | return awaitedResult 132 | .filter(r => r !== undefined) 133 | .flatMap(r => r.releases) 134 | .toSorted( 135 | (a, b) => 136 | new Date(b.published_at ?? b.created_at).getTime() - 137 | new Date(a.published_at ?? a.created_at).getTime() 138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /src/routes/packages/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { discoverer } from "$lib/server/package-discoverer"; 2 | import { getAllPackagesReleases } from "../all-package-releases"; 3 | 4 | export async function load({ locals }) { 5 | // 1. Get all the packages 6 | const categorizedPackages = await discoverer.getOrDiscoverCategorized(); 7 | 8 | // 2. Use them to get a map of packages to promises of releases 9 | const allReleases = getAllPackagesReleases(categorizedPackages, locals.posthog); 10 | 11 | // 3. Send all that down to the page's load function 12 | return { allReleases }; 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/packages/+page.svelte: -------------------------------------------------------------------------------- 1 | 50 | 51 | {#snippet newBadge(count: number)} 52 | {#if count > 0} 53 | {count} new 54 | {/if} 55 | {/snippet} 56 | 57 | 133 | -------------------------------------------------------------------------------- /src/routes/packages/+page.ts: -------------------------------------------------------------------------------- 1 | import type { MetaTagsProps } from "svelte-meta-tags"; 2 | 3 | export function load({ data }) { 4 | return { 5 | ...data, 6 | pageMetaTags: Object.freeze({ 7 | title: "All Packages" 8 | }) satisfies MetaTagsProps 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/tracker/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | import { uniqueRepos } from "$lib/repositories"; 3 | 4 | export function load({ url }) { 5 | // Load the first repo of our list 6 | const firstRepo = uniqueRepos[0]; 7 | if (!firstRepo) redirect(307, "/"); 8 | redirect(307, `${url.pathname}/${firstRepo.owner}/${firstRepo.name}`); 9 | } 10 | -------------------------------------------------------------------------------- /src/routes/tracker/[org]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | import { uniqueRepos } from "$lib/repositories"; 3 | 4 | export function load({ params }) { 5 | // Load the first repo that has the org 6 | const matchingRepo = uniqueRepos.find( 7 | ({ owner }) => owner.localeCompare(params.org, undefined, { sensitivity: "base" }) === 0 8 | ); 9 | if (!matchingRepo) redirect(307, "/"); 10 | redirect(307, `/tracker/${matchingRepo.owner}/${matchingRepo.name}`); 11 | } 12 | -------------------------------------------------------------------------------- /src/routes/tracker/[org]/[repo]/+layout.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | {#snippet repoList()} 13 |
      14 | {#each uniqueRepos as repo (repo)} 15 | {@const active = repo.owner === page.params.org && repo.name === page.params.repo} 16 |
    • 17 | {#if active} 18 | {repo.owner}/{repo.name} 20 | {:else} 21 | 25 | {repo.owner}/{repo.name} 26 | 27 | {/if} 28 |
    • 29 | {/each} 30 |
    31 | {/snippet} 32 | 33 |
    34 |
    35 | {@render children()} 36 |
    37 | 38 | 39 | 40 | {#snippet child({ props })} 41 | 49 | {/snippet} 50 | 51 | 52 | 53 | Repositories 54 | 55 | 56 | {@render repoList()} 57 | 58 | 59 | 60 | 61 | 67 |
    68 | -------------------------------------------------------------------------------- /src/routes/tracker/[org]/[repo]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error } from "@sveltejs/kit"; 2 | import { githubCache } from "$lib/server/github-cache"; 3 | 4 | // source: https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword 5 | const closingKeywords = [ 6 | "close", 7 | "closes", 8 | "closed", 9 | "fix", 10 | "fixes", 11 | "fixed", 12 | "resolve", 13 | "resolves", 14 | "resolved" 15 | ]; 16 | 17 | export async function load({ params }) { 18 | const members = await githubCache.getOrganizationMembers(params.org); 19 | if (!members.length) error(404, `Organization ${params.org} not found or empty`); 20 | 21 | const membersNames = members.map(({ login }) => login); 22 | const now = new Date(); 23 | 24 | const [unfilteredPRs, unfilteredIssues, unfilteredDiscussions] = await Promise.all([ 25 | githubCache.getAllPRs(params.org, params.repo), 26 | githubCache.getAllIssues(params.org, params.repo), 27 | githubCache.getAllDiscussions(params.org, params.repo) 28 | ]); 29 | return { 30 | prs: unfilteredPRs 31 | .filter(({ user, body, updated_at }) => { 32 | if (new Date(updated_at).getFullYear() < now.getFullYear() - 1) return false; 33 | if (!membersNames.includes(user?.login ?? "")) return false; 34 | if (!body) return true; 35 | const lowerBody = body.toLowerCase(); 36 | for (const keyword of closingKeywords) { 37 | if ( 38 | lowerBody.includes(`${keyword} #`) || 39 | lowerBody.includes(`${keyword} https://github.com`) || 40 | new RegExp(`${keyword} [A-Za-z0-9_-]+/[A-Za-z0-9_-]+#[0-9]+`).test(lowerBody) 41 | ) { 42 | return false; 43 | } 44 | } 45 | return true; 46 | }) 47 | .toSorted((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()), 48 | issues: unfilteredIssues 49 | .filter( 50 | ({ author_association, pull_request, updated_at }) => 51 | !pull_request && 52 | author_association === "MEMBER" && 53 | new Date(updated_at).getFullYear() >= now.getFullYear() - 1 54 | ) 55 | .toSorted((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()), 56 | discussions: unfilteredDiscussions 57 | .filter( 58 | ({ author_association, updated_at }) => 59 | author_association === "MEMBER" && 60 | new Date(updated_at).getFullYear() >= now.getFullYear() - 1 61 | ) 62 | .toSorted((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/routes/tracker/[org]/[repo]/+page.svelte: -------------------------------------------------------------------------------- 1 | 54 | 55 | {#snippet list(title: string, items: T[], itemToLink: (item: T) => string)} 56 |
    57 |

    {title}

    58 | {#each items as item, i (item.id)} 59 | {#if i > 0} 60 | 61 | {/if} 62 | {@render listItem(item, itemToLink(item))} 63 | {/each} 64 |
    65 | {/snippet} 66 | 67 | {#snippet listItem(item: Item, link: string)} 68 | {@const lastUpdate = new Date(item.updated_at)} 69 | {@const isUpdated = !areSameDay(lastUpdate, new Date(item.created_at))} 70 | 74 | 94 |
    95 |
    96 | 97 | 98 | #{item.number} 99 | 100 | 101 | {#if isNew(lastUpdate)} 102 | {daysAgo(lastUpdate)} • 103 | {/if} 104 | 105 | {new Intl.DateTimeFormat("en", { 106 | dateStyle: "medium" 107 | }).format(lastUpdate)} 108 | 109 | 110 |
    111 | 135 | {#snippet img({ alt })} 136 |
    137 | 138 | {alt} 139 |
    140 | {/snippet} 141 |
    142 |
    143 |
    144 | {/snippet} 145 | 146 |
    147 |

    148 | 153 | {page.params.org}/{page.params.repo} 156 | 157 |

    158 |
    159 |

    Repository tracker

    160 | 161 | 167 | 168 | 169 | 170 |

    How it works

    171 |

    The content comes, as everything else in this site, from GitHub.

    172 |

    173 | All content, be it issues, discussions, or PRs, is filtered to only get displayed if 174 | authored by the org's members since last year. 175 |

    176 |

    177 | For PRs, an additional filtering is performed to not pick any PR that fixes any issue. 178 |

    179 |

    Then, all of this is sorted by update date, and... that's it!

    180 |

    181 | This algorithm might be not fine-grained enough, but I find it working quite well for now. 182 | Open an issue/PR if you have any improvement suggestion! 183 |

    184 |
    185 |
    186 |
    187 |
    188 | 189 | {#if data.prs.length} 190 | {@render list( 191 | "Pull requests", 192 | data.prs, 193 | pr => `/pull/${pr.base.repo.owner.login}/${pr.base.repo.name}/${pr.number}` 194 | )} 195 | {/if} 196 | 197 | {#if data.discussions.length} 198 | {@render list("Discussions", data.discussions, d => { 199 | const ownerSlashRepo = d.repository_url.replace("https://api.github.com/repos/", ""); 200 | return `/discussions/${ownerSlashRepo}/${d.number}`; 201 | })} 202 | {/if} 203 | 204 | {#if data.issues.length} 205 | {@render list("Issues", data.issues, issue => { 206 | const ownerSlashRepo = issue.html_url 207 | .replace("https://github.com/", "") 208 | .replace(/\/[A-z]+\/\d+$/, ""); 209 | return `/issues/${ownerSlashRepo}/${issue.number}`; 210 | })} 211 | {/if} 212 | 213 | {#if [data.prs.length, data.discussions.length, data.issues.length].every(len => !len)} 214 |
    Nothing interesting to show here :/
    215 | {/if} 216 | -------------------------------------------------------------------------------- /src/routes/tracker/[org]/[repo]/+page.ts: -------------------------------------------------------------------------------- 1 | import type { MetaTagsProps } from "svelte-meta-tags"; 2 | 3 | export function load({ data, params }) { 4 | return { 5 | ...data, 6 | pageMetaTags: Object.freeze({ 7 | title: `Tracker for ${params.org}/${params.repo}` 8 | }) satisfies MetaTagsProps 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/tracker/[org]/[repo]/RepoSidePanel.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | {#if !headless} 26 | 27 | {title} 28 | 29 | {/if} 30 | 31 | {@render children?.()} 32 | 33 | 34 | -------------------------------------------------------------------------------- /static/github.svg: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-vercel"; 2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 3 | 4 | /** @type {import("@sveltejs/kit").Config} */ 5 | const config = { 6 | preprocess: [vitePreprocess({})], 7 | 8 | kit: { 9 | adapter: adapter(), 10 | paths: { 11 | relative: false 12 | } 13 | } 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "moduleDetection": "force", 9 | "noUncheckedIndexedAccess": true, 10 | "resolveJsonModule": true, 11 | "skipLibCheck": true, 12 | "sourceMap": true, 13 | "strict": true 14 | } 15 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/ingest/static/:path(.*)", 5 | "destination": "https://eu-assets.i.posthog.com/static/:path*" 6 | }, 7 | { 8 | "source": "/ingest/:path(.*)", 9 | "destination": "https://eu.i.posthog.com/:path*" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from "@sveltejs/kit/vite"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import { defineConfig } from "vite"; 4 | import devtoolsJson from "vite-plugin-devtools-json"; 5 | import lucidePreprocess from "vite-plugin-lucide-preprocess"; 6 | 7 | export default defineConfig({ 8 | plugins: [devtoolsJson(), lucidePreprocess(), sveltekit(), tailwindcss()], 9 | server: { 10 | strictPort: true // default port required for Login with GH workflow 11 | } 12 | }); 13 | --------------------------------------------------------------------------------