From b0904f1db54cbab1bf50736cde58372335cf9e7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:14:56 +0000 Subject: [PATCH 01/16] chore: remove legacy sveltekit monorepo The project restarts from scratch as a single Astro site with an Adwaita design, per the owner's request. History is kept; every tracked file of the SvelteKit + Hono/Workers monorepo is removed so nothing legacy leaks into the new codebase. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8A7WuJeLxG7aBvK6xfgC9 --- .ai/api.md | 223 -- .ai/architecture.md | 124 - .ai/backend-rules.md | 204 -- .ai/component-rules.md | 65 - .ai/content.md | 70 - .ai/data-sources.md | 227 -- .ai/database.md | 245 -- .ai/decisions.md | 524 ---- .ai/design-system.md | 249 -- .ai/frontend-contract.md | 204 -- .ai/frontend-rules.md | 114 - .ai/handoff.md | 340 --- .ai/i18n.md | 133 - .ai/project.md | 85 - .ai/roadmap.md | 105 - .ai/security.md | 126 - .claude/hooks/session-start.sh | 28 - .claude/settings.json | 14 - .editorconfig | 34 - .github/CODEOWNERS | 36 - .github/ISSUE_TEMPLATE/config.yml | 8 - .github/ISSUE_TEMPLATE/data-problem.yml | 42 - .github/ISSUE_TEMPLATE/maintainer-request.yml | 43 - .github/pull_request_template.md | 56 - .github/workflows/ci.yml | 102 - .gitignore | 27 - CLAUDE.md | 173 -- CONTRIBUTING.md | 91 - LICENSE.md | 99 - README.md | 141 - SECURITY.md | 63 - apps/api/.dev.vars.example | 49 - apps/api/migrations/0001_init.sql | 101 - apps/api/migrations/0002_taxonomy.sql | 24 - apps/api/migrations/0003_rankings_stats.sql | 38 - .../api/migrations/0004_content_community.sql | 34 - apps/api/migrations/0005_indexes.sql | 13 - .../migrations/0006_mirror_artifact_roots.sql | 21 - apps/api/package.json | 25 - apps/api/src/cron/index.ts | 696 ----- apps/api/src/db/artifacts.ts | 175 -- apps/api/src/db/distros.ts | 79 - apps/api/src/db/downloads.ts | 212 -- apps/api/src/db/ingest-log.ts | 45 - apps/api/src/db/mirrors.ts | 98 - apps/api/src/db/queries.ts | 333 --- apps/api/src/db/releases.ts | 97 - apps/api/src/env.ts | 15 - apps/api/src/index.ts | 45 - apps/api/src/lib/cache.ts | 61 - apps/api/src/lib/errors.ts | 36 - apps/api/src/lib/rate-limit.ts | 70 - apps/api/src/middleware/index.ts | 124 - apps/api/src/routes/distros.ts | 111 - apps/api/src/routes/downloads.ts | 77 - apps/api/src/routes/health.ts | 51 - apps/api/src/services/distros.ts | 216 -- apps/api/src/services/downloads.ts | 133 - apps/api/test/artifacts.test.ts | 230 -- apps/api/test/cron.test.ts | 503 ---- apps/api/test/download-counters.test.ts | 107 - apps/api/test/downloads.test.ts | 274 -- apps/api/test/endpoints.test.ts | 335 --- apps/api/test/harness.test.ts | 63 - apps/api/test/harness.ts | 73 - apps/api/test/health.test.ts | 64 - apps/api/tsconfig.json | 8 - apps/api/wrangler.toml | 51 - apps/web/.env.example | 39 - apps/web/e2e/a11y.spec.ts | 19 - apps/web/e2e/browse.spec.ts | 53 - apps/web/e2e/i18n.spec.ts | 41 - apps/web/e2e/public-surface.spec.ts | 54 - apps/web/e2e/search-quiz.spec.ts | 29 - apps/web/e2e/security.spec.ts | 67 - apps/web/package.json | 32 - apps/web/playwright.config.ts | 30 - apps/web/src/app.d.ts | 15 - apps/web/src/app.html | 21 - apps/web/src/hooks.server.ts | 99 - apps/web/src/lib/components/AppDrawer.svelte | 76 - apps/web/src/lib/components/AppFooter.svelte | 42 - apps/web/src/lib/components/AppHeader.svelte | 126 - .../src/lib/components/CommandPalette.svelte | 211 -- .../lib/components/DownloadSelector.svelte | 447 --- apps/web/src/lib/content/index.ts | 63 - apps/web/src/lib/format.ts | 61 - apps/web/src/lib/server/adapt.ts | 50 - apps/web/src/lib/server/api.test.ts | 92 - apps/web/src/lib/server/api.ts | 75 - apps/web/src/lib/server/data.ts | 790 ------ apps/web/src/lib/server/feed.test.ts | 92 - apps/web/src/lib/server/feed.ts | 128 - apps/web/src/lib/server/live-data.json | 462 ---- apps/web/src/lib/server/worker.ts | 137 - apps/web/src/lib/state/ui.svelte.ts | 19 - apps/web/src/params/locale.ts | 6 - apps/web/src/routes/+error.svelte | 76 - apps/web/src/routes/+layout.svelte | 33 - .../src/routes/[[locale=locale]]/+layout.ts | 11 - .../routes/[[locale=locale]]/+page.server.ts | 30 - .../src/routes/[[locale=locale]]/+page.svelte | 151 - .../[[locale=locale]]/about/+page.svelte | 139 - .../[[locale=locale]]/compare/+page.server.ts | 22 - .../[[locale=locale]]/compare/+page.svelte | 207 -- .../[[locale=locale]]/contribute/+page.svelte | 198 -- .../distro/[slug]/+page.server.ts | 28 - .../distro/[slug]/+page.svelte | 365 --- .../[[locale=locale]]/distro/[slug]/+page.ts | 14 - .../[[locale=locale]]/explore/+page.server.ts | 13 - .../[[locale=locale]]/explore/+page.svelte | 84 - .../hall-of-fame/+page.server.ts | 13 - .../hall-of-fame/+page.svelte | 87 - .../[[locale=locale]]/quiz/+page.server.ts | 10 - .../[[locale=locale]]/quiz/+page.svelte | 215 -- .../rankings/+page.server.ts | 23 - .../[[locale=locale]]/rankings/+page.svelte | 106 - .../[[locale=locale]]/search/+page.server.ts | 10 - .../[[locale=locale]]/search/+page.svelte | 95 - apps/web/src/routes/api/v1/distros/+server.ts | 42 - .../routes/api/v1/distros/[slug]/+server.ts | 22 - .../[slug]/download-options/+server.ts | 35 - .../routes/api/v1/downloads/downloads.test.ts | 195 -- .../api/v1/downloads/resolve/+server.ts | 38 - .../routes/api/v1/downloads/track/+server.ts | 36 - .../api/v1/feeds/releases.atom/+server.ts | 15 - .../api/v1/feeds/releases.rss/+server.ts | 15 - .../src/routes/api/v1/hall-of-fame/+server.ts | 7 - apps/web/src/routes/api/v1/health/+server.ts | 102 - .../src/routes/api/v1/health/health.test.ts | 123 - apps/web/src/routes/api/v1/quiz/+server.ts | 7 - .../web/src/routes/api/v1/rankings/+server.ts | 37 - .../routes/api/v1/releases/recent/+server.ts | 44 - apps/web/src/routes/api/v1/search/+server.ts | 48 - apps/web/src/routes/robots.txt/+server.ts | 25 - apps/web/src/routes/sitemap.xml/+server.ts | 105 - apps/web/static/distros/arch.svg | 20 - apps/web/static/distros/debian.svg | 9 - apps/web/static/distros/elementary.svg | 94 - apps/web/static/distros/endeavouros.svg | 29 - apps/web/static/distros/fedora.svg | 36 - apps/web/static/distros/linux-mint.svg | 18 - apps/web/static/distros/manjaro.svg | 22 - apps/web/static/distros/nixos.svg | 89 - apps/web/static/distros/opensuse.svg | 2 - apps/web/static/distros/pop-os.svg | 77 - apps/web/static/distros/ubuntu.svg | 8 - apps/web/static/distros/zorin.svg | 165 -- apps/web/static/favicon.svg | 7 - apps/web/static/theme.js | 16 - apps/web/svelte.config.js | 49 - apps/web/tsconfig.json | 14 - apps/web/vercel.json | 31 - apps/web/vite.config.ts | 76 - assets/distros/ATTRIBUTION.md | 29 - assets/distros/arch.svg | 20 - assets/distros/debian.svg | 9 - assets/distros/elementary.svg | 94 - assets/distros/endeavouros.svg | 29 - assets/distros/fedora.svg | 36 - assets/distros/linux-mint.svg | 18 - assets/distros/manjaro.svg | 22 - assets/distros/nixos.svg | 89 - assets/distros/opensuse.svg | 2 - assets/distros/pop-os.svg | 77 - assets/distros/ubuntu.svg | 8 - assets/distros/zorin.svg | 165 -- biome.json | 68 - bun.lock | 879 ------ content/README.md | 15 - content/distros/arch/en/description.md | 31 - content/distros/arch/en/install.md | 54 - content/distros/arch/en/usage.md | 39 - content/distros/debian/en/description.md | 31 - content/distros/debian/en/install.md | 42 - content/distros/debian/en/usage.md | 43 - content/distros/elementary/en/description.md | 28 - content/distros/elementary/en/install.md | 41 - content/distros/elementary/en/usage.md | 42 - content/distros/endeavouros/en/description.md | 30 - content/distros/endeavouros/en/install.md | 44 - content/distros/endeavouros/en/usage.md | 38 - content/distros/fedora/en/description.md | 31 - content/distros/fedora/en/install.md | 41 - content/distros/fedora/en/usage.md | 40 - content/distros/linux-mint/en/description.md | 30 - content/distros/linux-mint/en/install.md | 33 - content/distros/linux-mint/en/usage.md | 34 - content/distros/manjaro/en/description.md | 30 - content/distros/manjaro/en/install.md | 42 - content/distros/manjaro/en/usage.md | 46 - content/distros/nixos/en/description.md | 30 - content/distros/nixos/en/install.md | 49 - content/distros/nixos/en/usage.md | 46 - content/distros/opensuse/en/description.md | 33 - content/distros/opensuse/en/install.md | 42 - content/distros/opensuse/en/usage.md | 45 - content/distros/pop-os/en/description.md | 29 - content/distros/pop-os/en/install.md | 42 - content/distros/pop-os/en/usage.md | 42 - content/distros/ubuntu/en/description.md | 31 - content/distros/ubuntu/en/install.md | 40 - content/distros/ubuntu/en/usage.md | 38 - content/distros/zorin/en/description.md | 30 - content/distros/zorin/en/install.md | 43 - content/distros/zorin/en/usage.md | 41 - design/README.md | 59 - design/index.html | 69 - design/screens/about.html | 174 -- design/screens/category-tag.html | 172 -- design/screens/compare.html | 205 -- design/screens/contribute.html | 206 -- design/screens/distro-detail.html | 309 --- design/screens/download-selector.html | 243 -- design/screens/error-404.html | 142 - design/screens/explore.html | 251 -- design/screens/global-nav.html | 131 - design/screens/hall-of-fame.html | 173 -- design/screens/home.html | 276 -- design/screens/mobile-nav.html | 225 -- design/screens/quiz.html | 241 -- design/screens/rankings.html | 211 -- design/screens/search-results.html | 231 -- design/shared/design.css | 2452 ----------------- design/shared/mock.js | 326 --- docs/README.md | 12 - docs/deployment.md | 332 --- docs/monorepo.md | 135 - docs/next-session-prompt.md | 158 -- icons/dark.svg | 7 - icons/light.svg | 7 - package.json | 54 - packages/i18n/package.json | 18 - packages/i18n/src/geo.ts | 101 - packages/i18n/src/index.test.ts | 73 - packages/i18n/src/index.ts | 24 - packages/i18n/src/messages.ko.ts | 255 -- packages/i18n/src/messages.ts | 262 -- packages/i18n/src/registry.ts | 98 - packages/i18n/src/runtime.ts | 75 - packages/i18n/tsconfig.json | 4 - packages/ingest/package.json | 29 - packages/ingest/src/content-index.json | 411 --- packages/ingest/src/content-index.ts | 121 - packages/ingest/src/content.ts | 30 - packages/ingest/src/http.test.ts | 127 - packages/ingest/src/http.ts | 197 -- packages/ingest/src/index.ts | 27 - packages/ingest/src/live.ts | 110 - packages/ingest/src/registry.ts | 70 - packages/ingest/src/sources/arch-mirrors.ts | 50 - packages/ingest/src/sources/arch-releases.ts | 97 - packages/ingest/src/sources/artifacts.test.ts | 122 - .../src/sources/debian-artifacts.test.ts | 91 - .../ingest/src/sources/debian-artifacts.ts | 167 -- packages/ingest/src/sources/endoflife.ts | 56 - .../ingest/src/sources/fedora-artifacts.ts | 111 - packages/ingest/src/sources/fedora-mirrors.ts | 48 - .../ingest/src/sources/mint-artifacts.test.ts | 64 - packages/ingest/src/sources/mint-artifacts.ts | 144 - .../src/sources/pop-os-artifacts.test.ts | 89 - .../ingest/src/sources/pop-os-artifacts.ts | 132 - packages/ingest/src/sources/sources.test.ts | 95 - .../src/sources/ubuntu-artifacts.test.ts | 146 - .../ingest/src/sources/ubuntu-artifacts.ts | 202 -- packages/ingest/src/types.ts | 63 - packages/ingest/tsconfig.json | 7 - packages/shared/package.json | 21 - packages/shared/src/index.test.ts | 37 - packages/shared/src/index.ts | 84 - packages/shared/src/schemas.ts | 313 --- packages/shared/tsconfig.json | 4 - packages/ui/package.json | 23 - packages/ui/src/brand.test.ts | 66 - packages/ui/src/brand.ts | 85 - packages/ui/src/components/DistroCard.svelte | 27 - packages/ui/src/components/Icon.svelte | 27 - packages/ui/src/components/IconDefs.svelte | 39 - packages/ui/src/components/LogoTile.svelte | 41 - packages/ui/src/index.ts | 25 - packages/ui/src/motion.ts | 14 - packages/ui/src/styles.css | 2387 ---------------- packages/ui/src/tokens.css | 274 -- packages/ui/src/tokens.test.ts | 73 - packages/ui/src/tokens.ts | 23 - packages/ui/tsconfig.json | 7 - prompts/README.md | 29 - prompts/stitch/_foundation.md | 98 - prompts/stitch/about.md | 50 - prompts/stitch/category-tag.md | 43 - prompts/stitch/compare.md | 54 - prompts/stitch/contribute.md | 57 - prompts/stitch/distro-detail.md | 61 - prompts/stitch/download-selector.md | 62 - prompts/stitch/error-404.md | 46 - prompts/stitch/explore.md | 56 - prompts/stitch/global-nav.md | 62 - prompts/stitch/hall-of-fame.md | 45 - prompts/stitch/home.md | 52 - prompts/stitch/mobile-nav.md | 56 - prompts/stitch/quiz.md | 55 - prompts/stitch/rankings.md | 50 - prompts/stitch/search-results.md | 49 - scripts/check-boundaries.ts | 163 -- scripts/check-deploy-config.test.ts | 58 - scripts/check-deploy-config.ts | 115 - tsconfig.base.json | 25 - vitest.scripts.config.ts | 18 - 308 files changed, 33968 deletions(-) delete mode 100644 .ai/api.md delete mode 100644 .ai/architecture.md delete mode 100644 .ai/backend-rules.md delete mode 100644 .ai/component-rules.md delete mode 100644 .ai/content.md delete mode 100644 .ai/data-sources.md delete mode 100644 .ai/database.md delete mode 100644 .ai/decisions.md delete mode 100644 .ai/design-system.md delete mode 100644 .ai/frontend-contract.md delete mode 100644 .ai/frontend-rules.md delete mode 100644 .ai/handoff.md delete mode 100644 .ai/i18n.md delete mode 100644 .ai/project.md delete mode 100644 .ai/roadmap.md delete mode 100644 .ai/security.md delete mode 100755 .claude/hooks/session-start.sh delete mode 100644 .claude/settings.json delete mode 100644 .editorconfig delete mode 100644 .github/CODEOWNERS delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/data-problem.yml delete mode 100644 .github/ISSUE_TEMPLATE/maintainer-request.yml delete mode 100644 .github/pull_request_template.md delete mode 100644 .github/workflows/ci.yml delete mode 100644 .gitignore delete mode 100644 CLAUDE.md delete mode 100644 CONTRIBUTING.md delete mode 100644 LICENSE.md delete mode 100644 README.md delete mode 100644 SECURITY.md delete mode 100644 apps/api/.dev.vars.example delete mode 100644 apps/api/migrations/0001_init.sql delete mode 100644 apps/api/migrations/0002_taxonomy.sql delete mode 100644 apps/api/migrations/0003_rankings_stats.sql delete mode 100644 apps/api/migrations/0004_content_community.sql delete mode 100644 apps/api/migrations/0005_indexes.sql delete mode 100644 apps/api/migrations/0006_mirror_artifact_roots.sql delete mode 100644 apps/api/package.json delete mode 100644 apps/api/src/cron/index.ts delete mode 100644 apps/api/src/db/artifacts.ts delete mode 100644 apps/api/src/db/distros.ts delete mode 100644 apps/api/src/db/downloads.ts delete mode 100644 apps/api/src/db/ingest-log.ts delete mode 100644 apps/api/src/db/mirrors.ts delete mode 100644 apps/api/src/db/queries.ts delete mode 100644 apps/api/src/db/releases.ts delete mode 100644 apps/api/src/env.ts delete mode 100644 apps/api/src/index.ts delete mode 100644 apps/api/src/lib/cache.ts delete mode 100644 apps/api/src/lib/errors.ts delete mode 100644 apps/api/src/lib/rate-limit.ts delete mode 100644 apps/api/src/middleware/index.ts delete mode 100644 apps/api/src/routes/distros.ts delete mode 100644 apps/api/src/routes/downloads.ts delete mode 100644 apps/api/src/routes/health.ts delete mode 100644 apps/api/src/services/distros.ts delete mode 100644 apps/api/src/services/downloads.ts delete mode 100644 apps/api/test/artifacts.test.ts delete mode 100644 apps/api/test/cron.test.ts delete mode 100644 apps/api/test/download-counters.test.ts delete mode 100644 apps/api/test/downloads.test.ts delete mode 100644 apps/api/test/endpoints.test.ts delete mode 100644 apps/api/test/harness.test.ts delete mode 100644 apps/api/test/harness.ts delete mode 100644 apps/api/test/health.test.ts delete mode 100644 apps/api/tsconfig.json delete mode 100644 apps/api/wrangler.toml delete mode 100644 apps/web/.env.example delete mode 100644 apps/web/e2e/a11y.spec.ts delete mode 100644 apps/web/e2e/browse.spec.ts delete mode 100644 apps/web/e2e/i18n.spec.ts delete mode 100644 apps/web/e2e/public-surface.spec.ts delete mode 100644 apps/web/e2e/search-quiz.spec.ts delete mode 100644 apps/web/e2e/security.spec.ts delete mode 100644 apps/web/package.json delete mode 100644 apps/web/playwright.config.ts delete mode 100644 apps/web/src/app.d.ts delete mode 100644 apps/web/src/app.html delete mode 100644 apps/web/src/hooks.server.ts delete mode 100644 apps/web/src/lib/components/AppDrawer.svelte delete mode 100644 apps/web/src/lib/components/AppFooter.svelte delete mode 100644 apps/web/src/lib/components/AppHeader.svelte delete mode 100644 apps/web/src/lib/components/CommandPalette.svelte delete mode 100644 apps/web/src/lib/components/DownloadSelector.svelte delete mode 100644 apps/web/src/lib/content/index.ts delete mode 100644 apps/web/src/lib/format.ts delete mode 100644 apps/web/src/lib/server/adapt.ts delete mode 100644 apps/web/src/lib/server/api.test.ts delete mode 100644 apps/web/src/lib/server/api.ts delete mode 100644 apps/web/src/lib/server/data.ts delete mode 100644 apps/web/src/lib/server/feed.test.ts delete mode 100644 apps/web/src/lib/server/feed.ts delete mode 100644 apps/web/src/lib/server/live-data.json delete mode 100644 apps/web/src/lib/server/worker.ts delete mode 100644 apps/web/src/lib/state/ui.svelte.ts delete mode 100644 apps/web/src/params/locale.ts delete mode 100644 apps/web/src/routes/+error.svelte delete mode 100644 apps/web/src/routes/+layout.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/+layout.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/+page.server.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/+page.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/about/+page.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/compare/+page.server.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/compare/+page.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/contribute/+page.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.server.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/explore/+page.server.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/explore/+page.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/hall-of-fame/+page.server.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/hall-of-fame/+page.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/quiz/+page.server.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/quiz/+page.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/rankings/+page.server.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/rankings/+page.svelte delete mode 100644 apps/web/src/routes/[[locale=locale]]/search/+page.server.ts delete mode 100644 apps/web/src/routes/[[locale=locale]]/search/+page.svelte delete mode 100644 apps/web/src/routes/api/v1/distros/+server.ts delete mode 100644 apps/web/src/routes/api/v1/distros/[slug]/+server.ts delete mode 100644 apps/web/src/routes/api/v1/distros/[slug]/download-options/+server.ts delete mode 100644 apps/web/src/routes/api/v1/downloads/downloads.test.ts delete mode 100644 apps/web/src/routes/api/v1/downloads/resolve/+server.ts delete mode 100644 apps/web/src/routes/api/v1/downloads/track/+server.ts delete mode 100644 apps/web/src/routes/api/v1/feeds/releases.atom/+server.ts delete mode 100644 apps/web/src/routes/api/v1/feeds/releases.rss/+server.ts delete mode 100644 apps/web/src/routes/api/v1/hall-of-fame/+server.ts delete mode 100644 apps/web/src/routes/api/v1/health/+server.ts delete mode 100644 apps/web/src/routes/api/v1/health/health.test.ts delete mode 100644 apps/web/src/routes/api/v1/quiz/+server.ts delete mode 100644 apps/web/src/routes/api/v1/rankings/+server.ts delete mode 100644 apps/web/src/routes/api/v1/releases/recent/+server.ts delete mode 100644 apps/web/src/routes/api/v1/search/+server.ts delete mode 100644 apps/web/src/routes/robots.txt/+server.ts delete mode 100644 apps/web/src/routes/sitemap.xml/+server.ts delete mode 100644 apps/web/static/distros/arch.svg delete mode 100644 apps/web/static/distros/debian.svg delete mode 100644 apps/web/static/distros/elementary.svg delete mode 100644 apps/web/static/distros/endeavouros.svg delete mode 100644 apps/web/static/distros/fedora.svg delete mode 100644 apps/web/static/distros/linux-mint.svg delete mode 100644 apps/web/static/distros/manjaro.svg delete mode 100644 apps/web/static/distros/nixos.svg delete mode 100644 apps/web/static/distros/opensuse.svg delete mode 100644 apps/web/static/distros/pop-os.svg delete mode 100644 apps/web/static/distros/ubuntu.svg delete mode 100644 apps/web/static/distros/zorin.svg delete mode 100644 apps/web/static/favicon.svg delete mode 100644 apps/web/static/theme.js delete mode 100644 apps/web/svelte.config.js delete mode 100644 apps/web/tsconfig.json delete mode 100644 apps/web/vercel.json delete mode 100644 apps/web/vite.config.ts delete mode 100644 assets/distros/ATTRIBUTION.md delete mode 100644 assets/distros/arch.svg delete mode 100644 assets/distros/debian.svg delete mode 100644 assets/distros/elementary.svg delete mode 100644 assets/distros/endeavouros.svg delete mode 100644 assets/distros/fedora.svg delete mode 100644 assets/distros/linux-mint.svg delete mode 100644 assets/distros/manjaro.svg delete mode 100644 assets/distros/nixos.svg delete mode 100644 assets/distros/opensuse.svg delete mode 100644 assets/distros/pop-os.svg delete mode 100644 assets/distros/ubuntu.svg delete mode 100644 assets/distros/zorin.svg delete mode 100644 biome.json delete mode 100644 bun.lock delete mode 100644 content/README.md delete mode 100644 content/distros/arch/en/description.md delete mode 100644 content/distros/arch/en/install.md delete mode 100644 content/distros/arch/en/usage.md delete mode 100644 content/distros/debian/en/description.md delete mode 100644 content/distros/debian/en/install.md delete mode 100644 content/distros/debian/en/usage.md delete mode 100644 content/distros/elementary/en/description.md delete mode 100644 content/distros/elementary/en/install.md delete mode 100644 content/distros/elementary/en/usage.md delete mode 100644 content/distros/endeavouros/en/description.md delete mode 100644 content/distros/endeavouros/en/install.md delete mode 100644 content/distros/endeavouros/en/usage.md delete mode 100644 content/distros/fedora/en/description.md delete mode 100644 content/distros/fedora/en/install.md delete mode 100644 content/distros/fedora/en/usage.md delete mode 100644 content/distros/linux-mint/en/description.md delete mode 100644 content/distros/linux-mint/en/install.md delete mode 100644 content/distros/linux-mint/en/usage.md delete mode 100644 content/distros/manjaro/en/description.md delete mode 100644 content/distros/manjaro/en/install.md delete mode 100644 content/distros/manjaro/en/usage.md delete mode 100644 content/distros/nixos/en/description.md delete mode 100644 content/distros/nixos/en/install.md delete mode 100644 content/distros/nixos/en/usage.md delete mode 100644 content/distros/opensuse/en/description.md delete mode 100644 content/distros/opensuse/en/install.md delete mode 100644 content/distros/opensuse/en/usage.md delete mode 100644 content/distros/pop-os/en/description.md delete mode 100644 content/distros/pop-os/en/install.md delete mode 100644 content/distros/pop-os/en/usage.md delete mode 100644 content/distros/ubuntu/en/description.md delete mode 100644 content/distros/ubuntu/en/install.md delete mode 100644 content/distros/ubuntu/en/usage.md delete mode 100644 content/distros/zorin/en/description.md delete mode 100644 content/distros/zorin/en/install.md delete mode 100644 content/distros/zorin/en/usage.md delete mode 100644 design/README.md delete mode 100644 design/index.html delete mode 100644 design/screens/about.html delete mode 100644 design/screens/category-tag.html delete mode 100644 design/screens/compare.html delete mode 100644 design/screens/contribute.html delete mode 100644 design/screens/distro-detail.html delete mode 100644 design/screens/download-selector.html delete mode 100644 design/screens/error-404.html delete mode 100644 design/screens/explore.html delete mode 100644 design/screens/global-nav.html delete mode 100644 design/screens/hall-of-fame.html delete mode 100644 design/screens/home.html delete mode 100644 design/screens/mobile-nav.html delete mode 100644 design/screens/quiz.html delete mode 100644 design/screens/rankings.html delete mode 100644 design/screens/search-results.html delete mode 100644 design/shared/design.css delete mode 100644 design/shared/mock.js delete mode 100644 docs/README.md delete mode 100644 docs/deployment.md delete mode 100644 docs/monorepo.md delete mode 100644 docs/next-session-prompt.md delete mode 100644 icons/dark.svg delete mode 100644 icons/light.svg delete mode 100644 package.json delete mode 100644 packages/i18n/package.json delete mode 100644 packages/i18n/src/geo.ts delete mode 100644 packages/i18n/src/index.test.ts delete mode 100644 packages/i18n/src/index.ts delete mode 100644 packages/i18n/src/messages.ko.ts delete mode 100644 packages/i18n/src/messages.ts delete mode 100644 packages/i18n/src/registry.ts delete mode 100644 packages/i18n/src/runtime.ts delete mode 100644 packages/i18n/tsconfig.json delete mode 100644 packages/ingest/package.json delete mode 100644 packages/ingest/src/content-index.json delete mode 100644 packages/ingest/src/content-index.ts delete mode 100644 packages/ingest/src/content.ts delete mode 100644 packages/ingest/src/http.test.ts delete mode 100644 packages/ingest/src/http.ts delete mode 100644 packages/ingest/src/index.ts delete mode 100644 packages/ingest/src/live.ts delete mode 100644 packages/ingest/src/registry.ts delete mode 100644 packages/ingest/src/sources/arch-mirrors.ts delete mode 100644 packages/ingest/src/sources/arch-releases.ts delete mode 100644 packages/ingest/src/sources/artifacts.test.ts delete mode 100644 packages/ingest/src/sources/debian-artifacts.test.ts delete mode 100644 packages/ingest/src/sources/debian-artifacts.ts delete mode 100644 packages/ingest/src/sources/endoflife.ts delete mode 100644 packages/ingest/src/sources/fedora-artifacts.ts delete mode 100644 packages/ingest/src/sources/fedora-mirrors.ts delete mode 100644 packages/ingest/src/sources/mint-artifacts.test.ts delete mode 100644 packages/ingest/src/sources/mint-artifacts.ts delete mode 100644 packages/ingest/src/sources/pop-os-artifacts.test.ts delete mode 100644 packages/ingest/src/sources/pop-os-artifacts.ts delete mode 100644 packages/ingest/src/sources/sources.test.ts delete mode 100644 packages/ingest/src/sources/ubuntu-artifacts.test.ts delete mode 100644 packages/ingest/src/sources/ubuntu-artifacts.ts delete mode 100644 packages/ingest/src/types.ts delete mode 100644 packages/ingest/tsconfig.json delete mode 100644 packages/shared/package.json delete mode 100644 packages/shared/src/index.test.ts delete mode 100644 packages/shared/src/index.ts delete mode 100644 packages/shared/src/schemas.ts delete mode 100644 packages/shared/tsconfig.json delete mode 100644 packages/ui/package.json delete mode 100644 packages/ui/src/brand.test.ts delete mode 100644 packages/ui/src/brand.ts delete mode 100644 packages/ui/src/components/DistroCard.svelte delete mode 100644 packages/ui/src/components/Icon.svelte delete mode 100644 packages/ui/src/components/IconDefs.svelte delete mode 100644 packages/ui/src/components/LogoTile.svelte delete mode 100644 packages/ui/src/index.ts delete mode 100644 packages/ui/src/motion.ts delete mode 100644 packages/ui/src/styles.css delete mode 100644 packages/ui/src/tokens.css delete mode 100644 packages/ui/src/tokens.test.ts delete mode 100644 packages/ui/src/tokens.ts delete mode 100644 packages/ui/tsconfig.json delete mode 100644 prompts/README.md delete mode 100644 prompts/stitch/_foundation.md delete mode 100644 prompts/stitch/about.md delete mode 100644 prompts/stitch/category-tag.md delete mode 100644 prompts/stitch/compare.md delete mode 100644 prompts/stitch/contribute.md delete mode 100644 prompts/stitch/distro-detail.md delete mode 100644 prompts/stitch/download-selector.md delete mode 100644 prompts/stitch/error-404.md delete mode 100644 prompts/stitch/explore.md delete mode 100644 prompts/stitch/global-nav.md delete mode 100644 prompts/stitch/hall-of-fame.md delete mode 100644 prompts/stitch/home.md delete mode 100644 prompts/stitch/mobile-nav.md delete mode 100644 prompts/stitch/quiz.md delete mode 100644 prompts/stitch/rankings.md delete mode 100644 prompts/stitch/search-results.md delete mode 100644 scripts/check-boundaries.ts delete mode 100644 scripts/check-deploy-config.test.ts delete mode 100644 scripts/check-deploy-config.ts delete mode 100644 tsconfig.base.json delete mode 100644 vitest.scripts.config.ts diff --git a/.ai/api.md b/.ai/api.md deleted file mode 100644 index 932c5ab..0000000 --- a/.ai/api.md +++ /dev/null @@ -1,223 +0,0 @@ -# API Catalog — `/api/v1` - -The public API is served by the SvelteKit BFF (`apps/web`) and mirrored 1:1 by -the internal Hono Worker (`apps/api`, prefix `/v1`, internal-token-gated). -All schemas are Zod definitions in `packages/shared` — this doc and those -schemas must stay synchronized. - -> ⚠️ **This is the target API.** The shipped Phase 4 frontend consumes -> different shapes (pre-formatted numbers, composed English strings, -> presentation baked into payloads). `.ai/frontend-contract.md` documents the -> gap field by field and lists the open questions to settle before moving any -> endpoint. Read it before implementing from this catalog. - -## Implemented so far (tasks 5.3, 5.6) - -Twelve endpoints are live on the Worker: **#1, #2, #3, #12, #14, #15, #21, -#22, #32 (+#34 folded in), #35, #37, #41**. They return facts only — no composed English, no -pre-formatted numbers, no presentation (ADR-0020). The BFF still serves the -Phase 4 shapes from `data.ts` for most routes; task 5.4 moves it over, endpoint -by endpoint. **#14, #15 and #21 are proxied end to end** — those three have no -snapshot to fall back to, because `data.ts` never carried an artifact path, a -size or a checksum. With no Worker configured, #14 answers an empty matrix and -the page keeps its placeholder section. - -Two conventions were settled while building them: - -- **`meta.total` is a real `COUNT(*)`**, issued alongside the page query in one - `db.batch()`. The worked example below flagged this as undecided; a total - that is really the page length makes pagination lie. -- **`POST /downloads/resolve` returns no `instructions` string**, despite the - catalog below listing one. That would be composed English in a payload, which - ADR-0020 keeps out; the page has the checksum and composes the verify steps - through `@linuxhub/i18n`. The response adds `mirror_choice` - (`requested|country|fallback|origin`) so the UI can say how the mirror was - picked instead of implying it measured something. -- **A bad internal token answers `404`, not `403`.** The error taxonomy has no - auth code, and an internal surface should not confirm it exists to a caller - that cannot already reach it. The Worker logs the real reason, so a - misconfigured token stays diagnosable. - -## Conventions - -### Envelope - -```jsonc -// success -{ "ok": true, "data": { … }, "meta": { "page": 1, "limit": 24, "total": 312, "next_cursor": null } } -// error -{ "ok": false, "error": { "code": "NOT_FOUND", "message": "distro not found", "details": {}, "request_id": "req_…" } } -``` - -`meta` appears only on paginated/list responses. - -### Pagination - -`?page=` + `?limit=` (default 24, max 100) for browse lists; -`?cursor=` + `?limit=` for feeds (`releases/recent`). Responses always include -`meta` with whichever scheme applies. - -### Errors - -| HTTP | code | -|---|---| -| 400 | `VALIDATION_ERROR` (details: zod issues) | -| 403 | `CAPTCHA_FAILED` | -| 404 | `NOT_FOUND` | -| 429 | `RATE_LIMITED` (+ `Retry-After` header) | -| 502 | `UPSTREAM_ERROR` | -| 500 | `INTERNAL` | - -### Caching & versioning - -GET responses set `ETag` + `Cache-Control` (see `.ai/frontend-rules.md`). -Breaking changes require a new version prefix (`/api/v2`); additive changes -are allowed in place. `?locale=` selects content language where relevant -(validated against the registry, falls back to `en`). - -## Endpoints (61) - -### Distros - -| # | Endpoint | Notes | -|---|---|---| -| 1 | `GET /api/v1/distros` | list; filters `category, tag, family, desktop, arch, based_on, status, q`; sort `popularity, name, latest_release, newest`; paginated | -| 2 | `GET /api/v1/distros/:slug` | detail: profile, family, links, badges, latest release summary | -| 3 | `GET /api/v1/distros/:slug/releases` | all releases; filter `channel` | -| 4 | `GET /api/v1/distros/:slug/releases/:version` | single release detail | -| 5 | `GET /api/v1/distros/:slug/editions` | GNOME/KDE/Xfce/minimal/server… | -| 6 | `GET /api/v1/distros/:slug/screenshots` | ordered gallery with captions | -| 7 | `GET /api/v1/distros/:slug/requirements` | min/recommended system requirements | -| 8 | `GET /api/v1/distros/:slug/related` | same family + shared tags heuristic | -| 9 | `GET /api/v1/distros/:slug/content/:doc` | `doc ∈ description\|install\|usage`; `?locale=`; returns compiled MDX + frontmatter + `translated: bool` | -| 10 | `GET /api/v1/distros/:slug/changelog` | release notes summaries, newest first | - -### Releases / versions - -| # | Endpoint | Notes | -|---|---|---| -| 11 | `GET /api/v1/releases/latest` | latest stable per distro (paginated) | -| 12 | `GET /api/v1/releases/recent` | cross-distro recent releases, cursor-paginated | -| 13 | `GET /api/v1/distros/:slug/releases/:version/artifacts` | artifacts: edition × arch × format, size, sha256 | - -### Downloads / mirrors - -| # | Endpoint | Notes | -|---|---|---| -| 14 | `GET /api/v1/distros/:slug/download-options` | full selection matrix for the selector (versions → editions → archs → formats → mirror availability) | -| 15 | `POST /api/v1/downloads/resolve` | body `{slug, version, edition, arch, format, mirror}` → `{url, size, sha256, sig_url, instructions}`; KV-cached; rate-limited | -| 16 | `GET /api/v1/mirrors` | all mirrors; filter `country, protocol` | -| 17 | `GET /api/v1/mirrors/nearest?country=` | geo-ranked list (country → region → global) | -| 18 | `GET /api/v1/artifacts/:id/checksums` | sha256 (+ others if published) + verify instructions | -| 19 | `GET /api/v1/artifacts/:id/torrent` | torrent file URL | -| 20 | `GET /api/v1/artifacts/:id/magnet` | magnet URI | -| 21 | `POST /api/v1/downloads/track` | count a download click; rate-limited; no body beyond `{artifact_id, mirror_id}` | - -### Search - -| # | Endpoint | Notes | -|---|---|---| -| 22 | `GET /api/v1/search?q=` | fuzzy over names/aliases/summaries across locales; facet filters as in #1 | -| 23 | `GET /api/v1/search/suggest?q=` | autocomplete (≤10); rate-limited | -| 24 | `GET /api/v1/search/filters` | available facets + counts (drives filter UI) | - -### Categories / tags / taxonomy - -| # | Endpoint | Notes | -|---|---|---| -| 25 | `GET /api/v1/categories` | all categories + counts | -| 26 | `GET /api/v1/categories/:slug` | category detail | -| 27 | `GET /api/v1/categories/:slug/distros` | paginated distros in category | -| 28 | `GET /api/v1/tags` | all tags + counts | -| 29 | `GET /api/v1/tags/:slug/distros` | paginated distros with tag | -| 30 | `GET /api/v1/families` | base families (Debian/Arch/RPM/…) → derivative counts | -| 31 | `GET /api/v1/desktops` | desktop environments → distros offering them | - -### Rankings / Hall of Fame - -| # | Endpoint | Notes | -|---|---|---| -| 32 | `GET /api/v1/rankings?period=week\|month\|year\|all` | popularity ranking snapshot | -| 33 | `GET /api/v1/rankings/trending` | short-window momentum | -| 34 | `GET /api/v1/rankings/rising` | biggest rank movers | -| 35 | `GET /api/v1/hall-of-fame` | curated entries + cited rationale | -| 36 | `GET /api/v1/rankings/by-category/:slug` | ranking within a category | -| 37 | `GET /api/v1/distros/:slug/rank-history` | sparkline series (period snapshots) | - -### Stats / meta - -| # | Endpoint | Notes | -|---|---|---| -| 38 | `GET /api/v1/stats/overview` | totals: distros, releases, mirrors, downloads tracked | -| 39 | `GET /api/v1/stats/distro/:slug` | per-distro downloads/views series | -| 40 | `GET /api/v1/stats/downloads` | aggregate download counts by period | -| 41 | ✅ `GET /api/v1/health` | liveness: `{ok, db, kv, version}` (no auth). The **BFF's** copy adds `mode`, `authorized` and `distros`: the Worker exempts `/v1/health` from the internal-token check, so an unauthorized deploy answers 200 here while every other route 404s — `authorized` is the only field that catches it | -| 42 | `GET /api/v1/meta` | API build/version, docs link | - -### i18n - -| # | Endpoint | Notes | -|---|---|---| -| 43 | `GET /api/v1/locales` | locale registry (BCP-47, label, rtl) | -| 44 | `GET /api/v1/geo/locale` | resolve preferred locale from request geo (KV-cached) | -| 45 | `GET /api/v1/i18n/:locale` | UI message bundle (only if served dynamically) | - -### Discovery / comparison - -| # | Endpoint | Notes | -|---|---|---| -| 46 | `GET /api/v1/compare?slugs=a,b,c` | side-by-side specs (max 4 slugs) | -| 47 | `GET /api/v1/recommend?based_on=slug` | similar-distro recommendations | -| 48 | `GET /api/v1/random` | one random active distro | -| 49 | `GET /api/v1/quiz` | "which distro?" question set | -| 50 | `POST /api/v1/quiz/result` | answers → scored recommendations | - -### Community (write — hCaptcha-gated, rate-limited) - -| # | Endpoint | Notes | -|---|---|---| -| 51 | `POST /api/v1/suggest-distro` | `{name, homepage, reason, captcha_token}` | -| 52 | `POST /api/v1/report` | broken mirror/link `{kind, target_id, detail, captcha_token}` | -| 53 | `POST /api/v1/feedback` | `{topic, message, captcha_token}` | -| 54 | `POST /api/v1/mirrors/report-status` | `{mirror_id, status, detail, captcha_token}` | - -### Feeds / integrations - -| # | Endpoint | Notes | -|---|---|---| -| 55 | ✅ `GET /api/v1/feeds/releases.rss` | recent releases, RSS 2.0. Served by the BFF from the same rows as `releases/recent`, so a feed cannot disagree with the page | -| 56 | ✅ `GET /api/v1/feeds/releases.atom` | recent releases, Atom | -| 57 | `GET /api/v1/badges/:slug` | shields-style SVG badge (latest version) | -| 58 | `GET /api/v1/opensearch.xml` | OpenSearch description | -| 59 | ✅ `GET /sitemap.xml` | served by the web app directly, alongside `GET /robots.txt`. Only locales with a message catalog are listed: a locale with none renders English behind a translated URL, and telling a crawler otherwise is the same class of claim as a hand-typed fact | -| 60 | `GET /api/v1/distros/:slug/badges/downloads` | SVG badge (download count) | -| 61 | `GET /api/v1/stats/languages` | content translation coverage per locale | - -## Key schemas (canonical shapes — Zod in `packages/shared`) - -```ts -Distro { slug, name, summary, family, based_on, homepage, - status: 'active'|'discontinued', logo: string, - categories: string[], tags: string[], desktops: string[], - latest_release: Release | null, downloads: number, - rank: number | null, trend: number | null } -Release { version, channel: 'stable'|'beta'|'rolling', lts: boolean, - codename, latest_point, released_at, eol_at, - eol: boolean, // derived from eol_at < today, never stored - notes_url, source_url, fetched_at } -Edition { id, name, desktop?: string, kind: 'desktop'|'server'|'minimal'|'other' } -Artifact { id, edition_id, arch: 'x86_64'|'aarch64'|'riscv64'|string, - format: 'iso'|'torrent'|'magnet'|'checksum'|'signature', - size: number, sha256?: string, sig_url?: string } -Mirror { id, country, region, base_url, protocol: 'https'|'http'|'ftp'|'rsync', - sponsor?, healthy: boolean } -DownloadResolution { url, size, sha256?, sig_url?, mirror: Mirror, instructions: string } -RankingEntry { rank, slug, name, score, delta } -RankHistoryPoint { snapshot_at, rank, score } // a series; the client draws it -``` - -Canonical Zod definitions live in `packages/shared/src/schemas.ts`, imported by -the Worker and the BFF so the two cannot disagree. - -Adding/changing an endpoint = update this doc + `packages/shared` schemas + -both route layers in the **same commit**. diff --git a/.ai/architecture.md b/.ai/architecture.md deleted file mode 100644 index 5ed8b17..0000000 --- a/.ai/architecture.md +++ /dev/null @@ -1,124 +0,0 @@ -# Architecture - -## System diagram - -``` - ┌────────────────────────────── Vercel ─────────────────────────────┐ - │ apps/web — SvelteKit (Svelte 5 runes, TS strict) │ - Browser ──────────▶ │ ┌─────────────┐ ┌─────────────────────────────┐ │ - │ │ │ UI routes │ │ /api/v1/* — BFF routes │ │ - │ HTML/JSON │ │ (SSR+CSR) │─────▶│ public API, proxy + cache │ │ - └─────────────── │ └─────────────┘ └──────────────┬──────────────┘ │ - └──────────────────────────────────────┼───────────────────────────┘ - │ fetch (service token header) - ┌────────────────────── Cloudflare ────▼───────────────────────────┐ - │ apps/api — Hono on Workers (workerd runtime) │ - │ ┌──────────────┐ ┌──────────┐ ┌───────────────────────────┐ │ - │ │ REST handlers│──▶│ D1 │ │ Cron Triggers → ingest │ │ - │ │ (Zod-valid.) │ │ (SQLite) │ │ (packages/ingest fetchers)│ │ - │ └──────┬───────┘ └──────────┘ └─────────────┬─────────────┘ │ - │ │ ┌──────────┐ │ │ - │ └───────▶│ KV │◀───────────────────┘ │ - │ │ caches │ │ - │ └──────────┘ │ - └──────────────────────────────────────────────────────────────────┘ - │ robots-aware fetch - Official distro APIs / release pages / mirror - manifests / Repology / GitHub Releases / Wikidata -``` - -## Runtime boundaries (critical) - -| Where | Runtime | What runs there | What NOT to use there | -|---|---|---|---| -| `apps/web` | Vercel (Node/Edge via SvelteKit adapter) | UI SSR, `/api` BFF, sitemap | D1/KV bindings (Worker-only) | -| `apps/api` | Cloudflare Workers (workerd) | Hono REST, ingestion cron, D1/KV | Bun/Node-only APIs (`fs`, `Bun.*`, `node:*` beyond compat list) | -| Local dev | Bun | package management, scripts, Vitest, builds; `wrangler dev` for the Worker | assuming Bun APIs exist in production | - -Backend code targets Web-standard APIs (`fetch`, `Request`, `Response`, -`crypto.subtle`) plus Workers bindings (`env.DB` for D1, `env.KV_*` for KV). - -## Layers and responsibilities - -### 1. `apps/web` — SvelteKit on Vercel -- Renders all UI routes with SSR + hydration; locale-prefixed routing (`/ko/...`). -- Hosts the **public API** under `/api/v1/*` as a BFF: - - Validates query/body with shared Zod schemas (`packages/shared`). - - Proxies to the Worker, attaching an internal service token header. - - Caches responses (`Cache-Control`, `ETag`, `stale-while-revalidate`) so the - Worker and D1 stay cold for hot paths. - - Serves web-native outputs: `sitemap.xml`, OpenSearch XML, RSS/Atom passthrough. -- Never talks to D1/KV directly — always through the Worker. - -### 2. `apps/api` — Hono on Cloudflare Workers -- Owns all data: D1 reads/writes, KV caching, download resolution, rankings - computation, hCaptcha verification, rate limiting. -- Exposes the internal REST surface mirrored by the BFF (same paths, `/v1/*`). -- Runs ingestion on **Cron Triggers** (see `.ai/data-sources.md` for cadence). - -### 3. `packages/*` -- `shared` — TS types, Zod schemas, constants (single source for both apps). -- `ui` — shared Svelte components + design tokens (consumed by `apps/web`). -- `i18n` — Paraglide message catalogs + locale registry (see `.ai/i18n.md`). -- `ingest` — robots-aware fetchers/normalizers; imported by the Worker cron. - -## Data flow — browse - -1. Browser requests `/{locale}/explore?category=desktop`. -2. SvelteKit `load` calls the BFF (`/api/v1/distros?...`) server-side. -3. BFF checks its HTTP cache → on miss, fetches Worker `/v1/distros?...`. -4. Worker checks KV (`cache:distros:`) → on miss, queries D1, - stores in KV with TTL, returns JSON envelope. -5. BFF sets `ETag`/`Cache-Control` and returns; page renders cards from - shared `packages/ui` components. - -## Data flow — download resolution - -1. User walks the selector: version → edition → arch → format → mirror - (options come from `GET /distros/:slug/download-options`, geo-ranked - mirrors from `GET /mirrors/nearest?country=`). -2. Client posts `{ slug, version, edition, arch, format, mirror }` to - `POST /api/v1/downloads/resolve`. -3. Worker checks KV `dl:` → on miss, joins `artifacts` × - `artifact_mirrors` × `mirrors` in D1, builds the direct URL, caches in KV. -4. Response: direct URL + size + sha256 + signature URL + verify instructions. -5. Client fires `POST /api/v1/downloads/track` (rate-limited, KV counter → - periodically flushed to D1 for rankings). - -## Data flow — ingestion - -1. Cron Trigger fires (per-source schedule). -2. `packages/ingest` fetcher checks robots.txt policy (cached), fetches the - official API/release page with descriptive UA + contact URL. -3. Normalizer maps the payload to D1 rows (`releases`, `artifacts`, - `mirrors`, `artifact_mirrors`), recording `source_url` + `fetched_at`. -4. Warm KV: latest-version cache, download-options cache invalidation. -5. Failures log + back off; never hammer a source. - -## Caching strategy (layered) - -| Layer | What | TTL | -|---|---|---| -| Vercel/BFF HTTP cache | list/detail/search GET responses | 60s–5min + SWR | -| KV | query results, resolved downloads, geo→locale, version fetches | per key, see `.ai/database.md` | -| D1 | source of truth | — | -| Browser | static assets immutable; API responses via ETag | — | - -Invalidation: ingestion bumps a per-distro `version` KV key that the Worker -includes in cache keys — no manual purging. - -## Error handling - -Single typed error envelope everywhere (defined in `.ai/api.md`). The BFF maps -Worker failures to the same envelope; unexpected errors become `500` with an -opaque `request_id` for correlation (logged, never leaked stack traces). - -## Environments - -| Env | Web | API | -|---|---|---| -| local | `bun dev` (SvelteKit) | `wrangler dev` (Miniflare, local D1/KV) | -| preview | Vercel preview deploys | Workers preview env (`wrangler deploy --env preview`) | -| production | Vercel prod | Workers prod + D1/KV prod bindings | - -Secrets: Vercel env vars (web) and Wrangler secrets (api). Never in the repo. diff --git a/.ai/backend-rules.md b/.ai/backend-rules.md deleted file mode 100644 index 6557d16..0000000 --- a/.ai/backend-rules.md +++ /dev/null @@ -1,204 +0,0 @@ -# Backend Rules — `apps/api` (Hono on Cloudflare Workers) - -## Runtime - -Deployed on the **Cloudflare Workers runtime (workerd)** via Wrangler. Code -uses Web-standard APIs + Workers bindings only: - -- `env.DB` — D1 database -- `env.KV_CACHE`, `env.KV_RATE`, `env.KV_GEO` — KV namespaces -- `env.HCAPTCHA_SECRET`, `env.INTERNAL_API_TOKEN` — Wrangler secrets - -No Bun/Node-only APIs in `apps/api`. Bun is used only to run scripts/tests -locally; local serving via `wrangler dev`. - -## Project structure - -``` -apps/api/src/ -├─ index.ts # Hono app: routes mounted, cron export -├─ routes/ # one file per resource (distros.ts, downloads.ts, …) -├─ middleware/ # auth (internal token), rate-limit, cache, error -├─ services/ # business logic (download-resolver, rankings, search) -├─ db/ # D1 query modules (typed, no inline SQL in routes) -├─ cron/ # ingestion entrypoints per schedule -└─ lib/ # envelope, errors, hcaptcha, geo, hashing -packages/ingest/ # fetchers + normalizers (imported by cron/) -``` - -Routes stay thin: validate → call service → wrap in envelope. SQL lives only -in `db/` modules. Shared types/schemas come from `packages/shared`. - -## Worked example — one endpoint, all four layers - -Build the first endpoint exactly like this, then copy the shape for the rest. -Sixty-one endpoints written to one pattern review far faster than sixty-one -variations. Helpers referenced here already exist in `packages/shared`: -`ok`, `err`, `ERROR_STATUS`, `paginationSchema`, `PAGINATION`. - -**1. Schema — `packages/shared`** (imported by the Worker *and* the BFF, so -one definition constrains both sides): - -```ts -export const distroListQuery = paginationSchema.extend({ - category: z.string().min(1).optional(), - sort: z.enum(["popularity", "name", "latest_release", "newest"]).default("popularity"), -}); -export type DistroListQuery = z.infer; -``` - -**2. Query module — `src/db/distros.ts`** (all SQL lives here; parameters are -always bound, never interpolated): - -```ts -export async function listDistros(db: D1Database, q: DistroListQuery) { - const offset = (q.page - 1) * q.limit; - const stmt = db - .prepare(`SELECT d.slug, d.name, d.summary, d.family, d.homepage, d.status - FROM distros d - WHERE d.status = 'active' - ORDER BY d.name - LIMIT ?1 OFFSET ?2`) - .bind(q.limit, offset); - const { results } = await stmt.all(); - return results; -} -``` - -**3. Service — `src/services/distros.ts`** (business logic + caching; routes -never call `db/` directly, so the cache is impossible to bypass): - -```ts -export function listDistrosCached(env: Env, q: DistroListQuery) { - return cached(env.KV_CACHE, `cache:distros:${gen}:${hashQuery(q)}`, 300, () => - listDistros(env.DB, q), - ); -} -``` - -**4. Route — `src/routes/distros.ts`** (validate → service → envelope; three -lines of logic, no SQL, no business rules): - -```ts -distros.get("/", async (c) => { - const parsed = distroListQuery.safeParse(c.req.query()); - if (!parsed.success) { - return c.json(err("VALIDATION_ERROR", "invalid query", parsed.error.issues), - ERROR_STATUS.VALIDATION_ERROR); - } - const rows = await listDistrosCached(c.env, parsed.data); - return c.json(ok(rows, { page: parsed.data.page, limit: parsed.data.limit, total: rows.length })); -}); -``` - -**5. Tests** — the three cases every endpoint owes (see Testing below): happy -path, validation failure, `NOT_FOUND`. - -Things this example pins down, which are easy to get wrong once and then -repeat sixty times: - -- `safeParse` + `VALIDATION_ERROR`, never a thrown Zod error reaching the client. -- `ERROR_STATUS[code]` for the HTTP status — never a hand-written number. -- `meta` on every list response, using the shared pagination defaults. -- Bound parameters (`?1`, `?2`) — string interpolation into SQL is a review - blocker. -- `total` in the sketch above is a placeholder. **Settled in 5.3: it is a real - `COUNT(*)`,** issued alongside the page query in a single `db.batch()`, so - pagination does not lie about how much there is. - -## Validation - -- **Zod on every input**: params, query, body — schemas imported from - `packages/shared` (the same schemas the BFF uses). -- Validation failure → `400` with `VALIDATION_ERROR` envelope listing issues. -- Never trust `?locale=` etc. — validate against the registry. - -## Error format - -Single envelope (canonical definition in `.ai/api.md`): - -```json -{ "ok": false, "error": { "code": "NOT_FOUND", "message": "…", "details": {}, "request_id": "…" } } -``` - -- Typed error codes: `VALIDATION_ERROR`, `NOT_FOUND`, `RATE_LIMITED`, - `CAPTCHA_FAILED`, `UPSTREAM_ERROR`, `INTERNAL`. -- Thrown `ApiError`s become the envelope; unknown errors → `INTERNAL` + logged - with `request_id`; stack traces never leak. -- **Register the converter with `app.onError`, not as try/catch middleware.** - Hono's composer hands a thrown error straight to the error handler, so - middleware wrapping `next()` never sees it and the client gets Hono's default - plain-text 500. `app.notFound` gets the same treatment, so unmatched routes - answer in the envelope too and the BFF has exactly one shape to parse. - -## Auth (internal) - -The Worker only accepts requests carrying `X-Internal-Token` matching -`INTERNAL_API_TOKEN` (BFF-to-Worker), except `/v1/health`. There is no public -user auth in v1. Comparison is constant-time. - -A bad or missing token answers **404**, not 403: the error taxonomy has no auth -code, and an internal surface should not confirm it exists to a caller that -cannot already reach it. The Worker logs the real reason (`internal token -missing` / `mismatch`), so a misconfiguration is diagnosable from logs rather -than looking like a missing distro. - -With no token configured — local dev and tests, where `wrangler.toml` holds -placeholder ids and no secrets exist — the check is skipped. It cannot be -skipped in production, because Phase 7 provisions the secret. - -## Caching - -- KV read-through helper: `cached(key, ttl, fetcher)` — all cacheable services - use it. Keyspace + TTLs defined in `.ai/database.md`. -- Cache keys include a per-distro generation counter bumped by ingestion, so - updates invalidate naturally. -- Responses also carry `Cache-Control` hints the BFF respects. - -## Rate limiting - -KV-based fixed-window counters (`rl:::`), enforced by -middleware on: all write endpoints, `downloads/resolve`, `downloads/track`, -`search/suggest`. Over limit → `429` + `Retry-After`. Limits per endpoint in -`.ai/security.md`. IPs are hashed with a salt secret — raw IPs are never stored. - -## hCaptcha - -Write endpoints (`suggest-distro`, `report`, `feedback`, -`mirrors/report-status`) require an hCaptcha token, verified server-side -against `HCAPTCHA_SECRET` **before** any processing. Failure → -`403 CAPTCHA_FAILED`. Never gate reads. - -## Ingestion (Cron Triggers) - -- Schedules (start point; tune per source in `.ai/data-sources.md`): - - hourly — release checks for rolling/high-frequency sources - - every 6h — standard release + artifact refresh - - daily — mirror lists, metadata, logo/license audit, **and the KV → D1 - download-counter flush** (`dlcount:*` → `download_events`), which must run - before the 48 h KV TTL or the day's clicks are gone - - weekly — rankings snapshot to `rankings` table -- Every fetch: robots-aware, descriptive UA + contact URL, timeout + retry - with backoff, result logged with `source_url` + `fetched_at`. -- Ingestion is idempotent (upserts keyed on natural keys); partial failure of - one source never blocks others. The counter flush is idempotent the same - way: it accumulates (`count = count + excluded.count`) and deletes each key - it banked, so a second run in the same day cannot double-count. - -## Logging - -Structured JSON logs: `{ ts, level, request_id, route, status, ms, msg }`. -Cron logs add `{ source, fetched, changed }`. No PII, no raw IPs, no secrets. - -## Testing - -Vitest against a **real D1 and real KV**, created by Miniflare in -`apps/api/test/harness.ts` (ADR-0021). The harness applies the files in -`migrations/` verbatim, so every test also exercises the schema — CHECK -constraints, UNIQUE keys and upsert conflict targets bite there exactly as they -will in production, which a hand-written stub would silently accept. - -Every endpoint has at least: happy path, validation failure, and NOT_FOUND -coverage. Ingestion tests stub the HTTP transport (a test suite has no business -calling an upstream) but keep the database real. Download resolution and rate -limiting get dedicated suites (quality gate). diff --git a/.ai/component-rules.md b/.ai/component-rules.md deleted file mode 100644 index b6bd098..0000000 --- a/.ai/component-rules.md +++ /dev/null @@ -1,65 +0,0 @@ -# Component Rules - -## Where components live - -- `packages/ui` — shared, reusable, design-system components (the inventory in - `.ai/design-system.md`). Anything used by 2+ routes or expressing a design - token belongs here. -- `apps/web/src/lib/components` — app-only composition components (route - shells, page sections) that assemble `packages/ui` parts. No visual - primitives here. -- Route files compose components; they do not define new visual patterns. - -## Naming - -- Svelte components: `PascalCase.svelte` (`DistroCard.svelte`). -- One component per file; file name = component name. -- Props/events: `camelCase`; events are verbs (`onselect`, `onresolve`). -- Variant props are unions, not booleans, when >2 variants: - `variant: 'primary' | 'secondary' | 'ghost' | 'danger'`. -- Boolean props are positive (`disabled`, not `notEnabled`). - -## Composition & reuse (anti-duplication) - -1. **Before writing any component, check the inventory** in - `.ai/design-system.md`. If a similar one exists: reuse or extend it. -2. If a needed component is missing, **add it to the inventory first** (same - change), then implement it in `packages/ui`. -3. Never fork a component to tweak styling — add a variant or token. -4. Styling comes only from design tokens (CSS custom properties from - `packages/ui/src/tokens.css`). No hardcoded colors, sizes, shadows, or durations. -5. Use Svelte 5 runes (`$props()`, `$state`, `$derived`) and snippets for - slot-like composition. No legacy `export let` / `$:` in new code. -6. Components are presentation-only: data comes in via props; they never fetch. - Data loading lives in route `load` functions (see `.ai/frontend-rules.md`). - -## Accessibility baseline (every component) - -- Semantic HTML first; ARIA only to fill real gaps. -- Fully keyboard-operable; focus visible (token focus ring); focus trapped in - modals and returned on close. -- Labels for every input; `aria-live="polite"` for async results (search, - download resolution); `role="status"` for toasts. -- Contrast per `.ai/design-system.md`; touch targets ≥44px. -- Use logical properties (`margin-inline-start`, not `margin-left`) so RTL - mirrors for free. Never encode direction in styles. -- Every animation checks `prefers-reduced-motion` (shared `motion` helper in - `packages/ui`). - -## States - -Every data-driven component ships loading / empty / error states (skeletons -per design system, `EmptyState`, `ErrorState`) — designed in Stitch, not -improvised. - -## Testing - -- `packages/ui` components: Vitest + Testing Library smoke tests (renders, - variants, keyboard interaction). -- axe checks run in Playwright e2e on composed pages (see quality gates). - -## Change control - -Visual changes to an approved component route through Stitch (§7 of -CLAUDE.md). Code may fix bugs and a11y issues without redesign, but any -layout/visual change updates the design source first. diff --git a/.ai/content.md b/.ai/content.md deleted file mode 100644 index c60044f..0000000 --- a/.ai/content.md +++ /dev/null @@ -1,70 +0,0 @@ -# Content — Distro MDX Docs - -## What exists per distro - -``` -content/distros/// -├─ description.md # what it is, who it's for, philosophy, editions -├─ install.md # how to get it installed (paraphrased, cited) -└─ usage.md # first steps, package management, updating, help channels -``` - -Format: **mdsvex** (`.md` with frontmatter; Svelte components allowed but use -sparingly — content must survive as plain markdown). `en` is always authored -first; other locales follow the progressive strategy in `.ai/i18n.md`. - -## Authoring rules (binding) - -1. **Never copy official documentation verbatim.** Read the official docs, - then write our own concise version in our own words. This is both a - copyright requirement and a product choice (consistent voice). -2. **Cite sources.** Every doc lists the official pages it was written from - in frontmatter `sources`; the rendered page links to official docs. -3. Keep it accurate and current — recheck on each release ingest; update - `last_reviewed` when verified. -4. Neutral, helpful tone. No marketing superlatives, no editorializing about - distro rivalries. State facts; let users decide. -5. Commands shown must be real and tested against the current stable release - where feasible; wrap in fenced code blocks with the right language tag. -6. Keep each doc scannable: intro paragraph → short sections with headings → - links out for deep detail. Target 300–800 words per doc. - -## Frontmatter schema (Zod-validated at build) - -```yaml ---- -title: "Fedora Workstation" # localized -summary: "A polished, fast-moving desktop distro sponsored by Red Hat." -distro: fedora # slug, must match directory -doc: description # description | install | usage -locale: en # BCP-47, must match directory -official_links: - homepage: https://fedoraproject.org - docs: https://docs.fedoraproject.org -sources: # pages this content was paraphrased from - - https://docs.fedoraproject.org/en-US/fedora/latest/ -last_reviewed: 2026-07-18 # ISO date; bump when re-verified -translated: true # false for machine-draft placeholders ---- -``` - -The build registers every doc into D1 `content_index` (slug, doc, locale, -source_urls, reviewed_at) so the API can report coverage and serve fallbacks. - -## Review checklist (before a doc merges) - -- [ ] Paraphrased — no sentence lifted from official docs; spot-check phrases. -- [ ] `sources` present and are official (project-owned) URLs. -- [ ] Commands/paths verified against the current stable release. -- [ ] Frontmatter validates; slug/doc/locale match the path. -- [ ] Links work; external links open official sites. -- [ ] Tone neutral; no copied trademarks misuse; logos referenced per - `.ai/data-sources.md` licensing. -- [ ] `last_reviewed` set to the verification date. - -## Rendering - -Served via `GET /api/v1/distros/:slug/content/:doc?locale=` (compiled + -frontmatter + `translated` flag) and rendered in the distro detail -`ContentTabs`. Missing locale → English + "untranslated" badge. Headings get -anchor links; code blocks get the `CopyButton`. diff --git a/.ai/data-sources.md b/.ai/data-sources.md deleted file mode 100644 index a18d4b4..0000000 --- a/.ai/data-sources.md +++ /dev/null @@ -1,227 +0,0 @@ -# Data Sources & Ingestion Registry - -> Every source used by `packages/ingest` MUST have a row here **before** its -> fetcher is written, with robots/ToS verified at that time (not assumed). -> Columns marked *verify* are filled during Phase 5 implementation. - -## Source registry - -| Source | What we take | Access | robots/ToS status *(verify)* | Cadence | -|---|---|---|---|---| -| **endoflife.date API** (`/api/.json`) | release cycles, release dates, EOL dates, LTS flags | public JSON API, explicitly for programmatic use | **verified 2026-07-19** — public API, MIT-licensed site, no auth | 6 h — **Worker cron** | -| Official distro release pages / JSON endpoints (per distro) | versions, dates, artifacts, checksums | official API/page | per-distro row below | 6 h | -| Repology API | cross-distro version tracking | public API | pending verification | daily | -| GitHub Releases API | releases for GitHub-hosted distros | official API (token, rate-limited) | pending verification | 6 h | -| GitLab Releases API | releases for GitLab-hosted distros | official API | pending verification | 6 h | -| **Fedora MirrorManager** (`mirrors.fedoraproject.org/mirrorlist`) | Fedora mirror list | official API | **verified 2026-07-19** — official mirror-list endpoint. ⚠️ Returns **per-repo directories** (`…/releases/44/Everything/x86_64/os/`), not mirror roots: these rows are stored with `serves_artifacts = 0` and are never a download base (ADR-0024) | daily — **Worker cron** | -| **Fedora download redirector** (`download.fedoraproject.org`) | the base Fedora artifact paths are relative to; 302s to a nearby mirror | official redirector | **verified 2026-08-05** — a `HEAD` of a resolved ISO path follows to a mirror and answers 200 with the published size | stored by the artifacts cron | -| openSUSE download redirector (mirrorbrain) | openSUSE mirrors/artifacts | official endpoint | pending verification | daily | -| Ubuntu/Debian cdimage + mirror lists | ISO paths, mirror lists | official pages/manifests | pending verification | daily | -| **Arch mirror status** (`archlinux.org/mirrors/status/json/`) | Arch mirrors + health scores | official JSON | **verified 2026-07-19** — official status JSON endpoint | daily — **Worker cron** | -| **Arch release snapshots** (`archlinux.org/releng/releases/json/`) | monthly ISO snapshots: version, date, sha256, iso path, torrent, magnet, PGP fingerprint | official JSON | **verified 2026-08-05** — 200, no `robots.txt` rule covers the path | 6 h — **Worker cron** | -| **Fedora releases index** (`fedoraproject.org/releases.json`) | the full artifact matrix: variant, arch, absolute link, sha256, **size** | official JSON | **verified 2026-08-05** — 200, no `robots.txt` rule covers the path | 6 h — **Worker cron** | -| **Ubuntu checksums** (`releases.ubuntu.com//SHA256SUMS`) | sha256 per ISO; edition, arch and point release are encoded in the filename | official plain text | **verified 2026-08-05 — allowed** (`robots.txt` disallows only `.pool`). **Wired 2026-08-09.** Publishes no sizes, so each ISO costs one paced HEAD. `releases.ubuntu.com` is itself the download base (`serves_artifacts = 1`) | 6 h — **Worker cron** | -| **Debian checksums** (`cdimage.debian.org/debian-cd///iso-cd/SHA256SUMS`) | sha256 per ISO; flavour, arch and point release encoded in the filename | official plain text | **verified 2026-08-09 — checksums allowed, ISOs NOT.** See the note below: `Disallow: /*.iso$` applies to us, so sizes stay `null` — we must not HEAD them. Our UA is `linuxhub-ingest/…`, which does not match the AI-crawler group that gets `Disallow: /` | 6 h — **Worker cron** | -| **Linux Mint checksums** (`mirrors.edge.kernel.org/linuxmint/stable//sha256sum.txt`) | sha256 per ISO; the **desktop environment** and arch are in the filename | official plain text, from the Linux Foundation's Mint mirror | **verified 2026-08-09** — 200, and `mirrors.edge.kernel.org` serves no `robots.txt` (404), so nothing restricts it. This is a *mirror* rather than an origin: Mint distributes through mirrors and publishes the same signed `sha256sum.txt` on each | 6 h — **Worker cron** | -| **Pop!_OS build API** (`api.pop-os.org/builds//`) | current build per version and channel: absolute ISO url, **size**, sha256, build number | official JSON API (the one the OS's own updater calls) | **verified 2026-08-09** — 200, and `api.pop-os.org` serves no `robots.txt` (404), so no rule restricts it | 6 h — **Worker cron** | -| **Wikidata entity data** (`www.wikidata.org/wiki/Special:EntityData/.json`) | lineage (P144 *based on*), inception (P571), type (P31/P279) | official entity endpoint, CC0 | **verified 2026-08-04 — ALLOWED.** `robots.txt` disallows `/wiki/Special:` but carves this back out with `Allow: /wiki/Special:EntityData/*.`, which matches only the format-suffixed form. The extensionless `/wiki/Special:EntityData/Q381` stays disallowed | weekly (not yet wired — see below) | -| ~~Wikidata SPARQL~~ (`query.wikidata.org/sparql`) | — | — | **verified 2026-08-04 — DISALLOWED.** `query.wikidata.org/robots.txt` is four lines: `Disallow: /sparql`. **Not used**, per the binding rule that a source whose terms forbid our use is not used | — | -| ~~Wikidata / Wikipedia search APIs~~ (`/w/api.php`, `en.wikipedia.org/api/rest_v1/`) | — | — | **verified 2026-08-04 — DISALLOWED** by `Disallow: /w/` and `Disallow: /api/` respectively. **Not used** | — | -| Wikimedia Commons | distro logo SVGs (license-permitting) | official API (`Special:FilePath`) | **in use** — per-file license rows in `assets/distros/ATTRIBUTION.md` | on demand | - -### Ingestion pipeline - -Two callers, one set of fetchers. `packages/ingest/src/sources/*` holds the -normalizers; only the transport differs, so a fix to how a payload is read -lands in both places at once. - -| Caller | Transport | Writes | Purpose | -|---|---|---|---| -| **Worker cron** (`apps/api/src/cron`) | native `fetch`, KV-backed conditional requests | D1 + KV | production ingestion (task 5.2) | -| `bun packages/ingest/src/live.ts` | curl (this container's proxy breaks Bun's fetch) | `apps/web/src/lib/server/live-data.json`, committed | the Phase 4 BFF snapshot; an offline fallback and a way to eyeball a source | - -Cron schedules are declared in `apps/api/wrangler.toml` and dispatched by cron -expression in `src/cron/index.ts`. Every pass is idempotent (upserts on natural -keys) and isolates per-source failures, so one source being down never costs -another its refresh. Each source writes an `ingest_log` row per run, and every -ingested row carries its own `source_url` + `fetched_at` (ADR-0019). - -**Which distro identity comes from where.** `distros` rows are seeded from -`packages/ingest/src/content-index.json`, generated from the authored MDX -frontmatter by `bun packages/ingest/src/content-index.ts`. Name, summary and -homepage are therefore the values a human reviewed and cited in `sources`, with -a `last_reviewed` date — nothing about a distro is asserted without a -resolvable citation. `packages/ingest/src/registry.ts` holds only *pointers* -(which upstream serves which distro), never facts. Regenerate the index in the -same commit as any frontmatter change. - -**Debian's robots.txt forbids fetching the ISOs themselves.** Under -`User-agent: *` it carries `Disallow: /*.iso$`, alongside a long -`User-agent: ClaudeBot`/`GPTBot`/… group that gets a blanket `Disallow: /`. -Our crawler identifies as `linuxhub-ingest/ (+/about#crawler)` -and matches neither of those names, so the `*` rules are the ones that bind us -— and they permit `SHA256SUMS` while forbidding the `.iso` files. - -The consequence is concrete: **Debian artifacts carry no `size`**, because -learning one would mean a `HEAD` against a path we are told not to request. -Linking a human to that ISO is not crawling it, so the download itself is -unaffected. Ubuntu's robots.txt has no such rule, which is why Ubuntu artifacts -do have sizes. - -**Ranking signals:** our own only (page views, download clicks tracked via -`downloads/track`, release recency). Any external ranking source is -supplementary, used only if its terms allow, and must be added here first. - -## Per-distro rows - -Added as each distro is onboarded (Phase 5+), one row per distro: - -| Distro (slug) | Release source | Mirror source | robots/ToS | Logo source + license | Notes | -|---|---|---|---|---|---| -| ubuntu | endoflife.date `ubuntu` + **releases.ubuntu.com/``/SHA256SUMS** for artifacts | releases.ubuntu.com is itself the download base | verified | Commons — see ATTRIBUTION.md | LTS flags from API; edition/arch/point release parsed from the ISO filename, sizes by HEAD | -| fedora | endoflife.date `fedora` + **fedoraproject.org/releases.json** for artifacts | mirrors.fedoraproject.org | verified | Commons — see ATTRIBUTION.md | releases.json carries variant, arch, sha256 and size | -| linux-mint | endoflife.date `linuxmint` + **mirrors.edge.kernel.org `stable//sha256sum.txt`** for artifacts | that mirror is itself the download base | verified — no robots.txt on the host | Commons — see ATTRIBUTION.md | the one source whose editions really are desktop environments, so `editions.desktop` is set here; sizes by HEAD | -| arch | **archlinux.org releng snapshots** (verified 2026-08-05) | archlinux.org mirror status | verified | Commons — see ATTRIBUTION.md | rolling; monthly ISO snapshots carry sha256, torrent and magnet | -| debian | endoflife.date `debian` + **cdimage.debian.org `current//iso-cd/SHA256SUMS`** for artifacts | cdimage.debian.org is itself the download base | verified — **ISOs are robots-disallowed**, so artifacts carry no size | Commons — see ATTRIBUTION.md | current stable only (cdimage archives older point releases); paths stored against the point-release directory, never the moving `current` symlink | -| opensuse | endoflife.date `opensuse` | — (Phase 5) | verified | Commons — see ATTRIBUTION.md | | -| manjaro | rolling (no cycles) | — (Phase 5) | verified | Commons — see ATTRIBUTION.md | rolling; registry kind `rolling` | -| pop-os | endoflife.date `pop-os` + **api.pop-os.org/builds/``/``** for artifacts | iso.pop-os.org is itself the download base | verified — no robots.txt on the API host | Commons — see ATTRIBUTION.md | channels `intel`/`nvidia` are graphics-driver builds, not desktops; API supplies url, size and sha256 directly | -| nixos | endoflife.date `nixos` | — (Phase 5) | verified | Commons — see ATTRIBUTION.md | | -| zorin | none found | — (Phase 5) | n/a | Commons — see ATTRIBUTION.md | registry kind `unsourced` — fixed-cadence but no machine-readable source, so no release rows | -| elementary | none found | — (Phase 5) | n/a | Commons — see ATTRIBUTION.md | registry kind `unsourced` — fixed-cadence but no machine-readable source, so no release rows | -| endeavouros | rolling (no cycles) | — (Phase 5) | verified | Commons — see ATTRIBUTION.md | rolling; registry kind `rolling` | - -## Replacing the placeholder data in `data.ts` (binding, Phase 5) - -`apps/web/src/lib/server/data.ts` still holds invented values from the -frontend build-out. **Every factual field below must be replaced by data -fetched from an official API, feed, or structured source and stored with its -`source_url` + `fetched_at`.** Nothing factual ships hand-typed. - -Three categories, and the distinction matters: - -| Category | Rule | -|---|---| -| **Facts** (versions, dates, editions, desktops, package managers, requirements) | Fetch from official APIs / feeds / Wikidata. Never hand-maintain. | -| **Popularity** (downloads, rank, trend) | **Our own signals only** — view counters and download clicks in KV, aggregated into D1. Scraping DistroWatch or any third-party popularity chart is forbidden (see "Ranking signals" above). | -| **Editorial** (quiz weights, Hall of Fame entries, banner copy, category assignment) | Stays authored by us, but every claim carries a resolvable source URL and a `last_reviewed` date, like the MDX docs do. | - -Per-symbol plan: - -| Symbol in `data.ts` | Field(s) | Replace with | -|---|---|---| -| `DISTROS` | `downloads`, `rank`, `trend` | our own telemetry → `rankings` table (`.ai/database.md`) | -| `DISTROS` | `based_on`, `family`, `familyLine` | Wikidata **entity data** (P144 *based on*) — blocked on QIDs, see below. `categories` stay editorial in the taxonomy tables | -| `SPECS` | `latest`, `releaseModel` | endoflife.date — **already ingested**, just derive it instead of hand-typing | -| `SPECS` | `desktop`, `pkg`, `minMem` | Wikidata entity data where the claims exist, official docs otherwise. **Not SPARQL** — that endpoint is robots-disallowed | -| `EDITIONS` | all | official release APIs — Bodhi (Fedora), Launchpad (Ubuntu series), cdimage/mirror manifests (Debian), per-distro download endpoints | -| `REQUIREMENTS` | all | official install docs per distro, paraphrased and cited (currently one shared table for every distro — that is wrong and visible to users) | -| `RECENT_RELEASES` | all | already live from endoflife.date; **join the announcement feeds below** so each entry links to the real release note | -| `HOMEPAGES` | all | keep, but verify with a scheduled liveness check | -| `BANNERS` | `slug` rotation | derive from live release recency; the tagline stays editorial | -| `HALL_OF_FAME`, `QUIZ` | all | stay editorial; move out of `data.ts` into content or D1 so the file can be deleted | - -`data.ts` is deleted once every symbol above has a home. Until then it is the -list of work remaining, not a data store to extend. - -## Wikidata lineage — blocked, and precisely on what - -Investigated 2026-08-04 while attempting task 5.3b. Recording it here so nobody -spends the same hour twice. - -**Every programmatic way to look up a QID is robots-disallowed** — SPARQL -(`/sparql`), the Wikidata action API (`/w/`), and Wikipedia's REST API -(`/api/`). The one permitted endpoint, -`Special:EntityData/.json`, requires the QID as input. So the lookup step -cannot be automated inside our own crawler policy; only the fetch step can. - -The data itself is good once you have the QID — verified against the live -endpoint: - -``` -Q381 Ubuntu → P144 (based on) = Q7715973, P571 (inception) = 2004-10-20 -Q7715973 Debian → P144 = Q3251801 -Q48267 Fedora Linux → P144 = Q220182 -``` - -**What is needed to unblock it: nine QIDs, looked up by a human in a browser** -(wikidata.org, search the distro, copy the Q-number) and pasted into -`packages/ingest/src/registry.ts` as pointers. That is legitimate — a QID is an -identifier, the same class of value as the endoflife.date product slug already -in that file, not a fact we are asserting. - -Confirmed so far: `ubuntu` **Q381**, `debian` **Q7715973**, `fedora` **Q48267**. -Still needed: `arch`, `linux-mint`, `opensuse`, `manjaro`, `pop-os`, `nixos`, -`zorin`, `elementary`, `endeavouros`. - -Do not guess them. Nine of twelve guessed QIDs resolved to entirely unrelated -entities — a bridge in Paris, an Indian political party, a Swedish baptismal -font — and each would have silently written wrong lineage into the catalog. Any -QID added here must be verified by fetching it and checking the label matches. - -**This does not block task 5.4.** The shipped Explore page filters by -`category` only; there is no family facet. `family`/`based_on` feed the -`familyLine` display string in the command palette, the rankings rows and the -compare table — which degrade to the family being unknown, not to a broken page. - -One design question to settle when it is unblocked: `family` today uses a -*packaging* vocabulary (`debian|rpm|arch|suse|independent`) that was invented -during the frontend build-out. P144 gives a *derivation* chain instead, whose -root would make Fedora's family `fedora` rather than `rpm`. Deriving family -from the sourced chain is honest but loses the RPM grouping; keeping the -packaging vocabulary needs its own sourced signal or an explicitly editorial, -cited taxonomy. Decide it with an ADR, not in code. - -## Announcement feeds (RSS/Atom) - -Verified reachable 2026-07-20 with the ingest User-Agent; no host disallows -these paths in `robots.txt`. Use them for release-announcement links, the -"recently updated" feed, and a future news surface — **not** as a version -source (endoflife.date and the official release APIs are authoritative there). - -| Distro | Feed | Status | -|---|---|---| -| arch | `https://archlinux.org/feeds/news/` | ✅ 200 | -| fedora | `https://fedoramagazine.org/feed/` | ✅ 200 | -| ubuntu | `https://ubuntu.com/blog/feed` | ✅ 200 | -| debian | `https://www.debian.org/News/news.rdf` | ✅ 200 | -| linux-mint | `https://blog.linuxmint.com/?feed=rss2` | ✅ 200 | -| opensuse | `https://news.opensuse.org/feed.xml` | ✅ 200 | -| manjaro | `https://forum.manjaro.org/c/announcements/stable-updates.rss` | ✅ 200 | -| nixos | `https://nixos.org/blog/announcements-rss.xml` | ✅ 200 | -| zorin | `https://blog.zorin.com/feed/` | ✅ 200 | -| elementary | `https://blog.elementary.io/feed.xml` | ✅ 200 | -| endeavouros | `https://endeavouros.com/feed/` | ✅ 200 | -| pop-os | `https://system76.com/blog/rss.xml` | ✅ 200 (release-specific feed still to be found; `github.com/pop-os/iso/releases.atom` was 403 through this container's proxy — retry from the Worker) | - -Additional verified structured sources: `query.wikidata.org/sparql` -(CC0, SPARQL), `api.launchpad.net/1.0/ubuntu/series` (Ubuntu series/EOL), -`bodhi.fedoraproject.org/releases/` (Fedora releases + state). -Add a registry row above before writing a fetcher for any of them. - -## Crawler policy (summary — full rules in `.ai/security.md`) - -- Official APIs preferred; HTML scraping only within `robots.txt`. -- UA: `linuxhub-ingest/ (+https:///about#crawler)`. -- ≤1 req/sec/host, conditional requests, exponential backoff, KV caching. -- Every fetch logged to `ingest_log` with `source_url` + `fetched_at`. -- A source whose terms forbid our use is **not used** — link out instead. - -## Logos & trademarks - -- Real official brand SVGs only, from each project's press/brand kit or - Wikimedia Commons, stored at `assets/distros/.svg`. -- `assets/distros/ATTRIBUTION.md` lists, per logo: source URL, license, - trademark notes, retrieval date. Maintained in the same commit as the asset. -- Respect trademark/brand guidelines (no recoloring, no distortion). If a - project forbids logo redistribution, do not bundle it — render a neutral - placeholder and link out. - -## Refresh cadence (Cron Triggers — see `.ai/backend-rules.md`) - -| Schedule | Work | -|---|---| -| hourly | rolling/high-frequency release checks | -| every 6 h | standard release + artifact refresh | -| daily | mirror lists + health, download-counter flush to D1, metadata | -| weekly | rankings snapshot, Wikidata metadata, logo/license audit | diff --git a/.ai/database.md b/.ai/database.md deleted file mode 100644 index 1f5b882..0000000 --- a/.ai/database.md +++ /dev/null @@ -1,245 +0,0 @@ -# Database — Cloudflare D1 (SQLite) + KV - -## Principles - -- D1 is the **source of truth**; KV is cache + counters only (rebuildable). -- Migrations via Wrangler (`apps/api/migrations/NNNN_name.sql`), sequential, - forward-only; every schema change = new migration + this doc updated in the - same commit. -- Natural keys where stable (`distros.slug`); integer PKs elsewhere. -- All timestamps are ISO-8601 UTC strings (`TEXT`). -- **Provenance is per value, not per run.** Every table fed by ingestion carries - `source_url` + `fetched_at`, so any single row can answer "where did this come - from and when". `ingest_log` records the *run*; it cannot answer that question - for a row. This is what makes the binding sourcing rule in - `.ai/data-sources.md` enforceable rather than aspirational. -- **Derived states are never stored.** `eol` is computed from `eol_at < today` - at read time, so no row silently goes stale as dates pass. See ADR-0019. -- Closed enums carry `CHECK` constraints, so an ingest bug fails at the write - instead of surfacing as an impossible value on a page. - -## D1 schema - -Below is the schema as shipped. It is the authority; the migration files in -`apps/api/migrations/` implement it verbatim. - -```sql --- 0001_init.sql -CREATE TABLE distros ( - id INTEGER PRIMARY KEY, - slug TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - summary TEXT NOT NULL DEFAULT '', - family TEXT NOT NULL DEFAULT '', -- debian|arch|rpm|suse|…; '' until Wikidata fills it - based_on TEXT, -- parent slug, NULL for roots - homepage TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active' - CHECK (status IN ('active','discontinued')), - logo_path TEXT NOT NULL, -- assets/distros/.svg - aliases TEXT NOT NULL DEFAULT '[]', -- JSON array, for search - source_url TEXT, - fetched_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE releases ( - id INTEGER PRIMARY KEY, - distro_id INTEGER NOT NULL REFERENCES distros(id), - version TEXT NOT NULL, - channel TEXT NOT NULL DEFAULT 'stable' - CHECK (channel IN ('stable','beta','rolling')), - lts INTEGER NOT NULL DEFAULT 0 CHECK (lts IN (0,1)), - codename TEXT, - latest_point TEXT, -- newest point release, e.g. 24.04.3 - released_at TEXT, - eol_at TEXT, - notes_url TEXT, - source_url TEXT, - fetched_at TEXT, - UNIQUE (distro_id, version) -); - -CREATE TABLE editions ( - id INTEGER PRIMARY KEY, - release_id INTEGER NOT NULL REFERENCES releases(id), - name TEXT NOT NULL, -- GNOME|KDE|Xfce|minimal|server|… - desktop TEXT, -- desktop env slug, NULL for server/minimal - kind TEXT NOT NULL DEFAULT 'desktop' - CHECK (kind IN ('desktop','server','minimal','other')), - UNIQUE (release_id, name) -); - -CREATE TABLE artifacts ( - id INTEGER PRIMARY KEY, - edition_id INTEGER NOT NULL REFERENCES editions(id), - arch TEXT NOT NULL, -- x86_64|aarch64|riscv64|… - format TEXT NOT NULL - CHECK (format IN ('iso','torrent','magnet','checksum','signature')), - path TEXT NOT NULL, -- mirror-relative path - size INTEGER, - sha256 TEXT, - sig_url TEXT, - source_url TEXT, - fetched_at TEXT, - UNIQUE (edition_id, arch, format) -); - -CREATE TABLE mirrors ( - id INTEGER PRIMARY KEY, - distro_id INTEGER REFERENCES distros(id), -- mirror networks are per distro - name TEXT NOT NULL DEFAULT '', -- hostname, for display - country TEXT NOT NULL DEFAULT '', -- ISO 3166-1 alpha-2, '' when unpublished - region TEXT NOT NULL DEFAULT '', -- continent/region code - base_url TEXT NOT NULL, - protocol TEXT NOT NULL DEFAULT 'https' - CHECK (protocol IN ('https','http','ftp','rsync')), - sponsor TEXT, - healthy INTEGER NOT NULL DEFAULT 1 CHECK (healthy IN (0,1)), - -- Is base_url a root that `artifacts.path` extends? Not every mirror row is: - -- see the note below. Defaults to 0, so a mirror is not an artifact base - -- until a source says it is (migration 0006). - serves_artifacts INTEGER NOT NULL DEFAULT 0 CHECK (serves_artifacts IN (0,1)), - last_checked TEXT, - source_url TEXT, - fetched_at TEXT, - UNIQUE (distro_id, base_url) -); - -CREATE TABLE artifact_mirrors ( -- availability of artifact on mirror - artifact_id INTEGER NOT NULL REFERENCES artifacts(id), - mirror_id INTEGER NOT NULL REFERENCES mirrors(id), - path_override TEXT, - available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), - PRIMARY KEY (artifact_id, mirror_id) -); - --- 0002_taxonomy.sql -CREATE TABLE categories ( id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '' ); -CREATE TABLE tags ( id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE, - name TEXT NOT NULL ); -CREATE TABLE distro_taxonomy ( - distro_id INTEGER NOT NULL REFERENCES distros(id), - kind TEXT NOT NULL CHECK (kind IN ('category','tag','desktop')), - ref_slug TEXT NOT NULL, - PRIMARY KEY (distro_id, kind, ref_slug) -); - --- 0003_rankings_stats.sql --- Fed by OUR OWN signals only (.ai/data-sources.md § "Ranking signals"), so --- these tables stay empty until the site has traffic. That is correct, not a --- gap to fill by hand. -CREATE TABLE rankings ( -- weekly snapshots - id INTEGER PRIMARY KEY, - distro_id INTEGER NOT NULL REFERENCES distros(id), - period TEXT NOT NULL CHECK (period IN ('week','month','year','all')), - snapshot_at TEXT NOT NULL, - rank INTEGER NOT NULL, - score REAL NOT NULL, - UNIQUE (distro_id, period, snapshot_at) -); - -CREATE TABLE download_events ( -- flushed KV counters, aggregated - id INTEGER PRIMARY KEY, - artifact_id INTEGER NOT NULL REFERENCES artifacts(id), - mirror_id INTEGER NOT NULL DEFAULT 0, -- 0 = unattributed; see note below - day TEXT NOT NULL, -- YYYY-MM-DD - count INTEGER NOT NULL DEFAULT 0, - UNIQUE (artifact_id, mirror_id, day) -); - -CREATE TABLE hall_of_fame ( - id INTEGER PRIMARY KEY, - distro_id INTEGER NOT NULL REFERENCES distros(id) UNIQUE, - rationale TEXT NOT NULL, -- editorial, with citations - sources TEXT NOT NULL DEFAULT '[]', - ordering INTEGER NOT NULL DEFAULT 0 -); - --- 0004_content_community.sql -CREATE TABLE content_index ( -- registry of MDX docs in /content - id INTEGER PRIMARY KEY, - distro_id INTEGER NOT NULL REFERENCES distros(id), - doc TEXT NOT NULL CHECK (doc IN ('description','install','usage')), - locale TEXT NOT NULL, -- BCP-47 - source_urls TEXT NOT NULL DEFAULT '[]', - reviewed_at TEXT, - UNIQUE (distro_id, doc, locale) -); - -CREATE TABLE submissions ( -- anonymous community writes - id INTEGER PRIMARY KEY, - kind TEXT NOT NULL - CHECK (kind IN ('suggest','report','feedback','mirror_status')), - payload TEXT NOT NULL, -- JSON - created_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'new' - CHECK (status IN ('new','reviewed','done','spam')) -); - -CREATE TABLE ingest_log ( - id INTEGER PRIMARY KEY, - source TEXT NOT NULL, - url TEXT NOT NULL, - fetched_at TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('ok','error','skipped')), - changed INTEGER NOT NULL DEFAULT 0, - detail TEXT -); -``` - -`download_events.mirror_id` is `NOT NULL DEFAULT 0` rather than a nullable FK: -SQLite treats every `NULL` as distinct in a `UNIQUE` constraint, so a nullable -column would let the daily counter flush insert duplicate rows instead of -accumulating into one. - -`mirrors.serves_artifacts` exists because "a mirror of this distro" and "a base -URL our artifact paths are relative to" are two different facts, and treating -them as one produced download URLs that 404. Arch's mirror status JSON -publishes mirror **roots** (`https://host/archlinux/`), which a path like -`iso/2026.08.01/…iso` extends. Fedora's MirrorManager publishes per-repo -**directories** (`https://host/fedora/linux/releases/44/Everything/x86_64/os/`), -which nothing extends — Fedora artifact paths are relative to the redirector, -`https://download.fedoraproject.org/`, which is stored as a mirror row of its -own. Only rows with `serves_artifacts = 1` are joined to artifacts or offered -in the picker (ADR-0024). - -Retiring a mirror is likewise scoped to `source_url`: a distro can have mirrors -from more than one source, and "absent from the mirrorlist" says nothing about -a row the mirrorlist never wrote. - -### Indexes (0005) - -```sql -CREATE INDEX idx_releases_distro ON releases(distro_id, released_at DESC); -CREATE INDEX idx_editions_release ON editions(release_id); -CREATE INDEX idx_artifacts_edition ON artifacts(edition_id); -CREATE INDEX idx_mirrors_distro ON mirrors(distro_id, healthy); -CREATE INDEX idx_mirrors_country ON mirrors(country, healthy); -CREATE INDEX idx_taxonomy_ref ON distro_taxonomy(kind, ref_slug); -CREATE INDEX idx_rankings_period ON rankings(period, snapshot_at DESC, rank); -CREATE INDEX idx_dl_events_day ON download_events(day); -CREATE INDEX idx_distros_family ON distros(family, status); -``` - -Search: start with `LIKE` over `name || aliases || summary`; if too slow, -adopt D1 FTS5 virtual table (`distros_fts`) — record as ADR when done. - -## KV keyspaces - -| Namespace | Key pattern | Value | TTL | -|---|---|---|---| -| `KV_CACHE` | `cache:distros::` | list JSON | 5 min | -| `KV_CACHE` | `cache:distro::` | detail JSON | 1 h | -| `KV_CACHE` | `dl:` | DownloadResolution | 24 h | -| `KV_CACHE` | `versions::` | upstream fetch cache | 1–6 h per source | -| `KV_CACHE` | `gen:` | generation counter (bumped by ingest) | none | -| `KV_CACHE` | `rankings:` | ranking snapshot JSON | 10 min | -| `KV_GEO` | `geo:` | locale code | 30 d | -| `KV_RATE` | `rl:::` | counter | window length (60 s) | -| `KV_RATE` | `dlcount:::` | click counter | 48 h (flushed daily to D1) | - -Rule: anything in KV must be rebuildable from D1 or upstream; losing KV must -never lose data (download counters are flushed to `download_events` daily by -cron before expiry). diff --git a/.ai/decisions.md b/.ai/decisions.md deleted file mode 100644 index 59f7cdb..0000000 --- a/.ai/decisions.md +++ /dev/null @@ -1,524 +0,0 @@ -# Decisions (ADR Log) - -> Append-only. Every meaningful decision gets an entry: context → decision → -> consequences. Newest at the bottom. Reversing a decision = new ADR -> referencing the old one, never editing history. - ---- - -## ADR-0001 — Monorepo on Bun workspaces -**Date:** 2026-07-18 · **Status:** accepted -**Context:** Single repo for web, api, shared packages, content, prompts. -**Decision:** Bun workspaces as package manager / script runner / test -toolchain; optional turbo/bunfig task orchestration added later if needed. -**Consequences:** Fast installs/tests; one lockfile; deployed runtimes still -differ from Bun (see ADR-0002). - -## ADR-0002 — Deployed runtimes: Vercel (web) + Cloudflare Workers (api) -**Date:** 2026-07-18 · **Status:** accepted -**Context:** Bun is the dev toolchain, but production runtimes are fixed by -the deployment targets. -**Decision:** `apps/web` runs on Vercel via `@sveltejs/adapter-vercel`; -`apps/api` runs on the Workers runtime (workerd) via Wrangler. Backend code is -written against Web-standard APIs + D1/KV bindings only — no Bun/Node-only -APIs in `apps/api`. -**Consequences:** Local dev uses `wrangler dev` for parity; CI must test -against a Workers-compatible environment (Miniflare pool for Vitest). - -## ADR-0003 — SvelteKit `/api` routes as the public BFF -**Date:** 2026-07-18 · **Status:** accepted -**Context:** We need a public API surface, caching, and web-native outputs -without exposing the Worker directly. -**Decision:** All public traffic hits SvelteKit `/api/v1/*`, which validates, -proxies to the internal-token-gated Worker, and caches. The Worker is never -public except `/v1/health`. -**Consequences:** Two thin route layers sharing Zod schemas from -`packages/shared`; endpoint changes touch doc + schemas + both layers in one -commit. - -## ADR-0004 — mdsvex for MDX-style content (not true `.mdx`) -**Date:** 2026-07-18 · **Status:** accepted -**Context:** True `.mdx` is React-oriented; we're on SvelteKit. -**Decision:** Use **mdsvex** (`.md`/`.svx` + frontmatter + Svelte components) -for `content/distros/**`. If strict `.mdx` interop is ever required, add a -compatible loader as a new ADR. -**Consequences:** Content stays portable markdown; frontmatter schema is -Zod-validated at build (see `.ai/content.md`). - -## ADR-0005 — Paraglide (inlang) for i18n -**Date:** 2026-07-18 · **Status:** accepted -**Context:** ~57 locales, type safety, and minimal bundle overhead needed. -**Decision:** Paraglide for SvelteKit; locale-prefixed routes; registry in -`packages/i18n`; English fallback; RTL for `ar`, `he`, `fa`, `ckb`. -**Consequences:** Tree-shaken messages per locale; no hardcoded UI strings -(review blocker); geo redirect once with cookie override (see `.ai/i18n.md`). - -## ADR-0006 — Biome for lint + format -**Date:** 2026-07-18 · **Status:** accepted -**Context:** Bun-friendly toolchain; want one fast tool. -**Decision:** Biome replaces ESLint + Prettier across the monorepo -(`biome.json` at root). -**Consequences:** Some Svelte-specific lint rules may need supplementing later; -if so, record as a new ADR rather than ad hoc additions. - -## ADR-0007 — hCaptcha only on community write endpoints -**Date:** 2026-07-18 · **Status:** accepted -**Context:** Anonymous contributions need abuse protection; browsing must -stay frictionless. -**Decision:** hCaptcha on `suggest-distro`, `report`, `feedback`, -`mirrors/report-status` only, verified server-side. Reads and download -resolution are protected by KV rate limiting instead. -**Consequences:** See limits in `.ai/security.md`; captcha secret is a -Wrangler secret. - -## ADR-0008 — Rankings computed from our own signals -**Date:** 2026-07-18 · **Status:** accepted -**Context:** External popularity sources have restrictive terms and dubious -methodology. -**Decision:** Rankings derive from our own signals (page views, tracked -download clicks, release recency), snapshotted weekly to D1. External signals -only as supplements, only with permitted terms, and only after registration in -`.ai/data-sources.md`. -**Consequences:** Rankings start cold and mature with traffic; methodology is -transparent and documentable on the site. - -## ADR-0009 — D1 as source of truth; KV strictly rebuildable -**Date:** 2026-07-18 · **Status:** accepted -**Context:** Two storage systems invite drift. -**Decision:** D1 holds all durable data. KV holds only caches and counters -that can be rebuilt or are flushed to D1 (download counters flushed daily). -Cache invalidation via per-distro generation keys, not purges. -**Consequences:** Losing KV loses nothing durable; schema and keyspace are -documented together in `.ai/database.md`. - -## ADR-0010 — Search starts with SQL LIKE, FTS5 as upgrade path -**Date:** 2026-07-18 · **Status:** accepted -**Context:** Catalog size at MVP (~30–100 distros) doesn't justify search -infrastructure. -**Decision:** Implement search over `name || aliases || summary` with LIKE + -ranking heuristics; adopt D1 FTS5 when measurements show need (new ADR). -**Consequences:** Simple, fast enough at our scale; fuzzy behavior handled by -alias lists and normalization in the search service. - -## ADR-0011 — Internal packages ship TypeScript source (no build step) -**Date:** 2026-07-18 · **Status:** accepted -**Context:** `packages/shared|ui|i18n|ingest` are consumed only by the two -apps; a compile step would add build orchestration for no runtime benefit. -**Decision:** Internal packages expose `./src/index.ts` directly via -`exports`/`types`. Vite (web) and Wrangler's esbuild (api) bundle TS sources; -each package still typechecks itself (`tsc --noEmit`). -**Consequences:** No dist/ artifacts or watch pipelines; if a package is ever -published externally, add a build then (new ADR). - -## ADR-0012 — Claude Code assumes the designer role; HTML design comps replace Stitch/Figma -**Date:** 2026-07-19 · **Status:** accepted -**Context:** The master brief assigned UI design to Google Stitch (human-run, -Figma export). The project owner has explicitly asked Claude Code to produce -the designs itself instead, based on the existing briefs. -**Decision:** Claude Code takes over the designer role. The design source of -truth becomes **high-fidelity HTML/CSS comps in `design/`**, built strictly -from `packages/ui` tokens and the `prompts/stitch/*` briefs (which remain the -design requirements). Responsive behavior, light/dark themes, and RTL are -demonstrated live in the comps rather than as separate static frames. The -approval gate is unchanged: the human reviews the comps and marks the design -approved before Phase 4 implementation begins. -**Consequences:** `prompts/stitch/` briefs stay as the per-screen design -requirements; `design/` holds the deliverable; distro logos appear as neutral -placeholder tiles in comps (official SVGs only enter via -`assets/distros` + ATTRIBUTION per `.ai/data-sources.md`); "never redesign an -approved screen in code" now means: change the comp + get re-approval first. - -## ADR-0013 — Design language pivot: Flathub clone (drop the Modrinth blend) -**Date:** 2026-07-19 · **Status:** accepted -**Context:** The owner reviewed the first comp set (Flathub+Modrinth blend) -and rejected it as feeling generic/AI-generated; the direction requested is -a faithful **Flathub-style** look. -**Decision:** The visual language becomes a Flathub/GNOME (libadwaita-like) -clone: flat gray cards with no borders or shadows (hover = surface darken), -horizontal app-style cards (logo left), pill-shaped flat buttons, GNOME blue -accent `#3584e4`, boxed-list rows with hairline separators, modest type -scale, narrower content width (1176px), calm motion (fades/tints only — no -lifts, no gradients, no stat-chip/marketing bands). Light `#ffffff`/dark -`#242424` libadwaita-style themes. `.ai/design-system.md` + -`packages/ui/tokens.css` are rewritten to these values and `design/` comps -are rebuilt from scratch. -**Consequences:** Modrinth references in the per-screen briefs are void -where they conflict; briefs remain valid for structure/states/a11y. -Rankings/download flows adopt the boxed-list pattern rather than -Modrinth-style density. - -## ADR-0014 — Amendments: Modrinth-style downloads + colorful Flathub home -**Date:** 2026-07-19 · **Status:** accepted -**Context:** Owner review of the Flathub-clone comps: the download flow -should feel like Modrinth's dense version list, and the home should be as -colorful as Flathub's (gradient category tiles, saturated banners); mobile -needed a pass with a no-breakage guarantee across device widths. -**Decision:** Within the Flathub-clone system (ADR-0013): (a) the download -surface becomes a **Modrinth-style version table** — filter dropdowns -(edition/arch/format) above dense version rows with channel-colored badges -(release=green, beta=orange), per-row round download buttons, and an -expandable files/mirror area with checksum; (b) home gains a **banner -carousel** with saturated brand-gradient tiles and a **gradient category -tile grid** (each category its own hue pair, white text); (c) the header -gets a mobile search button and compact <400px treatment, and every comp -must pass an automated horizontal-overflow check at 320/375/768/1024/1440. -**Consequences:** Component inventory gains VersionTable, BannerCarousel, -CategoryTile; the five-step wizard is replaced by filters + version table -(mirror choice folds into the expanded row). - -## ADR-0015 — Committed live-data snapshot via ingest CLI (pre-Phase 5) -**Date:** 2026-07-19 · **Status:** accepted -**Context:** The owner requires real distro data — releases, EOL dates, and -mirror lists — on the detail pages now, but the Hono/D1 backend and its Cron -ingestion only land in Phase 5. Fabricated release data is worse than none. -**Decision:** Add `packages/ingest/src/live.ts`, a Bun CLI that fetches three -verified public/official sources (endoflife.date API for release cycles and -EOL; archlinux.org mirror-status JSON; mirrors.fedoraproject.org mirrorlist) -and writes a committed snapshot `apps/web/src/lib/server/live-data.json`. The -mock BFF derives version tables, mirror lists, and the home "recently -updated" feed from it; the detail page surfaces `fetched_at` + sources as a -provenance line. Rolling distros (arch, endeavouros, manjaro) get an explicit -`rolling` channel row; distros without a usable API (zorin, elementary) fall -back to curated SPECS. Distro logos are official SVGs from Wikimedia Commons -with per-file license rows in `assets/distros/ATTRIBUTION.md`. -**Consequences:** Builds and deploys stay network-free; data freshness equals -snapshot recency until Phase 5 replaces the CLI with Workers Cron Triggers -writing the same shapes into D1/KV. The CLI transport uses curl via -`Bun.spawn` (environment proxy constraint); Workers code will use `fetch`. - -## ADR-0016 — Hand-rolled Paraglide-compatible i18n runtime + locale routing -**Date:** 2026-07-20 · **Status:** accepted -**Context:** `.ai/i18n.md` specifies Paraglide (inlang) with locale-prefixed -routes, geo/cookie redirect, and RTL. The full inlang toolchain (project -file, compiler, vite plugin) is heavyweight to introduce mid-milestone, and -call sites already use property-style `m.key` from `packages/i18n`. -**Decision:** Implement the specified behavior with a small runtime in -`packages/i18n` instead of the inlang compiler, keeping the catalog keys -Paraglide-shaped: per-locale catalogs (`en` complete, `ko` authored; others -fall back), a Proxy-based `m` resolving through the registry's fallback -chain, `localizeHref`/`splitLocale` helpers, and a country→locale geo table. -Routing per spec: routes live under `[[locale=locale]]` with a param -matcher; `hooks.server.ts` does the one-time geo/cookie redirect -(`x-vercel-ip-country`, Accept-Language tiebreaker, `lh-locale` cookie), -canonicalizes `/en/*` → `/*`, sets `` via placeholder -transform, and binds the per-request locale in AsyncLocalStorage so -concurrent SSR renders can't leak locales. Locale switching is a full -navigation (`data-sveltekit-reload`), so a document has exactly one locale. -**Consequences:** Call sites keep `m.key` property access (frontend-rules' -`m.key()` function style arrives only if the inlang compiler is adopted -later — the swap stays mechanical). New locale = one `messages..ts` -file. Content docs remain progressively translated per `.ai/i18n.md`; the -detail page derives its English-fallback badge from the loaded doc. - -## ADR-0017 — Flathub × WinUI 3 (Fluent) visual system -**Date:** 2026-07-20 · **Status:** accepted -**Context:** Owner review of the implemented Phase 4 UI: the flat -libadwaita-style surfaces from ADR-0013 read as dated. The request was to -redesign toward "Flathub + WinUI 3" and apply it to every component. -**Decision:** Keep Flathub's information architecture wholesale (horizontal -app cards, boxed lists, banner carousel, colored category tiles, 1176px -column, calm content-first tone) and replace the *material* with WinUI 3 -(Fluent): a Mica page base with a fixed accent wash, lighter card fills that -read by elevation rather than tint, 1px control strokes with a darker bottom -lip, layered shadows (control/card/flyout/dialog), acrylic header and -flyouts, 6px controls / 8px cards / 12px dialogs with pills kept only for -badges and chips, the Fluent type ramp on Segoe UI Variable (Inter -fallback) with SemiBold carrying all emphasis, Fluent motion (150/250/350ms -on a strong decelerate) with press-shrink instead of lift, the Fluent focus -visual (high-contrast ring, drawn inside clipping containers), and the -NavigationView selection bar. The accent inverts per theme exactly as WinUI -does: a deep blue fill with white text in light, a light blue fill with -near-black text in dark, resolved through `--color-accent-fill` / -`--color-on-accent` so components never branch on theme. -**Consequences:** ADR-0013's "flat surfaces, no borders, no shadows, pill -buttons" clauses are superseded; its Flathub structure clauses stand. -ADR-0014's Modrinth download table and colorful home survive, restyled as -Fluent surfaces. `--color-surface` inverts meaning (now lighter than the -page), so nested fills moved to `--color-well` and reveal fills to -`--color-subtle-*`. Tokens renamed: `--color-accent-contrast` → -`--color-on-accent`, `--color-accent-strong*` → `--color-accent-fill*`. -Verified: axe WCAG 2 AA clean on the five gate pages, 17/17 e2e green, and -no horizontal overflow across 12 routes × 5 widths including RTL. - -## ADR-0018 — Acrylic material system (amends ADR-0017) -**Date:** 2026-07-20 · **Status:** accepted -**Context:** ADR-0017 introduced acrylic but only on the header and the -locale flyout, and without grain — so it read as a plain blur rather than -Fluent's acrylic. Owner asked for more of the acrylic feel. -**Decision:** Make acrylic a first-class material with all four Fluent -ingredients — tint, blur **and saturation boost** (40px/180%), an inline-SVG -`feTurbulence` **grain**, and the smoke scrim (now blurred) behind dialogs — -and apply it to every surface that overlays content: header, locale flyout, -mobile drawer, bottom sheet, dialogs (command palette included), and the -banner arrows. Tint opacity is tiered by how much text a surface owns -(header 70% → flyouts/panes 85% → dialogs 90%), the rule being that content -behind acrylic must read as colour and shape, never as competing text. The -Mica base gains a second radial wash and its own lighter grain -(`--mica-noise`). The toast is excluded: its inverted fill needs full -opacity. -**Consequences:** All acrylic lives in a single `@supports (backdrop-filter)` -block; unsupported browsers keep the solid fills declared on each rule, so -the effect is purely additive. Grain is an inline data URI, so it adds no -request and works offline. Verified: axe WCAG 2 AA clean, 17/17 e2e green, -no overflow across 12 routes × 5 widths. - -## ADR-0019 — D1 schema: per-value provenance, LTS as a flag, per-distro mirrors -**Date:** 2026-08-04 · **Status:** accepted -**Context:** Phase 5.1 turned the schema sketch in `.ai/database.md` into real -migrations. Writing it out surfaced four places where the sketch could not -hold the data the verified sources actually return, or could not enforce a -rule the project treats as binding. -**Decision:** -1. **Per-value provenance.** `distros`, `releases`, `artifacts` and `mirrors` - each carry `source_url` + `fetched_at`. `ingest_log` records a *run* and so - can never answer "where did this row come from"; the sourcing rule in - `.ai/data-sources.md` is about values, so the columns belong on the values. -2. **`lts` is a flag, not a channel; `eol` is derived, not stored.** - `channel ∈ stable|beta|rolling` plus `lts INTEGER`. endoflife.date reports - LTS as a boolean per cycle — a cycle is stable *and* long-term-supported — - so the documented four-value enum would have destroyed information. `eol` is - computed from `eol_at < today` at read time, so no row goes quietly stale as - dates pass. This answers open question 3 in `.ai/frontend-contract.md`; the - frontend's `release|beta|eol|rolling` values are display states derived from - `channel` + `lts` + `eol_at`, not storage. -3. **Mirrors belong to a distro.** `mirrors.distro_id` added and - `UNIQUE(base_url)` relaxed to `UNIQUE(distro_id, base_url)`. Arch's mirror - network is not Fedora's, one host can mirror several distros, and a mirror - is known long before any artifact is known to sit on it — the - `artifact_mirrors` join alone left ingested mirror lists with nowhere to go. -4. **`CHECK` constraints on every closed enum**, and `distros.family` defaults - to `''` because lineage comes from Wikidata and an empty value is honest - where a hand-typed one would violate the sourcing rule. -Also: `download_events.mirror_id` is `NOT NULL DEFAULT 0` rather than a -nullable FK, because SQLite treats each `NULL` as distinct in a `UNIQUE` -constraint and the daily counter flush would insert duplicates instead of -accumulating. -**Consequences:** `.ai/database.md` is updated to match in the same commit and -remains the authority. Reads must derive `eol` rather than filter on it, and -`releases.lts` is a separate predicate from `channel`. Migrations `0001_init` … -`0005_indexes` create 15 tables and 9 indexes; verified with -`wrangler d1 migrations apply linuxhub --local`, including a rejected write -proving the `CHECK` constraints bite. - -## ADR-0020 — The API returns facts; presentation is derived client-side -**Date:** 2026-08-04 · **Status:** accepted (owner approved) -**Context:** The Phase 4 frontend was built against `data.ts`, which returned -`downloads: "1.2M"`, `familyLine: "Debian family · Ubuntu-based"`, -`title: "Fedora 44"`, a pre-rendered `spark` polyline, `flag` emoji, `color` -and `initials`. `.ai/frontend-contract.md` recorded three recurring problems -in that shape — pre-composed English, pre-formatted numbers, and presentation -baked into payloads — and asked where `color`, `initials` and `spark` should -live before any endpoint moved. -**Decision:** The API returns facts and nothing else. -- `downloads` is a number; the page formats it with `Intl.NumberFormat`, which - is the only way it can be locale-correct across ~57 locales. -- No composed English anywhere. `familyLine`, release `line`/`note`, - `RecentRelease.title`/`subtitle` and the `meta[]` tile labels are built by - the page through `@linuxhub/i18n`. The API returns `family`, `based_on`, - `version`, `channel`, `lts`, `released_at`, `eol_at`. -- `initials` derives from `name`; brand `color` is a slug-keyed lookup in - `packages/ui`, cited against `assets/distros/ATTRIBUTION.md`. Neither becomes - a D1 column: they are presentation with no upstream source, and a hand-seeded - column would sit badly against the no-hand-typed-values rule. -- `spark` ships as a series (`GET /v1/distros/:slug/rank-history` → - `{ snapshot_at, rank, score }[]`), drawn by the client. `flag` derives from - the mirror's country code. -**Consequences:** Answers questions 1 and 2 in `.ai/frontend-contract.md` -(question 3 is ADR-0019's). Task 5.4 must move the formatting and composition -into the pages as each endpoint is repointed — that work is the reason the -sequence there is one endpoint at a time. Endpoint tests assert the *absence* -of `familyLine`, `color`, `initials`, `title` and `subtitle`, so a regression -into presentation-in-payload fails a named test. - -## ADR-0021 — Worker tests run against a real D1 via Miniflare -**Date:** 2026-08-04 · **Status:** accepted -**Context:** `.ai/backend-rules.md` called for a "Miniflare/workers-pool -environment" without choosing one. Task 5.3 needed a database for endpoint -tests, and 5.1 had just added CHECK constraints and upsert conflict targets -that only a real SQLite engine enforces. -**Decision:** `apps/api/test/harness.ts` starts Miniflare with an in-memory D1 -and the three KV namespaces, applies the files in `migrations/` verbatim, and -hands the bindings to `app.request(path, init, env)`. Miniflare is used -directly rather than `@cloudflare/vitest-pool-workers`: it needs no new -top-level dependency (it already ships with Wrangler), leaves the existing -Vitest setup unchanged, and lets a test drive both a cron pass and an HTTP -request against the same database. Ingestion tests stub the HTTP transport — -a test suite has no business calling an upstream — but never the database. -**Consequences:** Every test also exercises the schema, which is how the -`ON CONFLICT` parse failure in the rankings snapshot and the retry bug in the -ingest HTTP client were both caught before they shipped. Tests run ~1s slower -per suite because workerd starts per context; that is worth it. If Miniflare -ever misbehaves under Bun, the fallback is a `node:sqlite`-backed D1 shim, -which would have to be documented as no longer exercising the real engine. - -## ADR-0022 — Keep the hand-rolled i18n runtime for v1; do not adopt the inlang compiler -**Date:** 2026-08-04 · **Status:** accepted (owner delegated the choice) -**Context:** ADR-0016 shipped a small Paraglide-shaped runtime instead of the -inlang toolchain, and left "adopt the real compiler" open as a dependency the -owner should agree to. `.ai/handoff.md` has carried it as an open question ever -since. At deployment time it had to be answered. - -Measured before deciding, rather than argued from preference: - -| | Today | -|---|---| -| Authored catalogs | **2** (`en`, `ko`) of 59 registry locales | -| Catalog payload | ~19 KB total, both statically imported | -| `m.` call sites | **266** across **15** files | - -**Decision:** Keep the hand-rolled runtime through v1. - -Adopting the compiler now means converting two TS catalogs to inlang's message -format, adding a project file and a Vite plugin, and rewriting 266 call sites -from `m.key` to `m.key()` across every screen — then re-running the full e2e and -axe gate — immediately before the first deploy. The payoff at two catalogs and -19 KB is indistinguishable from zero. That is risk without benefit, and the -timing is the worst part of it. - -**Revisit when a number says to**, not on a date. Two triggers: -1. **authored catalogs reach ~5**, or -2. the message payload becomes a measurable share of the JS bundle in the - Phase 6 Lighthouse budget. - -**Consequences:** Call sites keep property access (`m.key`); ADR-0016's note -that the swap stays mechanical still holds, and 266 sites is the size of it. - -The honest limitation this leaves in place: `CATALOGS` in -`packages/i18n/src/runtime.ts` statically imports every authored catalog, so -each one ships to every visitor regardless of their locale. At 59 authored -locales that would be roughly 560 KB of messages sent to everybody — a real -defect, and the *first* thing to fix when the trigger fires. Note that fixing it -does not require the compiler: a dynamic per-locale import solves the same -problem while keeping this runtime. Weigh both options at that point instead of -assuming the compiler is the answer. - -`.ai/i18n.md` is updated in this commit to describe what is actually built — -it had been describing Paraglide as the stack since Phase 0. - -## ADR-0023 — Monorepo conventions: catalog, shared tsconfig, enforced boundaries; no task runner yet -**Date:** 2026-08-05 · **Status:** accepted -**Context:** ADR-0001 chose Bun workspaces and left "optional turbo/bunfig task -orchestration added later if needed" open. With deployment imminent the repo -needed the conventions a multi-package repo actually depends on, and the -question of a task runner had to be answered rather than left hanging. - -Two failures had already been paid for, and both were structural rather than -bad luck: -- Biome was declared `^2.3.0` while `biome.json` pinned the 2.5.4 schema, and - CI asked for `bun-version: latest`. A fresh install moved the toolchain - without a commit and broke lint on files nobody had touched. -- `.ai/architecture.md` declares the workerd runtime boundary binding, but - nothing enforced it. `packages/ingest` is imported by the Worker and its CLI - half uses `Bun.file` and `node:fs`; only the import graph kept them apart, - and a single wrong import would have failed at deploy rather than at review. - -**Decision:** -1. **A Bun workspace catalog** holds every version used in more than one place - (`typescript`, `vitest`, `svelte`). Workspaces say `catalog:`; drift between - packages becomes unrepresentable rather than merely discouraged. -2. **The toolchain is pinned**: `packageManager` + `engines` at the root, Biome - at an exact version matching its own schema, and CI reads the Bun version - from `packageManager` instead of tracking latest. -3. **`tsconfig.base.json`** holds the compiler options five workspaces - duplicated. `apps/web` is exempt and stays on SvelteKit's generated config, - which owns `paths` and `rootDirs`. -4. **`scripts/check-boundaries.ts` enforces the two boundaries** and runs first - in CI: no Node builtin or Bun global in `apps/api/src`, and no `packages/*` - importing an app. It scans import specifiers by regex — exact enough for a - question that is only about what a module imports, and dependency-free. - -**No task runner (turbo/nx) yet**, deliberately. Its three benefits do not -apply here: `bun run --filter` already parallelises; there is no build graph to -order, because `packages/*` are consumed as TypeScript source with no build -step; and remote caching would save perhaps a minute on a CI run that is -already short. Adopting one would add a dependency, a config, and a rewrite of -every script for that minute. **Revisit when a build step appears in any -`packages/*`, or when CI wall time passes ~10 minutes** — the first is the real -trigger, since that is when ordering stops being free. - -**Consequences:** Bumping a shared dependency is a one-line edit to the root -catalog. Dependabot is *not* enabled: it does not understand Bun catalogs and -would either miss those versions or rewrite them wrongly, so catalog bumps stay -manual until that support exists. Adding a workspace now means extending the -base tsconfig and, if it is an app, teaching `check-boundaries.ts` about it. - ---- - -## ADR-0024 — A mirror row is not automatically a download base - -*Date: 2026-08-05 · Status: accepted · Supersedes nothing; corrects ADR-0019's -`mirrors` model* - -**Context.** Task 5.6 joins `mirrors.base_url` with `artifacts.path` to build a -download URL. That works only if `base_url` is a root the path extends, and the -code assumed every mirror row was one. It is not: - -| Source | What `base_url` is | Extends `artifacts.path`? | -|---|---|---| -| `archlinux.org/mirrors/status/json/` | a mirror root, `https://host/archlinux/` | yes — `iso/2026.08.01/…iso` | -| `mirrors.fedoraproject.org/mirrorlist` | a **repo directory**, `https://host/fedora/linux/releases/44/Everything/x86_64/os/` | no | -| `download.fedoraproject.org` (redirector) | the base Fedora paths are written against | yes — `pub/fedora/linux/releases/…` | - -Joining a Fedora artifact onto a mirrorlist entry produced a URL that answered -**404**, which is how this was found: the resolved URL was fetched rather than -merely inspected. - -A second defect had the same root. `replaceMirrors` retired every mirror of the -distro absent from the list it had just fetched — including the redirector, -which a different cron writes. The two crons took turns marking each other's -rows unhealthy. - -**Decision.** - -1. `mirrors.serves_artifacts` (migration 0006, default `0`) records whether a - row's `base_url` is a base our artifact paths extend. It is set from the - source, not guessed: `MIRROR_SERVES_ARTIFACTS` in - `packages/ingest/src/registry.ts` is the one place that knows. -2. Only `serves_artifacts = 1` rows are linked in `artifact_mirrors`, and only - those are offered in the picker — choosing a mirror must never be a way to - get a URL that 404s. -3. `pruneArtifactMirrorsStatement` drops links to a mirror that stops being a - base, because the link step only ever inserts. -4. Retirement in `replaceMirrors` is scoped to `source_url`: a source may only - retire rows it wrote. - -**Rejected: deriving a root from a repo URL** by stripping the repo suffix. -Mirrors disagree on their prefix (`/fedora/linux/`, `/pub/fedora/linux/`, -`/linux/`), so it would be a guess that fails silently for some hosts — the -exact failure mode this ADR exists to remove. A per-mirror root is a fact we -can fetch later, from a source that publishes it. - -**Consequences.** Fedora's picker currently offers one base, the official -redirector, which is also what Fedora's own download page uses; the eleven -mirrorlist hosts stay in `mirrors` as cited facts about who mirrors Fedora, and -become usable the day a source gives us their roots. Any new mirror source must -declare which kind it is; the default of `0` means forgetting is inert rather -than wrong. ---- - -## ADR-0025 — The UI does not claim a "nearest" mirror - -*Date: 2026-08-05 · Status: accepted · Amends the approved comp -`design/screens/download-selector.html` (copy only)* - -**Context.** The approved comp labels the mirror list "Mirror — nearest first" -and the default row "Automatic — nearest mirror". The resolver does not measure -distance and has no latency data. It picks in this order: the mirror the caller -named, then one whose country matches the request's edge geo, then any healthy -one — and it returns `mirror_choice` saying which of the three happened. - -"Nearest" is therefore a measurement claim that nothing behind it makes. That -is the same class of defect as a hand-typed fact, and the binding sourcing rule -does not stop at payloads. - -**Decision.** Correct the copy, in the comp and in code together: the heading -becomes "Mirrors", the default row "Automatic — chosen for your region". No -layout, component, token, or state changes — this ADR moves words, not pixels, -so it is a correction inside the frozen design rather than a redesign. The -owner is asked to confirm at the next review; nothing is blocked on that. - -**Consequences.** `dl_mirror_nearest` is renamed `dl_mirrors` in -`packages/i18n`. The snapshot in `apps/web/src/lib/server/data.ts` carries the -same corrected string until it is deleted in 5.7. Endpoint #17 -(`GET /mirrors/nearest`) keeps its name in `.ai/api.md` but, when it is built, -must rank by a published fact rather than an implied measurement. diff --git a/.ai/design-system.md b/.ai/design-system.md deleted file mode 100644 index 314e777..0000000 --- a/.ai/design-system.md +++ /dev/null @@ -1,249 +0,0 @@ -# Design System — Flathub × WinUI 3 (ADR-0017) - -> Per-screen design requirements live in `prompts/stitch/*.md`; the -> **high-fidelity design comps** built from them live in `design/` -> (ADR-0012 — Claude Code is the designer). Tokens are implemented in -> `packages/ui` and must match this doc exactly; `design/shared/design.css` -> consumes them directly. Where a brief's Modrinth reference conflicts with -> this doc, **this doc wins** (ADR-0013 → superseded in look by ADR-0017, -> which keeps Flathub's structure and replaces its flat surfaces with -> Fluent materials). - -## Design language - -**Flathub's information architecture rendered in WinUI 3 (Fluent) materials.** -What Flathub gives us stays: horizontal app cards, boxed lists, the banner -carousel, colored category tiles, a 1176px content column, calm and -content-first. What Fluent gives us is the *material*: - -- **Layered surfaces.** The page is a Mica base (`--color-bg`, a soft grey - carrying a fixed accent wash); content sits on **lighter** card fills - (`--color-surface`). Cards read by elevation, not by tinting — this is the - inverse of the old flat scheme and the single biggest visual change. -- **Control strokes.** Every card, control, and field carries a 1px - translucent stroke whose **bottom edge is darker** (`--color-stroke-lip`), - the subtle lip that makes WinUI controls feel physical. -- **Real elevation.** Soft, layered shadows: `--shadow-card` at rest, - `--shadow-card-hover` on hover, `--shadow-flyout` for popovers, - `--shadow-dialog` for modals. -- **Acrylic chrome.** The sticky header and flyouts blur what scrolls under - them (`backdrop-filter`), with a solid `@supports` fallback. -- **Rounded rectangles, not pills.** Buttons/inputs are 6px, cards 8px, - dialogs 12px. Pills survive only where they carry meaning: badges, status - dots, filter chips. -- **Fluent accent behaviour.** Light theme fills accent surfaces with a deep - blue and white text; **dark theme inverts it** — a light blue fill with - near-black text, exactly as WinUI does. `--color-accent-fill` / - `--color-on-accent` resolve per theme, so components never branch. -- **Fluent selection.** The active nav item carries a short accent bar - (NavigationView); the mobile drawer uses the vertical variant. -- **Press, don't lift.** Controls dim and shrink ~3% on `:active` - (motion-preference gated); they never bounce. -- Distro brand color still appears only in logo tiles and banner tints. - -## Color tokens - -Layer model — each row sits visually *above* the one before it. - -| Token | Light | Dark | Use | -|---|---|---|---| -| `--color-bg` | `#f3f3f3` | `#202020` | Mica page base (under everything) | -| `--color-well` | `#eeeeee` | `#272727` | nested well *inside* a card | -| `--color-surface` | `#ffffff` | `#2b2b2b` | card / boxed-list fill | -| `--color-surface-hover` | `#fafafa` | `#323232` | hovered card | -| `--color-surface-raised` | `#ffffff` | `#2d2d2d` | flyouts, dialogs, drawer | -| `--color-control` | `#fdfdfd` | `#333333` | buttons, inputs, pills | -| `--color-control-hover` / `-active` | `#f6f6f6` / `#f0f0f0` | `#3a3a3a` / `#2f2f2f` | control states | -| `--color-subtle-hover` / `-active` | `rgb(0 0 0 /.04)` / `.07` | `rgb(255 255 255 /.06)` / `.04` | reveal fills on any layer | -| `--color-stroke` | `rgb(0 0 0 /.06)` | `rgb(255 255 255 /.08)` | control/card hairline | -| `--color-stroke-lip` | `rgb(0 0 0 /.13)` | `rgb(255 255 255 /.06)` | darker bottom edge | -| `--color-stroke-strong` | `rgb(0 0 0 /.16)` | `rgb(255 255 255 /.16)` | field underline, hovered card | -| `--color-border` | `rgb(0 0 0 /.08)` | `rgb(255 255 255 /.09)` | separators inside a card | -| `--color-text` | `#1a1a1a` | `#ffffff` | primary text | -| `--color-text-muted` | `#5c5c5c` | `#c7c7c7` | secondary text | -| `--color-text-subtle` | `#6e6e6e` | `#a4a4a4` | tertiary/caption | - -**Accent ramp** (GNOME blue in Fluent's light3→dark3 structure): -`--color-accent-light3` `#c3dbf7` · `-light2` `#99c1f1` · `-light1` `#62a0ea` · -`--color-accent` `#3584e4` · `-dark1` `#1c71d8` · `-dark2` `#1a5fb4` · -`-dark3` `#15487f`. - -| Token | Light | Dark | Use | -|---|---|---|---| -| `--color-accent-fill` | `#1a5fb4` (dark2) | `#62a0ea` (light1) | text-bearing accent surfaces | -| `--color-accent-fill-hover` | `#1c71d8` | `#99c1f1` | hovered accent | -| `--color-accent-fill-active` | `#15487f` | `#3584e4` | pressed accent | -| `--color-on-accent` | `#ffffff` | `#06182b` | text on accent (theme-inverted) | -| `--color-accent-soft` | `#e8f1fc` | `#1c3348` | selected row tint | -| `--color-accent-text` | `#1a5fb4` | `#99c1f1` | accent text on soft tint | -| `--color-link` | `#1a5fb4` | `#99c1f1` | inline links | -| `--color-success` / `-text` | `#26a269` / `#156b41` | `#2ec27e` / `#78e9a8` | active, verified | -| `--color-warning` / `-text` | `#c78a00` / `#7a5200` | `#e5a50a` / `#f9c440` | EOL-soon, untranslated | -| `--color-danger` / `-text` | `#c01c28` / `#8f1521` | `#f66151` / `#ff9186` | errors, discontinued | - -The `-text` variants are the AA-safe colors for text *on* the matching 15% -tint. Every pair is axe-verified at WCAG 2 AA on the five gate pages. - -### Materials - -**Mica** — the page base. A fixed layer (`body::before`) combining -`--color-bg` with two radial washes (`--mica-tint` 8%/14%, -`--mica-tint-2` 5%/7%) and a light grain (`--mica-noise`). It never scrolls, -so the page reads as a material rather than a flat grey. - -**Acrylic** — every chrome surface that sits *over* content. Four -ingredients, and the grain is the one most web imitations skip: - -| Ingredient | Token | Value | -|---|---|---| -| tint | `--acrylic-fill` / `-flyout` / `-pane` / `-thick` | see below | -| blur + saturation | `--acrylic-blur` / `--acrylic-blur-thin` | `blur(40px) saturate(180%)` / `blur(20px) saturate(150%)` | -| grain | `--acrylic-noise` | inline SVG `feTurbulence`, offline-safe | -| smoke | `--scrim` | `rgb(0 0 0 /.35)` + `blur(2px)` behind dialogs | - -Tint opacity scales with how much text the surface owns — the more it has to -stay readable, the thicker it sits: - -| Surface | Token | Light | Dark | -|---|---|---|---| -| header (signature, little text) | `--acrylic-fill` | 70% | 68% | -| small floating controls | `--acrylic-flyout` | 76% | 74% | -| flyouts, drawer, sheet | `--acrylic-pane` | 85% | 84% | -| dialogs (over smoke) | `--acrylic-thick` | 90% | 88% | - -Content behind an acrylic surface must read as **colour and shape, never as -competing text**. Every acrylic rule lives in one `@supports -(backdrop-filter: …)` block; without support each surface keeps the solid -fill declared on its own rule, so nothing depends on the effect. The toast -is deliberately excluded — its inverted fill needs full opacity. - -## Typography - -Fluent's ramp, with 16px kept as the prose body size for web readability. -`Segoe UI Variable` renders natively on Windows; Inter carries everywhere -else. Headings use the Display optical face. - -| Token | Value | Fluent role | -|---|---|---| -| `--font-sans` | `"Segoe UI Variable Text", "Segoe UI", Inter, "Noto Sans", system-ui` | body/UI | -| `--font-display` | `"Segoe UI Variable Display", "Segoe UI", Inter, …` | h1–h3 | -| `--font-mono` | `"Cascadia Code", "JetBrains Mono", ui-monospace` | code, checksums | -| `--text-xs` | 12px / lh 1.34 | caption | -| `--text-sm` | 14px / lh 1.43 | body, control labels | -| `--text-base` | 16px / lh 1.6 | prose body | -| `--text-lg` | 18px / lh 1.5 | body large | -| `--text-xl` | 20px / lh 1.4 | subtitle | -| `--text-2xl` | 28px / lh 1.29 | title | -| `--text-3xl` | 40px / lh 1.2 | title large (page h1) | -| `--text-4xl` | 52px / lh 1.08 | display (banner only) | - -`--weight-strong: 600` (Fluent SemiBold) carries **all** emphasis — headings, -card titles, button labels. 700 is not used. Large text takes -`--tracking-tight: -0.015em`. Left-aligned, never justified; logical -properties only (RTL mirrors free). - -## Spacing, radius, elevation - -4px grid: `--space-1..8` = 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64. - -| Radius | Value | Use | -|---|---|---| -| `--radius-xs` | 4px | segmented tabs, small chips | -| `--radius-sm` | 6px | buttons, inputs, icon buttons, nav items | -| `--radius-md` | 8px | cards, boxed lists, tiles | -| `--radius-lg` | 12px | dialogs, banner tiles, sheets | -| `--radius-xl` | 16px | large logo tiles, hero frames | -| `--radius-full` | 9999px | badges, status dots, filter chips, avatars | - -| Elevation | Value (light) | Use | -|---|---|---| -| `--shadow-control` | `0 1px 1px rgb(0 0 0 /.04)` | buttons, pills, fields | -| `--shadow-card` | `0 1px 2px /.05, 0 2px 4px /.03` | cards, boxed lists, tiles | -| `--shadow-card-hover` | `0 2px 4px /.06, 0 6px 14px /.07` | hovered card | -| `--shadow-flyout` | `0 8px 16px rgb(0 0 0 /.14)` | popovers, banner arrows | -| `--shadow-dialog` | `0 32px 64px /.19, 0 2px 21px /.14` | dialogs, drawer, sheet | - -Dark theme uses the same structure at higher opacity (.14–.37). -Content max width: `--content-max-width: 1176px`. - -## Motion - -Fluent timings: fast 150ms (fades) / base 250ms (point-to-point) / slow -350ms (sheets, drawer). `--ease-out: cubic-bezier(0, 0, 0, 1)` is Fluent's -strong decelerate and is the default; `--ease-standard` for reversible -moves, `--ease-emphasized` for entrances. - -Hover = fill + stroke + elevation change. Press = dim **and** a ~3% shrink -(`scale(0.97)` controls, `0.995` cards), gated behind -`prefers-reduced-motion: no-preference`. Route changes cross-fade via View -Transitions. No staggered reveals, no bounce. `prefers-reduced-motion` -disables everything non-essential (global kill-switch in tokens.css + -`motion.ts` helper). - -## Breakpoints - -mobile 0+ (1-col, sheets) · tablet 640+ (2-col cards) · desktop 1024+ -(3-col cards, side filters) · wide 1440+ (content capped at 1176px). - -## Iconography & imagery - -- UI glyphs: single stroke set, 1.5px, 20/24px grid (sprite in comps). -- Distro logos: official SVGs on flat surface, never recolored - (placeholder initial-tiles in comps per ADR-0012). -- Screenshots 16:10, radius-lg, inside a flat surface band. - -## Component inventory - -Shell: `AppHeader` (wordmark · wide center search · nav links · locale · -theme), `AppFooter` (flat, multi-column), `MobileNav` (drawer), -`CommandPalette`, `LocaleSwitcher`, `ThemeToggle`. - -Browse: `DistroCard` (horizontal: logo 64 → name/summary), `CardGrid`, -`BannerCarousel` (saturated brand-gradient tiles + dots/arrows, ADR-0014), -`CategoryTile` (gradient hue-pair tiles, white text, ADR-0014), -`CategoryPill`, `FilterList` (flat boxed groups) / `FilterSheet`, -`SortSelect`, `SearchInput`, `Pagination`. - -Detail: `DistroHeader` (icon + name + family / Download pill), -`ScreenshotCarousel` (gray band), `MetaTileRow` (downloads · size · -version · license · arches), `ContentTabs`/prose, `BoxedList` + `Row` -(releases, links), `VersionTable` (Modrinth-style downloads, ADR-0014: -edition/arch/format filter dropdowns → dense version rows with channel -badges + round per-row download buttons → expandable mirror/checksum -area), `ChecksumRow`, `RelatedRow`, `RankSparkline`. - -Rankings/discovery: `RankingBoxedList` (numbered rows, hairlines), -`TrendMark`, `HallCard`, `CompareTable`, `QuizStepper`, `RandomButton`. - -Feedback/system: `Badge`, `Button` (primary/flat/danger — all pills), -`Chip`, `Tabs` (pill-style switcher), `Dialog`, `Tooltip`, `Toast`, -`Skeleton*`, `EmptyState`, `ErrorState`, `CaptchaGate`, `CopyButton`. - -New components require updating this inventory first -(`.ai/component-rules.md`). - -## States - -Every screen designs loading (skeleton), empty, and error. Untranslated -content shows the warning badge. - -## Accessibility baseline - -Keyboard-complete; **Fluent focus visual** — a 2px `--focus-outer` ring -(near-black on light, white on dark) at 2px offset, drawn *inside* clipping -containers (`.boxed`, `.vfiles`, flyouts) so it is never cut off; text -fields additionally show the accent underline. AA contrast; -44px touch targets; RTL-mirrored (`ar`, `he`, `fa`, `ckb`); -reduced-motion variants; axe-clean. - -## Reference - -Two references, one system. **flathub.org** sets the structure: home banner + -category tiles + section grids; app page (icon/title/install button, -carousel, metadata tiles, links list). **WinUI 3 / Fluent 2** sets the -material: layered Mica/card/flyout surfaces, control strokes with a bottom -lip, soft elevation, acrylic chrome, the Segoe UI Variable ramp, the accent -that inverts between themes, and the selection indicator bar. - -When the two disagree, structure follows Flathub and surface follows Fluent. -When in doubt, remove decoration — Fluent's depth is subtle by design. diff --git a/.ai/frontend-contract.md b/.ai/frontend-contract.md deleted file mode 100644 index a185c82..0000000 --- a/.ai/frontend-contract.md +++ /dev/null @@ -1,204 +0,0 @@ -# Frontend ↔ Backend Contract - -> **Read before writing any Phase 5 endpoint.** `.ai/api.md` is the *target* -> API. This doc is what the shipped frontend **actually consumes today**. They -> do not match, and the difference is the single biggest way to break Phase 4 -> while doing Phase 5. -> -> Derived from the code on 2026-07-20. When you change a route or a page's -> `load`, update this file in the same commit. - -## Why this file exists - -The BFF routes in `apps/web/src/routes/api/v1/*` currently read -`apps/web/src/lib/server/data.ts` directly and return shapes that grew from -the UI's needs, not from `.ai/api.md`. Task 5.4 ("point the BFF at the -Worker") swaps the data source underneath ~15 screens at once. If the Worker -returns `.ai/api.md` shapes and the pages expect these shapes, everything -breaks simultaneously and the failure is hard to localise. - -**So: reconcile the two contracts deliberately, before 5.4.** The reconciled -shape belongs in `packages/shared` as Zod schemas, which then constrain both -sides. - -## The mismatch, field by field - -`Distro` — the type every list, card, and search result uses. - -| Field | Shipped frontend (`data.ts`) | `.ai/api.md` canonical | Resolution needed | -|---|---|---|---| -| `slug`, `name`, `summary` | ✅ same | ✅ same | — | -| `family` | `"debian"` | `"debian"` | — | -| `familyLine` | `"Debian family · Ubuntu-based"` | ✖ absent | display string; derive from `family` + `based_on` | -| `color` | `"#e95420"` | ✖ absent | brand colour; **decide where it lives** (see below) | -| `initials` | `"U"` | ✖ absent | logo-tile fallback; derivable from `name` | -| `logo` | `"/distros/ubuntu.svg"` | `logo: string` | same idea; align on absolute vs. rooted path | -| `downloads` | **`"1.2M"` (string)** | **`number`** | ⚠️ formatting belongs on the client (`Intl.NumberFormat`, locale-aware) — the API must return a number | -| `rank` | `number` | `rank?` optional | — | -| `trend` | `number` (rank delta) | ✖ absent | comes from `rankings` snapshots | -| `categories` | `string[]` | `string[]` | — | -| `tags`, `desktops`, `homepage`, `status`, `latest_release` | ✖ absent | ✅ present | frontend gains them; additive, safe | - -**Decide and record as an ADR:** `color` and `initials` are presentation data -with no upstream source. Three defensible options — pick one, don't drift: - -1. columns on `distros` (simple, one source of truth, needs seeding); -2. a frontend-side lookup keyed by slug (keeps the API purely factual); -3. derive `initials` from `name`, store only `color`. - -## What each BFF route returns today - -| Route | Returns | Consumed by | Target (`.ai/api.md`) | D1 source | -|---|---|---|---|---| -| `GET /api/v1/distros` | `Distro[]` + `meta{page,limit,total}`; query `sort` (`popularity\|trending\|name`), `category` | explore, home | #1 | `distros` + `distro_taxonomy` + `rankings` | -| `GET /api/v1/distros/:slug` | `{ distro: Distro, detail: DetailPayload }` | distro detail | #2 (+ #5, #7, #8, #14 folded in) | many — see below | -| `GET /api/v1/rankings` | `{ entries: RankRow[], movers: RankRow[] }` | rankings | #32, #34 | `rankings` | -| `GET /api/v1/releases/recent` | `RecentRelease[]` + `meta{next_cursor}` | home | #12 | `releases` | -| `GET /api/v1/search?q=` | `Distro[]` + `meta{total}` | search, ⌘K palette | #22 | `distros` (LIKE → FTS5) | -| `GET /api/v1/hall-of-fame` | `HallEntry[]` | hall of fame | #35 | `hall_of_fame` | -| `GET /api/v1/quiz` | `QuizQuestion[]` | quiz | #49 | editorial — needs a home | -| `GET /api/v1/health` | `{service,status,version}` | — | #41 | — | - -Two loaders bypass the BFF and import `data.ts` **values** directly: - -- `compare/+page.server.ts` — `DISTROS`, `SPECS` → needs #46 -- `+page.server.ts` (home) — `BANNERS`, `getDistro` → needs a banners source - -Every other loader imports **types only** and fetches the BFF properly. Those -type imports are the ones to repoint at `packages/shared` once the Zod -schemas exist — a mechanical change, and a good first commit in 5.4. - -### `RankRow` - -```ts -{ rank, slug, name, familyLine, color, initials, logo, trend, spark } -``` - -`spark` is a pre-rendered SVG polyline `points` string, generated -deterministically from the slug in the mock. Real implementation: build it -from `rank-history` (#37) — either server-side into the same string, or ship -the series and let the client draw it. **Decide once**; the rankings page -currently renders the string verbatim. - -### `RecentRelease` - -```ts -{ slug, title: "Fedora 44", subtitle: "Stable release" | "Long-term support release", date: "2026-04-28" } -``` - -`title` and `subtitle` are pre-composed English strings — **an i18n bug in -waiting.** The API should return `{ slug, version, channel, released_at }` -and let the page compose the label through `@linuxhub/i18n`. Fix this when -the endpoint moves to the Worker. - -### `DetailPayload` — the biggest surface - -Returned by `getDetailFor()` and consumed by -`routes/[[locale=locale]]/distro/[slug]/+page.svelte`: - -```ts -{ - summary: string - homepage: string - badges: { family: string; active: boolean; translated: boolean } - meta: Array<{ icon: string; value: string; labelKey?: MessageKey; label?: string }> - editions: string[] // ["Workstation (GNOME)", "KDE Plasma spin", …] - architectures: string[] // ["x86_64", "aarch64"] - formats: string[] // [".iso", ".iso.torrent", "Checksum", "GPG signature"] - versions: Version[] - mirrors: DetailMirror[] - requirements: Array<{ row: string; min: string; rec: string }> - fetchedAt: string // ISO — provenance line - sources: string[] // provenance line - related: Distro[] -} - -Version { version: string; channel: "release"|"beta"|"eol"|"rolling"; - line: string; note: string; date: string; size?: string; downloads?: string } -DetailMirror { flag: string | null; name: string; note: string; - healthy: boolean; auto: boolean } -``` - -Per-field mapping for the Worker: - -| Field | Comes from | Notes | -|---|---|---| -| `summary`, `homepage` | `distros` | — | -| `badges.family` | derived from `family` + `based_on` | display string | -| `badges.active` | `distros.status = 'active'` | — | -| `badges.translated` | **already overridden by the page** from the loaded MDX doc | the API value is ignored; drop it | -| `meta[]` | composed | ⚠️ presentation, not data — see below | -| `editions` | `editions` table | currently `string[]`; the table has `{name, desktop, kind}` | -| `architectures`, `formats` | `artifacts` | currently hardcoded for every distro | -| `versions[]` | `releases` | `channel` values differ: frontend `release\|beta\|eol\|rolling` vs. schema `stable\|lts\|beta\|rolling`. **Reconcile** — `eol` is derived from `eol_at < today`, and `lts` is a flag, not a channel | -| `versions[].line`, `.note` | composed English strings | same i18n bug as `RecentRelease` | -| `versions[].size`, `.downloads` | `artifacts.size`, counters | optional today, always absent | -| `mirrors[]` | `mirrors` + `artifact_mirrors` | `flag` is an emoji built from `country`; `auto` marks the synthetic "let the server choose" row. Only mirrors with `serves_artifacts = 1` are offered — see ADR-0024 | -| `requirements` | ⚠️ **one shared table for every distro** — factually wrong and user-visible | per-distro, from official install docs | -| `fetchedAt`, `sources` | `ingest_log` | keep — the provenance line is a product feature | -| `related` | joined in the BFF today (same family, then rank) | #8 | - -**`meta[]` is presentation, not data.** It is an ordered array of tiles with -icon names and i18n keys — the API should return facts -(`downloads`, `latest_version`, `rank`, `default_desktop`, `architectures`) -and let the page build the tiles. Moving this to the client also fixes the -`label` strings that are currently hardcoded English (`"Default desktop"`, -`` `Latest · ${date}` ``). - -## Recurring themes - -Three problems show up across the whole surface. Fix them as the endpoints -move rather than porting them: - -1. **Pre-composed English strings** (`title`, `subtitle`, `line`, `note`, - `label`, `familyLine`) defeat i18n. The API returns data; the page - composes text through `@linuxhub/i18n`. -2. **Pre-formatted numbers** (`downloads: "1.2M"`) defeat locale formatting. - The API returns numbers; the page formats with `Intl`. -3. **Presentation baked into payloads** (`meta[]`, `spark`, `flag`, `initials`) - couples the API to one UI. Return facts; derive presentation client-side. - -## Suggested sequence for 5.4 (avoids the big-bang break) - -1. ✅ Done in 5.3 — the reconciled Zod schemas are in - `packages/shared/src/schemas.ts`, imported by both sides, so a shape drift - fails at the type level instead of silently at runtime. -2. Move **one** endpoint at a time, cheapest first: - `health` → `releases/recent` → `search` → `distros` → `distros/:slug`. - **Stop there.** `rankings`, `hall-of-fame` and `quiz` are not ready: the - first two read tables that are legitimately empty (no download signals until - 5.6; question 4 above unsettled), and `quiz` has no endpoint at all. Moving - them would replace working editorial screens with blank ones. -3. After each move run the e2e suite — it covers every screen that consumes - these routes, so a broken shape fails a named test rather than silently - rendering an empty page. -4. Keep the envelope `{ ok, data, meta }` identical throughout. -5. `compare` and the home loader still import `data.ts` **values** — give - them endpoints (#46 for compare, a banners source for home) before - deleting the file. - -## Open questions — three answered, one still open - -Per golden rule 3 these are architecture, so each is settled with an ADR -rather than implicitly in code. - -1. ✅ **`color` and `initials` — frontend, not the API** (ADR-0020, owner - approved 2026-08-04). `initials` derives from `name`; brand `color` becomes - a slug-keyed lookup in `packages/ui`, cited against - `assets/distros/ATTRIBUTION.md`. No D1 column. -2. ✅ **`spark` — the client draws it** (ADR-0020). `GET /v1/distros/:slug/ - rank-history` returns a `{ snapshot_at, rank, score }` series; the API never - ships a rendered points string. -3. ✅ **`channel` — `stable|beta|rolling` plus an `lts` flag, `eol` derived** - (ADR-0019). The frontend's `release|beta|eol|rolling` are display states the - page computes, not storage. -4. ⬜ **Still open:** do the editorial sets (`QUIZ`, `HALL_OF_FAME`, banner - copy, category assignment) live in D1 or in `content/` as MDX? Nothing in - 5.1–5.3 depends on it; settle it in 5.7. `hall_of_fame` exists as a table - and `GET /v1/hall-of-fame` reads it, but it stays empty until this is - decided — **so 5.4 must not repoint the BFF's hall-of-fame route yet.** - -**Already settled — do not reopen:** the BFF **always** proxies the Worker and -never touches D1/KV directly (`.ai/architecture.md` §"Layers and -responsibilities", decided in Phase 0). It holds under Vercel, where a -function cannot bind D1 anyway. diff --git a/.ai/frontend-rules.md b/.ai/frontend-rules.md deleted file mode 100644 index 13f8f38..0000000 --- a/.ai/frontend-rules.md +++ /dev/null @@ -1,114 +0,0 @@ -# Frontend Rules — `apps/web` (SvelteKit on Vercel) - -## Framework & conventions - -- SvelteKit with **Svelte 5 runes** (`$state`, `$derived`, `$props`, - `$effect`), TypeScript strict, `@sveltejs/adapter-vercel`. -- Biome for lint/format. No ESLint/Prettier. -- Svelte snippets over slots in new code; no legacy reactive statements. - -## Routing - -``` -src/routes/ -├─ [[locale=locale]]/ # optional locale prefix, matcher-validated -│ ├─ +layout.svelte # shell: header, footer, theme, dir=rtl -│ ├─ +page.svelte # Home -│ ├─ explore/+page.svelte # browse grid + filters -│ ├─ distro/[slug]/+page.svelte # detail (MDX tabs + download panel) -│ ├─ rankings/+page.svelte -│ ├─ hall-of-fame/+page.svelte -│ ├─ category/[slug]/+page.svelte -│ ├─ tag/[slug]/+page.svelte -│ ├─ search/+page.svelte -│ ├─ compare/+page.svelte -│ ├─ quiz/+page.svelte -│ ├─ contribute/+page.svelte # suggest/report/feedback (captcha-gated) -│ └─ about/+page.svelte -├─ api/v1/[...]/+server.ts # BFF endpoints (NOT locale-prefixed) -├─ sitemap.xml/+server.ts -└─ +error.svelte # 404/error page (designed screen) -``` - -- Locale segment is validated by a param matcher against the registry in - `packages/i18n`. Unknown locale → 404. No prefix → geo/cookie redirect once - (see `.ai/i18n.md`). -- `/api` routes are locale-independent; content language comes from `?locale=`. - -## BFF conventions (`/api/v1/*`) - -- The BFF is the **only** public API. It validates input with shared Zod - schemas from `packages/shared`, forwards to the Worker with the internal - service token (`INTERNAL_API_TOKEN` env), and returns the standard envelope - from `.ai/api.md` unchanged. -- Caching: set `Cache-Control: public, s-maxage=, stale-while-revalidate` - per endpoint class (list 60s, detail 300s, rankings 600s, search 30s) and - `ETag`. Write endpoints: `no-store`. -- Never add business logic in the BFF beyond validation, caching, and shaping - web-native outputs (sitemap, OpenSearch, feeds passthrough). - -## Data loading - -- All page data loads in `+page.ts` / `+page.server.ts` `load` via `fetch` to - the BFF — components never fetch. -- Use SvelteKit streaming for below-the-fold data (screenshots, related, - rank history) so the hero renders fast. -- Client-side navigation reuses cached `load` results; invalidate with - `depends('app:distros')` keys. -- Mutations (suggest/report/feedback/track) go through form actions or typed - `fetch` wrappers in `src/lib/api.ts` — one wrapper per endpoint, sharing Zod - types. - -## State - -- Server is the source of truth; keep client state minimal. -- Cross-cutting client state (theme, locale choice, last download selection, - command palette open) lives in small rune-based stores in `src/lib/state/`, - persisted to `localStorage` where it should survive reload (theme, download - selection) with SSR-safe guards. -- URL is state for browse: filters/sort/page live in query params so results - are shareable; `load` reads them. - -## i18n usage - -- All UI strings via Paraglide messages (`packages/i18n`) — **zero hardcoded - strings** in markup. Message ids: `m.explore_title()`. -- `dir="rtl"` set in the root layout for RTL locales; only logical CSS - properties (enforced by Biome rule where possible + review). -- Dates/numbers via `Intl.*` with the active locale. -- Untranslated MDX shows the English fallback + "untranslated" badge. - -## Images & icons - -- Distro logos: inline `` with fixed - dimensions to avoid CLS; `loading="lazy"` off-screen. -- Screenshots: lazy, `srcset`, LQIP blur-up, 16:10 container reserves space. -- Site icon swaps with theme (`icons/light.svg` / `icons/dark.svg`). -- UI glyph set is a single sprite/module in `packages/ui` — no ad-hoc SVGs. - -## Theme & motion - -- Dark/light via `data-theme` on ``, defaulting to - `prefers-color-scheme`, user override persisted. -- All motion uses tokens from `packages/ui/src/tokens.css`; every animation goes - through the shared `motion` helper which no-ops under - `prefers-reduced-motion: reduce`. -- View transitions for route changes where supported; graceful fallback. - -## Performance budget - -- Explore + Distro pages: Lighthouse ≥90 perf/a11y/best-practices/SEO. -- JS per route ≤ 150KB gzipped; no client-side data waterfalls; fonts - self-hosted with `font-display: swap`. - -## Error handling - -- BFF/API failures render the designed `ErrorState` with retry — never a blank - page. `+error.svelte` covers route-level errors (404 uses the designed 404). -- Report `request_id` from the error envelope for debuggability. - -## Implementation discipline - -Frontend implementation starts only after the Stitch design is **approved** -(Phase 3 gate). Build exactly to the approved Figma + `design-system.md` -tokens. Deviations route back through Stitch — never redesign in code. diff --git a/.ai/handoff.md b/.ai/handoff.md deleted file mode 100644 index 3715fb2..0000000 --- a/.ai/handoff.md +++ /dev/null @@ -1,340 +0,0 @@ -# Handoff — state of the repo - -> **Read this first in a new session.** It says what is actually built, what is -> real data versus placeholder, which commands work, and where the next piece -> of work starts. When it disagrees with a phase doc, the phase doc wins on -> *intent* and this file wins on *current state*. -> -> Last updated: 2026-08-06, after Phase 5 tasks 5.1–5.4 and 5.6, and the -> deployment-readiness pass. -> -> A ready-to-paste continuation prompt for the next session lives in -> `docs/next-session-prompt.md`. - -## Where the project stands - -Phases 0–4 are complete. Phase 5 is **in progress**: the schema, cron -ingestion and read endpoints are done, four of five movable BFF routes proxy -the Worker, and **downloads now resolve to a real mirror URL with its -checksum** (5.6) — for the two distros that have a verified artifact source. -What is left is 5.5 (writes behind hCaptcha), the rest of the artifact sources, -and 5.7 (deleting `data.ts`). **The design is frozen** — see "Design is closed" -below before touching any styling. - -| Phase | State | -|---|---| -| 0 Documentation | ✅ `.ai/` is the SSOT, 14 docs + this one | -| 1 Repo init | ✅ Bun workspaces, Biome, CI, placeholder Worker | -| 2 Design system | ✅ tokens + `design/` comps | -| 3 UI design | ✅ approved, then rebuilt as Flathub × WinUI 3 (ADR-0017/0018) | -| 4 Frontend | ✅ all 15 screens, live data, MDX content, i18n + RTL, motion, e2e + axe | -| **5 Backend** | 🟡 **5.1–5.3 done**, **5.4 four-fifths done**, **5.6 done for arch + fedora** — the download button delivers a real file; `distros/:slug` still blocked (below) | -| 6 Testing | ⬜ Lighthouse budget + coverage still to measure | -| 7 Deployment | 🟡 **the repo is deploy-ready**: security headers, robots/sitemap/feeds, a build that fails loudly on a misconfigured Vercel root, and a deploy guard against placeholder binding ids. Only the account steps are left, and they are the owner's | - -## What is real and what is not - -Being precise about this saves a session from "fixing" something that is -deliberately a placeholder. - -**Real** -- **Release/EOL data** for 7 distros and **mirror lists** for Arch + Fedora, - fetched from public APIs into a committed snapshot - (`apps/web/src/lib/server/live-data.json`, regenerate with - `bun packages/ingest/src/live.ts`). Sources registered in - `.ai/data-sources.md`; provenance is surfaced on the distro page. -- **The public surface** — `robots.txt`, `sitemap.xml` (both translated - locales × every distro, hreflang alternates), and the RSS + Atom release - feeds the footer links. Security headers on both apps: CSP from - `svelte.config.js`, the rest from `hooks.server.ts` and — for static assets, - which never reach the hook — `apps/web/vercel.json`. Asserted by - `e2e/security.spec.ts` and `e2e/public-surface.spec.ts`. -- **Distro logos** — 12 official SVGs from Wikimedia Commons in - `assets/distros/` + `apps/web/static/distros/`, licensed and attributed in - `assets/distros/ATTRIBUTION.md`. -- **Distro prose** — `content/distros//en/{description,install,usage}.md`, - 36 docs written from official documentation with sources cited, compiled by - mdsvex and rendered in the detail tabs. -- **Korean UI** — a complete `ko` catalog; locale routing, geo redirect, and - RTL are wired end to end. -- **The Worker's D1** — 15 tables, populated by cron from the same three - verified sources. A local run holds 12 distros, 36 content docs, 42 releases - and 16 mirrors, every ingested row carrying `source_url` + `fetched_at`. -- **Twelve endpoints** on the Worker (#1, #2, #3, #12, #14, #15, #21, #22, - #32/#34, #35, #37, #41), returning facts only — numbers not formatted - strings, no composed English, no presentation (ADR-0020). -- **Downloads for arch and fedora.** 39 editions, 93 artifacts and their - mirror links are ingested from the Arch release-snapshot JSON and the Fedora - releases index; `POST /downloads/resolve` returns a URL that was fetched and - answered 200 (Fedora 43 Workstation x86_64 → 2,742,190,080 bytes through the - redirector; Arch 2026.08.01 → 1,597,014,016 from a Swedish mirror). - -**Placeholder — do not treat as truth** -- **`apps/web/src/lib/server/data.ts` is a work list, not a data store.** - `downloads`, `rank`, `trend`, `SPECS`, `EDITIONS`, `REQUIREMENTS`, - `BANNERS`, `QUIZ`, `HALL_OF_FAME` were invented during the build-out. - **The BFF still serves it** — task 5.4 is what starts replacing it. The - per-symbol plan and the 12 verified announcement feeds are in - `.ai/data-sources.md` → "Replacing the placeholder data". Do not add new - facts to that file; wire a source instead. -- Screenshots are grey frames. There is no screenshot pipeline yet. -- Download buttons resolve for **arch and fedora only**. The other ten - distros still render the placeholder version table and mirror picker from - `data.ts`, because no artifact source is registered for them yet. The page - chooses per distro: a non-empty option matrix gets the real selector. -- The contribute forms complete locally; there is no hCaptcha and no POST - endpoint yet. -- The D1/KV ids in `wrangler.toml` are still zeros — everything above was - built and verified against `--local`. `bun run --filter '@linuxhub/api' - deploy` refuses to ship while they are, so this cannot reach production by - accident. - -**Empty on purpose — not a gap to fill by hand** - -This distinction matters more than it looks, because the obvious "fix" for -each of these is the one the project forbids: - -| Table / field | Why it is empty | Filled by | -|---|---|---| -| `rankings`, `download_events`, `downloads`, `rank`, `trend` | popularity comes from **our own signals only**. The counting path now exists end to end — `POST /downloads/track` writes a KV counter and the daily cron banks it into `download_events` — so these fill with **traffic**, not with a fetch. Third-party charts remain a forbidden source | real visitors | -| `distros.family`, `based_on`, taxonomy | lineage comes from Wikidata, and **every QID-lookup API is robots-disallowed** — needs 9 QIDs looked up by a human first. `.ai/data-sources.md` § "Wikidata lineage — blocked". **Does not block 5.4:** there is no family facet in the UI | after the QIDs land | -| `releases` for arch/manjaro/endeavouros | rolling: no version cycles exist to fetch. Their ISO-snapshot endpoints are identified but unverified | when that source is verified | -| `releases` for zorin/elementary | fixed-cadence but no machine-readable source found — registry kind `unsourced` | when a source is found | -| `editions`, `artifacts` for the other 6 distros | arch, fedora, **ubuntu**, **debian**, **pop-os** and **linux-mint** are ingested; the rest need a verified per-distro artifact source. Debian carries no artifact `size` on purpose — its robots.txt forbids requesting the ISOs | 5.7 | -| `requirements` | there is no requirements table and no source for one; the detail page renders a hardcoded four-row list from `data.ts` | 5.7 | -| `hall_of_fame` | blocked on frontend-contract question 4 (editorial in D1 or `content/`?) | 5.7 | - -### Which BFF routes have moved - -| Route | Source | Why | -|---|---|---| -| `health` | ✅ Worker | also reports the Worker's db/kv probes and a `mode` field | -| `releases/recent` | ✅ Worker | facts; the page composes the label and formats the date | -| `search` | ✅ Worker | LIKE over name/summary/aliases in SQL | -| `distros` | ✅ Worker | `?sort=trending` maps to the default — there is no trend yet | -| `distros/:slug/download-options` (#14) | ✅ Worker | no snapshot exists to fall back to — `data.ts` never held an artifact path. Unconfigured Worker ⇒ empty matrix ⇒ the page keeps its placeholder section | -| `downloads/resolve` (#15), `downloads/track` (#21) | ✅ Worker | geo from the edge header, never from the body; the user's address forwarded as `X-Client-IP` so the Worker limits the visitor and not the BFF | -| `distros/:slug` | ⛔ `data.ts` | **the one blocker left in 5.4**, now smaller: editions/architectures/formats exist for arch + fedora, but `requirements` still has no table and no source, and 10 distros have no artifacts. Moving it today blanks sections for most of the catalog. Needs 5.7 | -| `rankings`, `hall-of-fame`, `quiz` | ⛔ `data.ts` | empty tables / editorial home unsettled — same class of reason | - -**The dual mode is deliberate and temporary.** With `LINUXHUB_API_URL` unset -every route falls back to the committed snapshot, which is what makes a deploy -with empty env vars work. `GET /api/v1/health` reports which mode is live, and -the mode is logged once per cold start. `apps/web/src/lib/server/adapt.ts` -lifts the snapshot to the shared shape so no page can tell the branches apart; -it dies with `data.ts` in 5.7. - -## Commands that work - -```bash -bun install # workspaces: apps/* + packages/* -bun run check:boundaries # workerd + dependency-direction rules (ADR-0023) -bun run check # tsc + svelte-check across all 6 packages -bun run lint # Biome (bun run format to autofix) -bun run test # vitest, all packages -bun run build # every workspace with a build script, in parallel -bun run dev:web # SvelteKit dev server -``` - -Repo conventions — the dependency catalog, the shared tsconfig base, the -enforced boundaries, and how to add a workspace — are in `docs/monorepo.md` -(ADR-0023). Shared dependency versions live in the **catalog** in the root -`package.json`, not in each workspace. - -Backend-specific, all from `apps/api` and all local — no Cloudflare account -needed: - -```bash -bunx wrangler d1 migrations apply linuxhub --local # create/refresh the schema -bunx wrangler dev --local --test-scheduled # serve + allow cron firing -curl 'localhost:8787/__scheduled?cron=0+*/6+*+*+*' # releases ingest -curl 'localhost:8787/__scheduled?cron=0+3+*+*+*' # mirrors ingest -curl 'localhost:8787/__scheduled?cron=0+4+*+*+1' # weekly rankings snapshot -curl localhost:8787/v1/health -bunx wrangler d1 execute linuxhub --local --command "SELECT COUNT(*) FROM releases" -``` - -Run the BFF against a real Worker — the only way to exercise the proxy branch, -since e2e covers the snapshot one: - -```bash -cd apps/api && bunx wrangler dev --port 8787 --local & -bun run --filter '@linuxhub/web' build -cd apps/web && LINUXHUB_API_URL=http://127.0.0.1:8787 bunx vite preview --port 4173 -curl -sS --noproxy '*' localhost:4173/api/v1/health # expect mode: "worker" -``` - -Mind the working directory — `vite preview` started from `apps/api` silently -serves nothing and every route 404s. - -Regenerate the two committed data artifacts when their inputs change: - -```bash -bun packages/ingest/src/content-index.ts # after editing MDX frontmatter -bun packages/ingest/src/live.ts # the Phase 4 BFF snapshot -``` - -E2E is **not** part of `bun run test` — it needs a production build first: - -```bash -bun run --filter '@linuxhub/web' build -cd apps/web && bun run test:e2e # 17 tests incl. the axe WCAG 2 AA gate -``` - -Gate status at handoff: check/lint/test/build green in all 6 packages, -**73 unit tests** (44 in `apps/api`), 17/17 e2e green. - -Two traps this session paid for, worth knowing before you touch either file: - -- **`apps/web/src/app.html`**: `hooks.server.ts` substitutes the lang/dir - placeholders with a single-occurrence `String.replace`. Writing either token - literally anywhere earlier in the file — even inside a comment — consumes the - substitution and ships an unresolved attribute. It fails the axe gate on five - routes and breaks locale switching, and `bun run test` does **not** catch it. -- **Biome resolves to 2.5.4** (biome.json pins that schema; package.json still - allows `^2.3.0`), and 2.5 added rules that fire on Phase 4 files. If lint - breaks on code you did not touch, that is why. - -Docs carry no automated gate, so when you change one, check that the paths it -names still exist — `.ai/` docs referencing moved files is the most common -form of documentation rot here. (ADR entries in `.ai/decisions.md` are -historical records: never rewrite them, even when a path they mention has -since moved.) - -## Environment notes (remote container) - -These cost real time to rediscover. - -- **`bun install` and SvelteKit type generation run automatically** via - `.claude/hooks/session-start.sh` on session start. If `$types` imports fail, - run `cd apps/web && bunx svelte-kit sync`. -- **Bun's `fetch` fails through this container's HTTPS proxy.** The ingest CLI - therefore shells out to `curl` (`packages/ingest/src/live.ts`). On Workers in - Phase 5, use native `fetch` — the proxy constraint is local only. -- **Playwright** uses the prebuilt Chromium at `/opt/pw-browsers/chromium` when - present and falls back to its own download elsewhere; never run - `playwright install` here. -- **Do not `pkill -f` a pattern matching your own shell** — it kills the tool - call. Free a port with `fuser -k /tcp` instead. -- Preview/e2e ports in use: **4173** manual preview, **4174** Playwright. - -## Design is closed - -`.ai/design-system.md` is the contract; `design/` comps are the visual source -of truth and `packages/ui/src/styles.css` is their production port — the two -must stay identical. ADR-0017 (Flathub × WinUI 3) and ADR-0018 (acrylic) set -the language; the owner approved it on 2026-07-20. - -**Do not restyle screens while building features.** If a feature genuinely -needs a new visual pattern: add it to the component inventory in -`.ai/design-system.md`, build it in `design/`, get approval, then implement — -in that order (ADR-0012, and the golden rules in `CLAUDE.md`). - -Guardrails that must survive any feature work: -- every UI string goes through `@linuxhub/i18n` — no hardcoded text; -- logical CSS properties only, so RTL keeps mirroring; -- the axe gate blocks serious/critical violations — run it before committing UI; -- new colours come from tokens, never literal hex in a component. - -## Phase 5 — where it stands - -Goal: replace the mock BFF with the real Hono/D1 backend. `.ai/api.md` -(61 endpoints — the *target*), `.ai/database.md` (schema + KV keyspace), and -`.ai/backend-rules.md` (structure, validation, errors, caching, rate limits, -cron) are the specs. - -**Read `.ai/frontend-contract.md` first.** The shipped frontend consumes -shapes that differ from `.ai/api.md` — pre-formatted numbers, composed -English strings, presentation baked into payloads. That file maps every -current response field to its D1 source, lists the recurring problems to fix -rather than port, gives a safe endpoint-by-endpoint sequence for 5.4, and -records which of its architecture questions are now settled (1–3 answered by -ADR-0019/0020; question 4 is still open and blocks the editorial endpoints). - -The Worker's shape is set by the worked example in `.ai/backend-rules.md`: -route validates → service caches → `db/` holds every line of SQL. Fifty more -endpoints written to one pattern review far faster than fifty variations, so -copy `routes/distros.ts` rather than inventing a second shape. - -1. ✅ **Schema** — `migrations/0001_init.sql` … `0005_indexes.sql`, 15 tables - and 9 indexes (ADR-0019). -2. ✅ **Ingestion on the Worker** — cron triggers writing D1/KV from the three - verified sources, with per-row provenance and an `ingest_log` audit trail. -3. ✅ **Read endpoints** — nine of them, facts only (ADR-0020), tested against - a real Miniflare D1 (ADR-0021). -4. 🟡 **Point the BFF at the Worker** — `apps/web/src/routes/api/v1/*` still - reads `data.ts`; switch each to proxy the Worker with the caching TTLs in - `.ai/frontend-rules.md`. **This is the risky one** — it swaps the data - source under ~15 screens. Move one endpoint at a time in the order in - `.ai/frontend-contract.md`, run the e2e suite after each, and skip - `rankings`/`hall-of-fame`/`quiz` (see "Empty on purpose" above). The pages - also have to take back the formatting the API no longer does: `Intl` for - `downloads`, `@linuxhub/i18n` for every composed label, `initials` from - `name`, and a brand-colour lookup in `packages/ui`. - Delete `data.ts` only when nothing imports it (the compare/quiz/hall-of-fame - editorial content needs a home first). -5. **Write endpoints** — suggest/report/feedback behind hCaptcha + KV rate - limits, then wire the contribute forms. **Not started.** -6. ✅ **Download resolution** — endpoints #14/#15/#21 on the Worker and through - the BFF, the approved selector built in full, clicks counted in KV and - banked into `download_events` by the daily cron. **Done for arch and - fedora**; the other ten distros need an artifact source each before their - selector can light up, which is 5.7 work. Two rules learned here and worth - not relearning: a mirror row is only a download base if its source says so - (ADR-0024), and a resolved URL is not verified until it has been fetched. -7. **Retire `data.ts`** — replace each remaining placeholder symbol with its - real source per `.ai/data-sources.md`: specs and taxonomy from Wikidata, - editions from the official release APIs, requirements from official install - docs, release links from the announcement feeds, popularity from our own - counters. Editorial content (quiz, Hall of Fame) moves to content/ or D1. - The file is deleted when nothing imports it. - -Sitting outside that numbered list, and worth doing **before** 5.4 if the -family facet matters: a **Wikidata lineage fetcher** to fill -`distros.family`/`based_on` and the taxonomy tables. It was deliberately left -out of 5.2 to keep that task at its stated scope. `query.wikidata.org/sparql` -(CC0) needs a verified registry row in `.ai/data-sources.md` before its -fetcher is written, like any other source. - -Keep the BFF envelope `{ ok, data, meta }` unchanged throughout so the -frontend does not move while the backend lands under it. - -## Needs the owner, not an agent - -**The procedure for all of this is `docs/deployment.md`** — resources, secrets, -remote migrations, cron seeding, Vercel, rollback. Env templates are committed -at `apps/api/.dev.vars.example` and `apps/web/.env.example`. - - -- **Cloudflare account** — D1 database + 3 KV namespaces provisioned, real ids - substituted into `apps/api/wrangler.toml`; secrets (`HCAPTCHA_SECRET`, - `INTERNAL_API_TOKEN`, `RATE_SALT`) set via `wrangler secret put`. Until then - the schema, cron triggers and endpoints exist only in local state under - `apps/api/.wrangler/` (gitignored) — production D1 is empty and no cron has - ever fired there. First deploy needs - `wrangler d1 migrations apply linuxhub --remote`, then one manual run of each - schedule to populate it. -- **Two things to check after the first deploy**, because both fail quietly: - `GET /api/v1/health` must report `"mode":"worker"` (`"snapshot"` means the - Vercel env vars are unset and the site is serving committed data), and a - `POST /api/v1/downloads/resolve` URL must answer **200 when fetched** — a - wrong mirror base produces a plausible-looking URL that 404s (ADR-0024). -- ~~`SITE_ORIGIN`~~ — **set to `https://linuxhub.kro.kr`.** It lives in exactly - two places, both commented to point at each other: `[vars]` in - `apps/api/wrangler.toml` (what production uses) and `DEFAULT_SITE_ORIGIN` in - `packages/ingest/src/index.ts` (the build-time fallback). Changing domains = - edit both, then redeploy. `docs/deployment.md` §7. -- **Vercel project** — linked for the SvelteKit app, Root Directory - `apps/web`, with `LINUXHUB_API_URL` and a matching `INTERNAL_API_TOKEN`. - **The build needs the whole repo**, not just `apps/web`: `packages/*` are - consumed as TypeScript source and `content/distros/` is globbed from the - root. A missing `content/` would otherwise build cleanly and serve empty - distro pages, so the build fails on it instead — look for - `distro content: 36 docs across 12 distros` in the build log. -- **hCaptcha** site + secret keys — the *secret* half is a Wrangler secret, the - *site* half is a Vercel `PUBLIC_` var. Neither is read until task 5.5. -- ~~A decision on the Paraglide/inlang compiler~~ — **settled 2026-08-04 - (ADR-0022): keep the hand-rolled runtime through v1.** Revisit when authored - catalogs reach ~5 or the message payload shows up in the Phase 6 Lighthouse - budget. The first thing to fix then is the static `CATALOGS` map, which is - not necessarily the compiler. diff --git a/.ai/i18n.md b/.ai/i18n.md deleted file mode 100644 index a00a5bd..0000000 --- a/.ai/i18n.md +++ /dev/null @@ -1,133 +0,0 @@ -# Internationalization - -## Stack - -A **hand-rolled, Paraglide-shaped runtime** in `packages/i18n` (ADR-0016), -which the project will keep through v1 rather than adopting the inlang -compiler (**ADR-0022** — decided 2026-08-04, with the measurable trigger for -revisiting it). Message catalogs + the locale registry live in `packages/i18n` -and are the single runtime source; this table is the canonical human-readable -registry. - -Two consequences to know before writing i18n code: - -- Call sites use **property access** — `m.some_key`, not `m.some_key()`. The - function-call form arrives only if the compiler is adopted. -- Catalogs are **statically imported** into one `CATALOGS` map, so every - authored locale ships to every visitor. Harmless at two catalogs (~19 KB); - it is the first thing to fix as that number grows — see ADR-0022. - -## Locale registry - -| Label | BCP-47 | RTL | -|---|---|---| -| 繁體中文 | `zh-Hant` | | -| 日本語 | `ja` | | -| 한국어 | `ko` | | -| 简体中文 | `zh-Hans` | | -| azərbaycan | `az` | | -| brezhoneg | `br` | | -| British English | `en-GB` | | -| català | `ca` | | -| čeština | `cs` | | -| dansk | `da` | | -| Deutsch | `de` | | -| eesti | `et` | | -| English | `en` | | -| español | `es` | | -| Esperanto | `eo` | | -| euskara | `eu` | | -| Filipino | `fil` | | -| français | `fr` | | -| Gaeilge | `ga` | | -| galego | `gl` | | -| hrvatski | `hr` | | -| Indonesia | `id` | | -| interlingua | `ia` | | -| italiano | `it` | | -| kernewek | `kw` | | -| lietuvių | `lt` | | -| magyar | `hu` | | -| Nederlands | `nl` | | -| norsk bokmål | `nb` | | -| occitan | `oc` | | -| polski | `pl` | | -| português | `pt` | | -| português (Brasil) | `pt-BR` | | -| română | `ro` | | -| shqip | `sq` | | -| slovenčina | `sk` | | -| suomi | `fi` | | -| svenska | `sv` | | -| Taqbaylit | `kab` | | -| Tiếng Việt | `vi` | | -| Türkçe | `tr` | | -| Ελληνικά | `el` | | -| беларуская | `be` | | -| български | `bg` | | -| русский | `ru` | | -| українська | `uk` | | -| հայերեն | `hy` | | -| עברית | `he` | ✅ | -| العربية | `ar` | ✅ | -| فارسی | `fa` | ✅ | -| کوردیی ناوەندی | `ckb` | ✅ | -| हिन्दी | `hi` | | -| বাংলা | `bn` | | -| ਪੰਜਾਬੀ | `pa` | | -| தமிழ் | `ta` | | -| සිංහල | `si` | | - -Base/fallback locale: `en`. `en-GB` and `pt-BR` fall back to `en` / `pt` -respectively before `en`. - -## Routing - -- Locale prefix on every UI route: `/ko/explore`, `/ar/distro/fedora`. -- Prefix validated by a SvelteKit param matcher against the registry; - unknown → 404. -- `/api/*` routes are never locale-prefixed; content language via `?locale=`. - -## Geo detection & redirect - -1. Request without locale prefix → look up country - (Cloudflare `request.cf.country` on the Worker / Vercel geo headers on the - edge for the web app). -2. Map country → default locale via `KV_GEO` (`geo:`, 30 d TTL; - mapping table maintained in `packages/i18n`). -3. Redirect **once** to the localized route and set the locale cookie. -4. A cookie / explicit user choice (LocaleSwitcher) always overrides geo; - never re-redirect a user who has a stored preference. -5. `Accept-Language` is the tiebreaker when a country maps to multiple locales. - -## RTL - -RTL locales: **`ar`, `he`, `fa`, `ckb`**. Root layout sets -``. All styles use logical properties; icons with -inherent direction (arrows, chevrons) flip via a shared utility. Every screen -is verified mirrored in the a11y gate (Playwright screenshot pass in `ar`). - -## UI strings - -- 100% of UI strings via `packages/i18n` messages — hardcoded strings are a - review blocker (quality gate). -- English is authored first and is the complete reference catalog. -- Missing translation → English fallback automatically; no runtime errors. -- Dates, numbers, file sizes via `Intl` with active locale. - -## Content (MDX) translation strategy - -Distro docs (`content/distros///`) are translated -**progressively**: English first, then high-traffic locales (initial set: -`ko`, `ja`, `zh-Hans`, `de`, `fr`, `es`, `pt-BR`, `ru`). When a locale is -missing, serve English with `translated: false` and show the "untranslated" -badge. Translation coverage is reported by `GET /api/v1/stats/languages`. - -## Workflow - -1. Add/change a message in the `en` catalog (with the feature, same commit). -2. Machine-translate placeholder + mark for review, or leave missing - (fallback covers it) — never ship broken interpolations. -3. Reviewed translations replace placeholders over time. There is no inlang - project file while ADR-0022 stands; a new locale is one - `packages/i18n/src/messages..ts` file added to the `CATALOGS` map. diff --git a/.ai/project.md b/.ai/project.md deleted file mode 100644 index 928ed7f..0000000 --- a/.ai/project.md +++ /dev/null @@ -1,85 +0,0 @@ -# Project — Linuxhub - -## Vision - -Linuxhub is a modern, fast, multilingual catalog for browsing *almost every* -Linux distribution. It pairs **Flathub-style clean, content-first browsing** -with a **Modrinth-style guided download experience**: pick a version → edition -→ architecture → file format → country mirror, and get a direct download with -checksum and verification instructions — without leaving the page. It works on -every device (fully responsive) and in ~57 locales. - -## Primary users - -1. **Newcomers** choosing a first distro — need guidance (categories, the - distro-finder quiz, comparisons, plain-language descriptions). -2. **Enthusiasts** comparing releases — need dense, accurate release/edition/ - architecture data, changelogs, rank history. -3. **Downloaders** who want a fast, trustworthy download — need the right - mirror (geo-ranked), checksums, signatures, torrents/magnets. - -## Core features (v1) - -- **Explore/browse:** Flathub-style card grid with facet filters (category, - tag, family, desktop environment, architecture, based-on, status) and sort - (popularity, name, latest release, newest). -- **Distro detail:** hero (logo, name, family, badges) → MDX content tabs - (description / install / usage, per locale) → download panel → screenshots → - system requirements → related distros. -- **Download flow (Modrinth-style):** guided selector resolving to a direct - URL via `POST /api/v1/downloads/resolve`; persists last selection; shows file - size, checksum, mirror sponsor; click tracking. -- **Search:** fast fuzzy search over names/aliases/summaries across locales, - autocomplete, facet filters, keyboard command palette. -- **Taxonomy:** categories, tags, families (Debian/Arch/RPM/independent), - desktop environments. -- **Rankings & Hall of Fame:** week/month/year/all popularity from our own - signals, trending, rising movers, editorially curated Hall of Fame, per-distro - rank history sparkline. -- **Discovery extras:** compare view, distro-finder quiz, random distro, - recommendations, recently-updated feed, RSS/Atom, shareable SVG badges, - dark/light theme (`icons/light.svg` / `icons/dark.svg`). -- **Community (anonymous, captcha-gated):** suggest a distro, report a broken - mirror/link, feedback, mirror status reports. -- **i18n:** ~57 locales, locale-prefixed routes, geo-based initial locale, - RTL support (`ar`, `he`, `fa`, `ckb`), English fallback. - -## Non-goals (v1) - -- Hosting ISO files ourselves (we link to official mirrors only). -- User accounts / authentication. -- Comments or forums. - -Contributions stay anonymous + captcha-gated. These remain out of scope unless -a later roadmap item explicitly promotes them (see `.ai/roadmap.md`). - -## Glossary - -| Term | Meaning | -|---|---| -| **Distro** | A Linux distribution (e.g. Fedora, Arch). Identified by a URL-safe `slug`. | -| **Family** | Lineage root: Debian, Arch, RPM/Fedora, SUSE, Gentoo, Slackware, independent… | -| **Release** | A published version of a distro (version string + channel + dates). | -| **Channel** | Release track: `stable`, `lts`, `beta`, `rolling`. | -| **Edition** | A variant of a release: GNOME, KDE, Xfce, minimal, server, … | -| **Artifact** | A downloadable file: arch + format + size + sha256 (+ signature URL). | -| **Format** | Artifact kind: `.iso`, `.iso.torrent`, magnet link, checksum file, signature. | -| **Mirror** | A download host with country/region, protocol, sponsor. | -| **BFF** | The SvelteKit `/api` layer — the public API surface, proxying/caching the Worker. | -| **Ingest** | Scheduled fetching of release/mirror data from official sources into D1/KV. | -| **SSOT** | Single source of truth: `.ai/` > `docs/` > `prompts/` > code. | -| **Hall of Fame** | Editorially curated list of historically significant distros with cited rationale. | - -## Design language - -Modern, spacious, card-based — a blend of **Flathub** (calm, clean, -content-first) and **Modrinth** (playful micro-interactions, smooth -transitions, confident color accents). Final visuals come from Google Stitch; -see `.ai/design-system.md` and `prompts/stitch/`. - -## Constraints - -- Long-term project: correctness, consistency, and documentation always beat speed. -- All content paraphrased from official sources with attribution — never copied. -- All crawling robots.txt-compliant; official APIs preferred. -- No secrets in the repo. diff --git a/.ai/roadmap.md b/.ai/roadmap.md deleted file mode 100644 index 4fb512c..0000000 --- a/.ai/roadmap.md +++ /dev/null @@ -1,105 +0,0 @@ -# Roadmap & Phase Status - -> **Starting a new session?** Read `.ai/handoff.md` first — it carries the -> current state, what is real versus placeholder, the commands that work, and -> the environment notes. This file carries the plan. - -## Stage gates (binding — see CLAUDE.md) - -``` -Project Documentation → Repository Initialization → Design System → UI Design -→ Frontend Implementation → Backend Implementation → Testing → Deployment -``` - -## Phase status - -| Phase | Scope | Gate | Status | -|---|---|---|---| -| **0 — Documentation** | Root `CLAUDE.md` + all `.ai/` docs | docs committed | ✅ done | -| **1 — Repository Initialization** | Bun workspaces; `apps/web` + `apps/api`; `packages/shared\|ui\|i18n\|ingest`; Biome; CI; Wrangler + Vercel config | hello-world builds on both targets | ✅ done — live deploys need owner accounts (Phase 7) | -| **2 — Design System** | Tokens in `packages/ui`; 15 screen briefs in `prompts/stitch/` | briefs ready | ✅ done | -| **3 — UI Design** | HTML/CSS comps in `design/` (ADR-0012) | owner approval | ✅ **approved 2026-07-20** — Flathub × WinUI 3 (ADR-0017 + acrylic ADR-0018). **Design is frozen; see `.ai/handoff.md` before restyling anything** | -| **4 — Frontend Implementation** | All screens from the approved design, real data, content, i18n/RTL, motion, tests | quality gates pass | ✅ **complete** — 8 milestones, detail below | -| **5 — Backend Implementation** | D1/KV, Cron ingestion, endpoint surface, hCaptcha, rate limits, caching, feeds | quality gates pass | 🟡 **in progress — 5.1–5.3 done, 5.4 next** | -| **6 — Testing** | Coverage, Lighthouse budget, i18n + a11y sweep per Definition of Done | all green | ⬜ not started | -| **7 — Deployment** | Web → Vercel, API → Workers; secrets; smoke tests; runbook in `docs/` | live + runbook committed | 🟡 **repo side done** — security headers, robots/sitemap/feeds, a build that fails loudly when the Vercel root excludes `content/`, and a deploy guard against placeholder binding ids. Blocked only on owner accounts | - -### Phase 4 milestones (all done) - -1. Shell + all 15 screens on a mock BFF. -2. Screens wired to the BFF envelope with URL-as-state browsing. -3. Real data (ADR-0015) — official logos, brand mark, live release/EOL cycles - and Arch/Fedora mirrors from the committed snapshot, provenance surfaced. -4. mdsvex content pipeline — 36 authored distro docs with cited sources. -5. i18n runtime + locale routing (ADR-0016) — geo redirect, `/en` - canonicalization, full Korean catalog, RTL verified in `ar`. -6. Motion pass — View Transitions, reduced-motion honored throughout. -7. Playwright e2e + axe WCAG 2 AA gate (17 tests) and the contrast - corrections it surfaced. -8. Visual system rebuilt as Flathub × WinUI 3 (ADR-0017/0018) across - `packages/ui`, the `design/` comps, and every page-scoped style. - -### Phase 5 breakdown (ordered; one commit each) - -Specs: `.ai/api.md` (61 endpoints, the *target*) · `.ai/database.md` (schema + -KV keyspace) · `.ai/backend-rules.md` (structure, validation, errors, caching, -limits, cron) · **`.ai/frontend-contract.md`** (what the shipped frontend -actually consumes, and the five open questions to settle first). - -| # | Task | Done when | -|---|---|---| -| 5.1 | ✅ **D1 schema** — replace the empty `0001_init.sql` with the real tables | done — 15 tables, 9 indexes, applied with `wrangler d1 migrations apply linuxhub --local`; deviations in ADR-0019 | -| 5.2 | ✅ **Cron ingestion on the Worker** — port `packages/ingest/src/live.ts` to a Cron Trigger writing D1/KV with native `fetch` | done — a local scheduled run populates 42 releases + 16 mirrors, every row with `source_url` + `fetched_at`; a second pass changes no counts | -| 5.3 | ✅ **Read endpoints** — distros list/detail, releases, rankings, search | done — 9 endpoints, shared Zod schemas, standard envelope, 44 tests against a real D1 (ADR-0020, ADR-0021) | -| 5.4 | 🟡 **BFF proxies the Worker** — 4 of 5 movable routes done | `health`, `releases/recent`, `search`, `distros` proxy the Worker with the TTLs in `.ai/frontend-rules.md`, verified on both branches. **`distros/:slug` is the one left and is blocked**: the detail page renders editions, architectures, formats and requirements, and D1 has 0 editions, 0 artifacts and no requirements table — moving it would blank four sections. Unblocks with 5.6/5.7. `rankings`/`hall-of-fame`/`quiz` stay on `data.ts` for the same class of reason | -| 5.3b | **Wikidata lineage** — fill `distros.family`/`based_on` + taxonomy | ⛔ **blocked on 9 human-supplied QIDs.** Every lookup API (SPARQL, `/w/api.php`, Wikipedia REST) is robots-disallowed; only `Special:EntityData/.json` is permitted and it needs the QID as input. Verified 2026-08-04 — see `.ai/data-sources.md`. **Not a 5.4 prerequisite**: the UI has no family facet | -| 5.5 | **Write endpoints** — suggest/report/feedback behind hCaptcha + KV rate limits | contribute forms submit for real; limits covered by tests | -| 5.6 | ✅ **Download resolution** — edition/arch/format + region → real mirror URL + checksum | done for **arch and fedora** — the two distros with a verified artifact source. Endpoints #14/#15/#21 on the Worker and through the BFF; the approved selector is now built in full (checksum + Copy, skeletons, no-match and resolve-failed states); download clicks count in KV and the daily cron banks them into `download_events`. Verified by fetching resolved URLs: Fedora 43 Workstation x86_64 → 2,742,190,080 bytes, Arch 2026.08.01 → 1,597,014,016 bytes, both 200. **The other ten distros show the placeholder section** until their artifact sources are registered (ADR-0024 for the mirror-base rule) | -| 5.7 | **Retire `data.ts`** — every placeholder symbol replaced by an API/feed/Wikidata source per `.ai/data-sources.md`; editorial content moved to content/ or D1 | nothing imports `data.ts` and the file is deleted; every factual field carries `source_url` + `fetched_at` | - -**Sourcing rule for 5.7 (binding):** facts come from official APIs, RSS/Atom -feeds, or Wikidata — never hand-typed; popularity comes from our own counters -— never from third-party charts; editorial content stays authored but carries -resolvable citations. The per-symbol plan and 12 verified announcement feeds -are in `.ai/data-sources.md`. - -Then iterate: Documentation → Design → Implementation → Testing → Review → -Documentation Update. - -## Stitch screens to brief (Phase 2 checklist, minimum) - -- [x] Home / landing -- [x] Explore / browse (grid + filters) -- [x] Distro detail (MDX + download panel) -- [x] Download selector modal -- [x] Rankings -- [x] Hall of Fame -- [x] Category / Tag page -- [x] Search results -- [x] Compare -- [x] Distro-finder quiz -- [x] Contribute / report -- [x] About -- [x] 404 / error -- [x] Global nav + language switcher -- [x] Mobile nav - -## Product roadmap - -### MVP (first public deploy) -Explore grid + facets; distro detail with MDX (en + ko) and download selector; -search + autocomplete; categories/tags/families/desktops; ~30 onboarded -distros with verified sources; mirrors + geo ranking; checksums; i18n shell -(all locales routed, UI translated for initial set, English fallback); -dark/light; RSS/Atom; sitemap. - -### v1 -50+ endpoint surface complete; rankings (week/month/year/all) + trending + -rising + rank history; Hall of Fame (editorial); compare; quiz; random; -recommendations; badges + OpenSearch; contribute/report/feedback -(hCaptcha-gated); 100+ distros; translation coverage growth. - -### Later (explicitly out of v1 scope — revisit deliberately) -User accounts; comments/reviews; API keys for third parties; mirror -self-service portal; more ranking signals (only with permitted sources); -mobile apps. diff --git a/.ai/security.md b/.ai/security.md deleted file mode 100644 index df9e81a..0000000 --- a/.ai/security.md +++ /dev/null @@ -1,126 +0,0 @@ -# Security - -## hCaptcha — write actions only - -| Endpoint | Captcha | -|---|---| -| `POST /suggest-distro`, `POST /report`, `POST /feedback`, `POST /mirrors/report-status` | **required**, verified server-side in the Worker against `HCAPTCHA_SECRET` before any processing | -| Browsing, search, `downloads/resolve`, `downloads/track` | **never** gated by captcha (rate-limited instead) | - -Frontend renders hCaptcha only on the contribute/report forms via the shared -`CaptchaGate` component. Sitekey is public config; the secret exists only as a -Wrangler secret. - -## Rate limiting - -KV fixed-window counters keyed on a **salted hash** of the client IP -(`rl:::`); raw IPs are never stored or logged. - -| Scope | Limit (per IP) | -|---|---| -| write endpoints (each) | 5 / min, 30 / day | -| `downloads/resolve` | 30 / min | -| `downloads/track` | 30 / min | -| `search/suggest` | 60 / min | - -Over limit → `429` + `Retry-After`. Limits are constants in -`packages/shared` — change = update here too. - -## Headers (both apps) - -**Implemented.** Asserted by `apps/web/e2e/security.spec.ts` and -`apps/api/test/health.test.ts` — a wrong header is invisible on the page, so it -has to be a test rather than a convention. - -### Web (`apps/web`) - -`Content-Security-Policy` is declared in **`svelte.config.js`** under -`kit.csp`, not hand-written: only SvelteKit knows the hashes and the nonce of -the scripts it injects. Everything else is set in `hooks.server.ts`, so the -headers are identical in dev, in `vite preview` and in production. - -| Directive | Value | Note | -|---|---|---| -| `default-src` | `'self'` | | -| `script-src` | `'self'` + SvelteKit's nonce | | -| `style-src` | `'self' 'unsafe-inline'` | the approved comps use inline `style="…"` attributes throughout; see below | -| `img-src` | `'self' data:` | | -| `font-src`, `connect-src` | `'self'` | `connect-src` is what keeps the BFF the browser's only correspondent | -| `frame-src` | `'none'` | hCaptcha's hosts are added here when the write endpoints land in 5.5, and not before | -| `object-src`, `frame-ancestors` | `'none'` | | -| `base-uri`, `form-action` | `'self'` | | - -`'unsafe-inline'` on **`style-src` only** is a deliberate, bounded exception. -A style attribute cannot execute, every value in one comes from our own markup -rather than from user input, and removing it means editing every approved -screen — a design change, not a security fix. It is **never** acceptable on -`script-src`. - -Plus, from `hooks.server.ts`: - -- `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` -- `Referrer-Policy: strict-origin-when-cross-origin` -- `X-Content-Type-Options: nosniff` -- `X-Frame-Options: DENY` — redundant with `frame-ancestors` in modern - browsers, and the only protection in the ones that predate it -- `Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()` - -The pre-paint theme script lives in **`static/theme.js`**, not inline in -`app.html`. SvelteKit nonces only the scripts it injects itself, so an inline -one is blocked and dark-mode visitors get a flash of light before hydration. -`script-src 'self'` covers the external file. - -### Worker (`apps/api`) - -`withSecurityHeaders` sets `default-src 'none'; frame-ancestors 'none'`, -`nosniff`, `DENY`, `no-referrer` and HSTS on every response including errors. -The Worker returns JSON to the BFF and nothing else; if one of its responses is -ever being rendered by a browser, something has gone wrong, and the strictest -possible policy makes that failure inert. - -## Secrets - -| Secret | Where | -|---|---| -| `HCAPTCHA_SECRET` | Wrangler secret (api) | -| `INTERNAL_API_TOKEN` | Wrangler secret (api) + Vercel env (web) | -| `RATE_SALT` | Wrangler secret (api) | - -Never in the repo, never in logs, never in error responses. `.env*` is -gitignored; local dev uses `.dev.vars` (gitignored) for Wrangler. - -## Internal surface - -The Worker rejects requests without a valid `X-Internal-Token` (constant-time -compare) except `/v1/health`. The BFF is the only public entry point and -forwards nothing sensitive from clients. - -## Input handling - -- Zod validation on every input at both layers (BFF + Worker). -- D1 access only via bound parameters — no string-built SQL. -- MDX content is authored in-repo (trusted), but frontmatter is schema-validated - at build; community submission payloads are stored as opaque JSON and - rendered escaped in any admin view. -- Uploaded/ingested URLs are validated (https, allowlisted hosts per - `.ai/data-sources.md`) before being stored as download/mirror URLs. - -## Crawler ethics (binding for `packages/ingest`) - -- **Respect `robots.txt`** — parsed and cached per host; disallowed paths are - never fetched. Prefer official APIs over HTML scraping. -- Descriptive User-Agent: `linuxhub-ingest/ (+https:///about#crawler)` - with a contact URL. -- Polite rates: ≤1 request/sec/host, exponential backoff on 429/5xx, - conditional requests (`ETag`/`If-Modified-Since`) wherever supported. -- Cache aggressively (KV `versions:*`); never re-fetch unchanged resources - inside a TTL window. -- Log `source_url` + `fetched_at` for every ingested record (`ingest_log`). -- Per-source robots/ToS status recorded in `.ai/data-sources.md` **before** - a fetcher is written; if terms forbid use, the source is not used. - -## Privacy - -No accounts, no cookies beyond locale/theme preferences (no tracking -cookies), no third-party analytics in v1. Download tracking is aggregate -per-artifact-per-day counts only. diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh deleted file mode 100755 index 751a99b..0000000 --- a/.claude/hooks/session-start.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -# SessionStart hook — prepares a cold Claude Code on the web container so the -# quality gates (`bun run check` / `lint` / `test` / `build` / `test:e2e`) work -# on the first try. Safe to re-run; skipped on local machines. -set -euo pipefail - -if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then - exit 0 -fi - -cd "${CLAUDE_PROJECT_DIR:-$(dirname "$0")/../..}" - -# Bun workspaces: one install covers apps/* and packages/*. -bun install - -# SvelteKit generates ./$types for every route from the route tree. Without a -# sync, `bun run check` and any editor tooling fail on a fresh clone with -# "Cannot find module './$types'". -(cd apps/web && bunx svelte-kit sync) - -# This image ships a prebuilt Chromium; playwright.config.ts uses it when the -# path exists and falls back to Playwright's own browser everywhere else. -if [ -x /opt/pw-browsers/chromium ] && [ -n "${CLAUDE_ENV_FILE:-}" ]; then - echo 'export PLAYWRIGHT_CHROMIUM_PATH="/opt/pw-browsers/chromium"' >> "$CLAUDE_ENV_FILE" - echo 'export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1' >> "$CLAUDE_ENV_FILE" -fi - -echo "linuxhub: dependencies installed, SvelteKit types synced." diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 23532b3..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" - } - ] - } - ] - } -} diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index b8e0e02..0000000 --- a/.editorconfig +++ /dev/null @@ -1,34 +0,0 @@ -# Editor defaults for the files Biome does not own. -# -# Biome formats TS/JS/JSON/CSS and is the authority there — these settings -# agree with biome.json (tabs, 100 columns) so an editor and the formatter -# never fight. The value is in everything else: SQL migrations, Markdown, and -# YAML workflows, which Biome does not touch and which drift otherwise. - -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true -indent_style = tab -max_line_length = 100 - -# YAML has no tab support at all — a tab is a parse error, not a style choice. -[*.{yml,yaml}] -indent_style = space -indent_size = 2 - -# D1 migrations are read as much as run; two spaces keeps column alignment -# readable at the width the schema is written to. -[*.sql] -indent_style = space -indent_size = 2 - -# Trailing whitespace is a hard line break in Markdown, so stripping it would -# silently reflow prose. -[*.md] -trim_trailing_whitespace = false -indent_style = space -indent_size = 2 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index b0f0a48..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,36 +0,0 @@ -# Code owners. -# -# GitHub requests a review from the owner of every path a pull request touches. -# The point here is not gatekeeping — it is a single maintainer repo — but that -# the paths below are the ones where a change has consequences beyond the diff, -# so a review request on them is a useful signal rather than noise. - -* @neramc - -# Project memory. `.ai/` outranks docs/, prompts/ and the code itself in the -# SSOT order declared in CLAUDE.md, so a change here silently redefines what -# every future session believes about the project. -/.ai/ @neramc -/CLAUDE.md @neramc - -# Append-only by rule. An edit to an existing ADR is a rewrite of history and -# should never appear in a diff; a new one at the end should. -/.ai/decisions.md @neramc - -# The design is frozen (ADR-0017/0018, owner-approved). Restyling an approved -# screen requires a new comp and re-approval before any code moves. -/design/ @neramc -/packages/ui/src/tokens.css @neramc -/packages/ui/src/styles.css @neramc - -# Anything that changes what we fetch, how often, or from where. The crawler -# policy in .ai/security.md is binding and a source must be verified before its -# fetcher exists. -/.ai/data-sources.md @neramc -/packages/ingest/ @neramc - -# Deployment surface: schema migrations are forward-only, secrets and bindings -# live here, and CI is what stops the rest from shipping broken. -/apps/api/migrations/ @neramc -/apps/api/wrangler.toml @neramc -/.github/ @neramc diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 24962ce..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -blank_issues_enabled: true -contact_links: - - name: Report a security vulnerability - url: https://github.com/neramc/linuxhub/security/advisories/new - about: Please report privately here rather than in a public issue. Scope and expectations are in SECURITY.md. - - name: How this project works - url: https://github.com/neramc/linuxhub/blob/main/CONTRIBUTING.md - about: Setup, the checks a change has to pass, and the rules that will get one rejected. diff --git a/.github/ISSUE_TEMPLATE/data-problem.yml b/.github/ISSUE_TEMPLATE/data-problem.yml deleted file mode 100644 index dfc342d..0000000 --- a/.github/ISSUE_TEMPLATE/data-problem.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Wrong or stale data -description: A version, date, mirror or other fact on the site is incorrect. -labels: [data] -body: - - type: markdown - attributes: - value: | - Every factual value on the site is fetched from an official source and - stored with the URL it came from and when. So the useful report is not - just "this is wrong" but **which value, and what the source says** — - that tells us whether the fetcher is broken, the source moved, or the - source itself is wrong. - - Some fields are blank on purpose because no permitted source exists for - them yet — rankings and download counts especially. Those are not bugs; - see the "empty on purpose" table in `.ai/handoff.md`. - - - type: input - id: where - attributes: - label: Where - description: The page, and the field on it. - placeholder: /distro/fedora — the version table says 43, latest is 44 - validations: - required: true - - - type: textarea - id: expected - attributes: - label: What it should say, and how you know - description: An official URL carries far more weight than a correction on its own. - placeholder: | - Fedora 44 released 2026-04-28 — https://fedoraproject.org/… - validations: - required: true - - - type: input - id: provenance - attributes: - label: Provenance line on the page, if shown - description: The distro page prints where its data came from and when. Pasting it tells us if ingestion is simply stale. - placeholder: endoflife.date — fetched 2026-08-04T03:44Z diff --git a/.github/ISSUE_TEMPLATE/maintainer-request.yml b/.github/ISSUE_TEMPLATE/maintainer-request.yml deleted file mode 100644 index dc43994..0000000 --- a/.github/ISSUE_TEMPLATE/maintainer-request.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Distribution maintainer request -description: You maintain a distro or mirror we catalog and want something changed or removed. -labels: [maintainer-request] -body: - - type: markdown - attributes: - value: | - These are honoured without argument and without asking you to justify - them. You do not need to prove you speak for the project — if the - request is to *stop* doing something, we stop first and sort out the - details afterwards. - - - type: dropdown - id: kind - attributes: - label: What would you like changed? - options: - - Remove our use of your logo - - Our crawler is causing you problems - - Stop using a particular source of ours - - Correct how the project is described - - Something else - validations: - required: true - - - type: input - id: distro - attributes: - label: Distribution or mirror - validations: - required: true - - - type: textarea - id: detail - attributes: - label: Details - description: | - For crawler problems, the User-Agent and rough timing help — our - ingestion identifies as `linuxhub-ingest/` and should be - making at most one request per second per host. If you are seeing more - than that, it is a bug and we want to know. - validations: - required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index d95ddd4..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,56 +0,0 @@ - - -## What changed and why - - - -## Which doc this satisfies - - - -## Gates - -``` -bun run check:boundaries && bun run check && bun run lint && bun run test && bun run build -``` - -- [ ] The above passes. -- [ ] **UI changed?** Also ran the e2e + axe gate, which is *not* part of - `bun run test`: - `bun run --filter '@linuxhub/web' build && cd apps/web && bun run test:e2e` - -## Things this project treats as blocking - -Tick only what applies; delete the rest. - -- [ ] **Design is frozen** (ADR-0017/0018). No approved screen was restyled. - A genuinely new visual pattern went inventory → `design/` comp → - approval → code, in that order. -- [ ] **No hand-typed facts.** Every factual value came from an official API, - feed or structured source and is stored with `source_url` + `fetched_at`. - Popularity comes only from our own counters. -- [ ] **A new data source** was registered in `.ai/data-sources.md` with its - robots/ToS verified *before* its fetcher was written. -- [ ] **No hardcoded UI strings** — everything goes through `@linuxhub/i18n`, - and layout uses logical CSS properties so RTL keeps mirroring. -- [ ] **Schema change** ships as a new forward-only migration, with - `.ai/database.md` updated in the same commit. - -## Anything left deliberately incomplete - - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 04df901..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -# A push that supersedes an in-flight run makes that run irrelevant; cancel it -# rather than paying for both. `main` is excluded so pushes there always finish -# — a cancelled run on the default branch leaves the badge lying about state. -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} - -jobs: - ci: - name: check · lint · test · build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - # Read from the root package.json "packageManager" field rather than - # tracking latest — a toolchain that moves without a commit is how - # Biome 2.5 silently started failing lint on untouched files. - bun-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Workspace boundaries - run: bun run check:boundaries - - - name: Lint & format (Biome) - run: bun run lint - - - name: Typecheck - run: bun run check - - - name: Test - run: bun run test - - - name: Build (web + api dry-run) - run: bun run build - - e2e: - name: e2e · accessibility - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile - - # Chromium is ~150 MB and changes only when Playwright does, so key the - # cache on the resolved Playwright version rather than the lockfile — - # otherwise every unrelated dependency bump throws the browser away. - - name: Resolve Playwright version - id: pw - working-directory: apps/web - run: echo "version=$(bunx playwright --version | tr ' ' '-')" >> "$GITHUB_OUTPUT" - - - name: Cache Playwright browsers - id: pw-cache - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }} - - - name: Install Playwright Chromium - working-directory: apps/web - run: bunx playwright install --with-deps chromium - if: steps.pw-cache.outputs.cache-hit != 'true' - - # A cache hit restores the browser but not its OS packages, which are - # outside ~/.cache. This installs those alone and is a no-op otherwise. - - name: Install Chromium system dependencies - working-directory: apps/web - run: bunx playwright install-deps chromium - if: steps.pw-cache.outputs.cache-hit == 'true' - - # The e2e suite runs against the production build, and the axe job is - # part of the accessibility gate in .ai/roadmap.md — both must block. - - name: Build web - run: bun run --filter '@linuxhub/web' build - - - name: E2E + accessibility (Playwright + axe) - working-directory: apps/web - run: bun run test:e2e - - - name: Upload Playwright report on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: apps/web/playwright-report - retention-days: 7 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 2d0adb2..0000000 --- a/.gitignore +++ /dev/null @@ -1,27 +0,0 @@ -# Dependencies -node_modules/ - -# Build outputs -dist/ -.svelte-kit/ -.vercel/ -.wrangler/ -coverage/ - -# Environment & secrets (never commit — see .ai/security.md) -.env -.env.* -.dev.vars -# …but the templates carry no values and must be committed. Without these -# negations `.env.*` silently swallows `.env.example`. -!.env.example -!.dev.vars.example - -# Playwright -test-results/ -playwright-report/ - -# Misc -*.log -*.tsbuildinfo -.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 87eafc0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,173 +0,0 @@ -# CLAUDE.md — Linuxhub Operating Rules - -> This file is the entry point for Claude Code in the `neramc/linuxhub` monorepo. -> It was generated in **Phase 0** from the master brief and is now, together with -> `.ai/`, the repository's single source of truth. When this file and the master -> brief disagree, **this file and `.ai/` win**. - -## What Linuxhub is - -A modern, fast, multilingual catalog for browsing almost every Linux distribution, -with a Modrinth-style guided download flow (version → edition → architecture → -file format → mirror) and Flathub-style clean, card-based browsing. Fully -responsive, ~57 locales, no user accounts in v1. See `.ai/project.md`. - -## Roles - -- **Claude Code** is both the *designer* and the *implementation environment* - (designer role assumed from Google Stitch per **ADR-0012**): it produces - high-fidelity HTML/CSS design comps in `design/` from the briefs in - `prompts/stitch/`, and after human approval implements frontend + backend, - writes tests, refactors, and keeps documentation synchronized. Claude Code - **never** invents architecture ad hoc, ignores project rules, duplicates - components, or changes an approved screen without updating the comp and - getting re-approval first. -- **The human** reviews and approves designs at the Phase 3 gate and remains - the decision authority for scope and process changes. - -## Golden rules (non-negotiable) - -1. Documentation comes before implementation. -2. Design comes before frontend development. -3. Architecture is never decided during implementation — if it's undefined, - stop and write the doc first. -4. Every change is documented. -5. Every implementation follows the documentation. -6. Every completed phase is committed independently. -7. Consistency is more important than speed. - -## Single Source of Truth — priority order - -When information conflicts, follow this order and **stop implementation until -the higher-priority doc is corrected**: - -1. `.ai/` — project memory (see index below) -2. `docs/` — human-facing docs, ADRs, runbooks -3. `prompts/` — AI prompts, including Stitch design briefs -4. Source code - -## Stage gates - -Work proceeds strictly through these stages. Never start a stage until the -previous one is completed **and committed**: - -``` -Project Documentation → Repository Initialization → Design System → UI Design -→ Frontend Implementation → Backend Implementation → Testing → Deployment -``` - -Current phase status is tracked in `.ai/roadmap.md`. - -## `.ai/` index - -| Doc | Contents | -|---|---| -| **`.ai/handoff.md`** | **Read first in a new session** — current state, real vs placeholder, working commands, environment notes, Phase 5 entry point | -| `.ai/project.md` | Vision, users, scope, non-goals, glossary | -| `.ai/architecture.md` | System diagram, runtime boundaries, caching, data flow | -| `.ai/design-system.md` | Tokens, component inventory, references — **Stitch consumes this** | -| `.ai/component-rules.md` | Naming, composition, reuse, a11y baseline | -| `.ai/frontend-rules.md` | SvelteKit routing, BFF conventions, state, i18n usage | -| `.ai/backend-rules.md` | Hono structure, validation, errors, caching, rate limits, cron | -| `.ai/api.md` | Target endpoint catalog (61), schemas, pagination, error envelope | -| **`.ai/frontend-contract.md`** | **What the shipped frontend actually consumes** — read before writing any endpoint; where it diverges from `api.md` | -| `.ai/database.md` | D1 schema, migrations plan, KV keyspace + TTLs | -| `.ai/security.md` | hCaptcha, headers/CSP, rate limits, secrets, crawler ethics | -| `.ai/i18n.md` | Locale registry, routing, RTL, geo detection, fallbacks | -| `.ai/content.md` | Distro MDX authoring rules, frontmatter, review checklist | -| `.ai/data-sources.md` | Per-distro sources, robots status, logo licenses, cadence | -| `.ai/roadmap.md` | Phase plan + MVP → v1 → later, current status | -| `.ai/decisions.md` | ADR log — append every meaningful decision | - -## Tech stack (fixed — changes require an ADR in `.ai/decisions.md`) - -| Layer | Choice | -|---|---| -| Monorepo | Bun workspaces | -| Language | TypeScript (strict) everywhere | -| Frontend | SvelteKit (Svelte 5 runes) → **Vercel** via `@sveltejs/adapter-vercel` | -| Public API | SvelteKit `/api` routes (BFF) proxying + caching the backend | -| Backend | Hono → **Cloudflare Workers** via Wrangler | -| Relational DB | Cloudflare **D1** (SQLite) | -| KV cache | Cloudflare **KV** | -| Captcha | hCaptcha (write endpoints only) | -| Content | mdsvex (MDX-style markdown), per distro per locale | -| Lint/format | Biome | -| Tests | Vitest (unit) + Playwright (e2e) | -| i18n | Paraglide (inlang) for SvelteKit | - -**Toolchain nuance — don't get this wrong:** Bun is the package manager, test -runner, and dev toolchain. The **deployed backend runs on the Cloudflare -Workers runtime (workerd)**, not Bun; the frontend runs on Vercel's runtime. -Write backend code against Workers APIs (Web `fetch`, D1/KV bindings). Use Bun -only for local dev/build/test. - -## Design workflow (ADR-0012) - -``` -Design Documentation (briefs) → HTML/CSS Design Comps in design/ -→ Human Design Review → Approved Design → Implementation -``` - -- `prompts/stitch/*.md` briefs are the per-screen design requirements; - `design/` holds the comps built strictly from `packages/ui` tokens. -- The human reviews the comps (both themes, breakpoints, RTL, states) and - marks the design **approved**. Only then does Phase 4 implement it. -- If something can't be built as designed, update the comp, get re-approval, - then implement. **Never silently redesign in code.** - -**Design status: frozen.** The owner approved Flathub × WinUI 3 on 2026-07-20 -(ADR-0017 + ADR-0018). Feature work must not restyle approved screens; a -genuinely new visual pattern goes inventory → comp → approval → code, in that -order. - -## Guardrails — never do these - -- Never copy official distro documentation verbatim. Paraphrase, keep it - accurate, cite sources (see `.ai/content.md`). -- Never crawl outside `robots.txt`. Prefer official APIs. Descriptive - User-Agent + contact URL, polite rate limits, aggressive caching - (see `.ai/security.md`, `.ai/data-sources.md`). -- Never put secrets in the repo. Wrangler secrets / Vercel env vars only. -- Never redesign an approved screen in code. -- Never merge unrelated changes into one commit. - -## Git workflow - -One completed task = one commit. Conventional Commits. Reference the doc a -change satisfies. Examples: - -``` -docs(ai): add project, architecture, and design-system docs -docs(design): write stitch briefs for explore and distro pages -feat(db): add d1 schema and migrations for distros and releases -feat(api): add distro list, detail, and download-resolve endpoints -feat(web): implement explore grid from approved design -test(api): cover download resolution and rate limiting -``` - -## Quality gates (run before every commit) - -```bash -bun run check # types across all packages -bun run lint # Biome (bun run format autofixes) -bun run test # vitest -bun run build # web + api dry-run -``` - -UI changes additionally need the e2e + accessibility gate, which runs against -a production build and is **not** part of `bun run test`: - -```bash -bun run --filter '@linuxhub/web' build && cd apps/web && bun run test:e2e -``` - -## Definition of Done (per stage) - -- TypeScript strict passes; Biome lint/format clean. -- Vitest unit tests for API + ingestion; Playwright e2e for browse + download. -- Accessibility: keyboard-navigable, axe passes, RTL verified, reduced-motion - honored. -- Performance: good Lighthouse scores on Explore + Distro pages. -- i18n: no hardcoded UI strings; all locales resolve; English fallback works. -- The relevant `.ai/` doc is updated **in the same change** and committed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index f5f6beb..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,91 +0,0 @@ -# Contributing - -Thanks for looking. This file is deliberately short — it points at the real -documents rather than restating them, because a second copy of a rule is a -copy that goes stale. - -## Setup - -```bash -bun install # Bun is the package manager, test runner and script runner -bun run dev:web # http://localhost:5173, no credentials needed -``` - -The dev server runs entirely from a committed data snapshot. You do not need a -Cloudflare or Vercel account to work on anything except deployment. - -Layout, workspace boundaries and how to add a package: [`docs/monorepo.md`](docs/monorepo.md). - -## Before you open a pull request - -```bash -bun run check:boundaries && bun run check && bun run lint && bun run test && bun run build -``` - -**If you touched the UI, also run the e2e and accessibility gate.** It is not -part of `bun run test`, it needs a production build first, and it has already -caught a change that every other check passed: - -```bash -bun run --filter '@linuxhub/web' build && cd apps/web && bun run test:e2e -``` - -The pull request template asks which documentation your change satisfies. That -is not paperwork — see the next section. - -## Five rules that will get a change rejected - -These are the project's, not preferences, and they are stated in full in -[`CLAUDE.md`](CLAUDE.md) and the `.ai/` docs. - -1. **No hand-typed facts.** Versions, dates, editions, requirements and mirror - lists come from an official API, feed or structured source, and are stored - with `source_url` + `fetched_at`. If there is no fetchable source, the field - stays empty. `apps/web/src/lib/server/data.ts` is a *work list of things - still to be sourced*, not a place to add data. -2. **Register a source before writing its fetcher.** A new upstream needs a row - in [`.ai/data-sources.md`](.ai/data-sources.md) with its `robots.txt` and - terms actually checked — not assumed. A source that disallows us is not - used; we link out instead. -3. **Popularity comes only from our own signals.** Never a third-party - popularity chart, under any framing. -4. **The design is frozen** (ADR-0017/0018, owner-approved). Do not restyle an - approved screen. A genuinely new visual pattern goes component inventory → - comp in [`design/`](design/) → approval → code, in that order. -5. **No hardcoded UI strings, no physical CSS properties.** Everything goes - through `@linuxhub/i18n`; layout uses logical properties so right-to-left - locales keep mirroring. - -Distro documentation has its own rules — paraphrase, never copy, and cite every -source in the frontmatter. See [`.ai/content.md`](.ai/content.md). - -## Commits and decisions - -One completed task, one commit, [Conventional Commits](https://www.conventionalcommits.org/), -and update the `.ai/` doc a change affects **in the same commit** — a doc that -lags its code is worse than no doc. - -If your change makes an architectural decision, append an ADR to -[`.ai/decisions.md`](.ai/decisions.md). Append: never edit an existing entry, -even to correct a path that has since moved. They are a record of what was -decided and why, not a description of the present. - -If a decision is genuinely open, stop and raise it rather than settling it -quietly in code. - -## Reporting a security issue - -See [`SECURITY.md`](SECURITY.md). Please do not open a public issue for a -vulnerability. - -## If you maintain a distribution we catalog - -Two things you may want, both handled without argument: - -- **Our crawler is bothering you.** The policy is in - [`.ai/security.md`](.ai/security.md) — official APIs preferred, `robots.txt` - respected, one request per second per host. If our traffic is still a problem, - open an issue and we will change or drop the source. -- **We are using your logo and you would rather we did not.** Say so and we - remove the file and link out to you instead. Attribution and trademark notes - are in [`assets/distros/ATTRIBUTION.md`](assets/distros/ATTRIBUTION.md). diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 86ae283..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,99 +0,0 @@ -# Apache License - -**Version 2.0, January 2004** - - ------ - -## TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -### 1. Definitions - -**“License”** shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. - -**“Licensor”** shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. - -**“Legal Entity”** shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, “control” means: - -- (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or -- (ii) ownership of fifty percent (50%) or more of the outstanding shares, or -- (iii) beneficial ownership of such entity. - -**“You”** (or **“Your”**) shall mean an individual or Legal Entity exercising permissions granted by this License. - -**“Source” form** shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. - -**“Object” form** shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. - -**“Work”** shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). - -**“Derivative Works”** shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - -**“Contribution”** shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, “submitted” means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as “Not a Contribution.” - -**“Contributor”** shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. - -### 2. Grant of Copyright License - -Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. - -### 3. Grant of Patent License - -Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. - -### 4. Redistribution - -You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - -- **(a)** You must give any other recipients of the Work or Derivative Works a copy of this License; and -- **(b)** You must cause any modified files to carry prominent notices stating that You changed the files; and -- **(c)** You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and -- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. - -You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. - -### 5. Submission of Contributions - -Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. - -### 6. Trademarks - -This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. - -### 7. Disclaimer of Warranty - -Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an **“AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND**, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. - -### 8. Limitation of Liability - -In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. - -### 9. Accepting Warranty or Additional Liability - -While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. - ------ - -**END OF TERMS AND CONDITIONS** - ------ - -## APPENDIX: How to apply the Apache License to your work - -To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets `[]` replaced with your own identifying information. (Don’t include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same “printed page” as the copyright notice for easier identification within third-party archives. - -```text -Copyright 2026 neramc - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` diff --git a/README.md b/README.md deleted file mode 100644 index b7e7d42..0000000 --- a/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# Linuxhub - -A fast, multilingual catalog for browsing almost every Linux distribution — -Flathub-style card browsing paired with a guided download flow that walks you -from version → edition → architecture → file format → country mirror and hands -you a direct link with its checksum. - -[![CI](https://github.com/neramc/linuxhub/actions/workflows/ci.yml/badge.svg)](https://github.com/neramc/linuxhub/actions/workflows/ci.yml) -[![License: Apache 2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE.md) - -> **Status: pre-launch.** Not deployed yet. The frontend is complete across all -> 15 screens with real release data, 36 authored distro docs, and a working -> Korean locale; the backend has its schema, cron ingestion and read endpoints, -> and the site is mid-migration from a committed data snapshot onto that -> backend. Download resolution — the flow described above — is **not built -> yet**. `.ai/handoff.md` is the honest, current picture, including a table of -> what is deliberately empty and why. - -## Why it exists - -Choosing and downloading a Linux distribution is worse than it should be. -Release pages disagree with each other, mirrors are a wall of unlabelled URLs, -checksums are a separate page you are trusted to find, and almost none of it -exists outside English. Linuxhub is an attempt at the boring version of that -problem being solved properly: accurate data with visible provenance, a -download you can verify, in your own language. - -## Two rules that shape the whole codebase - -Both are enforced rather than encouraged, and they explain most of the design -decisions you will run into: - -**Nothing factual is hand-typed.** Versions, release dates, EOL dates, LTS -flags and mirror lists all come from official APIs, and every row stores the -`source_url` and `fetched_at` it came from. If a fact has no source we can -fetch, the field stays empty and the UI says nothing rather than guessing. -Several tables are empty right now for exactly this reason. - -**Popularity comes only from our own signals.** Download counts and rankings -are built from clicks this site actually observes. Scraping a third-party -popularity chart is forbidden, which is why rankings are empty until the site -has traffic rather than seeded with plausible numbers. - -The corollary matters if you are running a distro's infrastructure: everything -we fetch goes through a documented crawler policy — official APIs preferred, -`robots.txt` respected (a disallowed source is simply not used), one request -per second per host, conditional requests, aggressive caching, and a -descriptive User-Agent with a contact URL. See -[`.ai/security.md`](.ai/security.md) and [`.ai/data-sources.md`](.ai/data-sources.md). -If our traffic is a problem for you, the contact URL in the User-Agent is the -fastest way to reach us. - -## Stack - -| Layer | Choice | -|---|---| -| Monorepo | Bun workspaces | -| Frontend | SvelteKit (Svelte 5 runes) → Vercel | -| Public API | SvelteKit routes acting as a BFF | -| Backend | Hono → Cloudflare Workers | -| Data | Cloudflare D1 (SQLite) + KV, filled by Cron Triggers | -| Content | mdsvex, authored per distro per locale | -| Tooling | TypeScript strict, Biome, Vitest, Playwright + axe | - -## Quick start - -```bash -bun install -bun run dev:web # http://localhost:5173 -``` - -That runs entirely from a committed data snapshot — no accounts, no -credentials, no network. To run the backend too: - -```bash -cd apps/api -bunx wrangler d1 migrations apply linuxhub --local -bunx wrangler dev --local --test-scheduled -``` - -Everything else — layout, workspace boundaries, the dependency catalog, how to -add a workspace — is in [`docs/monorepo.md`](docs/monorepo.md). - -## Checks - -```bash -bun run check:boundaries # runtime + dependency-direction rules -bun run check # TypeScript across every workspace -bun run lint # Biome (bun run format autofixes) -bun run test # Vitest -bun run build # every workspace with a build script -``` - -The accessibility and end-to-end gate is **not** part of `bun run test` — it -needs a production build first, and it has already caught a change every other -check passed: - -```bash -bun run --filter '@linuxhub/web' build -cd apps/web && bun run test:e2e -``` - -## Documentation - -Two audiences, and the split is deliberate. - -| Where | For | Contents | -|---|---|---| -| [`.ai/`](.ai/) | agents and maintainers | Project memory and the single source of truth: architecture, API catalog, schema, data sources, i18n, security, the ADR log, and `handoff.md` — read that one first | -| [`docs/`](docs/) | humans | [monorepo conventions](docs/monorepo.md), the [deployment runbook](docs/deployment.md) | -| [`design/`](design/) | — | Approved HTML/CSS comps. The visual system is frozen (ADR-0017/0018) | -| [`content/`](content/) | — | Authored distro documentation, cited, per locale | - -When these disagree, `.ai/` wins — the order is `.ai/` > `docs/` > `prompts/` > -code, and it is stated in [`CLAUDE.md`](CLAUDE.md). - -## Contributing - -See [`CONTRIBUTING.md`](CONTRIBUTING.md). The short version: the checks above -must pass, the design is frozen, no UI string is hardcoded, and no fact ships -without a source. - -## Licensing - -Not a single licence, and the difference matters here. - -- **Code** — [Apache-2.0](LICENSE.md). -- **Distro logos** in `assets/distros/` — **not ours**. They are trademarks of - their respective projects, used unmodified as nominative references to - identify each distribution. Per-file source, licence and trademark notes are - in [`assets/distros/ATTRIBUTION.md`](assets/distros/ATTRIBUTION.md). Some - carry their own free licence (Debian's Open Use Logo, the Nix snowflake); - most are used under trademark fair use. **If you represent a project and - object to our use of your mark, tell us and we will remove the file and link - out to you instead** — that is the standing policy, not a negotiation. -- **Distro documentation** in `content/` — written by us from official - documentation, never copied, with sources cited in each file's frontmatter. - The upstream documentation it paraphrases remains under its own licence. - -Linuxhub is not affiliated with, endorsed by, or sponsored by any of the -distributions it catalogs. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 37198d4..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,63 +0,0 @@ -# Security Policy - -## Reporting a vulnerability - -Please report privately via GitHub's -[**Report a vulnerability**](https://github.com/neramc/linuxhub/security/advisories/new) -form rather than opening a public issue. - -Include what you did, what happened, and what you expected. A proof of concept -helps; a full exploit chain is not required. - -Expect an acknowledgement within a few days. This is a small project and there -is no bug-bounty programme — what we can offer is a prompt fix and credit in -the advisory if you want it. - -## What is in scope - -Where this project's risk actually lives, roughly in order: - -- **The download flow.** Linuxhub's purpose is handing people a URL to an - operating system image and a checksum to verify it against. Anything that - could make us serve a wrong URL, a wrong checksum, or a mirror we did not - intend is the most serious class of bug here. (The resolution flow is not - built yet — see the status note in the README.) -- **Ingestion.** The backend fetches from third-party endpoints on a schedule - and writes to the database. Injection through an upstream payload, or a way - to make ingestion write a URL we did not fetch, is in scope. -- **The internal API boundary.** The Cloudflare Worker is meant to be reachable - only by the site's own backend-for-frontend, authenticated with a shared - token. Any way to bypass that is in scope. -- **The public API and the site**: injection, SSRF, XSS, cache poisoning, - bypassing rate limits or the captcha on write endpoints. -- **Secret exposure** in logs, error responses, or the repository history. - -## What is not - -- Findings from automated scanners with no demonstrated impact. -- Denial of service through raw volume. -- Vulnerabilities in the Linux distributions we catalog — report those to the - distribution. We are an index and are not affiliated with any of them. -- Missing hardening headers with no exploitable consequence, though we will - still take the report. - -## How the project handles secrets - -No credential is ever committed. Worker secrets are set with -`wrangler secret put` and web secrets are Vercel environment variables; the -templates in the repository (`apps/api/.dev.vars.example`, -`apps/web/.env.example`) carry names and explanations only, never values. Raw -IP addresses are never stored or logged — rate limiting keys on a salted hash. -Full policy: [`.ai/security.md`](.ai/security.md). - -## If you are a distribution or mirror operator - -You are more likely to be here because of our crawler than because of a -vulnerability, so: our ingestion identifies itself as -`linuxhub-ingest/` with a contact URL, prefers official APIs over -scraping, respects `robots.txt` absolutely — a disallowed source is not used at -all — makes at most one request per second per host, and caches aggressively so -unchanged resources cost you a 304. - -If our traffic is a problem, or you would rather we did not use a source at -all, open an issue and we will change or drop it. No justification needed. diff --git a/apps/api/.dev.vars.example b/apps/api/.dev.vars.example deleted file mode 100644 index 8632155..0000000 --- a/apps/api/.dev.vars.example +++ /dev/null @@ -1,49 +0,0 @@ -# Worker secrets — apps/api (Cloudflare Workers) -# -# Copy to `.dev.vars` for local development. `.dev.vars` is gitignored and is -# read automatically by `wrangler dev`. -# -# cp apps/api/.dev.vars.example apps/api/.dev.vars -# -# In PRODUCTION these are NOT set from a file. Each one is pushed separately: -# -# cd apps/api -# wrangler secret put INTERNAL_API_TOKEN -# wrangler secret put HCAPTCHA_SECRET -# wrangler secret put RATE_SALT -# -# Never put these in wrangler.toml — `[vars]` there is plaintext in the -# deployed Worker. Full procedure in docs/deployment.md. - -# --------------------------------------------------------------------------- -# INTERNAL_API_TOKEN — shared secret for BFF → Worker calls. -# -# ⚠️ SET THIS BEFORE THE FIRST DEPLOY. The Worker skips the auth check -# entirely when it is unset (so that local dev and tests work without -# secrets), which means a deployed Worker without it is a PUBLIC API. -# -# The same value goes into Vercel as INTERNAL_API_TOKEN — the two must match. -# Generate one with: openssl rand -hex 32 -# --------------------------------------------------------------------------- -INTERNAL_API_TOKEN= - -# --------------------------------------------------------------------------- -# RATE_SALT — salt for hashing client IPs in rate-limit keys, so raw IPs are -# never stored (.ai/security.md § "Rate limiting"). Any long random string. -# Generate one with: openssl rand -hex 32 -# -# In use since task 5.6: the download endpoints are rate-limited per IP. When -# it is unset, limiting is SKIPPED rather than run against a constant fallback -# salt, which would make the stored hashes reversible — so a deployed Worker -# without it has unlimited download endpoints. -# Changing it later resets every live rate-limit window. -# --------------------------------------------------------------------------- -RATE_SALT= - -# --------------------------------------------------------------------------- -# HCAPTCHA_SECRET — server-side hCaptcha key, verified before any write is -# processed. The matching PUBLIC sitekey belongs in Vercel, not here. -# -# Not read yet — the write endpoints arrive in task 5.5. -# --------------------------------------------------------------------------- -HCAPTCHA_SECRET= diff --git a/apps/api/migrations/0001_init.sql b/apps/api/migrations/0001_init.sql deleted file mode 100644 index 43bc334..0000000 --- a/apps/api/migrations/0001_init.sql +++ /dev/null @@ -1,101 +0,0 @@ --- 0001_init — core catalog tables (.ai/database.md § "D1 schema"). --- --- Conventions that hold across every migration here: --- * all timestamps are ISO-8601 UTC strings (TEXT); --- * natural keys where stable (distros.slug), integer PKs elsewhere; --- * CHECK constraints spell out the closed enums, so an ingest bug fails at --- the write instead of surfacing as an impossible value on a page; --- * source_url + fetched_at travel with every ingested row — the binding --- provenance rule in .ai/data-sources.md is per value, not per run --- (ingest_log in 0004 records the run). - -CREATE TABLE distros ( - id INTEGER PRIMARY KEY, - slug TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - summary TEXT NOT NULL DEFAULT '', - -- Empty until the Wikidata lineage step; never hand-typed. - family TEXT NOT NULL DEFAULT '', - based_on TEXT, -- parent slug, NULL for roots - homepage TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active' - CHECK (status IN ('active', 'discontinued')), - logo_path TEXT NOT NULL, -- assets/distros/.svg - aliases TEXT NOT NULL DEFAULT '[]', -- JSON array, for search - source_url TEXT, - fetched_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE releases ( - id INTEGER PRIMARY KEY, - distro_id INTEGER NOT NULL REFERENCES distros(id), - version TEXT NOT NULL, - -- 'eol' is NOT a channel: it is derived from eol_at < today at read time, - -- so a row never goes quietly stale. 'lts' is a flag, because upstream - -- (endoflife.date) reports it as one — a cycle is stable *and* LTS. - channel TEXT NOT NULL DEFAULT 'stable' - CHECK (channel IN ('stable', 'beta', 'rolling')), - lts INTEGER NOT NULL DEFAULT 0 CHECK (lts IN (0, 1)), - codename TEXT, - latest_point TEXT, -- newest point release, e.g. 24.04.3 - released_at TEXT, - eol_at TEXT, - notes_url TEXT, - source_url TEXT, - fetched_at TEXT, - UNIQUE (distro_id, version) -); - -CREATE TABLE editions ( - id INTEGER PRIMARY KEY, - release_id INTEGER NOT NULL REFERENCES releases(id), - name TEXT NOT NULL, -- GNOME|KDE|Xfce|minimal|server|… - desktop TEXT, -- desktop env slug, NULL for server/minimal - kind TEXT NOT NULL DEFAULT 'desktop' - CHECK (kind IN ('desktop', 'server', 'minimal', 'other')), - UNIQUE (release_id, name) -); - -CREATE TABLE artifacts ( - id INTEGER PRIMARY KEY, - edition_id INTEGER NOT NULL REFERENCES editions(id), - arch TEXT NOT NULL, -- x86_64|aarch64|riscv64|… - format TEXT NOT NULL - CHECK (format IN ('iso', 'torrent', 'magnet', 'checksum', 'signature')), - path TEXT NOT NULL, -- mirror-relative path - size INTEGER, - sha256 TEXT, - sig_url TEXT, - source_url TEXT, - fetched_at TEXT, - UNIQUE (edition_id, arch, format) -); - -CREATE TABLE mirrors ( - id INTEGER PRIMARY KEY, - -- Mirror networks are per distro: archlinux.org's mirrors are not Fedora's, - -- and a mirror exists before any artifact is known to sit on it. - distro_id INTEGER REFERENCES distros(id), - name TEXT NOT NULL DEFAULT '', -- hostname, for display - country TEXT NOT NULL DEFAULT '', -- ISO 3166-1 alpha-2, '' when unpublished - region TEXT NOT NULL DEFAULT '', -- continent/region code - base_url TEXT NOT NULL, - protocol TEXT NOT NULL DEFAULT 'https' - CHECK (protocol IN ('https', 'http', 'ftp', 'rsync')), - sponsor TEXT, - healthy INTEGER NOT NULL DEFAULT 1 CHECK (healthy IN (0, 1)), - last_checked TEXT, - source_url TEXT, - fetched_at TEXT, - UNIQUE (distro_id, base_url) -); - -CREATE TABLE artifact_mirrors ( -- availability of artifact on mirror - artifact_id INTEGER NOT NULL REFERENCES artifacts(id), - mirror_id INTEGER NOT NULL REFERENCES mirrors(id), - path_override TEXT, - available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0, 1)), - PRIMARY KEY (artifact_id, mirror_id) -); diff --git a/apps/api/migrations/0002_taxonomy.sql b/apps/api/migrations/0002_taxonomy.sql deleted file mode 100644 index 03cc4d3..0000000 --- a/apps/api/migrations/0002_taxonomy.sql +++ /dev/null @@ -1,24 +0,0 @@ --- 0002_taxonomy — categories, tags, and the distro↔taxonomy join --- (.ai/database.md § "D1 schema"). Assignments are editorial and arrive with --- the Wikidata lineage step; the tables exist so read endpoints can join them --- from the start rather than growing a second code path later. - -CREATE TABLE categories ( - id INTEGER PRIMARY KEY, - slug TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '' -); - -CREATE TABLE tags ( - id INTEGER PRIMARY KEY, - slug TEXT NOT NULL UNIQUE, - name TEXT NOT NULL -); - -CREATE TABLE distro_taxonomy ( - distro_id INTEGER NOT NULL REFERENCES distros(id), - kind TEXT NOT NULL CHECK (kind IN ('category', 'tag', 'desktop')), - ref_slug TEXT NOT NULL, - PRIMARY KEY (distro_id, kind, ref_slug) -); diff --git a/apps/api/migrations/0003_rankings_stats.sql b/apps/api/migrations/0003_rankings_stats.sql deleted file mode 100644 index 92a747a..0000000 --- a/apps/api/migrations/0003_rankings_stats.sql +++ /dev/null @@ -1,38 +0,0 @@ --- 0003_rankings_stats — popularity snapshots, aggregated download counters, --- and the Hall of Fame (.ai/database.md § "D1 schema"). --- --- Everything here is fed by OUR OWN signals only: download clicks and views --- counted in KV and flushed into download_events, then scored into rankings by --- the weekly cron. Third-party popularity charts are a forbidden source --- (.ai/data-sources.md § "Ranking signals"), so these tables stay empty until --- the site has traffic — which is correct, not a gap to fill by hand. - -CREATE TABLE rankings ( -- periodic snapshots - id INTEGER PRIMARY KEY, - distro_id INTEGER NOT NULL REFERENCES distros(id), - period TEXT NOT NULL CHECK (period IN ('week', 'month', 'year', 'all')), - snapshot_at TEXT NOT NULL, - rank INTEGER NOT NULL, - score REAL NOT NULL, - UNIQUE (distro_id, period, snapshot_at) -); - -CREATE TABLE download_events ( -- flushed KV counters, aggregated - id INTEGER PRIMARY KEY, - artifact_id INTEGER NOT NULL REFERENCES artifacts(id), - -- 0 means "mirror not attributed". A nullable column cannot carry the UNIQUE - -- below, because SQLite treats every NULL as distinct and the daily flush - -- would insert duplicates instead of accumulating. - mirror_id INTEGER NOT NULL DEFAULT 0, - day TEXT NOT NULL, -- YYYY-MM-DD - count INTEGER NOT NULL DEFAULT 0, - UNIQUE (artifact_id, mirror_id, day) -); - -CREATE TABLE hall_of_fame ( - id INTEGER PRIMARY KEY, - distro_id INTEGER NOT NULL REFERENCES distros(id) UNIQUE, - rationale TEXT NOT NULL, -- editorial, with citations - sources TEXT NOT NULL DEFAULT '[]', -- JSON array of source URLs - ordering INTEGER NOT NULL DEFAULT 0 -); diff --git a/apps/api/migrations/0004_content_community.sql b/apps/api/migrations/0004_content_community.sql deleted file mode 100644 index 3f82336..0000000 --- a/apps/api/migrations/0004_content_community.sql +++ /dev/null @@ -1,34 +0,0 @@ --- 0004_content_community — the MDX registry, community submissions, and the --- ingest audit trail (.ai/database.md § "D1 schema"). - -CREATE TABLE content_index ( -- registry of MDX docs in /content - id INTEGER PRIMARY KEY, - distro_id INTEGER NOT NULL REFERENCES distros(id), - doc TEXT NOT NULL CHECK (doc IN ('description', 'install', 'usage')), - locale TEXT NOT NULL, -- BCP-47 - source_urls TEXT NOT NULL DEFAULT '[]', -- JSON array, from frontmatter - reviewed_at TEXT, - UNIQUE (distro_id, doc, locale) -); - -CREATE TABLE submissions ( -- anonymous community writes - id INTEGER PRIMARY KEY, - kind TEXT NOT NULL - CHECK (kind IN ('suggest', 'report', 'feedback', 'mirror_status')), - payload TEXT NOT NULL, -- opaque JSON, rendered escaped - created_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'new' - CHECK (status IN ('new', 'reviewed', 'done', 'spam')) -); - --- One row per source per run: what was fetched, when, and whether it changed --- anything. Per-value provenance lives on the ingested rows themselves. -CREATE TABLE ingest_log ( - id INTEGER PRIMARY KEY, - source TEXT NOT NULL, - url TEXT NOT NULL, - fetched_at TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('ok', 'error', 'skipped')), - changed INTEGER NOT NULL DEFAULT 0, - detail TEXT -); diff --git a/apps/api/migrations/0005_indexes.sql b/apps/api/migrations/0005_indexes.sql deleted file mode 100644 index 8cab71e..0000000 --- a/apps/api/migrations/0005_indexes.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 0005_indexes — read-path indexes (.ai/database.md § "Indexes (0005)"). --- Search stays on LIKE over name/aliases/summary for now; adopting D1 FTS5 is --- an ADR-worthy change, recorded when the LIKE plan actually gets slow. - -CREATE INDEX idx_releases_distro ON releases(distro_id, released_at DESC); -CREATE INDEX idx_editions_release ON editions(release_id); -CREATE INDEX idx_artifacts_edition ON artifacts(edition_id); -CREATE INDEX idx_mirrors_distro ON mirrors(distro_id, healthy); -CREATE INDEX idx_mirrors_country ON mirrors(country, healthy); -CREATE INDEX idx_taxonomy_ref ON distro_taxonomy(kind, ref_slug); -CREATE INDEX idx_rankings_period ON rankings(period, snapshot_at DESC, rank); -CREATE INDEX idx_dl_events_day ON download_events(day); -CREATE INDEX idx_distros_family ON distros(family, status); diff --git a/apps/api/migrations/0006_mirror_artifact_roots.sql b/apps/api/migrations/0006_mirror_artifact_roots.sql deleted file mode 100644 index c738704..0000000 --- a/apps/api/migrations/0006_mirror_artifact_roots.sql +++ /dev/null @@ -1,21 +0,0 @@ --- 0006 — tell apart "a mirror of this distro" from "a base URL our artifact --- paths are relative to" (.ai/database.md § "mirrors"). --- --- These were treated as one thing, and they are not. Arch's mirror status JSON --- publishes mirror roots (https://host/archlinux/), which an artifact path like --- `iso/2026.08.01/…iso` extends correctly. Fedora's MirrorManager publishes --- per-repo directories (https://host/fedora/linux/releases/44/Everything/ --- x86_64/os/); nothing extends those, so joining a Fedora artifact path onto --- one produced a URL that answered 404. Only the Fedora redirector serves those --- paths, and it is a mirror row too. --- --- Defaulting to 0 is the safe direction: a mirror is not an artifact base until --- a source says its base_url is one. - -ALTER TABLE mirrors ADD COLUMN serves_artifacts INTEGER NOT NULL DEFAULT 0 - CHECK (serves_artifacts IN (0, 1)); - --- artifact_mirrors is derived from the two tables above and is rebuilt by every --- ingest run, so the rows written under the old assumption are simply dropped --- rather than migrated. -DELETE FROM artifact_mirrors; diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index 551bd61..0000000 --- a/apps/api/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@linuxhub/api", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "wrangler dev", - "build": "wrangler deploy --dry-run --outdir=dist", - "deploy": "bun ../../scripts/check-deploy-config.ts && wrangler deploy", - "check": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@linuxhub/ingest": "workspace:*", - "@linuxhub/shared": "workspace:*", - "hono": "^4.9.0" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20250620.0", - "miniflare": "^4.20260730.0", - "typescript": "catalog:", - "vitest": "catalog:", - "wrangler": "^4.30.0" - } -} diff --git a/apps/api/src/cron/index.ts b/apps/api/src/cron/index.ts deleted file mode 100644 index 15eeab6..0000000 --- a/apps/api/src/cron/index.ts +++ /dev/null @@ -1,696 +0,0 @@ -// Ingestion entrypoints, one per schedule (.ai/backend-rules.md § "Ingestion"). -// -// Two rules shape everything here: -// -// 1. Partial failure of one source never blocks another. Every source runs -// inside its own try/catch and writes an `ingest_log` row either way, so a -// Fedora outage cannot cost us the Ubuntu refresh. -// 2. Ingestion is idempotent. Every write is an upsert on a natural key, so -// re-running a schedule is free and a retry is never destructive. - -import { DEFAULT_SITE_ORIGIN } from "@linuxhub/ingest"; -import type { ContentIndex } from "@linuxhub/ingest/content"; -import contentIndex from "@linuxhub/ingest/content-index.json" with { type: "json" }; -import { createFetchClient, type HttpClient } from "@linuxhub/ingest/http"; -import { - DISTRO_SOURCES, - MIRROR_SERVES_ARTIFACTS, - mirrorSourcesFor, -} from "@linuxhub/ingest/registry"; -import { fetchArchMirrors } from "@linuxhub/ingest/sources/arch-mirrors"; -import { ARCH_RELEASES_SOURCE, fetchArchReleases } from "@linuxhub/ingest/sources/arch-releases"; -import { - DEBIAN_ARTIFACTS_SOURCE, - DEBIAN_BASE, - fetchDebianArtifacts, -} from "@linuxhub/ingest/sources/debian-artifacts"; -import { ENDOFLIFE_SOURCE, fetchReleaseCycles } from "@linuxhub/ingest/sources/endoflife"; -import { - FEDORA_ARTIFACTS_SOURCE, - FEDORA_REDIRECTOR, - fetchFedoraArtifacts, -} from "@linuxhub/ingest/sources/fedora-artifacts"; -import { fetchFedoraMirrors } from "@linuxhub/ingest/sources/fedora-mirrors"; -import { - fetchMintArtifacts, - MINT_ARTIFACTS_SOURCE, - MINT_BASE, -} from "@linuxhub/ingest/sources/mint-artifacts"; -import { - fetchPopOsArtifacts, - POP_OS_ARTIFACTS_SOURCE, - POP_OS_BASE, -} from "@linuxhub/ingest/sources/pop-os-artifacts"; -import { - fetchUbuntuArtifacts, - UBUNTU_ARTIFACTS_SOURCE, - UBUNTU_BASE, -} from "@linuxhub/ingest/sources/ubuntu-artifacts"; -import { - ensureMirrorStatement, - linkArtifactsToMirrorsStatement, - pruneArtifactMirrorsStatement, - upsertCatalog, -} from "../db/artifacts"; -import { syncCatalog } from "../db/distros"; -import { flushDownloadCounters } from "../db/downloads"; -import { type IngestLogRow, logIngest } from "../db/ingest-log"; -import { replaceMirrors } from "../db/mirrors"; -import { listVersions, newestVersion, upsertReleases } from "../db/releases"; -import type { Env } from "../env"; - -export const CRON_RELEASES = "0 */6 * * *"; -export const CRON_MIRRORS = "0 3 * * *"; -export const CRON_RANKINGS = "0 4 * * 1"; - -export type IngestSummary = { - schedule: string; - sources: number; - ok: number; - failed: number; - written: number; -}; - -const index = contentIndex as ContentIndex; - -function clientFor(env: Env): HttpClient { - return createFetchClient({ - siteOrigin: env.SITE_ORIGIN ?? DEFAULT_SITE_ORIGIN, - // KV-backed conditional requests: an unchanged upstream costs it a 304 - // instead of a full body (.ai/security.md § "Crawler ethics"). - store: { - get: (key) => env.KV_CACHE.get(key), - put: (key, value, ttl) => env.KV_CACHE.put(key, value, { expirationTtl: ttl }), - }, - }); -} - -/** Cache keys embed a per-distro generation counter, so an ingest that changes - * a distro invalidates its cached reads without any manual purging - * (.ai/architecture.md § "Caching strategy"). */ -async function bumpGeneration(env: Env, slug: string): Promise { - await env.KV_CACHE.put(`gen:${slug}`, Date.now().toString()); -} - -/** `http` is injectable so tests exercise the upserts and the failure handling - * without touching an upstream — never override it in production. */ -export async function ingestReleases( - env: Env, - now = new Date(), - http: HttpClient = clientFor(env), -): Promise { - const logs: IngestLogRow[] = []; - let ok = 0; - let failed = 0; - let written = 0; - - await syncCatalog(env.DB, index, now.toISOString()); - - for (const row of DISTRO_SOURCES) { - if (row.release.kind !== "endoflife") continue; - try { - const result = await fetchReleaseCycles(http, row.release.product, now); - const upserted = await upsertReleases( - env.DB, - row.slug, - result.data, - result.sourceUrl, - result.fetchedAt, - ); - written += upserted.written; - if (upserted.written > 0) await bumpGeneration(env, row.slug); - ok++; - logs.push({ - source: `${ENDOFLIFE_SOURCE}:${row.slug}`, - url: result.sourceUrl, - fetchedAt: result.fetchedAt, - status: "ok", - changed: upserted.written, - }); - } catch (error) { - failed++; - logs.push({ - source: `${ENDOFLIFE_SOURCE}:${row.slug}`, - url: `endoflife.date/api/${row.release.product}.json`, - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - } - - // Artifacts come after the release rows they hang off: every edition and - // artifact insert resolves its parent by subselect, so a release that is not - // there yet silently produces nothing. - const artifacts = await ingestArtifacts(env, now, http); - ok += artifacts.ok; - failed += artifacts.failed; - written += artifacts.written; - logs.push(...artifacts.logs); - - await logIngest(env.DB, logs); - return { schedule: CRON_RELEASES, sources: ok + failed, ok, failed, written }; -} - -/** - * ISO paths, checksums and sizes — what the download flow actually needs. - * - * Kept as its own function rather than folded into the loop above because the - * two sources are shaped differently: Arch publishes releases *and* their - * artifacts together (its snapshots are the versions), while Fedora publishes - * artifacts for releases another source already told us about. - */ -async function ingestArtifacts( - env: Env, - now: Date, - http: HttpClient, -): Promise<{ ok: number; failed: number; written: number; logs: IngestLogRow[] }> { - const logs: IngestLogRow[] = []; - let ok = 0; - let failed = 0; - let written = 0; - - // Arch: the snapshots are both the releases and the artifacts. - try { - const result = await fetchArchReleases(http, now); - const releases = await upsertReleases( - env.DB, - "arch", - result.data.releases, - result.sourceUrl, - result.fetchedAt, - "rolling", - ); - const catalog = await upsertCatalog( - env.DB, - "arch", - result.data, - result.sourceUrl, - result.fetchedAt, - ); - await env.DB.batch([ - pruneArtifactMirrorsStatement(env.DB, "arch"), - linkArtifactsToMirrorsStatement(env.DB, "arch"), - ]); - written += releases.written + catalog.written; - if (catalog.written > 0) await bumpGeneration(env, "arch"); - ok++; - logs.push({ - source: `${ARCH_RELEASES_SOURCE}:arch`, - url: result.sourceUrl, - fetchedAt: result.fetchedAt, - status: "ok", - changed: releases.written + catalog.written, - }); - } catch (error) { - failed++; - logs.push({ - source: `${ARCH_RELEASES_SOURCE}:arch`, - url: "archlinux.org/releng/releases/json/", - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - - // Fedora: bounded to versions we already have release rows for, because the - // index reaches further back than endoflife.date reports. - try { - const versions = await listVersions(env.DB, "fedora"); - if (versions.length === 0) { - logs.push({ - source: `${FEDORA_ARTIFACTS_SOURCE}:fedora`, - url: FEDORA_ARTIFACTS_URL_FOR_LOG, - fetchedAt: now.toISOString(), - status: "skipped", - detail: "no fedora releases ingested yet", - }); - } else { - const result = await fetchFedoraArtifacts(http, versions, now); - await env.DB.batch([ - ensureMirrorStatement( - env.DB, - "fedora", - FEDORA_REDIRECTOR, - "download.fedoraproject.org", - result.sourceUrl, - result.fetchedAt, - ), - ]); - const catalog = await upsertCatalog( - env.DB, - "fedora", - result.data, - result.sourceUrl, - result.fetchedAt, - ); - await env.DB.batch([ - pruneArtifactMirrorsStatement(env.DB, "fedora"), - linkArtifactsToMirrorsStatement(env.DB, "fedora"), - ]); - written += catalog.written; - if (catalog.written > 0) await bumpGeneration(env, "fedora"); - ok++; - logs.push({ - source: `${FEDORA_ARTIFACTS_SOURCE}:fedora`, - url: result.sourceUrl, - fetchedAt: result.fetchedAt, - status: "ok", - changed: catalog.written, - }); - } - } catch (error) { - failed++; - logs.push({ - source: `${FEDORA_ARTIFACTS_SOURCE}:fedora`, - url: FEDORA_ARTIFACTS_URL_FOR_LOG, - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - - // Ubuntu: same shape as Fedora — bounded to the versions we hold release - // rows for, because releases.ubuntu.com keeps directories we have no - // release for and drops ones we do at EOL. - try { - const versions = await listVersions(env.DB, "ubuntu"); - if (versions.length === 0) { - logs.push({ - source: `${UBUNTU_ARTIFACTS_SOURCE}:ubuntu`, - url: UBUNTU_BASE, - fetchedAt: now.toISOString(), - status: "skipped", - detail: "no ubuntu releases ingested yet", - }); - } else { - const result = await fetchUbuntuArtifacts(http, versions, now); - await env.DB.batch([ - ensureMirrorStatement( - env.DB, - "ubuntu", - UBUNTU_BASE, - "releases.ubuntu.com", - result.sourceUrl, - result.fetchedAt, - ), - ]); - const catalog = await upsertCatalog( - env.DB, - "ubuntu", - result.data, - result.sourceUrl, - result.fetchedAt, - ); - await env.DB.batch([ - pruneArtifactMirrorsStatement(env.DB, "ubuntu"), - linkArtifactsToMirrorsStatement(env.DB, "ubuntu"), - ]); - written += catalog.written; - if (catalog.written > 0) await bumpGeneration(env, "ubuntu"); - ok++; - logs.push({ - source: `${UBUNTU_ARTIFACTS_SOURCE}:ubuntu`, - url: result.sourceUrl, - fetchedAt: result.fetchedAt, - status: "ok", - changed: catalog.written, - }); - } - } catch (error) { - failed++; - logs.push({ - source: `${UBUNTU_ARTIFACTS_SOURCE}:ubuntu`, - url: UBUNTU_BASE, - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - - // Debian: unlike Ubuntu and Fedora this needs no version list — cdimage - // keeps one live tree under `current`, and the point release comes back in - // the filenames. Sizes stay absent by policy, not by omission: Debian's - // robots.txt forbids requesting the ISOs (.ai/data-sources.md). - try { - const result = await fetchDebianArtifacts(http, undefined, now); - await env.DB.batch([ - ensureMirrorStatement( - env.DB, - "debian", - DEBIAN_BASE, - "cdimage.debian.org", - result.sourceUrl, - result.fetchedAt, - ), - ]); - const catalog = await upsertCatalog( - env.DB, - "debian", - result.data, - result.sourceUrl, - result.fetchedAt, - ); - await env.DB.batch([ - pruneArtifactMirrorsStatement(env.DB, "debian"), - linkArtifactsToMirrorsStatement(env.DB, "debian"), - ]); - written += catalog.written; - if (catalog.written > 0) await bumpGeneration(env, "debian"); - ok++; - logs.push({ - source: `${DEBIAN_ARTIFACTS_SOURCE}:debian`, - url: result.sourceUrl, - fetchedAt: result.fetchedAt, - status: "ok", - changed: catalog.written, - }); - } catch (error) { - failed++; - logs.push({ - source: `${DEBIAN_ARTIFACTS_SOURCE}:debian`, - url: DEBIAN_BASE, - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - - // Pop!_OS: one API call per version and channel, and it hands back the url, - // the size and the checksum together — no filename parsing, no HEAD. - try { - const versions = await listVersions(env.DB, "pop-os"); - if (versions.length === 0) { - logs.push({ - source: `${POP_OS_ARTIFACTS_SOURCE}:pop-os`, - url: POP_OS_BASE, - fetchedAt: now.toISOString(), - status: "skipped", - detail: "no pop-os releases ingested yet", - }); - } else { - const result = await fetchPopOsArtifacts(http, versions, now); - await env.DB.batch([ - ensureMirrorStatement( - env.DB, - "pop-os", - POP_OS_BASE, - "iso.pop-os.org", - result.sourceUrl, - result.fetchedAt, - ), - ]); - const catalog = await upsertCatalog( - env.DB, - "pop-os", - result.data, - result.sourceUrl, - result.fetchedAt, - ); - await env.DB.batch([ - pruneArtifactMirrorsStatement(env.DB, "pop-os"), - linkArtifactsToMirrorsStatement(env.DB, "pop-os"), - ]); - written += catalog.written; - if (catalog.written > 0) await bumpGeneration(env, "pop-os"); - ok++; - logs.push({ - source: `${POP_OS_ARTIFACTS_SOURCE}:pop-os`, - url: result.sourceUrl, - fetchedAt: result.fetchedAt, - status: "ok", - changed: catalog.written, - }); - } - } catch (error) { - failed++; - logs.push({ - source: `${POP_OS_ARTIFACTS_SOURCE}:pop-os`, - url: POP_OS_BASE, - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - - // Linux Mint: checksum file per version, sizes by HEAD. This is the one - // source whose editions really are desktop environments. - try { - const versions = await listVersions(env.DB, "linux-mint"); - if (versions.length === 0) { - logs.push({ - source: `${MINT_ARTIFACTS_SOURCE}:linux-mint`, - url: MINT_BASE, - fetchedAt: now.toISOString(), - status: "skipped", - detail: "no linux-mint releases ingested yet", - }); - } else { - const result = await fetchMintArtifacts(http, versions, now); - await env.DB.batch([ - ensureMirrorStatement( - env.DB, - "linux-mint", - MINT_BASE, - "mirrors.edge.kernel.org", - result.sourceUrl, - result.fetchedAt, - ), - ]); - const catalog = await upsertCatalog( - env.DB, - "linux-mint", - result.data, - result.sourceUrl, - result.fetchedAt, - ); - await env.DB.batch([ - pruneArtifactMirrorsStatement(env.DB, "linux-mint"), - linkArtifactsToMirrorsStatement(env.DB, "linux-mint"), - ]); - written += catalog.written; - if (catalog.written > 0) await bumpGeneration(env, "linux-mint"); - ok++; - logs.push({ - source: `${MINT_ARTIFACTS_SOURCE}:linux-mint`, - url: result.sourceUrl, - fetchedAt: result.fetchedAt, - status: "ok", - changed: catalog.written, - }); - } - } catch (error) { - failed++; - logs.push({ - source: `${MINT_ARTIFACTS_SOURCE}:linux-mint`, - url: MINT_BASE, - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - - return { ok, failed, written, logs }; -} - -const FEDORA_ARTIFACTS_URL_FOR_LOG = "fedoraproject.org/releases.json"; - -export async function ingestMirrors( - env: Env, - now = new Date(), - http: HttpClient = clientFor(env), -): Promise { - const logs: IngestLogRow[] = []; - let ok = 0; - let failed = 0; - let written = 0; - - await syncCatalog(env.DB, index, now.toISOString()); - - for (const row of mirrorSourcesFor("arch")) { - try { - const result = await fetchArchMirrors(http, now); - const stored = await replaceMirrors( - env.DB, - row.slug, - result.data, - result.sourceUrl, - result.fetchedAt, - MIRROR_SERVES_ARTIFACTS.arch, - ); - // Re-link, because the mirror set just changed. Without this the - // links are only rebuilt by the 6-hourly artifact pass, which makes - // the two schedules order-dependent: on a fresh database the artifact - // pass runs before any Arch mirror exists, so every Arch download - // resolves to "mirror for artifact not found" until the artifact pass - // happens to run again. Linking here makes the order irrelevant and - // makes a newly added mirror usable within a day rather than six - // hours. - await env.DB.batch([ - pruneArtifactMirrorsStatement(env.DB, row.slug), - linkArtifactsToMirrorsStatement(env.DB, row.slug), - ]); - written += stored.written; - if (stored.written > 0 || stored.retired > 0) await bumpGeneration(env, row.slug); - ok++; - logs.push({ - source: `arch-mirrors:${row.slug}`, - url: result.sourceUrl, - fetchedAt: result.fetchedAt, - status: "ok", - changed: stored.written, - }); - } catch (error) { - failed++; - logs.push({ - source: `arch-mirrors:${row.slug}`, - url: "archlinux.org/mirrors/status/json/", - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - } - - for (const row of mirrorSourcesFor("fedora")) { - // The mirrorlist repo tracks whatever release we last ingested, so it - // cannot be left pinned to a version that has since been superseded. - const version = await newestVersion(env.DB, row.slug); - if (!version) { - logs.push({ - source: `fedora-mirrors:${row.slug}`, - url: "mirrors.fedoraproject.org/mirrorlist", - fetchedAt: now.toISOString(), - status: "skipped", - detail: "no release ingested yet, cannot pick a repo", - }); - continue; - } - try { - const result = await fetchFedoraMirrors(http, `fedora-${version}`, now); - const stored = await replaceMirrors( - env.DB, - row.slug, - result.data, - result.sourceUrl, - result.fetchedAt, - MIRROR_SERVES_ARTIFACTS.fedora, - ); - await env.DB.batch([ - pruneArtifactMirrorsStatement(env.DB, row.slug), - linkArtifactsToMirrorsStatement(env.DB, row.slug), - ]); - written += stored.written; - if (stored.written > 0 || stored.retired > 0) await bumpGeneration(env, row.slug); - ok++; - logs.push({ - source: `fedora-mirrors:${row.slug}`, - url: result.sourceUrl, - fetchedAt: result.fetchedAt, - status: "ok", - changed: stored.written, - }); - } catch (error) { - failed++; - logs.push({ - source: `fedora-mirrors:${row.slug}`, - url: "mirrors.fedoraproject.org/mirrorlist", - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - } - - // Bank the day's download clicks. KV is the write path because it is cheap - // on a hot page, but KV entries expire and rankings need history, so they - // have to land in D1 before they do (.ai/database.md § "KV keyspaces"). - try { - const flushed = await flushDownloadCounters(env.KV_RATE, env.DB); - written += flushed.flushed; - ok++; - logs.push({ - source: "downloads:flush", - url: "internal:kv", - fetchedAt: now.toISOString(), - status: "ok", - changed: flushed.flushed, - detail: flushed.flushed === 0 ? "no clicks to bank" : undefined, - }); - } catch (error) { - failed++; - logs.push({ - source: "downloads:flush", - url: "internal:kv", - fetchedAt: now.toISOString(), - status: "error", - detail: String(error), - }); - } - - await logIngest(env.DB, logs); - return { schedule: CRON_MIRRORS, sources: ok + failed, ok, failed, written }; -} - -/** - * Weekly popularity snapshot. - * - * Scores come only from our own download counters (`download_events`, fed by - * the KV flush) — third-party popularity charts are a forbidden source - * (.ai/data-sources.md § "Ranking signals"). Until `downloads/track` ships in - * 5.6 there is nothing to count, so this writes zero rows. That is the correct - * outcome, not a gap to paper over. - */ -export async function snapshotRankings(env: Env, now = new Date()): Promise { - const snapshotAt = now.toISOString(); - const since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - - const result = await env.DB.prepare( - `INSERT INTO rankings (distro_id, period, snapshot_at, rank, score) - SELECT distro_id, 'week', ?1, RANK() OVER (ORDER BY score DESC), score - FROM (SELECT d.id AS distro_id, SUM(de.count) AS score - FROM download_events de - JOIN artifacts a ON a.id = de.artifact_id - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE de.day >= ?2 - GROUP BY d.id) - -- SQLite cannot tell an upsert clause from a join constraint after a - -- SELECT with no WHERE of its own; "WHERE true" disambiguates it. - WHERE true - ON CONFLICT(distro_id, period, snapshot_at) - DO UPDATE SET rank = excluded.rank, score = excluded.score`, - ) - .bind(snapshotAt, since) - .run(); - - const written = result.meta?.changes ?? 0; - await logIngest(env.DB, [ - { - source: "rankings:week", - url: "internal:download_events", - fetchedAt: snapshotAt, - status: "ok", - changed: written, - detail: written === 0 ? "no download signals yet" : undefined, - }, - ]); - return { schedule: CRON_RANKINGS, sources: 1, ok: 1, failed: 0, written }; -} - -export async function runScheduled( - cron: string, - env: Env, - now = new Date(), - http?: HttpClient, -): Promise { - switch (cron) { - case CRON_MIRRORS: - return ingestMirrors(env, now, http); - case CRON_RANKINGS: - return snapshotRankings(env, now); - default: - return ingestReleases(env, now, http); - } -} diff --git a/apps/api/src/db/artifacts.ts b/apps/api/src/db/artifacts.ts deleted file mode 100644 index 2f339be..0000000 --- a/apps/api/src/db/artifacts.ts +++ /dev/null @@ -1,175 +0,0 @@ -// Edition and artifact writes. All SQL for `editions`, `artifacts` and -// `artifact_mirrors` lives here (.ai/backend-rules.md § "Project structure"). - -import type { LiveArtifact, LiveCatalog, LiveEdition } from "@linuxhub/ingest/types"; - -/** An artifact path is mirror-relative unless the source published something a - * mirror does not serve — a torrent page, a magnet URI. */ -export function isAbsoluteRef(path: string): boolean { - return /^(https?:|magnet:)/i.test(path); -} - -export function upsertEditionStatement( - db: D1Database, - slug: string, - version: string, - edition: LiveEdition & { version: string }, -): D1PreparedStatement { - // INSERT…SELECT rather than VALUES with a subselect: a subselect that finds - // no release yields NULL and trips the NOT NULL constraint, which fails the - // whole D1 batch. This form inserts nothing instead, so one unresolvable - // entry cannot cost the other eighty-three their refresh. - return db - .prepare( - `INSERT INTO editions (release_id, name, desktop, kind) - SELECT r.id, ?3, ?4, ?5 - FROM releases r - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = ?1 AND r.version = ?2 - ON CONFLICT(release_id, name) DO UPDATE SET - desktop = excluded.desktop, - kind = excluded.kind`, - ) - .bind(slug, version, edition.name, edition.desktop ?? null, edition.kind); -} - -export function upsertArtifactStatement( - db: D1Database, - slug: string, - artifact: LiveArtifact, - sourceUrl: string, - fetchedAt: string, -): D1PreparedStatement { - return db - .prepare( - `INSERT INTO artifacts - (edition_id, arch, format, path, size, sha256, source_url, fetched_at) - SELECT e.id, ?4, ?5, ?6, ?7, ?8, ?9, ?10 - FROM editions e - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = ?1 AND r.version = ?2 AND e.name = ?3 - ON CONFLICT(edition_id, arch, format) DO UPDATE SET - path = excluded.path, - size = excluded.size, - sha256 = excluded.sha256, - source_url = excluded.source_url, - fetched_at = excluded.fetched_at`, - ) - .bind( - slug, - artifact.version, - artifact.edition, - artifact.arch, - artifact.format, - artifact.path, - artifact.size ?? null, - artifact.sha256 ?? null, - sourceUrl, - fetchedAt, - ); -} - -/** Ensures a mirror row exists for an origin the artifact index points at — - * Fedora's redirector is a mirror in every sense that matters here, and it is - * the base its artifact paths are relative to, so it serves artifacts. */ -export function ensureMirrorStatement( - db: D1Database, - slug: string, - baseUrl: string, - name: string, - sourceUrl: string, - fetchedAt: string, -): D1PreparedStatement { - return db - .prepare( - `INSERT INTO mirrors - (distro_id, name, country, region, base_url, protocol, healthy, - last_checked, source_url, fetched_at, serves_artifacts) - VALUES ((SELECT id FROM distros WHERE slug = ?1), ?2, '', '', ?3, 'https', 1, ?4, ?5, ?4, 1) - ON CONFLICT(distro_id, base_url) DO UPDATE SET - healthy = 1, - last_checked = excluded.last_checked, - fetched_at = excluded.fetched_at, - serves_artifacts = 1`, - ) - .bind(slug, name, baseUrl, fetchedAt, sourceUrl); -} - -/** - * Links every mirror-relative artifact of a distro to every healthy mirror of - * that distro **whose base URL its path is relative to**. - * - * `serves_artifacts` is what makes that last clause true. A mirror row can be a - * perfectly real mirror of the distro and still be the wrong base: Fedora's - * MirrorManager publishes per-repo directories, and joining an artifact path - * onto one produces a 404 (migration 0006). - * - * Absolute references are skipped deliberately: a magnet URI is not served by - * a mirror, and pretending otherwise would put a row in `artifact_mirrors` - * that resolution would then have to special-case anyway. - */ -export function linkArtifactsToMirrorsStatement(db: D1Database, slug: string): D1PreparedStatement { - // The WHERE below is also what lets SQLite tell the ON CONFLICT clause apart - // from a join constraint — an INSERT…SELECT with no WHERE of its own fails - // to parse here (the same trap the rankings snapshot hit). - return db - .prepare( - `INSERT INTO artifact_mirrors (artifact_id, mirror_id, available) - SELECT a.id, m.id, 1 - FROM artifacts a - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - JOIN mirrors m ON m.distro_id = d.id AND m.healthy = 1 - AND m.serves_artifacts = 1 - WHERE d.slug = ?1 - AND a.path NOT LIKE 'http%' - AND a.path NOT LIKE 'magnet:%' - ON CONFLICT(artifact_id, mirror_id) DO UPDATE SET available = 1`, - ) - .bind(slug); -} - -/** Drops links to mirrors that are no longer an artifact base. The link step - * only ever inserts, so without this a mirror reclassified by a later ingest - * would keep serving resolutions from a base that does not work. */ -export function pruneArtifactMirrorsStatement(db: D1Database, slug: string): D1PreparedStatement { - return db - .prepare( - `DELETE FROM artifact_mirrors - WHERE mirror_id IN ( - SELECT m.id FROM mirrors m - JOIN distros d ON d.id = m.distro_id - WHERE d.slug = ?1 AND m.serves_artifacts = 0)`, - ) - .bind(slug); -} - -export async function upsertCatalog( - db: D1Database, - slug: string, - catalog: LiveCatalog, - sourceUrl: string, - fetchedAt: string, -): Promise<{ written: number }> { - let written = 0; - - // Three passes, because each level's insert resolves the one above it by a - // subselect: a release must exist before its edition, an edition before its - // artifacts. - if (catalog.editions.length > 0) { - const results = await db.batch( - catalog.editions.map((e) => upsertEditionStatement(db, slug, e.version, e)), - ); - written += results.reduce((n, r) => n + (r.meta?.changes ?? 0), 0); - } - if (catalog.artifacts.length > 0) { - const results = await db.batch( - catalog.artifacts.map((a) => upsertArtifactStatement(db, slug, a, sourceUrl, fetchedAt)), - ); - written += results.reduce((n, r) => n + (r.meta?.changes ?? 0), 0); - } - - return { written }; -} diff --git a/apps/api/src/db/distros.ts b/apps/api/src/db/distros.ts deleted file mode 100644 index 0257a4e..0000000 --- a/apps/api/src/db/distros.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Catalog identity writes. All SQL for `distros` and `content_index` lives -// here (.ai/backend-rules.md § "Project structure"); parameters are always -// bound, never interpolated. - -import type { ContentDistroRow, ContentDocRow, ContentIndex } from "@linuxhub/ingest/content"; - -/** - * Upserts the catalog identity every distro gets from its authored, cited MDX. - * - * `family` and `based_on` are deliberately not touched: lineage comes from - * Wikidata in a later step, and this statement must not overwrite it with the - * empty default on the next run. - */ -export function upsertDistroStatement( - db: D1Database, - row: ContentDistroRow, - now: string, -): D1PreparedStatement { - return db - .prepare( - `INSERT INTO distros - (slug, name, summary, homepage, logo_path, source_url, fetched_at, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?7) - ON CONFLICT(slug) DO UPDATE SET - name = excluded.name, - summary = excluded.summary, - homepage = excluded.homepage, - logo_path = excluded.logo_path, - source_url = excluded.source_url, - fetched_at = excluded.fetched_at, - updated_at = excluded.updated_at`, - ) - .bind(row.slug, row.name, row.summary, row.homepage, row.logo_path, row.source_url, now); -} - -export function upsertContentDocStatement( - db: D1Database, - slug: string, - doc: ContentDocRow, -): D1PreparedStatement { - return db - .prepare( - `INSERT INTO content_index (distro_id, doc, locale, source_urls, reviewed_at) - VALUES ((SELECT id FROM distros WHERE slug = ?1), ?2, ?3, ?4, ?5) - ON CONFLICT(distro_id, doc, locale) DO UPDATE SET - source_urls = excluded.source_urls, - reviewed_at = excluded.reviewed_at`, - ) - .bind(slug, doc.doc, doc.locale, JSON.stringify(doc.source_urls), doc.reviewed_at); -} - -/** - * Seeds distro identity and the MDX registry from the generated content index. - * Idempotent: safe to run at the head of every ingestion pass, which is what - * guarantees the foreign keys releases and mirrors need already resolve. - */ -export async function syncCatalog(db: D1Database, index: ContentIndex, now: string) { - const statements: D1PreparedStatement[] = []; - for (const row of index.distros) { - statements.push(upsertDistroStatement(db, row, now)); - } - // Content rows are a second pass so every distro row exists before the - // subselect on `slug` runs. - for (const row of index.distros) { - for (const doc of row.docs) { - statements.push(upsertContentDocStatement(db, row.slug, doc)); - } - } - const results = await db.batch(statements); - return { written: results.reduce((n, r) => n + (r.meta?.changes ?? 0), 0) }; -} - -export async function distroIdBySlug(db: D1Database, slug: string): Promise { - const row = await db - .prepare("SELECT id FROM distros WHERE slug = ?1") - .bind(slug) - .first<{ id: number }>(); - return row?.id ?? null; -} diff --git a/apps/api/src/db/downloads.ts b/apps/api/src/db/downloads.ts deleted file mode 100644 index 705453d..0000000 --- a/apps/api/src/db/downloads.ts +++ /dev/null @@ -1,212 +0,0 @@ -// Download queries. All SQL for the selector and for resolution lives here -// (.ai/backend-rules.md § "Project structure"). - -export type OptionRow = { - version: string; - channel: string; - lts: number; - released_at: string | null; - edition: string; - desktop: string | null; - kind: string; - arch: string; - format: string; -}; - -/** The whole decision tree in one query — versions, their editions, and what - * each is available as — so the selector never needs a second round trip. */ -export async function downloadOptionRows(db: D1Database, slug: string): Promise { - const { results } = await db - .prepare( - `SELECT r.version, r.channel, r.lts, r.released_at, - e.name AS edition, e.desktop, e.kind, - a.arch, a.format - FROM artifacts a - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = ?1 - ORDER BY r.released_at DESC NULLS LAST, r.version DESC, e.name, a.arch, a.format`, - ) - .bind(slug) - .all(); - return results ?? []; -} - -export type ResolvedArtifact = { - artifact_id: number; - path: string; - size: number | null; - sha256: string | null; - sig_url: string | null; -}; - -export async function findArtifact( - db: D1Database, - slug: string, - version: string, - edition: string, - arch: string, - format: string, -): Promise { - return db - .prepare( - `SELECT a.id AS artifact_id, a.path, a.size, a.sha256, a.sig_url - FROM artifacts a - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = ?1 AND r.version = ?2 AND e.name = ?3 - AND a.arch = ?4 AND a.format = ?5`, - ) - .bind(slug, version, edition, arch, format) - .first(); -} - -export type MirrorChoice = { - id: number; - name: string; - country: string; - base_url: string; -}; - -/** - * Picks a mirror for an artifact. - * - * Order: the one asked for, then one in the caller's country, then any healthy - * one. Deliberately not "closest by latency" — we have no latency data, and - * inventing a ranking from a country code alone would be a guess dressed as a - * measurement. The caller is told which of the three happened. - */ -export async function chooseMirror( - db: D1Database, - artifactId: number, - requestedId?: number, - country?: string, -): Promise<{ mirror: MirrorChoice | null; choice: "requested" | "country" | "fallback" }> { - if (requestedId !== undefined) { - const mirror = await db - .prepare( - `SELECT m.id, m.name, m.country, m.base_url - FROM artifact_mirrors am JOIN mirrors m ON m.id = am.mirror_id - WHERE am.artifact_id = ?1 AND m.id = ?2 AND am.available = 1 AND m.healthy = 1`, - ) - .bind(artifactId, requestedId) - .first(); - if (mirror) return { mirror, choice: "requested" }; - } - - if (country) { - const mirror = await db - .prepare( - `SELECT m.id, m.name, m.country, m.base_url - FROM artifact_mirrors am JOIN mirrors m ON m.id = am.mirror_id - WHERE am.artifact_id = ?1 AND m.country = ?2 AND am.available = 1 AND m.healthy = 1 - ORDER BY m.name LIMIT 1`, - ) - .bind(artifactId, country.toUpperCase()) - .first(); - if (mirror) return { mirror, choice: "country" }; - } - - const mirror = await db - .prepare( - `SELECT m.id, m.name, m.country, m.base_url - FROM artifact_mirrors am JOIN mirrors m ON m.id = am.mirror_id - WHERE am.artifact_id = ?1 AND am.available = 1 AND m.healthy = 1 - ORDER BY m.name LIMIT 1`, - ) - .bind(artifactId) - .first(); - return { mirror, choice: "fallback" }; -} - -/** The mirrors a visitor may pick from — only those a download can actually - * come from, so choosing one is never a way to get a URL that 404s. */ -export async function listDownloadMirrors(db: D1Database, slug: string): Promise { - const { results } = await db - .prepare( - `SELECT m.id, m.name, m.country, m.base_url - FROM mirrors m JOIN distros d ON d.id = m.distro_id - WHERE d.slug = ?1 AND m.healthy = 1 AND m.serves_artifacts = 1 - ORDER BY m.country, m.name`, - ) - .bind(slug) - .all(); - return results ?? []; -} - -/** - * Records a download click in KV. - * - * KV rather than D1 because this is the one write on a hot path, and a counter - * that costs a database round trip per click is a counter that shapes the page - * it is measuring. The daily cron flushes these into `download_events`; losing - * a day of KV loses a day of counts and nothing else (.ai/database.md). - */ -export async function trackDownload( - kv: KVNamespace, - artifactId: number, - mirrorId: number | undefined, - day: string, -): Promise { - const key = `dlcount:${artifactId}:${mirrorId ?? 0}:${day}`; - const current = Number.parseInt((await kv.get(key)) ?? "0", 10); - // 48h, so the daily flush has a full day of slack before a bucket expires. - await kv.put(key, String(current + 1), { expirationTtl: 48 * 60 * 60 }); -} - -/** - * Moves yesterday's and today's KV click counters into `download_events`. - * - * KV is the write path because it is cheap on a hot page; D1 is where the - * counts have to end up, because KV entries expire and rankings need history. - * The flush is additive (`count = count + excluded.count`) and deletes each key - * it banks, so running it twice in a day cannot double-count. - */ -export async function flushDownloadCounters( - kv: KVNamespace, - db: D1Database, -): Promise<{ flushed: number }> { - let cursor: string | undefined; - let flushed = 0; - - do { - const page = await kv.list({ prefix: "dlcount:", cursor, limit: 1000 }); - cursor = page.list_complete ? undefined : page.cursor; - - const statements: D1PreparedStatement[] = []; - const keys: string[] = []; - - for (const entry of page.keys) { - const [, artifactId, mirrorId, day] = entry.name.split(":"); - const count = Number.parseInt((await kv.get(entry.name)) ?? "0", 10); - if (!artifactId || !day || count <= 0) continue; - - statements.push( - db - .prepare( - `INSERT INTO download_events (artifact_id, mirror_id, day, count) - SELECT ?1, ?2, ?3, ?4 - WHERE EXISTS (SELECT 1 FROM artifacts WHERE id = ?1) - ON CONFLICT(artifact_id, mirror_id, day) - DO UPDATE SET count = count + excluded.count`, - ) - .bind(Number(artifactId), Number(mirrorId ?? 0), day, count), - ); - keys.push(entry.name); - } - - if (statements.length > 0) { - await db.batch(statements); - // Deleted only after the write landed: a crash between the two costs a - // re-flush of counts already banked, which the additive upsert would - // double. Losing the delete is the safer half of that trade — the keys - // expire on their own within 48h. - await Promise.all(keys.map((k) => kv.delete(k))); - flushed += statements.length; - } - } while (cursor); - - return { flushed }; -} diff --git a/apps/api/src/db/ingest-log.ts b/apps/api/src/db/ingest-log.ts deleted file mode 100644 index e831e7b..0000000 --- a/apps/api/src/db/ingest-log.ts +++ /dev/null @@ -1,45 +0,0 @@ -// The ingestion audit trail. One row per source per run: what was fetched, -// when, and whether it changed anything (.ai/security.md § "Crawler ethics"). -// Per-value provenance lives on the ingested rows themselves (ADR-0019). - -export type IngestStatus = "ok" | "error" | "skipped"; - -export type IngestLogRow = { - source: string; - url: string; - fetchedAt: string; - status: IngestStatus; - changed?: number; - detail?: string; -}; - -export function logIngestStatement(db: D1Database, row: IngestLogRow): D1PreparedStatement { - return db - .prepare( - `INSERT INTO ingest_log (source, url, fetched_at, status, changed, detail) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, - ) - .bind(row.source, row.url, row.fetchedAt, row.status, row.changed ?? 0, row.detail ?? null); -} - -export async function logIngest(db: D1Database, rows: IngestLogRow[]): Promise { - if (rows.length === 0) return; - await db.batch(rows.map((row) => logIngestStatement(db, row))); -} - -/** Newest successful fetch per source — powers the provenance line the distro - * page already shows ("data fetched from … at …"). */ -export async function latestProvenance( - db: D1Database, -): Promise> { - const { results } = await db - .prepare( - `SELECT source, url, MAX(fetched_at) AS fetched_at - FROM ingest_log - WHERE status = 'ok' - GROUP BY source - ORDER BY source`, - ) - .all<{ source: string; url: string; fetched_at: string }>(); - return results; -} diff --git a/apps/api/src/db/mirrors.ts b/apps/api/src/db/mirrors.ts deleted file mode 100644 index 02aee8d..0000000 --- a/apps/api/src/db/mirrors.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Mirror writes. All SQL for `mirrors` lives here -// (.ai/backend-rules.md § "Project structure"). - -import type { LiveMirror } from "@linuxhub/ingest/types"; - -function protocolOf(url: string): string { - try { - const scheme = new URL(url).protocol.replace(":", ""); - return ["https", "http", "ftp", "rsync"].includes(scheme) ? scheme : "https"; - } catch { - return "https"; - } -} - -export function upsertMirrorStatement( - db: D1Database, - slug: string, - mirror: LiveMirror, - sourceUrl: string, - fetchedAt: string, - servesArtifacts: boolean, -): D1PreparedStatement { - return db - .prepare( - `INSERT INTO mirrors - (distro_id, name, country, region, base_url, protocol, - sponsor, healthy, last_checked, source_url, fetched_at, serves_artifacts) - VALUES ((SELECT id FROM distros WHERE slug = ?1), - ?2, ?3, '', ?4, ?5, NULL, 1, ?6, ?7, ?6, ?8) - ON CONFLICT(distro_id, base_url) DO UPDATE SET - name = excluded.name, - country = excluded.country, - protocol = excluded.protocol, - healthy = 1, - last_checked = excluded.last_checked, - source_url = excluded.source_url, - fetched_at = excluded.fetched_at, - serves_artifacts = excluded.serves_artifacts`, - ) - .bind( - slug, - mirror.name, - // Only Arch's status JSON publishes a country; MirrorManager does not. - mirror.countryCode?.toUpperCase() ?? "", - mirror.url, - protocolOf(mirror.url), - fetchedAt, - sourceUrl, - servesArtifacts ? 1 : 0, - ); -} - -/** - * Replaces a distro's mirror list with what the source currently publishes. - * - * Mirrors that dropped out of the upstream list are marked unhealthy rather - * than deleted: a mirror can come back, and `artifact_mirrors` rows may already - * point at it. `healthy = 0` takes it out of the picker without losing history. - * - * Retirement is scoped to rows this source wrote. A distro can have mirrors - * from more than one source — Fedora has both its MirrorManager list and its - * redirector — and "absent from the mirrorlist" says nothing about a row the - * mirrorlist never owned. Without the scope the two sources took turns marking - * each other's rows unhealthy on every cron run. - */ -export async function replaceMirrors( - db: D1Database, - slug: string, - mirrors: LiveMirror[], - sourceUrl: string, - fetchedAt: string, - servesArtifacts: boolean, -): Promise<{ written: number; retired: number }> { - if (mirrors.length === 0) return { written: 0, retired: 0 }; - - const statements = mirrors.map((m) => - upsertMirrorStatement(db, slug, m, sourceUrl, fetchedAt, servesArtifacts), - ); - const results = await db.batch(statements); - - // Placeholder count comes from the fetched list length, not from any - // caller-supplied string — every URL is still a bound parameter. - const placeholders = mirrors.map((_, i) => `?${i + 3}`).join(", "); - const retired = await db - .prepare( - `UPDATE mirrors SET healthy = 0 - WHERE distro_id = (SELECT id FROM distros WHERE slug = ?1) - AND source_url = ?2 - AND base_url NOT IN (${placeholders})`, - ) - .bind(slug, sourceUrl, ...mirrors.map((m) => m.url)) - .run(); - - return { - written: results.reduce((n, r) => n + (r.meta?.changes ?? 0), 0), - retired: retired.meta?.changes ?? 0, - }; -} diff --git a/apps/api/src/db/queries.ts b/apps/api/src/db/queries.ts deleted file mode 100644 index b59d601..0000000 --- a/apps/api/src/db/queries.ts +++ /dev/null @@ -1,333 +0,0 @@ -// Read queries. All SQL lives here (.ai/backend-rules.md § "Project -// structure"); parameters are always bound, never interpolated. -// -// Rows come back flat and get shaped into the API entities by the services — -// these functions know SQL, not response shapes. - -import type { - DistroListQuery, - Edition, - Mirror, - RankHistoryPoint, - RankingPeriod, - ReleaseChannel, - SearchQuery, -} from "@linuxhub/shared"; - -export type DistroRow = { - id: number; - slug: string; - name: string; - summary: string; - family: string; - based_on: string | null; - homepage: string; - status: string; - logo_path: string; - downloads: number; - rank: number | null; - trend: number | null; -}; - -export type ReleaseRow = { - version: string; - channel: string; - lts: number; - codename: string | null; - latest_point: string | null; - released_at: string | null; - eol_at: string | null; - notes_url: string | null; - source_url: string | null; - fetched_at: string | null; -}; - -// Popularity, rank movement, and download totals all come from our own -// signals; the joins below yield NULL/0 until 5.6 starts counting, which is -// why `?sort=popularity` falls back to name ordering. -const DISTRO_COLUMNS = ` - d.id, d.slug, d.name, d.summary, d.family, d.based_on, d.homepage, d.status, d.logo_path, - COALESCE((SELECT SUM(de.count) - FROM download_events de - JOIN artifacts a ON a.id = de.artifact_id - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - WHERE r.distro_id = d.id), 0) AS downloads, - (SELECT rk.rank FROM rankings rk - WHERE rk.distro_id = d.id AND rk.period = 'week' - ORDER BY rk.snapshot_at DESC LIMIT 1) AS rank, - (SELECT prev.rank - cur.rank - FROM rankings cur - JOIN rankings prev ON prev.distro_id = cur.distro_id - AND prev.period = cur.period - AND prev.snapshot_at < cur.snapshot_at - WHERE cur.distro_id = d.id AND cur.period = 'week' - ORDER BY cur.snapshot_at DESC, prev.snapshot_at DESC LIMIT 1) AS trend`; - -const ORDER_BY: Record = { - // No ranking snapshot exists until we have download signals of our own, so - // popularity degrades to a stable alphabetical order rather than inventing one. - popularity: "rank IS NULL, rank ASC, d.name COLLATE NOCASE", - name: "d.name COLLATE NOCASE", - latest_release: - "(SELECT MAX(r.released_at) FROM releases r WHERE r.distro_id = d.id) DESC NULLS LAST", - newest: "d.created_at DESC", -}; - -type Filter = { sql: string; value: string }; - -function filtersFor(q: DistroListQuery): Filter[] { - const filters: Filter[] = []; - if (q.category) filters.push({ sql: taxonomyClause("category"), value: q.category }); - if (q.tag) filters.push({ sql: taxonomyClause("tag"), value: q.tag }); - if (q.desktop) filters.push({ sql: taxonomyClause("desktop"), value: q.desktop }); - if (q.family) filters.push({ sql: "d.family = ?", value: q.family }); - if (q.status) filters.push({ sql: "d.status = ?", value: q.status }); - if (q.q) filters.push({ sql: searchClause(), value: `%${q.q.toLowerCase()}%` }); - return filters; -} - -const taxonomyClause = (kind: string) => - `EXISTS (SELECT 1 FROM distro_taxonomy t - WHERE t.distro_id = d.id AND t.kind = '${kind}' AND t.ref_slug = ?)`; - -/** LIKE over name, summary and aliases, per ADR-0010. Adopting FTS5 is its own - * ADR, written when this plan actually gets slow. */ -const searchClause = () => - `(LOWER(d.name) LIKE ? OR LOWER(d.summary) LIKE ? OR LOWER(d.aliases) LIKE ?)`; - -/** Builds the shared WHERE clause and its bindings. `kind` is never - * caller-supplied — it comes from the fixed map above — and every value is a - * bound parameter. */ -function whereFor(q: DistroListQuery): { sql: string; bindings: string[] } { - const filters = filtersFor(q); - if (filters.length === 0) return { sql: "", bindings: [] }; - const bindings: string[] = []; - for (const f of filters) { - // The search clause carries three placeholders for one value. - const placeholders = (f.sql.match(/\?/g) ?? []).length; - for (let i = 0; i < placeholders; i++) bindings.push(f.value); - } - return { sql: `WHERE ${filters.map((f) => f.sql).join(" AND ")}`, bindings }; -} - -export async function listDistros( - db: D1Database, - q: DistroListQuery, -): Promise<{ rows: DistroRow[]; total: number }> { - const { sql: where, bindings } = whereFor(q); - const offset = (q.page - 1) * q.limit; - - // A real COUNT(*) alongside the page, so `meta.total` is honest rather than - // the length of the current page (.ai/backend-rules.md). - const [page, count] = await db.batch([ - db - .prepare( - `SELECT ${DISTRO_COLUMNS} FROM distros d ${where} - ORDER BY ${ORDER_BY[q.sort]} LIMIT ? OFFSET ?`, - ) - .bind(...bindings, q.limit, offset), - db.prepare(`SELECT COUNT(*) AS total FROM distros d ${where}`).bind(...bindings), - ]); - - return { - rows: (page.results ?? []) as DistroRow[], - total: ((count.results ?? [])[0] as { total: number } | undefined)?.total ?? 0, - }; -} - -export async function searchDistros( - db: D1Database, - q: SearchQuery, -): Promise<{ rows: DistroRow[]; total: number }> { - return listDistros(db, { - page: q.page, - limit: q.limit, - q: q.q, - sort: "popularity", - } as DistroListQuery); -} - -export async function getDistro(db: D1Database, slug: string): Promise { - return db - .prepare(`SELECT ${DISTRO_COLUMNS} FROM distros d WHERE d.slug = ?1`) - .bind(slug) - .first(); -} - -/** Same family first, then by rank — the heuristic behind endpoint #8. */ -export async function relatedDistros( - db: D1Database, - row: DistroRow, - limit = 3, -): Promise { - const { results } = await db - .prepare( - `SELECT ${DISTRO_COLUMNS} FROM distros d - WHERE d.slug != ?1 AND d.status = 'active' - ORDER BY (d.family != ?2 OR d.family = ''), rank IS NULL, rank ASC, - d.name COLLATE NOCASE - LIMIT ?3`, - ) - .bind(row.slug, row.family, limit) - .all(); - return results ?? []; -} - -export async function listReleases( - db: D1Database, - slug: string, - channel?: ReleaseChannel, -): Promise { - const { results } = await db - .prepare( - `SELECT r.version, r.channel, r.lts, r.codename, r.latest_point, r.released_at, - r.eol_at, r.notes_url, r.source_url, r.fetched_at - FROM releases r JOIN distros d ON d.id = r.distro_id - WHERE d.slug = ?1 AND (?2 IS NULL OR r.channel = ?2) - ORDER BY r.released_at DESC NULLS LAST, r.version DESC`, - ) - .bind(slug, channel ?? null) - .all(); - return results ?? []; -} - -export async function recentReleases( - db: D1Database, - limit: number, - cursor?: string, -): Promise> { - const { results } = await db - .prepare( - `SELECT d.slug, d.name, r.version, r.channel, r.lts, r.codename, r.latest_point, - r.released_at, r.eol_at, r.notes_url, r.source_url, r.fetched_at - FROM releases r JOIN distros d ON d.id = r.distro_id - WHERE r.released_at IS NOT NULL AND (?1 IS NULL OR r.released_at < ?1) - ORDER BY r.released_at DESC - LIMIT ?2`, - ) - .bind(cursor ?? null, limit) - .all(); - return results ?? []; -} - -export async function listMirrors(db: D1Database, slug: string): Promise { - const { results } = await db - .prepare( - `SELECT m.id, m.name, m.country, m.region, m.base_url, m.protocol, m.sponsor, - m.healthy, m.last_checked, m.source_url - FROM mirrors m JOIN distros d ON d.id = m.distro_id - WHERE d.slug = ?1 - ORDER BY m.healthy DESC, m.name`, - ) - .bind(slug) - .all & { healthy: number }>(); - return (results ?? []).map((m) => ({ ...m, healthy: m.healthy === 1 })); -} - -export async function listEditions(db: D1Database, slug: string): Promise { - const { results } = await db - .prepare( - `SELECT e.id, e.name, e.desktop, e.kind, r.version AS release_version - FROM editions e - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = ?1 - ORDER BY r.released_at DESC NULLS LAST, e.name`, - ) - .bind(slug) - .all(); - return results ?? []; -} - -export async function taxonomyFor( - db: D1Database, - distroIds: number[], -): Promise> { - const out = new Map(); - if (distroIds.length === 0) return out; - - // Placeholder count comes from the id array length; the ids themselves are - // still bound parameters. - const placeholders = distroIds.map((_, i) => `?${i + 1}`).join(", "); - const { results } = await db - .prepare( - `SELECT distro_id, kind, ref_slug FROM distro_taxonomy - WHERE distro_id IN (${placeholders}) ORDER BY ref_slug`, - ) - .bind(...distroIds) - .all<{ distro_id: number; kind: string; ref_slug: string }>(); - - for (const id of distroIds) out.set(id, { categories: [], tags: [], desktops: [] }); - for (const row of results ?? []) { - const entry = out.get(row.distro_id); - if (!entry) continue; - if (row.kind === "category") entry.categories.push(row.ref_slug); - else if (row.kind === "tag") entry.tags.push(row.ref_slug); - else if (row.kind === "desktop") entry.desktops.push(row.ref_slug); - } - return out; -} - -export async function rankingSnapshot( - db: D1Database, - period: RankingPeriod, - limit: number, -): Promise< - Array<{ rank: number; slug: string; name: string; score: number; delta: number | null }> -> { - const { results } = await db - .prepare( - `WITH latest AS (SELECT MAX(snapshot_at) AS at FROM rankings WHERE period = ?1) - SELECT rk.rank, d.slug, d.name, rk.score, - (SELECT prev.rank - rk.rank FROM rankings prev - WHERE prev.distro_id = rk.distro_id AND prev.period = rk.period - AND prev.snapshot_at < rk.snapshot_at - ORDER BY prev.snapshot_at DESC LIMIT 1) AS delta - FROM rankings rk - JOIN distros d ON d.id = rk.distro_id - WHERE rk.period = ?1 AND rk.snapshot_at = (SELECT at FROM latest) - ORDER BY rk.rank - LIMIT ?2`, - ) - .bind(period, limit) - .all<{ rank: number; slug: string; name: string; score: number; delta: number | null }>(); - return results ?? []; -} - -export async function rankHistory( - db: D1Database, - slug: string, - period: RankingPeriod, - limit = 12, -): Promise { - const { results } = await db - .prepare( - `SELECT rk.snapshot_at, rk.rank, rk.score - FROM rankings rk JOIN distros d ON d.id = rk.distro_id - WHERE d.slug = ?1 AND rk.period = ?2 - ORDER BY rk.snapshot_at DESC - LIMIT ?3`, - ) - .bind(slug, period, limit) - .all(); - // Oldest first, so the client can draw straight through the series. - return (results ?? []).reverse(); -} - -export async function hallOfFame(db: D1Database) { - const { results } = await db - .prepare( - `SELECT d.slug, d.name, h.rationale, h.sources, h.ordering - FROM hall_of_fame h JOIN distros d ON d.id = h.distro_id - ORDER BY h.ordering, d.name`, - ) - .all<{ - slug: string; - name: string; - rationale: string; - sources: string; - ordering: number; - }>(); - return results ?? []; -} diff --git a/apps/api/src/db/releases.ts b/apps/api/src/db/releases.ts deleted file mode 100644 index 3979573..0000000 --- a/apps/api/src/db/releases.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Release writes and the reads ingestion itself needs. All SQL for `releases` -// lives here (.ai/backend-rules.md § "Project structure"). - -import type { LiveRelease } from "@linuxhub/ingest/types"; - -/** - * Upserts one release cycle. - * - * Note what is *not* stored: whether the release is currently EOL. That is - * derived from `eol_at < today` at read time (ADR-0019), so a row cannot go - * quietly stale between ingestion runs as dates pass. - */ -export function upsertReleaseStatement( - db: D1Database, - slug: string, - release: LiveRelease, - sourceUrl: string, - fetchedAt: string, - channel: "stable" | "beta" | "rolling" = "stable", -): D1PreparedStatement { - return db - .prepare( - `INSERT INTO releases - (distro_id, version, channel, lts, codename, latest_point, - released_at, eol_at, source_url, fetched_at) - VALUES ((SELECT id FROM distros WHERE slug = ?1), - ?2, ?10, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - ON CONFLICT(distro_id, version) DO UPDATE SET - channel = excluded.channel, - lts = excluded.lts, - codename = excluded.codename, - latest_point = excluded.latest_point, - released_at = excluded.released_at, - eol_at = excluded.eol_at, - source_url = excluded.source_url, - fetched_at = excluded.fetched_at`, - ) - .bind( - slug, - release.cycle, - release.lts ? 1 : 0, - release.codename ?? null, - release.latest ?? null, - release.releaseDate ?? null, - release.eol ?? null, - sourceUrl, - fetchedAt, - channel, - ); -} - -export async function upsertReleases( - db: D1Database, - slug: string, - releases: LiveRelease[], - sourceUrl: string, - fetchedAt: string, - channel: "stable" | "beta" | "rolling" = "stable", -): Promise<{ written: number }> { - if (releases.length === 0) return { written: 0 }; - const results = await db.batch( - releases.map((r) => upsertReleaseStatement(db, slug, r, sourceUrl, fetchedAt, channel)), - ); - return { written: results.reduce((n, r) => n + (r.meta?.changes ?? 0), 0) }; -} - -/** Newest ingested version for a distro — used to build version-dependent - * upstream URLs (e.g. Fedora's mirrorlist repo) instead of pinning one by - * hand and letting it go stale a release later. */ -export async function newestVersion(db: D1Database, slug: string): Promise { - const row = await db - .prepare( - `SELECT r.version - FROM releases r - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = ?1 - ORDER BY r.released_at DESC NULLS LAST, r.id DESC - LIMIT 1`, - ) - .bind(slug) - .first<{ version: string }>(); - return row?.version ?? null; -} - -/** Versions the catalog already has release rows for. Artifact indexes reach - * further back than our release source does, so this is what bounds them. */ -export async function listVersions(db: D1Database, slug: string): Promise { - const { results } = await db - .prepare( - `SELECT r.version FROM releases r - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = ?1`, - ) - .bind(slug) - .all<{ version: string }>(); - return (results ?? []).map((r) => r.version); -} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts deleted file mode 100644 index a5473d9..0000000 --- a/apps/api/src/env.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Worker bindings (.ai/backend-rules.md § "Runtime"). Kept in its own module so -// route, cron, and db code can share the type without importing the Hono app. - -export type Env = { - DB: D1Database; - KV_CACHE: KVNamespace; - KV_RATE: KVNamespace; - KV_GEO: KVNamespace; - // Wrangler secrets (set in Phase 7): HCAPTCHA_SECRET, INTERNAL_API_TOKEN, RATE_SALT - HCAPTCHA_SECRET?: string; - INTERNAL_API_TOKEN?: string; - RATE_SALT?: string; - /** Origin used in the ingest User-Agent's contact URL (.ai/security.md). */ - SITE_ORIGIN?: string; -}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts deleted file mode 100644 index a3d8dac..0000000 --- a/apps/api/src/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Hono } from "hono"; -import { runScheduled } from "./cron"; -import type { Env } from "./env"; -import { - type App, - onError, - onNotFound, - withInternalAuth, - withLogging, - withSecurityHeaders, -} from "./middleware"; -import { distros } from "./routes/distros"; -import { downloads } from "./routes/downloads"; -import { health } from "./routes/health"; - -export type { Env }; - -const app = new Hono(); - -app.onError(onError); -app.notFound(onNotFound); - -app.use("*", withLogging); -app.use("*", withSecurityHeaders); -app.use("*", withInternalAuth); - -app.route("/v1", health); -app.route("/v1", distros); -app.route("/v1", downloads); - -export default { - fetch: app.fetch, - // Cron Triggers (.ai/backend-rules.md § "Ingestion"). waitUntil keeps the - // invocation alive for the whole pass; a schedule that throws is logged by - // the runtime and retried on its next tick. - scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) { - ctx.waitUntil( - runScheduled(controller.cron, env, new Date(controller.scheduledTime)).then((summary) => { - console.log(JSON.stringify({ ts: new Date().toISOString(), level: "info", ...summary })); - }), - ); - }, -} satisfies ExportedHandler; - -export { app }; diff --git a/apps/api/src/lib/cache.ts b/apps/api/src/lib/cache.ts deleted file mode 100644 index ba7498e..0000000 --- a/apps/api/src/lib/cache.ts +++ /dev/null @@ -1,61 +0,0 @@ -// KV read-through cache (.ai/backend-rules.md § "Caching"). -// -// Every cacheable service goes through `cached()`, so the cache is impossible -// to bypass by calling a db/ module directly from a route. - -/** - * Cache keys embed a generation counter that ingestion bumps whenever it - * changes a distro, so updates invalidate naturally and nothing ever needs - * manual purging (.ai/architecture.md § "Caching strategy"). - * - * `all` is the catalog-wide generation, used by list and search keys that no - * single distro owns. - */ -export async function generation(kv: KVNamespace, slug = "all"): Promise { - return (await kv.get(`gen:${slug}`)) ?? "0"; -} - -export async function cached( - kv: KVNamespace, - key: string, - ttlSeconds: number, - fetcher: () => Promise, -): Promise { - const hit = await kv.get(key, "json"); - if (hit !== null) return hit as T; - - const value = await fetcher(); - // A cache write must never fail a read that already succeeded. - try { - await kv.put(key, JSON.stringify(value), { expirationTtl: ttlSeconds }); - } catch { - // Losing a cache write costs a repeat query, nothing more. - } - return value; -} - -/** Stable, order-independent hash of a validated query object, so two requests - * that differ only in parameter order share one cache entry. */ -export function hashQuery(query: Record): string { - const canonical = Object.keys(query) - .filter((k) => query[k] !== undefined) - .sort() - .map((k) => `${k}=${String(query[k])}`) - .join("&"); - - // FNV-1a: short, dependency-free, and collisions only ever cost a stale - // cache entry inside a TTL window — this is not a security boundary. - let hash = 0x811c9dc5; - for (let i = 0; i < canonical.length; i++) { - hash ^= canonical.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash.toString(36); -} - -/** TTLs from .ai/database.md § "KV keyspaces". */ -export const TTL = { - list: 300, - detail: 3600, - rankings: 600, -} as const; diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts deleted file mode 100644 index 315f3f0..0000000 --- a/apps/api/src/lib/errors.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Typed errors and the single response envelope (.ai/api.md § "Errors"). - -import { type ApiErrorCode, ERROR_STATUS, err } from "@linuxhub/shared"; -import type { Context } from "hono"; -import type { ContentfulStatusCode } from "hono/utils/http-status"; - -export class ApiError extends Error { - constructor( - readonly code: ApiErrorCode, - message: string, - readonly details?: unknown, - ) { - super(message); - this.name = "ApiError"; - } -} - -export const notFound = (what: string) => new ApiError("NOT_FOUND", `${what} not found`); - -/** Correlates a log line with the response a caller saw; never a stack trace. */ -export function requestId(): string { - return `req_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; -} - -export function fail( - c: Context, - code: ApiErrorCode, - message: string, - details?: unknown, - id?: string, -) { - return c.json( - err(code, message, details, id ?? c.get("requestId")), - ERROR_STATUS[code] as ContentfulStatusCode, - ); -} diff --git a/apps/api/src/lib/rate-limit.ts b/apps/api/src/lib/rate-limit.ts deleted file mode 100644 index 2a30b55..0000000 --- a/apps/api/src/lib/rate-limit.ts +++ /dev/null @@ -1,70 +0,0 @@ -// KV fixed-window rate limiting (.ai/security.md § "Rate limiting"). -// -// Raw IPs are never stored or logged. The key holds a salted hash, so the KV -// contents are useless to anyone who reads them and we never hold the address -// itself beyond the length of a request. - -export type RateLimitResult = { - allowed: boolean; - /** Seconds until the current window rolls over — the `Retry-After` value. */ - retryAfter: number; -}; - -/** - * Which client a request belongs to. - * - * The Worker's own `CF-Connecting-IP` is the *BFF's* address, not the user's — - * the BFF is the only thing that ever calls it, so limiting on that would - * throttle the whole site as one client. The BFF forwards the real address in - * `X-Client-IP`, which is trustworthy precisely because the internal token - * already gates who can set it. - */ -export function clientAddress(request: Request): string { - return ( - request.headers.get("X-Client-IP") ?? - request.headers.get("CF-Connecting-IP") ?? - // No address at all (local dev, tests): everything shares one bucket, - // which is the safe direction to fail. - "unknown" - ); -} - -async function hashAddress(address: string, salt: string): Promise { - const data = new TextEncoder().encode(`${salt}:${address}`); - const digest = await crypto.subtle.digest("SHA-256", data); - return [...new Uint8Array(digest)] - .slice(0, 12) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -/** - * Fixed window, not a sliding one: a counter per `windowSeconds` bucket, which - * KV expires on its own. A sliding window would need either a read-modify-write - * of a timestamp list or a Durable Object, and neither is worth it to make the - * boundary behaviour slightly fairer on an endpoint whose limit exists to stop - * scripted abuse rather than to meter anything. - */ -export async function rateLimit( - kv: KVNamespace, - scope: string, - address: string, - limit: number, - windowSeconds: number, - salt: string, -): Promise { - const now = Math.floor(Date.now() / 1000); - const window = Math.floor(now / windowSeconds); - const key = `rl:${scope}:${await hashAddress(address, salt)}:${window}`; - const retryAfter = windowSeconds - (now % windowSeconds); - - const current = Number.parseInt((await kv.get(key)) ?? "0", 10); - if (current >= limit) return { allowed: false, retryAfter }; - - // Racy by construction: two concurrent requests can both read the same - // count. The overshoot is bounded by concurrency and this is a throttle, - // not an accounting system — paying for a Durable Object to close it would - // cost more than the requests it would prevent. - await kv.put(key, String(current + 1), { expirationTtl: windowSeconds + 60 }); - return { allowed: true, retryAfter }; -} diff --git a/apps/api/src/middleware/index.ts b/apps/api/src/middleware/index.ts deleted file mode 100644 index df8182c..0000000 --- a/apps/api/src/middleware/index.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Cross-cutting middleware (.ai/backend-rules.md). - -import type { Context, ErrorHandler, MiddlewareHandler, NotFoundHandler } from "hono"; -import type { Env } from "../env"; -import { ApiError, fail, requestId } from "../lib/errors"; - -export type Vars = { requestId: string }; -export type App = { Bindings: Env; Variables: Vars }; - -/** Structured JSON logs, one line per request. No PII, no raw IPs, no secrets - * (.ai/backend-rules.md § "Logging"). */ -export const withLogging: MiddlewareHandler = async (c, next) => { - const id = requestId(); - c.set("requestId", id); - const started = Date.now(); - await next(); - console.log( - JSON.stringify({ - ts: new Date().toISOString(), - level: c.res.status >= 500 ? "error" : "info", - request_id: id, - route: new URL(c.req.url).pathname, - status: c.res.status, - ms: Date.now() - started, - }), - ); -}; - -/** - * Response headers for the internal surface (.ai/security.md § "Headers", - * which specifies them for *both* apps). - * - * This one only ever returns JSON to the BFF, so the policy is the strictest - * one there is: `default-src 'none'` says nothing on this origin may load - * anything, which is exactly right for a surface no browser should be - * rendering. If a response from here is ever being displayed, something has - * gone wrong, and this makes that failure inert rather than exploitable. - */ -const SECURITY_HEADERS: Record = { - "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", - "Strict-Transport-Security": "max-age=63072000; includeSubDomains", - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", -}; - -export const withSecurityHeaders: MiddlewareHandler = async (c, next) => { - await next(); - for (const [name, value] of Object.entries(SECURITY_HEADERS)) { - c.header(name, value); - } -}; - -/** - * Converts thrown `ApiError`s to the envelope; anything else becomes an opaque - * INTERNAL, logged with its request_id. Stack traces never leak. - * - * Registered with `app.onError`, not as try/catch middleware: Hono's composer - * hands a thrown error straight to the error handler, so a middleware wrapping - * `next()` never sees it and the client gets Hono's default plain-text 500. - */ -export const onError: ErrorHandler = (error, c: Context) => { - if (error instanceof ApiError) { - return fail(c, error.code, error.message, error.details); - } - console.error( - JSON.stringify({ - ts: new Date().toISOString(), - level: "error", - request_id: c.get("requestId"), - route: new URL(c.req.url).pathname, - msg: String(error), - }), - ); - return fail(c, "INTERNAL", "internal error"); -}; - -/** Unknown routes answer in the same envelope as everything else, so the BFF - * has exactly one response shape to parse. */ -export const onNotFound: NotFoundHandler = (c) => fail(c, "NOT_FOUND", "not found"); - -/** Constant-time comparison, so a wrong token cannot be found byte by byte. */ -function tokensMatch(a: string, b: string): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); - return diff === 0; -} - -/** - * The Worker is internal: only the BFF may call it, carrying - * `X-Internal-Token` (.ai/security.md § "Internal surface"). `/v1/health` is - * exempt so liveness works before secrets are configured. - * - * With no token configured — local dev and tests, where wrangler.toml holds - * placeholder ids and no secrets exist — the check is skipped. It cannot be - * skipped in production, because Phase 7 provisions the secret. - * - * A bad token answers 404 rather than 403: the error taxonomy in .ai/api.md - * has no auth code, and an internal surface should not confirm it exists to - * anything that cannot already call it. The log line below carries the real - * reason, so a misconfigured token is still diagnosable rather than looking - * like a missing distro. - */ -export const withInternalAuth: MiddlewareHandler = async (c, next) => { - const expected = c.env.INTERNAL_API_TOKEN; - if (!expected) return next(); - if (c.req.path === "/v1/health") return next(); - - const presented = c.req.header("X-Internal-Token"); - if (!presented || !tokensMatch(presented, expected)) { - console.warn( - JSON.stringify({ - ts: new Date().toISOString(), - level: "warn", - request_id: c.get("requestId"), - route: c.req.path, - msg: presented ? "internal token mismatch" : "internal token missing", - }), - ); - throw new ApiError("NOT_FOUND", "not found"); - } - return next(); -}; diff --git a/apps/api/src/routes/distros.ts b/apps/api/src/routes/distros.ts deleted file mode 100644 index 33d9844..0000000 --- a/apps/api/src/routes/distros.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Routes stay thin: validate → service → envelope. No SQL, no business rules -// (.ai/backend-rules.md § "Worked example"). - -import { - distroListQuery, - ok, - rankingsQuery, - recentReleasesQuery, - releaseListQuery, - searchQuery, - slugParam, -} from "@linuxhub/shared"; -import { Hono } from "hono"; -import { fail } from "../lib/errors"; -import type { App } from "../middleware"; -import * as service from "../services/distros"; - -export const distros = new Hono(); - -/** Every route validates with `safeParse`, so a Zod error is never thrown at a - * client — it becomes a VALIDATION_ERROR envelope listing the issues. */ -function parseSlug(c: { req: { param: (k: string) => string } }) { - return slugParam.safeParse({ slug: c.req.param("slug") }); -} - -// #1 — catalog list -distros.get("/distros", async (c) => { - const parsed = distroListQuery.safeParse(c.req.query()); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid query", parsed.error.issues); - } - const { data, total } = await service.listDistros(c.env, parsed.data); - return c.json(ok(data, { page: parsed.data.page, limit: parsed.data.limit, total })); -}); - -// #22 — search -distros.get("/search", async (c) => { - const parsed = searchQuery.safeParse(c.req.query()); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid query", parsed.error.issues); - } - const { data, total } = await service.searchDistros(c.env, parsed.data); - return c.json(ok(data, { page: parsed.data.page, limit: parsed.data.limit, total })); -}); - -// #12 — cross-distro recent releases, cursor-paginated -distros.get("/releases/recent", async (c) => { - const parsed = recentReleasesQuery.safeParse(c.req.query()); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid query", parsed.error.issues); - } - const { data, nextCursor } = await service.recentReleases( - c.env, - parsed.data.limit, - parsed.data.cursor, - ); - return c.json(ok(data, { limit: parsed.data.limit, next_cursor: nextCursor })); -}); - -// #32 — popularity ranking snapshot (+ the biggest movers, #34) -distros.get("/rankings", async (c) => { - const parsed = rankingsQuery.safeParse(c.req.query()); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid query", parsed.error.issues); - } - const result = await service.rankings(c.env, parsed.data.period, parsed.data.limit); - return c.json(ok(result, { total: result.entries.length })); -}); - -// #35 — Hall of Fame -distros.get("/hall-of-fame", async (c) => { - const entries = await service.hallOfFame(c.env); - return c.json(ok(entries, { total: entries.length })); -}); - -// #2 — distro detail -distros.get("/distros/:slug", async (c) => { - const parsed = parseSlug(c); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid slug", parsed.error.issues); - } - return c.json(ok(await service.getDistroDetail(c.env, parsed.data.slug))); -}); - -// #3 — releases for one distro -distros.get("/distros/:slug/releases", async (c) => { - const parsed = parseSlug(c); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid slug", parsed.error.issues); - } - const query = releaseListQuery.safeParse(c.req.query()); - if (!query.success) { - return fail(c, "VALIDATION_ERROR", "invalid query", query.error.issues); - } - const releases = await service.listReleasesFor(c.env, parsed.data.slug, query.data.channel); - return c.json(ok(releases, { total: releases.length })); -}); - -// #37 — rank history, the series the client draws a sparkline from -distros.get("/distros/:slug/rank-history", async (c) => { - const parsed = parseSlug(c); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid slug", parsed.error.issues); - } - const query = rankingsQuery.safeParse(c.req.query()); - if (!query.success) { - return fail(c, "VALIDATION_ERROR", "invalid query", query.error.issues); - } - const points = await service.rankHistory(c.env, parsed.data.slug, query.data.period); - return c.json(ok(points, { total: points.length })); -}); diff --git a/apps/api/src/routes/downloads.ts b/apps/api/src/routes/downloads.ts deleted file mode 100644 index 1b68ddb..0000000 --- a/apps/api/src/routes/downloads.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Download endpoints (.ai/api.md #14, #15, #21). Routes stay thin: validate → -// service → envelope. - -import { ok, RATE_LIMITS, resolveRequest, slugParam, trackRequest } from "@linuxhub/shared"; -import { type Context, Hono } from "hono"; -import { fail } from "../lib/errors"; -import { clientAddress, rateLimit } from "../lib/rate-limit"; -import type { App } from "../middleware"; -import * as service from "../services/downloads"; - -export const downloads = new Hono(); - -const MINUTE = 60; - -/** Applies a per-IP limit, answering 429 with `Retry-After` when it bites. - * With no RATE_SALT configured — local dev and tests — limiting is skipped - * rather than run against a constant salt, which would be worse than nothing: - * a predictable salt makes the stored hashes reversible. */ -async function limited( - c: Context, - scope: string, - perMinute: number, -): Promise { - const salt = c.env.RATE_SALT; - if (!salt) return null; - - const { allowed, retryAfter } = await rateLimit( - c.env.KV_RATE, - scope, - clientAddress(c.req.raw), - perMinute, - MINUTE, - salt, - ); - if (allowed) return null; - - c.header("Retry-After", String(retryAfter)); - return fail(c, "RATE_LIMITED", "too many requests"); -} - -// #14 — the selector's whole decision tree -downloads.get("/distros/:slug/download-options", async (c) => { - const parsed = slugParam.safeParse({ slug: c.req.param("slug") }); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid slug", parsed.error.issues); - } - return c.json(ok(await service.downloadOptions(c.env, parsed.data.slug))); -}); - -// #15 — selection → a direct URL with its checksum -downloads.post("/downloads/resolve", async (c) => { - const limitedResponse = await limited(c, "dl-resolve", RATE_LIMITS.downloadsResolve.perMinute); - if (limitedResponse) return limitedResponse; - - const body = await c.req.json().catch(() => null); - const parsed = resolveRequest.safeParse(body); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid body", parsed.error.issues); - } - return c.json(ok(await service.resolveDownload(c.env, parsed.data))); -}); - -// #21 — count a click. Fire-and-forget for the caller; a lost count is a lost -// count, and must never be able to fail the download the user came for. -downloads.post("/downloads/track", async (c) => { - const limitedResponse = await limited(c, "dl-track", RATE_LIMITS.downloadsTrack.perMinute); - if (limitedResponse) return limitedResponse; - - const body = await c.req.json().catch(() => null); - const parsed = trackRequest.safeParse(body); - if (!parsed.success) { - return fail(c, "VALIDATION_ERROR", "invalid body", parsed.error.issues); - } - - await service.trackDownload(c.env, parsed.data.artifact_id, parsed.data.mirror_id); - return c.json(ok({ counted: true })); -}); diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts deleted file mode 100644 index 1b93ba9..0000000 --- a/apps/api/src/routes/health.ts +++ /dev/null @@ -1,51 +0,0 @@ -// #41 — liveness. The only route without internal-token auth -// (.ai/backend-rules.md § "Auth"), so it works before Phase 7 sets secrets. - -import { type Health, ok } from "@linuxhub/shared"; -import { Hono } from "hono"; -import type { App } from "../middleware"; - -export const VERSION = "0.1.0"; - -export const health = new Hono(); - -/** Longest probe failure we report. Enough to name the fault, short enough - * that no payload can ride out through a public endpoint. */ -const REASON_LIMIT = 200; - -type ProbeResult = { ok: boolean; error: string | null }; - -/** Probes are best-effort: a probe that throws reports the dependency as down - * rather than taking the health endpoint down with it. The reason travels with - * it, because "db: false" on its own does not say whether the binding id is - * wrong, the database is gone, or the query was bad. */ -async function probe(check: () => Promise): Promise { - try { - await check(); - return { ok: true, error: null }; - } catch (error) { - return { ok: false, error: String(error).slice(0, REASON_LIMIT) }; - } -} - -health.get("/health", async (c) => { - const [db, kv] = await Promise.all([ - probe(() => c.env.DB.prepare("SELECT 1").first()), - // `list`, not `get`: a `get` for a missing key resolves to null without - // throwing, so it passes even when the namespace id is wrong — which is - // exactly the deploy fault this is here to catch. `list` has to reach - // the namespace to answer. - probe(() => c.env.KV_CACHE.list({ limit: 1 })), - ]); - - const body: Health = { - service: "linuxhub-api", - status: db.ok && kv.ok ? "up" : "degraded", - version: VERSION, - db: db.ok, - kv: kv.ok, - db_error: db.error, - kv_error: kv.error, - }; - return c.json(ok(body)); -}); diff --git a/apps/api/src/services/distros.ts b/apps/api/src/services/distros.ts deleted file mode 100644 index d068dbb..0000000 --- a/apps/api/src/services/distros.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Business logic and caching. Routes never call db/ directly, so the cache is -// impossible to bypass (.ai/backend-rules.md § "Caching"). - -import { - type Distro, - type DistroDetail, - type DistroListQuery, - type HallOfFameEntry, - isEol, - type RankHistoryPoint, - type RankingEntry, - type RankingPeriod, - type RecentRelease, - type Release, - type ReleaseChannel, - releaseChannel, - type SearchQuery, -} from "@linuxhub/shared"; -import { latestProvenance } from "../db/ingest-log"; -import * as db from "../db/queries"; -import type { Env } from "../env"; -import { cached, generation, hashQuery, TTL } from "../lib/cache"; -import { notFound } from "../lib/errors"; - -function toRelease(row: db.ReleaseRow, today: string): Release { - return { - version: row.version, - // The CHECK constraint guarantees this, but parsing keeps the response - // honest if a migration ever widens the column. - channel: releaseChannel.catch("stable" as ReleaseChannel).parse(row.channel), - lts: row.lts === 1, - codename: row.codename, - latest_point: row.latest_point, - released_at: row.released_at, - eol_at: row.eol_at, - eol: isEol(row.eol_at, today), - notes_url: row.notes_url, - source_url: row.source_url, - fetched_at: row.fetched_at, - }; -} - -type Taxonomy = { categories: string[]; tags: string[]; desktops: string[] }; -const EMPTY_TAXONOMY: Taxonomy = { categories: [], tags: [], desktops: [] }; - -function toDistro(row: db.DistroRow, taxonomy: Taxonomy, latest: Release | null): Distro { - return { - slug: row.slug, - name: row.name, - summary: row.summary, - family: row.family, - based_on: row.based_on, - homepage: row.homepage, - status: row.status === "discontinued" ? "discontinued" : "active", - logo: row.logo_path, - categories: taxonomy.categories, - tags: taxonomy.tags, - desktops: taxonomy.desktops, - downloads: row.downloads, - rank: row.rank, - trend: row.trend, - latest_release: latest, - }; -} - -/** Attaches taxonomy and the newest release to a page of distro rows in two - * queries rather than two per row. */ -async function hydrate(env: Env, rows: db.DistroRow[], today: string): Promise { - if (rows.length === 0) return []; - const taxonomy = await db.taxonomyFor( - env.DB, - rows.map((r) => r.id), - ); - const latest = await Promise.all(rows.map((r) => db.listReleases(env.DB, r.slug))); - return rows.map((row, i) => { - const newest = latest[i]?.[0]; - return toDistro( - row, - taxonomy.get(row.id) ?? EMPTY_TAXONOMY, - newest ? toRelease(newest, today) : null, - ); - }); -} - -const today = () => new Date().toISOString().slice(0, 10); - -export async function listDistros( - env: Env, - query: DistroListQuery, -): Promise<{ data: Distro[]; total: number }> { - const gen = await generation(env.KV_CACHE); - return cached(env.KV_CACHE, `cache:distros:${gen}:${hashQuery(query)}`, TTL.list, async () => { - const { rows, total } = await db.listDistros(env.DB, query); - return { data: await hydrate(env, rows, today()), total }; - }); -} - -export async function searchDistros( - env: Env, - query: SearchQuery, -): Promise<{ data: Distro[]; total: number }> { - const gen = await generation(env.KV_CACHE); - return cached(env.KV_CACHE, `cache:search:${gen}:${hashQuery(query)}`, TTL.list, async () => { - const { rows, total } = await db.searchDistros(env.DB, query); - return { data: await hydrate(env, rows, today()), total }; - }); -} - -export async function getDistroDetail(env: Env, slug: string): Promise { - const gen = await generation(env.KV_CACHE, slug); - return cached(env.KV_CACHE, `cache:distro:${slug}:${gen}`, TTL.detail, async () => { - const row = await db.getDistro(env.DB, slug); - if (!row) throw notFound("distro"); - - const now = today(); - const [releases, editions, mirrors, related, provenance] = await Promise.all([ - db.listReleases(env.DB, slug), - db.listEditions(env.DB, slug), - db.listMirrors(env.DB, slug), - db.relatedDistros(env.DB, row), - latestProvenance(env.DB), - ]); - - const [distro] = await hydrate(env, [row], now); - if (!distro) throw notFound("distro"); - - return { - distro, - releases: releases.map((r) => toRelease(r, now)), - editions, - mirrors, - related: await hydrate(env, related, now), - provenance, - }; - }); -} - -export async function listReleasesFor( - env: Env, - slug: string, - channel?: ReleaseChannel, -): Promise { - const row = await db.getDistro(env.DB, slug); - if (!row) throw notFound("distro"); - const now = today(); - return (await db.listReleases(env.DB, slug, channel)).map((r) => toRelease(r, now)); -} - -export async function recentReleases( - env: Env, - limit: number, - cursor?: string, -): Promise<{ data: RecentRelease[]; nextCursor: string | null }> { - // One extra row tells us whether another page exists without a second query. - const rows = await db.recentReleases(env.DB, limit + 1, cursor); - const page = rows.slice(0, limit); - return { - data: page.map((row) => ({ - slug: row.slug, - name: row.name, - version: row.version, - channel: releaseChannel.catch("stable" as ReleaseChannel).parse(row.channel), - lts: row.lts === 1, - // The query filters these out, so the fallback is unreachable in practice. - released_at: row.released_at ?? "", - notes_url: row.notes_url, - })), - nextCursor: rows.length > limit ? (page[page.length - 1]?.released_at ?? null) : null, - }; -} - -export async function rankings( - env: Env, - period: RankingPeriod, - limit: number, -): Promise<{ entries: RankingEntry[]; movers: RankingEntry[] }> { - const gen = await generation(env.KV_CACHE); - return cached(env.KV_CACHE, `rankings:${period}:${gen}:${limit}`, TTL.rankings, async () => { - const entries = await db.rankingSnapshot(env.DB, period, limit); - const movers = [...entries] - .filter((e) => e.delta !== null && e.delta !== 0) - .sort((a, b) => Math.abs(b.delta ?? 0) - Math.abs(a.delta ?? 0)) - .slice(0, 3); - return { entries, movers }; - }); -} - -export async function rankHistory( - env: Env, - slug: string, - period: RankingPeriod, -): Promise { - const row = await db.getDistro(env.DB, slug); - if (!row) throw notFound("distro"); - return db.rankHistory(env.DB, slug, period); -} - -export async function hallOfFame(env: Env): Promise { - const rows = await db.hallOfFame(env.DB); - return rows.map((row) => ({ - slug: row.slug, - name: row.name, - rationale: row.rationale, - sources: safeJsonArray(row.sources), - ordering: row.ordering, - })); -} - -function safeJsonArray(value: string): string[] { - try { - const parsed: unknown = JSON.parse(value); - return Array.isArray(parsed) ? parsed.map(String) : []; - } catch { - return []; - } -} diff --git a/apps/api/src/services/downloads.ts b/apps/api/src/services/downloads.ts deleted file mode 100644 index 96bc572..0000000 --- a/apps/api/src/services/downloads.ts +++ /dev/null @@ -1,133 +0,0 @@ -// Download options and resolution (.ai/architecture.md § "Data flow — download -// resolution"). - -import type { - ArtifactFormat, - DownloadOptions, - DownloadResolution, - ReleaseChannel, - ResolveRequest, -} from "@linuxhub/shared"; -import { releaseChannel } from "@linuxhub/shared"; -import { isAbsoluteRef } from "../db/artifacts"; -import * as db from "../db/downloads"; -import type { Env } from "../env"; -import { cached, generation, hashQuery, TTL } from "../lib/cache"; -import { notFound } from "../lib/errors"; - -/** Joins a mirror base and a mirror-relative path without doubling or losing - * the separator between them. */ -export function joinMirror(baseUrl: string, path: string): string { - return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`; -} - -/** Flattens the option rows into the nested tree the selector walks. */ -export function buildOptions(slug: string, rows: db.OptionRow[], mirrors: db.MirrorChoice[]) { - const versions: DownloadOptions["versions"] = []; - - for (const row of rows) { - let version = versions.find((v) => v.version === row.version); - if (!version) { - version = { - version: row.version, - channel: releaseChannel.catch("stable" as ReleaseChannel).parse(row.channel), - lts: row.lts === 1, - released_at: row.released_at, - editions: [], - }; - versions.push(version); - } - - let edition = version.editions.find((e) => e.name === row.edition); - if (!edition) { - edition = { - name: row.edition, - desktop: row.desktop, - kind: - (row.kind as DownloadOptions["versions"][number]["editions"][number]["kind"]) ?? "other", - archs: [], - formats: [], - }; - version.editions.push(edition); - } - - if (!edition.archs.includes(row.arch)) edition.archs.push(row.arch); - const format = row.format as ArtifactFormat; - if (!edition.formats.includes(format)) edition.formats.push(format); - } - - return { slug, versions, mirrors } satisfies DownloadOptions; -} - -export async function downloadOptions(env: Env, slug: string): Promise { - const gen = await generation(env.KV_CACHE, slug); - return cached(env.KV_CACHE, `cache:dlopts:${slug}:${gen}`, TTL.detail, async () => { - const [rows, mirrors] = await Promise.all([ - db.downloadOptionRows(env.DB, slug), - db.listDownloadMirrors(env.DB, slug), - ]); - // An empty option tree is a real answer for a distro whose artifacts are - // not sourced yet, but a slug that does not exist is not. - if (rows.length === 0 && mirrors.length === 0) throw notFound("distro"); - return buildOptions(slug, rows, mirrors); - }); -} - -export async function resolveDownload( - env: Env, - request: ResolveRequest, -): Promise { - const key = `dl:${hashQuery({ ...request })}`; - return cached(env.KV_CACHE, key, TTL.detail, async () => { - const artifact = await db.findArtifact( - env.DB, - request.slug, - request.version, - request.edition, - request.arch, - request.format, - ); - if (!artifact) throw notFound("artifact"); - - // A torrent page or a magnet URI is already a complete reference and no - // mirror serves it — handing one a mirror would corrupt it. - if (isAbsoluteRef(artifact.path)) { - return { - url: artifact.path, - artifact_id: artifact.artifact_id, - size: artifact.size, - sha256: artifact.sha256, - sig_url: artifact.sig_url, - mirror: null, - mirror_choice: "origin" as const, - }; - } - - const { mirror, choice } = await db.chooseMirror( - env.DB, - artifact.artifact_id, - request.mirror_id, - request.country, - ); - if (!mirror) throw notFound("mirror for artifact"); - - return { - url: joinMirror(mirror.base_url, artifact.path), - artifact_id: artifact.artifact_id, - size: artifact.size, - sha256: artifact.sha256, - sig_url: artifact.sig_url, - mirror, - mirror_choice: choice, - }; - }); -} - -export async function trackDownload( - env: Env, - artifactId: number, - mirrorId: number | undefined, - now = new Date(), -): Promise { - await db.trackDownload(env.KV_RATE, artifactId, mirrorId, now.toISOString().slice(0, 10)); -} diff --git a/apps/api/test/artifacts.test.ts b/apps/api/test/artifacts.test.ts deleted file mode 100644 index c224cae..0000000 --- a/apps/api/test/artifacts.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -// Artifact ingestion against a real D1. The three-level insert (release → -// edition → artifact) resolves each parent by subselect, so an ordering -// mistake produces silence rather than an error — which is exactly why these -// assert on rows landing, not on the calls returning. - -import type { LiveCatalog } from "@linuxhub/ingest/types"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - isAbsoluteRef, - linkArtifactsToMirrorsStatement, - pruneArtifactMirrorsStatement, - upsertCatalog, -} from "../src/db/artifacts"; -import { upsertReleases } from "../src/db/releases"; -import { createTestContext, type TestContext } from "./harness"; - -const CATALOG: LiveCatalog = { - releases: [{ cycle: "44", releaseDate: "2026-04-28", lts: false, supported: true }], - editions: [ - { version: "44", name: "Workstation", kind: "desktop" }, - { version: "44", name: "KDE", desktop: "kde", kind: "desktop" }, - ], - artifacts: [ - { - version: "44", - edition: "Workstation", - arch: "x86_64", - format: "iso", - path: "pub/fedora/44/ws.iso", - size: 2299832320, - sha256: "aaa", - }, - { - version: "44", - edition: "KDE", - arch: "x86_64", - format: "iso", - path: "pub/fedora/44/kde.iso", - sha256: "bbb", - }, - { - version: "44", - edition: "KDE", - arch: "x86_64", - format: "magnet", - path: "magnet:?xt=urn:btih:deadbeef", - }, - ], -}; - -async function seedDistroAndRelease(ctx: TestContext) { - await ctx.env.DB.prepare( - `INSERT INTO distros (slug, name, homepage, logo_path, created_at, updated_at) - VALUES ('fedora', 'Fedora', 'https://f.test', '/f.svg', 't', 't')`, - ).run(); - await upsertReleases(ctx.env.DB, "fedora", CATALOG.releases, "https://src.test", "2026-08-05"); -} - -describe("artifact ingestion", () => { - let ctx: TestContext; - beforeEach(async () => { - ctx = await createTestContext(); - await seedDistroAndRelease(ctx); - }); - afterEach(() => ctx.dispose()); - - it("writes editions and artifacts under their release, with provenance", async () => { - await upsertCatalog(ctx.env.DB, "fedora", CATALOG, "https://src.test", "2026-08-05T00:00:00Z"); - - const counts = await ctx.env.DB.prepare( - "SELECT (SELECT COUNT(*) FROM editions) e, (SELECT COUNT(*) FROM artifacts) a", - ).first<{ e: number; a: number }>(); - expect(counts).toEqual({ e: 2, a: 3 }); - - const row = await ctx.env.DB.prepare( - `SELECT a.arch, a.size, a.sha256, a.source_url, a.fetched_at, e.name, e.desktop - FROM artifacts a JOIN editions e ON e.id = a.edition_id - WHERE a.path = 'pub/fedora/44/ws.iso'`, - ).first>(); - expect(row).toMatchObject({ - arch: "x86_64", - size: 2299832320, - sha256: "aaa", - source_url: "https://src.test", - fetched_at: "2026-08-05T00:00:00Z", - name: "Workstation", - desktop: null, - }); - }); - - it("is idempotent — a second pass updates rather than duplicating", async () => { - await upsertCatalog(ctx.env.DB, "fedora", CATALOG, "https://src.test", "2026-08-05T00:00:00Z"); - await upsertCatalog(ctx.env.DB, "fedora", CATALOG, "https://src.test", "2026-08-05T06:00:00Z"); - - const counts = await ctx.env.DB.prepare( - "SELECT (SELECT COUNT(*) FROM editions) e, (SELECT COUNT(*) FROM artifacts) a", - ).first<{ e: number; a: number }>(); - expect(counts).toEqual({ e: 2, a: 3 }); - - const fetched = await ctx.env.DB.prepare( - "SELECT fetched_at FROM artifacts WHERE path = 'pub/fedora/44/ws.iso'", - ).first<{ fetched_at: string }>(); - expect(fetched?.fetched_at).toBe("2026-08-05T06:00:00Z"); - }); - - it("skips an entry whose release is missing instead of failing the whole batch", async () => { - // A version we have no release row for, mixed in with one we do. The good - // entry must survive the bad one. - await upsertCatalog( - ctx.env.DB, - "fedora", - { - releases: [], - editions: [ - { version: "99", name: "Ghost", kind: "desktop" }, - { version: "44", name: "Server", kind: "server" }, - ], - artifacts: [], - }, - "https://src.test", - "2026-08-05", - ); - - const names = await ctx.env.DB.prepare("SELECT name FROM editions ORDER BY name").all<{ - name: string; - }>(); - expect(names.results.map((r) => r.name)).toEqual(["Server"]); - }); - - describe("mirror linking", () => { - beforeEach(async () => { - await upsertCatalog( - ctx.env.DB, - "fedora", - CATALOG, - "https://src.test", - "2026-08-05T00:00:00Z", - ); - await ctx.env.DB.batch([ - ctx.env.DB.prepare( - `INSERT INTO mirrors (distro_id, name, base_url, healthy, serves_artifacts) - VALUES ((SELECT id FROM distros WHERE slug='fedora'), 'a.test', 'https://a.test/', 1, 1)`, - ), - ctx.env.DB.prepare( - `INSERT INTO mirrors (distro_id, name, base_url, healthy, serves_artifacts) - VALUES ((SELECT id FROM distros WHERE slug='fedora'), 'down.test', 'https://down.test/', 0, 1)`, - ), - // A real mirror of the distro whose base_url is a repo directory, - // not a root our paths extend — Fedora's MirrorManager list. - ctx.env.DB.prepare( - `INSERT INTO mirrors (distro_id, name, base_url, healthy, serves_artifacts) - VALUES ((SELECT id FROM distros WHERE slug='fedora'), 'repo.test', - 'https://repo.test/fedora/linux/releases/44/Everything/x86_64/os/', 1, 0)`, - ), - ]); - }); - - it("links mirror-relative artifacts to healthy mirrors only", async () => { - await ctx.env.DB.batch([linkArtifactsToMirrorsStatement(ctx.env.DB, "fedora")]); - - const { results } = await ctx.env.DB.prepare( - `SELECT m.name, a.path FROM artifact_mirrors am - JOIN mirrors m ON m.id = am.mirror_id - JOIN artifacts a ON a.id = am.artifact_id - ORDER BY a.path`, - ).all<{ name: string; path: string }>(); - - // Two ISOs against the one healthy artifact base. The unhealthy mirror - // and the repo-directory mirror are both excluded. - expect(results.map((r) => r.path)).toEqual(["pub/fedora/44/kde.iso", "pub/fedora/44/ws.iso"]); - expect(new Set(results.map((r) => r.name))).toEqual(new Set(["a.test"])); - }); - - it("never links a mirror whose base_url is not a base for our paths", async () => { - // Joining `pub/fedora/44/ws.iso` onto a repo directory produces a URL - // that answers 404, which is how this was found. - await ctx.env.DB.batch([linkArtifactsToMirrorsStatement(ctx.env.DB, "fedora")]); - - const linked = await ctx.env.DB.prepare( - `SELECT COUNT(*) AS n FROM artifact_mirrors am - JOIN mirrors m ON m.id = am.mirror_id - WHERE m.name = 'repo.test'`, - ).first<{ n: number }>(); - expect(linked?.n).toBe(0); - }); - - it("drops links to a mirror that stops being an artifact base", async () => { - await ctx.env.DB.batch([linkArtifactsToMirrorsStatement(ctx.env.DB, "fedora")]); - await ctx.env.DB.prepare( - "UPDATE mirrors SET serves_artifacts = 0 WHERE name = 'a.test'", - ).run(); - - await ctx.env.DB.batch([pruneArtifactMirrorsStatement(ctx.env.DB, "fedora")]); - - const n = await ctx.env.DB.prepare("SELECT COUNT(*) AS n FROM artifact_mirrors").first<{ - n: number; - }>(); - expect(n?.n).toBe(0); - }); - - it("never links a magnet — no mirror serves one", async () => { - await ctx.env.DB.batch([linkArtifactsToMirrorsStatement(ctx.env.DB, "fedora")]); - - const magnet = await ctx.env.DB.prepare( - `SELECT COUNT(*) AS n FROM artifact_mirrors am - JOIN artifacts a ON a.id = am.artifact_id - WHERE a.format = 'magnet'`, - ).first<{ n: number }>(); - expect(magnet?.n).toBe(0); - }); - - it("is idempotent", async () => { - await ctx.env.DB.batch([linkArtifactsToMirrorsStatement(ctx.env.DB, "fedora")]); - await ctx.env.DB.batch([linkArtifactsToMirrorsStatement(ctx.env.DB, "fedora")]); - const n = await ctx.env.DB.prepare("SELECT COUNT(*) AS n FROM artifact_mirrors").first<{ - n: number; - }>(); - expect(n?.n).toBe(2); - }); - }); -}); - -describe("isAbsoluteRef", () => { - it("separates what a mirror can serve from what it cannot", () => { - expect(isAbsoluteRef("pub/fedora/44/ws.iso")).toBe(false); - expect(isAbsoluteRef("iso/2026.08.01/arch.iso")).toBe(false); - expect(isAbsoluteRef("https://archlinux.org/releng/releases/x/torrent/")).toBe(true); - expect(isAbsoluteRef("magnet:?xt=urn:btih:deadbeef")).toBe(true); - }); -}); diff --git a/apps/api/test/cron.test.ts b/apps/api/test/cron.test.ts deleted file mode 100644 index 5252b87..0000000 --- a/apps/api/test/cron.test.ts +++ /dev/null @@ -1,503 +0,0 @@ -// Ingestion tests. The transport is stubbed so these never touch an upstream — -// crawler politeness is not something a test suite should be exercising — but -// the database is real, so every upsert, CHECK constraint and conflict target -// is genuinely exercised. - -import type { HttpClient } from "@linuxhub/ingest/http"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - CRON_MIRRORS, - CRON_RANKINGS, - ingestMirrors, - ingestReleases, - runScheduled, - snapshotRankings, -} from "../src/cron"; -import { createTestContext, type TestContext } from "./harness"; - -const UBUNTU_CYCLES = [ - { - cycle: "26.04", - releaseDate: "2026-04-23", - eol: "2031-04-30", - latest: "26.04", - lts: true, - codename: "Resolute Raccoon", - }, - { cycle: "25.10", releaseDate: "2025-10-09", eol: "2026-07-09", latest: "25.10", lts: false }, -]; - -const ARCH_MIRRORS = { - urls: [ - { - url: "https://mirror.example.se/archlinux/", - protocol: "https", - country: "Sweden", - country_code: "SE", - score: 1.2, - completion_pct: 1, - active: true, - }, - { - url: "https://mirror.example.de/archlinux/", - protocol: "https", - country: "Germany", - country_code: "DE", - score: 2.4, - completion_pct: 1, - active: true, - }, - ], -}; - -const MINT_CYCLES = [{ cycle: "22.2", releaseDate: "2026-01-20", lts: true, latest: "22.2" }]; -const MINT_SUMS = `${"1".repeat(64)} *linuxmint-22.2-cinnamon-64bit.iso -${"2".repeat(64)} *linuxmint-22.2-xfce-64bit.iso`; - -const POP_CYCLES = [{ cycle: "24.04", releaseDate: "2026-04-25", lts: true, latest: "24.04" }]; -const POP_BUILD = { - version: "24.04", - url: "https://iso.pop-os.org/24.04/amd64/intel/20/pop-os_24.04_amd64_intel_20.iso", - size: 2955067392, - sha_sum: `${"f".repeat(64)}`, - channel: "intel", - build: "20", -}; - -const DEBIAN_CYCLES = [{ cycle: "13", releaseDate: "2026-08-09", lts: true, latest: "13.6" }]; - -const DEBIAN_SUMS = `${"d".repeat(64)} debian-13.6.0-amd64-netinst.iso -${"e".repeat(64)} debian-edu-13.6.0-amd64-netinst.iso`; - -const UBUNTU_SUMS = `${"a".repeat(64)} *ubuntu-26.04-desktop-amd64.iso -${"b".repeat(64)} *ubuntu-26.04-live-server-amd64.iso -${"c".repeat(64)} *ubuntu-26.04-wsl-amd64.wsl`; - -const ARCH_RELEASES = { - releases: [ - { - version: "2026.08.01", - release_date: "2026-08-01", - available: true, - sha256_sum: "b1ee7", - iso_url: "/iso/2026.08.01/archlinux-2026.08.01-x86_64.iso", - torrent_url: null, - magnet_uri: null, - }, - ], -}; - -const FEDORA_MIRRORLIST = "# repo = fedora-44\nhttps://mirror.example.fr/fedora/\n"; - -/** Serves fixtures by URL; any URL not listed throws, which is how the "one - * source fails, the others still land" case is set up. */ -function stubHttp(routes: Record): HttpClient { - async function body(url: string): Promise { - const key = Object.keys(routes).find((k) => url.includes(k)); - if (key === undefined) throw new Error(`no stub for ${url}`); - return routes[key]; - } - return { - async getText(url) { - return String(await body(url)); - }, - async getJson(url: string) { - return (await body(url)) as T; - }, - // Sources that publish no size ask for one with a HEAD; the stub has - // none to give, which is the same as an upstream that omits it. - async head() { - return null; - }, - }; -} - -describe("ingestion", () => { - let ctx: TestContext; - beforeEach(async () => { - ctx = await createTestContext(); - }); - afterEach(() => ctx.dispose()); - - it("seeds catalog identity from the cited content index", async () => { - await ingestReleases(ctx.env, new Date("2026-08-04T00:00:00Z"), stubHttp({})); - - const row = await ctx.env.DB.prepare( - "SELECT slug, name, summary, homepage, logo_path, source_url, family FROM distros WHERE slug = 'ubuntu'", - ).first<{ - name: string; - summary: string; - homepage: string; - logo_path: string; - source_url: string; - family: string; - }>(); - - expect(row?.name).toBe("Ubuntu"); - expect(row?.homepage).toBe("https://ubuntu.com"); - expect(row?.logo_path).toBe("/distros/ubuntu.svg"); - // Identity is asserted only where a resolvable citation backs it. - expect(row?.source_url).toMatch(/^https:\/\//); - // Lineage is Wikidata's job; empty is honest until that step runs. - expect(row?.family).toBe(""); - - const docs = await ctx.env.DB.prepare("SELECT COUNT(*) AS n FROM content_index").first<{ - n: number; - }>(); - expect(docs?.n).toBe(36); - }); - - it("writes releases with provenance and the lts flag", async () => { - await ingestReleases( - ctx.env, - new Date("2026-08-04T00:00:00Z"), - stubHttp({ "/ubuntu.json": UBUNTU_CYCLES }), - ); - - const row = await ctx.env.DB.prepare( - `SELECT r.version, r.channel, r.lts, r.codename, r.latest_point, r.released_at, - r.eol_at, r.source_url, r.fetched_at - FROM releases r JOIN distros d ON d.id = r.distro_id - WHERE d.slug = 'ubuntu' AND r.version = '26.04'`, - ).first>(); - - expect(row).toMatchObject({ - channel: "stable", - lts: 1, - codename: "Resolute Raccoon", - latest_point: "26.04", - released_at: "2026-04-23", - eol_at: "2031-04-30", - source_url: "https://endoflife.date/api/ubuntu.json", - }); - expect(row?.fetched_at).toBe("2026-08-04T00:00:00.000Z"); - }); - - it("logs a failing source as an error without blocking the others", async () => { - // Only Ubuntu has a stub; the other six endoflife products throw. - const summary = await ingestReleases( - ctx.env, - new Date("2026-08-04T00:00:00Z"), - stubHttp({ "/ubuntu.json": UBUNTU_CYCLES }), - ); - - // Asserted as behaviour rather than exact counts: the point is that the - // one stubbed source lands and every other one is recorded as failing. - // Pinning the numbers would make this break every time a source is added, - // which says nothing about isolation. - expect(summary.ok).toBeGreaterThanOrEqual(1); - expect(summary.failed).toBeGreaterThan(0); - - const ubuntu = await ctx.env.DB.prepare( - "SELECT COUNT(*) AS n FROM releases r JOIN distros d ON d.id = r.distro_id WHERE d.slug = 'ubuntu'", - ).first<{ n: number }>(); - expect(ubuntu?.n).toBe(2); - - const errors = await ctx.env.DB.prepare( - "SELECT COUNT(*) AS n FROM ingest_log WHERE status = 'error'", - ).first<{ n: number }>(); - expect(errors?.n).toBe(summary.failed); - - // The source that did work is logged as such, next to the ones that did not. - const ok = await ctx.env.DB.prepare( - "SELECT COUNT(*) AS n FROM ingest_log WHERE status = 'ok' AND source LIKE '%ubuntu%'", - ).first<{ n: number }>(); - expect(ok?.n).toBe(1); - }); - - it("is idempotent — a second pass updates rather than duplicates", async () => { - const http = stubHttp({ "/ubuntu.json": UBUNTU_CYCLES }); - await ingestReleases(ctx.env, new Date("2026-08-04T00:00:00Z"), http); - await ingestReleases(ctx.env, new Date("2026-08-04T06:00:00Z"), http); - - const counts = await ctx.env.DB.prepare( - "SELECT (SELECT COUNT(*) FROM distros) AS d, (SELECT COUNT(*) FROM releases) AS r, (SELECT COUNT(*) FROM content_index) AS c", - ).first<{ d: number; r: number; c: number }>(); - expect(counts).toEqual({ d: 12, r: 2, c: 36 }); - - // The row is refreshed, not re-inserted. - const fetchedAt = await ctx.env.DB.prepare( - "SELECT fetched_at FROM releases WHERE version = '26.04'", - ).first<{ fetched_at: string }>(); - expect(fetchedAt?.fetched_at).toBe("2026-08-04T06:00:00.000Z"); - }); - - it("stores arch mirrors with their published country and health", async () => { - await ingestMirrors( - ctx.env, - new Date("2026-08-04T00:00:00Z"), - stubHttp({ "mirrors/status": ARCH_MIRRORS }), - ); - - const { results } = await ctx.env.DB.prepare( - `SELECT m.name, m.country, m.protocol, m.healthy, m.source_url - FROM mirrors m JOIN distros d ON d.id = m.distro_id - WHERE d.slug = 'arch' ORDER BY m.name`, - ).all>(); - - expect(results).toHaveLength(2); - expect(results[0]).toMatchObject({ - name: "mirror.example.de", - country: "DE", - protocol: "https", - healthy: 1, - source_url: "https://archlinux.org/mirrors/status/json/", - }); - }); - - it("skips the fedora mirrorlist until a release tells it which repo to ask for", async () => { - await ingestMirrors(ctx.env, new Date("2026-08-04T00:00:00Z"), stubHttp({})); - - const skipped = await ctx.env.DB.prepare( - "SELECT detail FROM ingest_log WHERE status = 'skipped'", - ).first<{ detail: string }>(); - expect(skipped?.detail).toMatch(/no release ingested yet/); - }); - - it("derives the fedora mirrorlist repo from the newest ingested release", async () => { - const http = stubHttp({ - "/fedora.json": [{ cycle: "44", releaseDate: "2026-04-28", eol: "2027-06-02" }], - mirrorlist: FEDORA_MIRRORLIST, - }); - await ingestReleases(ctx.env, new Date("2026-08-04T00:00:00Z"), http); - await ingestMirrors(ctx.env, new Date("2026-08-04T00:00:00Z"), http); - - const row = await ctx.env.DB.prepare( - "SELECT m.source_url FROM mirrors m JOIN distros d ON d.id = m.distro_id WHERE d.slug = 'fedora'", - ).first<{ source_url: string }>(); - expect(row?.source_url).toContain("repo=fedora-44"); - }); - - it("retires a mirror that dropped out of the upstream list instead of deleting it", async () => { - await ingestMirrors( - ctx.env, - new Date("2026-08-04T00:00:00Z"), - stubHttp({ "mirrors/status": ARCH_MIRRORS }), - ); - await ingestMirrors( - ctx.env, - new Date("2026-08-05T00:00:00Z"), - stubHttp({ "mirrors/status": { urls: [ARCH_MIRRORS.urls[0]] } }), - ); - - const { results } = await ctx.env.DB.prepare( - "SELECT name, healthy FROM mirrors ORDER BY name", - ).all<{ name: string; healthy: number }>(); - expect(results).toEqual([ - { name: "mirror.example.de", healthy: 0 }, - { name: "mirror.example.se", healthy: 1 }, - ]); - }); - - it("links artifacts to mirrors that arrived after them, so seeding order cannot matter", async () => { - // The artifact pass runs before any Arch mirror exists on a fresh - // database — that is the real first-deploy sequence. If only the artifact - // pass linked, every Arch download would answer "mirror for artifact not - // found" until it happened to run again, up to six hours later. - await ingestReleases( - ctx.env, - new Date("2026-08-04T00:00:00Z"), - stubHttp({ "releng/releases": ARCH_RELEASES }), - ); - - const before = await ctx.env.DB.prepare( - `SELECT COUNT(*) AS n FROM artifact_mirrors am - JOIN artifacts a ON a.id = am.artifact_id - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = 'arch'`, - ).first<{ n: number }>(); - expect(before?.n).toBe(0); - - await ingestMirrors( - ctx.env, - new Date("2026-08-04T01:00:00Z"), - stubHttp({ "mirrors/status": ARCH_MIRRORS }), - ); - - const after = await ctx.env.DB.prepare( - `SELECT COUNT(*) AS n FROM artifact_mirrors am - JOIN artifacts a ON a.id = am.artifact_id - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = 'arch'`, - ).first<{ n: number }>(); - expect(after?.n).toBeGreaterThan(0); - }); - - it("ingests ubuntu ISOs from the checksum file, skipping what is not one", async () => { - await ingestReleases( - ctx.env, - new Date("2026-08-09T00:00:00Z"), - // Only 26.04's checksum file is served: Ubuntu removes a version's - // directory at EOL, so a 404 for an older one is the normal case. - stubHttp({ - "endoflife.date/api/ubuntu": UBUNTU_CYCLES, - "releases.ubuntu.com/26.04": UBUNTU_SUMS, - }), - ); - - const { results } = await ctx.env.DB.prepare( - `SELECT e.name AS edition, a.arch, a.path FROM artifacts a - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = 'ubuntu' ORDER BY e.name`, - ).all<{ edition: string; arch: string; path: string }>(); - - // The .wsl image is not an ISO and must not become an artifact. - expect(results).toEqual([ - { edition: "Desktop", arch: "x86_64", path: "26.04/ubuntu-26.04-desktop-amd64.iso" }, - { edition: "Server", arch: "x86_64", path: "26.04/ubuntu-26.04-live-server-amd64.iso" }, - ]); - - // releases.ubuntu.com is a real download base, so it is linkable. - const linked = await ctx.env.DB.prepare( - `SELECT COUNT(*) AS n FROM artifact_mirrors am - JOIN mirrors m ON m.id = am.mirror_id - WHERE m.name = 'releases.ubuntu.com'`, - ).first<{ n: number }>(); - expect(linked?.n).toBe(2); - }); - - it("stores debian paths against the point release, and no size", async () => { - await ingestReleases( - ctx.env, - new Date("2026-08-09T00:00:00Z"), - stubHttp({ - "endoflife.date/api/debian": DEBIAN_CYCLES, - "cdimage.debian.org/debian-cd/current/amd64": DEBIAN_SUMS, - }), - ); - - const { results } = await ctx.env.DB.prepare( - `SELECT e.name AS edition, a.path, a.size FROM artifacts a - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = 'debian' ORDER BY e.name`, - ).all<{ edition: string; path: string; size: number | null }>(); - - expect(results).toEqual([ - { - edition: "Edu netinst", - path: "13.6.0/amd64/iso-cd/debian-edu-13.6.0-amd64-netinst.iso", - size: null, - }, - { - edition: "Netinst", - path: "13.6.0/amd64/iso-cd/debian-13.6.0-amd64-netinst.iso", - size: null, - }, - ]); - // `current` is a moving symlink; a path under it would serve a later - // point release against this checksum. And robots.txt forbids the HEAD - // that would give us a size. - expect(results.every((r) => !r.path.includes("current"))).toBe(true); - }); - - it("takes pop-os url, size and checksum straight from the build API", async () => { - await ingestReleases( - ctx.env, - new Date("2026-08-09T00:00:00Z"), - stubHttp({ - "endoflife.date/api/pop-os": POP_CYCLES, - "api.pop-os.org/builds/24.04/intel": POP_BUILD, - }), - ); - - const row = await ctx.env.DB.prepare( - `SELECT e.name AS edition, a.path, a.size, a.sha256 FROM artifacts a - JOIN editions e ON e.id = a.edition_id - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = 'pop-os'`, - ).first<{ edition: string; path: string; size: number; sha256: string }>(); - - expect(row).toEqual({ - edition: "Desktop (Intel/AMD graphics)", - // Stored relative to iso.pop-os.org, so mirror linking applies. - path: "24.04/amd64/intel/20/pop-os_24.04_amd64_intel_20.iso", - size: 2955067392, - sha256: "f".repeat(64), - }); - - // The nvidia channel had no stub and must not have failed the pass. - const log = await ctx.env.DB.prepare( - "SELECT status FROM ingest_log WHERE source LIKE 'api.pop-os.org%'", - ).first<{ status: string }>(); - expect(log?.status).toBe("ok"); - }); - - it("records mint's desktop environments, because its editions are ones", async () => { - await ingestReleases( - ctx.env, - new Date("2026-08-09T00:00:00Z"), - stubHttp({ - "endoflife.date/api/linuxmint": MINT_CYCLES, - "linuxmint/stable/22.2": MINT_SUMS, - }), - ); - - const { results } = await ctx.env.DB.prepare( - `SELECT e.name, e.desktop FROM editions e - JOIN releases r ON r.id = e.release_id - JOIN distros d ON d.id = r.distro_id - WHERE d.slug = 'linux-mint' ORDER BY e.name`, - ).all<{ name: string; desktop: string | null }>(); - - // Ubuntu's "Desktop" and Fedora's "Workstation" set no desktop, because - // their names identify no environment. Mint's do. - expect(results).toEqual([ - { name: "Cinnamon", desktop: "cinnamon" }, - { name: "Xfce", desktop: "xfce" }, - ]); - }); - - it("snapshots no rankings while there are no download signals of our own", async () => { - const summary = await snapshotRankings(ctx.env, new Date("2026-08-04T00:00:00Z")); - expect(summary.written).toBe(0); - - const detail = await ctx.env.DB.prepare( - "SELECT detail FROM ingest_log WHERE source = 'rankings:week'", - ).first<{ detail: string }>(); - expect(detail?.detail).toBe("no download signals yet"); - }); - - it("ranks distros once download events exist", async () => { - await ingestReleases( - ctx.env, - new Date("2026-08-04T00:00:00Z"), - stubHttp({ "/ubuntu.json": UBUNTU_CYCLES }), - ); - await ctx.env.DB.batch([ - ctx.env.DB.prepare( - "INSERT INTO editions (release_id, name) VALUES ((SELECT id FROM releases LIMIT 1), 'Desktop')", - ), - ctx.env.DB.prepare( - "INSERT INTO artifacts (edition_id, arch, format, path) VALUES ((SELECT id FROM editions LIMIT 1), 'x86_64', 'iso', '/a.iso')", - ), - ctx.env.DB.prepare( - "INSERT INTO download_events (artifact_id, day, count) VALUES ((SELECT id FROM artifacts LIMIT 1), '2026-08-03', 42)", - ), - ]); - - const summary = await snapshotRankings(ctx.env, new Date("2026-08-04T00:00:00Z")); - expect(summary.written).toBe(1); - - const row = await ctx.env.DB.prepare( - "SELECT d.slug, r.rank, r.score, r.period FROM rankings r JOIN distros d ON d.id = r.distro_id", - ).first<{ slug: string; rank: number; score: number; period: string }>(); - expect(row).toMatchObject({ slug: "ubuntu", rank: 1, score: 42, period: "week" }); - }); - - it("dispatches each cron expression to its own pass", async () => { - const mirrors = await runScheduled(CRON_MIRRORS, ctx.env, new Date(), stubHttp({})); - expect(mirrors.schedule).toBe(CRON_MIRRORS); - - const rankings = await runScheduled(CRON_RANKINGS, ctx.env, new Date()); - expect(rankings.schedule).toBe(CRON_RANKINGS); - }); -}); diff --git a/apps/api/test/download-counters.test.ts b/apps/api/test/download-counters.test.ts deleted file mode 100644 index f3f1663..0000000 --- a/apps/api/test/download-counters.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -// The KV → D1 half of download tracking. `POST /downloads/track` only writes a -// KV counter; without this flush the counts expire at 48h and rankings never -// see them, so the loop is only closed once these two agree. - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { flushDownloadCounters, trackDownload } from "../src/db/downloads"; -import { createTestContext, type TestContext } from "./harness"; - -const DAY = "2026-08-04"; - -async function counterRow(ctx: TestContext, artifactId: number, mirrorId = 0) { - return ctx.env.DB.prepare( - "SELECT count FROM download_events WHERE artifact_id = ?1 AND mirror_id = ?2 AND day = ?3", - ) - .bind(artifactId, mirrorId, DAY) - .first<{ count: number }>(); -} - -describe("flushDownloadCounters", () => { - let ctx: TestContext; - let artifactId: number; - let mirrorId: number; - - beforeEach(async () => { - ctx = await createTestContext(); - await ctx.env.DB.batch([ - ctx.env.DB.prepare( - `INSERT INTO distros (slug, name, homepage, logo_path, created_at, updated_at) - VALUES ('arch', 'Arch Linux', 'https://a.test', '/a.svg', 't', 't')`, - ), - ctx.env.DB.prepare( - `INSERT INTO releases (distro_id, version, channel) - VALUES ((SELECT id FROM distros WHERE slug='arch'), '2026.08.01', 'rolling')`, - ), - ctx.env.DB.prepare( - `INSERT INTO editions (release_id, name, kind) - VALUES ((SELECT id FROM releases WHERE version='2026.08.01'), 'default', 'other')`, - ), - ctx.env.DB.prepare( - `INSERT INTO artifacts (edition_id, arch, format, path) - VALUES ((SELECT id FROM editions WHERE name='default'), 'x86_64', 'iso', 'iso/a.iso')`, - ), - ctx.env.DB.prepare( - `INSERT INTO mirrors (distro_id, name, country, base_url) - VALUES ((SELECT id FROM distros WHERE slug='arch'), 'se.test', 'SE', 'https://se.test/')`, - ), - ]); - artifactId = (await ctx.env.DB.prepare("SELECT id FROM artifacts").first<{ id: number }>()) - ?.id as number; - mirrorId = (await ctx.env.DB.prepare("SELECT id FROM mirrors").first<{ id: number }>()) - ?.id as number; - }); - - afterEach(() => ctx.dispose()); - - it("banks tracked clicks into download_events and clears the keys it banked", async () => { - await trackDownload(ctx.env.KV_RATE, artifactId, undefined, DAY); - await trackDownload(ctx.env.KV_RATE, artifactId, undefined, DAY); - await trackDownload(ctx.env.KV_RATE, artifactId, mirrorId, DAY); - - const result = await flushDownloadCounters(ctx.env.KV_RATE, ctx.env.DB); - - expect(result.flushed).toBe(2); // two buckets: unattributed + one mirror - expect((await counterRow(ctx, artifactId))?.count).toBe(2); - expect((await counterRow(ctx, artifactId, mirrorId))?.count).toBe(1); - - const remaining = await ctx.env.KV_RATE.list({ prefix: "dlcount:" }); - expect(remaining.keys).toHaveLength(0); - }); - - it("cannot double-count when it runs twice in the same day", async () => { - await trackDownload(ctx.env.KV_RATE, artifactId, undefined, DAY); - await flushDownloadCounters(ctx.env.KV_RATE, ctx.env.DB); - - const second = await flushDownloadCounters(ctx.env.KV_RATE, ctx.env.DB); - - expect(second.flushed).toBe(0); - expect((await counterRow(ctx, artifactId))?.count).toBe(1); - }); - - it("adds to a day's existing total rather than replacing it", async () => { - await trackDownload(ctx.env.KV_RATE, artifactId, undefined, DAY); - await flushDownloadCounters(ctx.env.KV_RATE, ctx.env.DB); - - // Clicks that arrive after the first flush, still inside the same day. - await trackDownload(ctx.env.KV_RATE, artifactId, undefined, DAY); - await trackDownload(ctx.env.KV_RATE, artifactId, undefined, DAY); - await flushDownloadCounters(ctx.env.KV_RATE, ctx.env.DB); - - expect((await counterRow(ctx, artifactId))?.count).toBe(3); - }); - - it("drops a counter for an artifact that no longer exists without losing the rest", async () => { - // Re-ingestion can retire an artifact between the click and the flush. - // The insert is guarded by EXISTS rather than left to the foreign key, - // because a constraint failure aborts the whole D1 batch — one dead - // artifact would cost every real count in the same page. - await trackDownload(ctx.env.KV_RATE, 999_999, undefined, DAY); - await trackDownload(ctx.env.KV_RATE, artifactId, undefined, DAY); - - const result = await flushDownloadCounters(ctx.env.KV_RATE, ctx.env.DB); - - expect(result.flushed).toBe(2); - expect((await counterRow(ctx, artifactId))?.count).toBe(1); - expect(await counterRow(ctx, 999_999)).toBeNull(); - }); -}); diff --git a/apps/api/test/downloads.test.ts b/apps/api/test/downloads.test.ts deleted file mode 100644 index 906ca9f..0000000 --- a/apps/api/test/downloads.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { LiveCatalog } from "@linuxhub/ingest/types"; -import type { ApiResponse, DownloadOptions, DownloadResolution } from "@linuxhub/shared"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { upsertCatalog } from "../src/db/artifacts"; -import { upsertReleases } from "../src/db/releases"; -import { app } from "../src/index"; -import { joinMirror } from "../src/services/downloads"; -import { createTestContext, type TestContext } from "./harness"; - -const CATALOG: LiveCatalog = { - releases: [{ cycle: "44", releaseDate: "2026-04-28", lts: false, supported: true }], - editions: [ - { version: "44", name: "Workstation", kind: "desktop" }, - { version: "44", name: "KDE", desktop: "kde", kind: "desktop" }, - ], - artifacts: [ - { - version: "44", - edition: "Workstation", - arch: "x86_64", - format: "iso", - path: "pub/f/44/ws.iso", - size: 2299832320, - sha256: "aaa", - }, - { - version: "44", - edition: "Workstation", - arch: "aarch64", - format: "iso", - path: "pub/f/44/ws-a.iso", - }, - { version: "44", edition: "KDE", arch: "x86_64", format: "iso", path: "pub/f/44/kde.iso" }, - { - version: "44", - edition: "KDE", - arch: "x86_64", - format: "magnet", - path: "magnet:?xt=urn:btih:beef", - }, - ], -}; - -async function post(ctx: TestContext, path: string, body: unknown) { - const res = await app.request( - path, - { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" } }, - ctx.env, - ); - return { status: res.status, body: await res.json() }; -} - -describe("download endpoints", () => { - let ctx: TestContext; - - beforeAll(async () => { - ctx = await createTestContext(); - await ctx.env.DB.prepare( - `INSERT INTO distros (slug, name, homepage, logo_path, created_at, updated_at) - VALUES ('fedora', 'Fedora', 'https://f.test', '/f.svg', 't', 't')`, - ).run(); - await upsertReleases(ctx.env.DB, "fedora", CATALOG.releases, "https://src.test", "2026-08-05"); - await upsertCatalog(ctx.env.DB, "fedora", CATALOG, "https://src.test", "2026-08-05"); - await ctx.env.DB.batch([ - ctx.env.DB.prepare( - `INSERT INTO mirrors (distro_id, name, country, base_url, healthy, serves_artifacts) - VALUES ((SELECT id FROM distros WHERE slug='fedora'), 'se.test', 'SE', 'https://se.test/', 1, 1)`, - ), - ctx.env.DB.prepare( - `INSERT INTO mirrors (distro_id, name, country, base_url, healthy, serves_artifacts) - VALUES ((SELECT id FROM distros WHERE slug='fedora'), 'de.test', 'DE', 'https://de.test/', 1, 1)`, - ), - // Listed for Fedora but not a base our paths extend: it must never - // appear in the picker, because picking it would 404. - ctx.env.DB.prepare( - `INSERT INTO mirrors (distro_id, name, country, base_url, healthy, serves_artifacts) - VALUES ((SELECT id FROM distros WHERE slug='fedora'), 'repo.test', 'JP', - 'https://repo.test/fedora/linux/releases/44/Everything/x86_64/os/', 1, 0)`, - ), - ]); - await ctx.env.DB.batch([ - ctx.env.DB.prepare( - `INSERT INTO artifact_mirrors (artifact_id, mirror_id, available) - SELECT a.id, m.id, 1 FROM artifacts a, mirrors m - WHERE a.path NOT LIKE 'magnet:%' AND m.serves_artifacts = 1`, - ), - ]); - }); - - afterAll(() => ctx.dispose()); - - describe("GET download-options", () => { - it("returns the decision tree grouped version → edition → arch/format", async () => { - const res = await app.request("/v1/distros/fedora/download-options", {}, ctx.env); - const body = (await res.json()) as ApiResponse; - expect(res.status).toBe(200); - if (!body.ok) throw new Error("expected success"); - - expect(body.data.versions).toHaveLength(1); - const [version] = body.data.versions; - expect(version?.version).toBe("44"); - - const ws = version?.editions.find((e) => e.name === "Workstation"); - expect(ws?.archs.sort()).toEqual(["aarch64", "x86_64"]); - expect(ws?.formats).toEqual(["iso"]); - - const kde = version?.editions.find((e) => e.name === "KDE"); - expect(kde?.desktop).toBe("kde"); - expect(kde?.formats.sort()).toEqual(["iso", "magnet"]); - - // repo.test is a healthy Fedora mirror and is deliberately absent: the - // picker only offers mirrors a download can actually come from. - expect(body.data.mirrors.map((m) => m.country).sort()).toEqual(["DE", "SE"]); - }); - - it("404s a distro that does not exist", async () => { - const res = await app.request("/v1/distros/nope/download-options", {}, ctx.env); - expect(res.status).toBe(404); - }); - }); - - describe("POST resolve", () => { - it("joins the chosen mirror with the artifact path and returns the checksum", async () => { - const mirror = await ctx.env.DB.prepare( - "SELECT id FROM mirrors WHERE name = 'se.test'", - ).first<{ id: number }>(); - - const { status, body } = await post(ctx, "/v1/downloads/resolve", { - slug: "fedora", - version: "44", - edition: "Workstation", - arch: "x86_64", - format: "iso", - mirror_id: mirror?.id, - }); - expect(status).toBe(200); - const parsed = body as ApiResponse; - if (!parsed.ok) throw new Error("expected success"); - - expect(parsed.data).toMatchObject({ - url: "https://se.test/pub/f/44/ws.iso", - size: 2299832320, - sha256: "aaa", - mirror_choice: "requested", - }); - expect(parsed.data.mirror?.name).toBe("se.test"); - }); - - it("prefers a mirror in the caller's country when none is named", async () => { - const { body } = await post(ctx, "/v1/downloads/resolve", { - slug: "fedora", - version: "44", - edition: "Workstation", - arch: "x86_64", - country: "DE", - }); - const parsed = body as ApiResponse; - if (!parsed.ok) throw new Error("expected success"); - expect(parsed.data.mirror?.country).toBe("DE"); - expect(parsed.data.mirror_choice).toBe("country"); - }); - - it("falls back to any healthy mirror, and says that is what it did", async () => { - const { body } = await post(ctx, "/v1/downloads/resolve", { - slug: "fedora", - version: "44", - edition: "Workstation", - arch: "x86_64", - country: "JP", - }); - const parsed = body as ApiResponse; - if (!parsed.ok) throw new Error("expected success"); - expect(parsed.data.mirror_choice).toBe("fallback"); - expect(parsed.data.url).toMatch(/^https:\/\/(de|se)\.test\//); - }); - - it("returns a magnet untouched — handing it a mirror would corrupt it", async () => { - const { body } = await post(ctx, "/v1/downloads/resolve", { - slug: "fedora", - version: "44", - edition: "KDE", - arch: "x86_64", - format: "magnet", - }); - const parsed = body as ApiResponse; - if (!parsed.ok) throw new Error("expected success"); - expect(parsed.data.url).toBe("magnet:?xt=urn:btih:beef"); - expect(parsed.data.mirror).toBeNull(); - expect(parsed.data.mirror_choice).toBe("origin"); - }); - - it("404s a combination that does not exist", async () => { - const { status, body } = await post(ctx, "/v1/downloads/resolve", { - slug: "fedora", - version: "44", - edition: "Workstation", - arch: "riscv64", - }); - expect(status).toBe(404); - const parsed = body as ApiResponse; - if (parsed.ok) throw new Error("expected failure"); - expect(parsed.error.code).toBe("NOT_FOUND"); - }); - - it("rejects a body missing required fields", async () => { - const { status, body } = await post(ctx, "/v1/downloads/resolve", { slug: "fedora" }); - expect(status).toBe(400); - const parsed = body as ApiResponse; - if (parsed.ok) throw new Error("expected failure"); - expect(parsed.error.code).toBe("VALIDATION_ERROR"); - }); - }); - - describe("POST track", () => { - it("counts a click in KV rather than on the hot database path", async () => { - const artifact = await ctx.env.DB.prepare( - "SELECT id FROM artifacts WHERE path = 'pub/f/44/ws.iso'", - ).first<{ id: number }>(); - - await post(ctx, "/v1/downloads/track", { artifact_id: artifact?.id }); - await post(ctx, "/v1/downloads/track", { artifact_id: artifact?.id }); - - const day = new Date().toISOString().slice(0, 10); - const count = await ctx.env.KV_RATE.get(`dlcount:${artifact?.id}:0:${day}`); - expect(count).toBe("2"); - }); - - it("rejects a body without an artifact", async () => { - const { status } = await post(ctx, "/v1/downloads/track", {}); - expect(status).toBe(400); - }); - }); - - describe("rate limiting", () => { - it("answers 429 with Retry-After once the window is spent", async () => { - const env = { ...ctx.env, RATE_SALT: "test-salt" }; - const body = { artifact_id: 1 }; - - let last = new Response(); - // The documented limit is 30/min; the 31st must be refused. - for (let i = 0; i < 31; i++) { - last = await app.request( - "/v1/downloads/track", - { method: "POST", body: JSON.stringify(body) }, - env, - ); - } - - expect(last.status).toBe(429); - expect(Number(last.headers.get("Retry-After"))).toBeGreaterThan(0); - }); - - it("is skipped entirely when no salt is configured", async () => { - // A constant fallback salt would make the stored hashes reversible, - // which is worse than not limiting in dev. - for (let i = 0; i < 40; i++) { - const res = await app.request( - "/v1/downloads/track", - { method: "POST", body: JSON.stringify({ artifact_id: 2 }) }, - ctx.env, - ); - expect(res.status).toBe(200); - } - }); - }); -}); - -describe("joinMirror", () => { - it("never doubles or drops the separator", () => { - expect(joinMirror("https://m.test/", "a/b.iso")).toBe("https://m.test/a/b.iso"); - expect(joinMirror("https://m.test", "a/b.iso")).toBe("https://m.test/a/b.iso"); - expect(joinMirror("https://m.test/", "/a/b.iso")).toBe("https://m.test/a/b.iso"); - }); -}); diff --git a/apps/api/test/endpoints.test.ts b/apps/api/test/endpoints.test.ts deleted file mode 100644 index 7431b24..0000000 --- a/apps/api/test/endpoints.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -// Every endpoint owes a happy path, a validation failure, and NOT_FOUND -// coverage (.ai/backend-rules.md § "Testing"). The database is real, so the -// SQL is genuinely exercised rather than mocked away. - -import type { HttpClient } from "@linuxhub/ingest/http"; -import type { - ApiResponse, - Distro, - DistroDetail, - HallOfFameEntry, - RankHistoryPoint, - RankingEntry, - RecentRelease, - Release, -} from "@linuxhub/shared"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { ingestReleases } from "../src/cron"; -import { app } from "../src/index"; -import { createTestContext, type TestContext } from "./harness"; - -const CYCLES: Record = { - "/ubuntu.json": [ - { - cycle: "26.04", - releaseDate: "2026-04-23", - eol: "2031-04-30", - latest: "26.04", - lts: true, - codename: "Resolute Raccoon", - }, - { cycle: "20.04", releaseDate: "2020-04-23", eol: "2025-05-29", lts: true }, - ], - "/fedora.json": [{ cycle: "44", releaseDate: "2026-04-28", eol: "2027-06-02" }], -}; - -const stubHttp: HttpClient = { - async getText(url) { - return String(await this.getJson(url)); - }, - async getJson(url: string) { - const key = Object.keys(CYCLES).find((k) => url.includes(k)); - if (key === undefined) throw new Error(`no stub for ${url}`); - return CYCLES[key] as T; - }, - async head() { - return null; - }, -}; - -async function get(ctx: TestContext, path: string): Promise<{ status: number; body: T }> { - const res = await app.request(path, {}, ctx.env); - return { status: res.status, body: (await res.json()) as T }; -} - -describe("read endpoints", () => { - let ctx: TestContext; - - beforeAll(async () => { - ctx = await createTestContext(); - await ingestReleases(ctx.env, new Date("2026-08-04T00:00:00Z"), stubHttp); - - // Taxonomy, mirrors and Hall of Fame are seeded by later tasks; inserted - // directly here so the joins that serve them are covered now. - await ctx.env.DB.batch([ - ctx.env.DB.prepare( - "INSERT INTO distro_taxonomy (distro_id, kind, ref_slug) VALUES ((SELECT id FROM distros WHERE slug='ubuntu'), 'category', 'desktop')", - ), - ctx.env.DB.prepare( - "INSERT INTO distro_taxonomy (distro_id, kind, ref_slug) VALUES ((SELECT id FROM distros WHERE slug='ubuntu'), 'desktop', 'gnome')", - ), - ctx.env.DB.prepare( - "INSERT INTO distro_taxonomy (distro_id, kind, ref_slug) VALUES ((SELECT id FROM distros WHERE slug='fedora'), 'category', 'desktop')", - ), - ctx.env.DB.prepare( - `INSERT INTO mirrors (distro_id, name, country, base_url, source_url) - VALUES ((SELECT id FROM distros WHERE slug='ubuntu'), 'mirror.example.se', 'SE', 'https://mirror.example.se/ubuntu/', 'https://example.test/list')`, - ), - ctx.env.DB.prepare( - `INSERT INTO hall_of_fame (distro_id, rationale, sources, ordering) - VALUES ((SELECT id FROM distros WHERE slug='debian'), 'The universal operating system.', '["https://www.debian.org/social_contract"]', 1)`, - ), - ]); - }); - - afterAll(() => ctx.dispose()); - - describe("GET /v1/distros", () => { - it("returns the catalog with an honest total, not the page length", async () => { - const { status, body } = await get>(ctx, "/v1/distros?limit=2"); - expect(status).toBe(200); - if (!body.ok) throw new Error("expected success"); - - expect(body.data).toHaveLength(2); - expect(body.meta).toMatchObject({ page: 1, limit: 2, total: 12 }); - }); - - it("returns facts, not presentation — a number for downloads and no composed strings", async () => { - const { body } = await get>(ctx, "/v1/distros?q=ubuntu"); - if (!body.ok) throw new Error("expected success"); - - const ubuntu = body.data[0]; - expect(ubuntu?.slug).toBe("ubuntu"); - expect(typeof ubuntu?.downloads).toBe("number"); - expect(ubuntu).not.toHaveProperty("familyLine"); - expect(ubuntu).not.toHaveProperty("color"); - expect(ubuntu).not.toHaveProperty("initials"); - }); - - it("filters by category and by tag", async () => { - const { body } = await get>(ctx, "/v1/distros?category=desktop"); - if (!body.ok) throw new Error("expected success"); - expect(body.data.map((d) => d.slug).sort()).toEqual(["fedora", "ubuntu"]); - - const none = await get>(ctx, "/v1/distros?tag=nonexistent"); - if (!none.body.ok) throw new Error("expected success"); - expect(none.body.data).toEqual([]); - expect(none.body.meta?.total).toBe(0); - }); - - it("attaches taxonomy and the newest release to each row", async () => { - const { body } = await get>(ctx, "/v1/distros?q=ubuntu"); - if (!body.ok) throw new Error("expected success"); - - const ubuntu = body.data[0]; - expect(ubuntu?.categories).toEqual(["desktop"]); - expect(ubuntu?.desktops).toEqual(["gnome"]); - expect(ubuntu?.latest_release).toMatchObject({ version: "26.04", lts: true, eol: false }); - }); - - it("falls back to name order for popularity while no ranking snapshot exists", async () => { - const { body } = await get>(ctx, "/v1/distros?sort=popularity"); - if (!body.ok) throw new Error("expected success"); - - const names = body.data.map((d) => d.name); - expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); - expect(body.data.every((d) => d.rank === null)).toBe(true); - }); - - it("rejects an unknown sort with VALIDATION_ERROR", async () => { - const { status, body } = await get>(ctx, "/v1/distros?sort=vibes"); - expect(status).toBe(400); - if (body.ok) throw new Error("expected failure"); - expect(body.error.code).toBe("VALIDATION_ERROR"); - expect(body.error.request_id).toMatch(/^req_/); - }); - - it("rejects a limit above the documented maximum", async () => { - const { status } = await get>(ctx, "/v1/distros?limit=500"); - expect(status).toBe(400); - }); - }); - - describe("GET /v1/distros/:slug", () => { - it("returns the distro with its releases, mirrors, related set and provenance", async () => { - const { status, body } = await get>(ctx, "/v1/distros/ubuntu"); - expect(status).toBe(200); - if (!body.ok) throw new Error("expected success"); - - expect(body.data.distro.name).toBe("Ubuntu"); - expect(body.data.releases.map((r) => r.version)).toEqual(["26.04", "20.04"]); - expect(body.data.mirrors[0]).toMatchObject({ name: "mirror.example.se", healthy: true }); - expect(body.data.related.length).toBeGreaterThan(0); - expect(body.data.related.map((r) => r.slug)).not.toContain("ubuntu"); - // The provenance line is a product feature, so it must survive to the client. - expect(body.data.provenance[0]).toMatchObject({ - source: expect.stringContaining("endoflife"), - }); - }); - - it("derives eol from eol_at rather than storing it", async () => { - const { body } = await get>(ctx, "/v1/distros/ubuntu"); - if (!body.ok) throw new Error("expected success"); - - const [current, old] = body.data.releases; - expect(current).toMatchObject({ eol_at: "2031-04-30", eol: false }); - expect(old).toMatchObject({ eol_at: "2025-05-29", eol: true }); - // eol is never a channel value (ADR-0019). - expect(body.data.releases.every((r) => r.channel === "stable")).toBe(true); - }); - - it("404s an unknown slug", async () => { - const { status, body } = await get>(ctx, "/v1/distros/nope"); - expect(status).toBe(404); - if (body.ok) throw new Error("expected failure"); - expect(body.error.code).toBe("NOT_FOUND"); - }); - - it("rejects a malformed slug before it reaches the database", async () => { - const { status, body } = await get>( - ctx, - "/v1/distros/Not%20A%20Slug", - ); - expect(status).toBe(400); - if (body.ok) throw new Error("expected failure"); - expect(body.error.code).toBe("VALIDATION_ERROR"); - }); - }); - - describe("GET /v1/distros/:slug/releases", () => { - it("lists releases newest first", async () => { - const { status, body } = await get>( - ctx, - "/v1/distros/ubuntu/releases", - ); - expect(status).toBe(200); - if (!body.ok) throw new Error("expected success"); - expect(body.data.map((r) => r.version)).toEqual(["26.04", "20.04"]); - }); - - it("filters by channel", async () => { - const { body } = await get>( - ctx, - "/v1/distros/ubuntu/releases?channel=rolling", - ); - if (!body.ok) throw new Error("expected success"); - expect(body.data).toEqual([]); - }); - - it("rejects an unknown channel", async () => { - const { status } = await get>( - ctx, - "/v1/distros/ubuntu/releases?channel=eol", - ); - expect(status).toBe(400); - }); - - it("404s an unknown distro", async () => { - const { status } = await get>(ctx, "/v1/distros/nope/releases"); - expect(status).toBe(404); - }); - }); - - describe("GET /v1/releases/recent", () => { - it("returns cross-distro releases newest first, with no composed English", async () => { - const { status, body } = await get>( - ctx, - "/v1/releases/recent?limit=2", - ); - expect(status).toBe(200); - if (!body.ok) throw new Error("expected success"); - - expect(body.data[0]).toMatchObject({ slug: "fedora", version: "44", lts: false }); - expect(body.data[0]).not.toHaveProperty("title"); - expect(body.data[0]).not.toHaveProperty("subtitle"); - expect(body.meta?.next_cursor).toBe("2026-04-23"); - }); - - it("pages through with the cursor and stops at the end", async () => { - const { body } = await get>( - ctx, - "/v1/releases/recent?limit=2&cursor=2026-04-23", - ); - if (!body.ok) throw new Error("expected success"); - expect(body.data.map((r) => r.version)).toEqual(["20.04"]); - expect(body.meta?.next_cursor).toBeNull(); - }); - - it("rejects a limit above the maximum", async () => { - const { status } = await get>( - ctx, - "/v1/releases/recent?limit=999", - ); - expect(status).toBe(400); - }); - }); - - describe("GET /v1/rankings", () => { - it("returns an empty snapshot while we have no download signals of our own", async () => { - const { status, body } = await get< - ApiResponse<{ entries: RankingEntry[]; movers: RankingEntry[] }> - >(ctx, "/v1/rankings"); - expect(status).toBe(200); - if (!body.ok) throw new Error("expected success"); - expect(body.data).toEqual({ entries: [], movers: [] }); - }); - - it("rejects an unknown period", async () => { - const { status } = await get>(ctx, "/v1/rankings?period=fortnight"); - expect(status).toBe(400); - }); - }); - - describe("GET /v1/distros/:slug/rank-history", () => { - it("returns a series for the client to draw, not a rendered points string", async () => { - const { status, body } = await get>( - ctx, - "/v1/distros/ubuntu/rank-history", - ); - expect(status).toBe(200); - if (!body.ok) throw new Error("expected success"); - expect(Array.isArray(body.data)).toBe(true); - }); - - it("404s an unknown distro", async () => { - const { status } = await get>( - ctx, - "/v1/distros/nope/rank-history", - ); - expect(status).toBe(404); - }); - }); - - describe("GET /v1/hall-of-fame", () => { - it("returns entries with their citations parsed", async () => { - const { status, body } = await get>(ctx, "/v1/hall-of-fame"); - expect(status).toBe(200); - if (!body.ok) throw new Error("expected success"); - expect(body.data).toHaveLength(1); - expect(body.data[0]).toMatchObject({ - slug: "debian", - sources: ["https://www.debian.org/social_contract"], - }); - }); - }); - - describe("internal auth", () => { - it("hides the surface from a caller with no token", async () => { - const res = await app.request( - "/v1/distros", - {}, - { ...ctx.env, INTERNAL_API_TOKEN: "secret" }, - ); - expect(res.status).toBe(404); - }); - - it("serves a caller presenting the right token", async () => { - const res = await app.request( - "/v1/distros", - { headers: { "X-Internal-Token": "secret" } }, - { ...ctx.env, INTERNAL_API_TOKEN: "secret" }, - ); - expect(res.status).toBe(200); - }); - }); -}); diff --git a/apps/api/test/harness.test.ts b/apps/api/test/harness.test.ts deleted file mode 100644 index 747e0a0..0000000 --- a/apps/api/test/harness.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { createTestContext, splitStatements, type TestContext } from "./harness"; - -describe("splitStatements", () => { - it("strips line comments before splitting, so a ';' in one cannot split a statement", () => { - const sql = "-- a comment; with a semicolon\nCREATE TABLE t (id INTEGER);\n"; - expect(splitStatements(sql)).toEqual(["CREATE TABLE t (id INTEGER)"]); - }); -}); - -describe("migrations applied to a real D1", () => { - let ctx: TestContext; - beforeAll(async () => { - ctx = await createTestContext(); - }); - afterAll(() => ctx.dispose()); - - it("creates every table in .ai/database.md", async () => { - const { results } = await ctx.env.DB.prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'", - ).all<{ name: string }>(); - const names = results.map((r) => r.name).sort(); - expect(names).toEqual([ - "artifact_mirrors", - "artifacts", - "categories", - "content_index", - "distro_taxonomy", - "distros", - "download_events", - "editions", - "hall_of_fame", - "ingest_log", - "mirrors", - "rankings", - "releases", - "submissions", - "tags", - ]); - }); - - it("enforces the closed-enum CHECK constraints", async () => { - await expect( - ctx.env.DB.prepare( - `INSERT INTO distros (slug, name, homepage, logo_path, status, created_at, updated_at) - VALUES ('x', 'X', 'https://x', '/x.svg', 'bogus', 't', 't')`, - ).run(), - ).rejects.toThrow(/CHECK constraint/i); - }); - - it("rejects a release channel of 'eol', because eol is derived not stored", async () => { - await ctx.env.DB.prepare( - `INSERT INTO distros (slug, name, homepage, logo_path, created_at, updated_at) - VALUES ('d', 'D', 'https://d', '/d.svg', 't', 't')`, - ).run(); - await expect( - ctx.env.DB.prepare( - `INSERT INTO releases (distro_id, version, channel) - VALUES ((SELECT id FROM distros WHERE slug='d'), '1', 'eol')`, - ).run(), - ).rejects.toThrow(/CHECK constraint/i); - }); -}); diff --git a/apps/api/test/harness.ts b/apps/api/test/harness.ts deleted file mode 100644 index ac42bc6..0000000 --- a/apps/api/test/harness.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Test harness: a real D1 and real KV namespaces, backed by workerd through -// Miniflare (.ai/backend-rules.md § "Testing"). -// -// Why a real database rather than a fake: the migrations in `migrations/` are -// applied verbatim, so every test also exercises the schema — CHECK -// constraints, UNIQUE keys, and upsert conflict targets all bite here exactly -// as they will in production. A hand-written stub would silently accept writes -// that D1 rejects. - -import { readdir, readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import { Miniflare } from "miniflare"; -import type { Env } from "../src/env"; - -// `fileURLToPath`, not `.pathname`: on Windows a file URL's pathname is -// `/C:/Users/...`, whose leading slash makes it an invalid path — every -// script here failed with ENOENT on a Windows checkout. -// `.href`, not the URL object: `fileURLToPath` is typed against Node's URL, -// while `new URL` here resolves to the Workers one that @cloudflare/workers-types -// declares. Structurally identical, nominally different — passing the string -// sidesteps a mismatch that is purely about which declaration won. -const MIGRATIONS = fileURLToPath(new URL("../migrations/", import.meta.url).href); - -/** Splits a migration file into statements. Line comments are stripped first so - * a `;` inside one can never split a statement in the wrong place. */ -export function splitStatements(sql: string): string[] { - return sql - .split("\n") - .map((line) => line.replace(/--.*$/, "")) - .join("\n") - .split(";") - .map((s) => s.trim()) - .filter((s) => s.length > 0); -} - -export async function applyMigrations(db: D1Database): Promise { - const files = (await readdir(MIGRATIONS)).filter((f) => f.endsWith(".sql")).sort(); - for (const file of files) { - const sql = await readFile(`${MIGRATIONS}${file}`, "utf8"); - for (const statement of splitStatements(sql)) { - await db.prepare(statement).run(); - } - } -} - -export type TestContext = { - env: Env; - /** Always call in `afterEach`/`afterAll` — it stops the workerd process. */ - dispose(): Promise; -}; - -export async function createTestContext(): Promise { - const mf = new Miniflare({ - modules: true, - // The harness drives bindings directly; the Worker under test is imported - // as a module, so this placeholder script is never invoked. - script: "export default { fetch: () => new Response('harness') };", - d1Databases: { DB: ":memory:" }, - kvNamespaces: ["KV_CACHE", "KV_RATE", "KV_GEO"], - }); - - // Miniflare's bindings are structurally the Workers runtime types but are - // declared against its own copies, so they are cast at this one boundary. - const env = { - DB: (await mf.getD1Database("DB")) as unknown as D1Database, - KV_CACHE: (await mf.getKVNamespace("KV_CACHE")) as unknown as KVNamespace, - KV_RATE: (await mf.getKVNamespace("KV_RATE")) as unknown as KVNamespace, - KV_GEO: (await mf.getKVNamespace("KV_GEO")) as unknown as KVNamespace, - } satisfies Env; - - await applyMigrations(env.DB); - return { env, dispose: () => mf.dispose() }; -} diff --git a/apps/api/test/health.test.ts b/apps/api/test/health.test.ts deleted file mode 100644 index ace8b84..0000000 --- a/apps/api/test/health.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ApiResponse, Health } from "@linuxhub/shared"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { app } from "../src/index"; -import { createTestContext, type TestContext } from "./harness"; - -describe("GET /v1/health", () => { - let ctx: TestContext; - beforeAll(async () => { - ctx = await createTestContext(); - }); - afterAll(() => ctx.dispose()); - - it("reports the dependency probes without auth", async () => { - const res = await app.request("/v1/health", {}, ctx.env); - expect(res.status).toBe(200); - - const body = (await res.json()) as ApiResponse; - expect(body.ok).toBe(true); - if (!body.ok) return; - expect(body.data).toMatchObject({ service: "linuxhub-api", status: "up", db: true, kv: true }); - }); - - it("reports degraded rather than throwing when a binding is missing", async () => { - const res = await app.request("/v1/health", {}, { ...ctx.env, DB: undefined }); - expect(res.status).toBe(200); - - const body = (await res.json()) as ApiResponse; - expect(body.ok).toBe(true); - if (!body.ok) return; - expect(body.data).toMatchObject({ status: "degraded", db: false, kv: true }); - }); - - it("stays reachable when an internal token is configured", async () => { - const res = await app.request("/v1/health", {}, { ...ctx.env, INTERNAL_API_TOKEN: "secret" }); - expect(res.status).toBe(200); - }); - - it("404s unknown routes", async () => { - const res = await app.request("/v1/nope", {}, ctx.env); - expect(res.status).toBe(404); - }); -}); - -describe("security headers", () => { - it("locks down the internal surface — nothing here should ever render", async () => { - const ctx = await createTestContext(); - const res = await app.request("/v1/health", {}, ctx.env); - - expect(res.headers.get("Content-Security-Policy")).toContain("default-src 'none'"); - expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); - expect(res.headers.get("X-Frame-Options")).toBe("DENY"); - expect(res.headers.get("Referrer-Policy")).toBe("no-referrer"); - await ctx.dispose(); - }); - - it("sets them on an error response too", async () => { - const ctx = await createTestContext(); - const res = await app.request("/v1/nope", {}, ctx.env); - - expect(res.status).toBe(404); - expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); - await ctx.dispose(); - }); -}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index 79bb69f..0000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["@cloudflare/workers-types"], - "resolveJsonModule": true - }, - "include": ["src", "test"] -} diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml deleted file mode 100644 index 04ebe02..0000000 --- a/apps/api/wrangler.toml +++ /dev/null @@ -1,51 +0,0 @@ -# linuxhub-api — Hono on Cloudflare Workers (see .ai/architecture.md) -# -# Placeholder D1/KV ids let local dev (`wrangler dev`) and dry-run builds work; -# real ids are provisioned and substituted in Phase 7 (deployment). -# Secrets (HCAPTCHA_SECRET, INTERNAL_API_TOKEN, RATE_SALT) are Wrangler -# secrets — never in this file. Local dev secrets go in .dev.vars (gitignored). - -name = "linuxhub-api" -main = "src/index.ts" -compatibility_date = "2025-11-01" - -[observability] -enabled = true - -# Public site origin. This is the authoritative value in production — it becomes -# the contact URL in the ingest User-Agent that .ai/security.md requires, so it -# must resolve. Changing domains means editing this line AND the build-time -# fallback `DEFAULT_SITE_ORIGIN` in packages/ingest/src/index.ts; both are -# listed in docs/deployment.md. -# -# Not a secret — vars are plaintext in the deployed Worker. Secrets go through -# `wrangler secret put`, never here. -[vars] -SITE_ORIGIN = "https://linuxhub.kro.kr" - -# Ingestion schedules (.ai/backend-rules.md § "Ingestion", cadence table in -# .ai/data-sources.md). Dispatched by cron expression in src/cron/index.ts. -[triggers] -crons = [ - "0 */6 * * *", # release + artifact refresh (endoflife.date) - "0 3 * * *", # mirror lists + health - "0 4 * * 1", # weekly rankings snapshot from our own download counters -] - -[[d1_databases]] -binding = "DB" -database_name = "linuxhub" -database_id = "00000000-0000-0000-0000-000000000000" -migrations_dir = "migrations" - -[[kv_namespaces]] -binding = "KV_CACHE" -id = "0000000000000000000000000000cace" - -[[kv_namespaces]] -binding = "KV_RATE" -id = "0000000000000000000000000000ea7e" - -[[kv_namespaces]] -binding = "KV_GEO" -id = "00000000000000000000000000000e60" diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index afabfa8..0000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,39 +0,0 @@ -# Web environment — apps/web (SvelteKit on Vercel) -# -# Copy to `.env` for local development (gitignored): -# -# cp apps/web/.env.example apps/web/.env -# -# In production these are Vercel Project → Settings → Environment Variables. -# Full procedure in docs/deployment.md. -# -# --------------------------------------------------------------------------- -# SET THE FIRST TWO BEFORE THE FIRST REAL DEPLOY. -# -# A deploy with them empty still builds and serves — that is deliberate, so a -# frontend deploy is never blocked on a Worker. But it serves the COMMITTED -# SNAPSHOT in apps/web/src/lib/server/data.ts for every route, and the download -# buttons resolve nothing at all, because no snapshot ever carried an artifact -# path or a checksum. -# -# `GET /api/v1/health` reports which mode is live ("worker" or "snapshot"), and -# the mode is logged once per cold start. Check it after deploying. -# --------------------------------------------------------------------------- - -# Base URL of the deployed Worker, with no trailing slash. After -# `wrangler deploy` it is printed as the workers.dev URL, e.g. -# https://linuxhub-api..workers.dev -# READ BY THE CODE. Unset = snapshot mode. Server-only — never PUBLIC_. -LINUXHUB_API_URL= - -# READ BY THE CODE. Must match the Worker secret of the same name exactly, or -# the Worker answers 404 to everything the BFF asks for — see .ai/api.md for -# why a bad token is a 404 rather than a 403. Server-only — never PUBLIC_, or -# the shared secret ships to the browser. -INTERNAL_API_TOKEN= - -# hCaptcha SITE key — the public half, safe in the browser, which is why it -# carries the PUBLIC_ prefix SvelteKit requires for client-exposed values. -# The secret half is a Wrangler secret on the Worker, never here. -# NOT read yet — the write endpoints arrive in task 5.5. -PUBLIC_HCAPTCHA_SITEKEY= diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts deleted file mode 100644 index 4f965be..0000000 --- a/apps/web/e2e/a11y.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import AxeBuilder from "@axe-core/playwright"; -import { expect, test } from "@playwright/test"; - -// Axe gate (.ai/roadmap.md DoD): serious/critical violations block. - -const PAGES = ["/", "/explore", "/distro/fedora", "/rankings", "/quiz"]; - -for (const path of PAGES) { - test(`axe: ${path}`, async ({ page }) => { - await page.goto(path); - const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze(); - const blocking = results.violations.filter( - (v) => v.impact === "serious" || v.impact === "critical", - ); - expect( - blocking.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`), - ).toEqual([]); - }); -} diff --git a/apps/web/e2e/browse.spec.ts b/apps/web/e2e/browse.spec.ts deleted file mode 100644 index a1f8484..0000000 --- a/apps/web/e2e/browse.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, test } from "@playwright/test"; - -// Core browse → detail → download flow (.ai/roadmap.md Phase 6 DoD). - -test("home renders banners, categories, and live release feed", async ({ page }) => { - await page.goto("/"); - await expect(page).toHaveTitle(/Linuxhub/); - await expect(page.getByRole("link", { name: /Fedora/ }).first()).toBeVisible(); - // live-data-derived "recently updated" list has rows with dates - await expect(page.getByRole("heading", { name: "Recently updated" })).toBeVisible(); -}); - -test("explore filters by category and opens a distro", async ({ page }) => { - await page.goto("/explore?category=beginners"); - const cards = page.locator(".card-grid a"); - await expect(cards.first()).toBeVisible(); - await page.goto("/explore"); - await page.locator(".card-grid a", { hasText: "Fedora" }).first().click(); - await expect(page).toHaveURL(/\/distro\/fedora/); - await expect(page.getByRole("heading", { level: 1, name: "Fedora" })).toBeVisible(); -}); - -test("detail shows live versions, mirrors, and MDX tabs", async ({ page }) => { - await page.goto("/distro/fedora"); - - // live version rows with channel badges - const firstRow = page.locator(".vrow").first(); - await expect(firstRow).toBeVisible(); - await expect(firstRow.locator(".badge", { hasText: "latest" }).first()).toBeVisible(); - - // first row is expanded by default → mirror list with the automatic row - await expect(page.getByText("Automatic — chosen for your region")).toBeVisible(); - // live Fedora mirrors come from mirrors.fedoraproject.org - await expect(page.getByText("via mirrors.fedoraproject.org").first()).toBeVisible(); - - // selecting a mirror marks it pressed - const mirror = page.locator(".vfile").nth(1); - await mirror.click(); - await expect(mirror).toHaveAttribute("aria-pressed", "true"); - - // MDX install tab renders authored content with code blocks and sources - await page.getByRole("tab", { name: "Install" }).click(); - await expect(page.locator("article.prose pre").first()).toBeVisible(); - await expect(page.locator("article.prose .sources")).toContainText("official sources"); - - // provenance line from the committed snapshot - await expect(page.getByText(/snapshot \d{4}-\d{2}-\d{2}/)).toBeVisible(); -}); - -test("rolling distro shows a rolling channel row", async ({ page }) => { - await page.goto("/distro/arch"); - await expect(page.locator(".vrow__title").first()).toContainText("rolling"); -}); diff --git a/apps/web/e2e/i18n.spec.ts b/apps/web/e2e/i18n.spec.ts deleted file mode 100644 index 5d5ee04..0000000 --- a/apps/web/e2e/i18n.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, test } from "@playwright/test"; - -// Locale routing, fallback, and RTL (.ai/i18n.md). - -test("korean locale renders the translated UI", async ({ page }) => { - await page.goto("/ko"); - await expect(page.locator("html")).toHaveAttribute("lang", "ko"); - await expect(page.getByRole("link", { name: "탐색" }).first()).toBeVisible(); - await expect(page.getByRole("heading", { name: "최근 업데이트" })).toBeVisible(); -}); - -test("arabic locale mirrors the layout", async ({ page }) => { - await page.goto("/ar/distro/fedora"); - await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); - // untranslated content falls back to English with the badge - await expect(page.getByText("English fallback").first()).toBeVisible(); -}); - -test("/en canonicalizes to the unprefixed route", async ({ page }) => { - const response = await page.goto("/en/explore"); - expect(response?.url()).not.toContain("/en/"); - await expect(page).toHaveURL(/\/explore$/); -}); - -test("unknown locale prefixes 404", async ({ browser }) => { - const context = await browser.newContext(); - await context.addCookies([{ name: "lh-locale", value: "en", url: "http://127.0.0.1:4174" }]); - const page = await context.newPage(); - const response = await page.goto("/zz/explore"); - expect(response?.status()).toBe(404); - await context.close(); -}); - -test("locale switcher navigates to the localized route", async ({ page }) => { - await page.goto("/explore"); - await page.locator(".locale-btn").click(); - await page.locator(".locale-popover__search input").fill("한국"); - await page.locator(".locale-option", { hasText: "한국어" }).click(); - await expect(page).toHaveURL(/\/ko\/explore/); - await expect(page.locator("html")).toHaveAttribute("lang", "ko"); -}); diff --git a/apps/web/e2e/public-surface.spec.ts b/apps/web/e2e/public-surface.spec.ts deleted file mode 100644 index a2f5277..0000000 --- a/apps/web/e2e/public-surface.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from "@playwright/test"; - -// The files a public deployment is expected to serve, and the links the site -// itself points at. Both feeds were linked from the footer and answered 404 -// until they were built; this is what keeps that from happening again. - -test("robots.txt is served at the root and points at the sitemap", async ({ page }) => { - const response = await page.request.get("/robots.txt"); - expect(response.status()).toBe(200); - - const body = await response.text(); - expect(body).toContain("User-agent: *"); - expect(body).toContain("Disallow: /api/"); - expect(body).toMatch(/Sitemap: https?:\/\/[^\s]+\/sitemap\.xml/); -}); - -test("robots.txt is not geo-redirected into a locale", async ({ page }) => { - // A crawler from a Korean IP must not be bounced to /ko/robots.txt, which - // does not exist. The locale hook redirects everything else. - const response = await page.request.get("/robots.txt", { - headers: { "x-vercel-ip-country": "KR" }, - maxRedirects: 0, - }); - expect(response.status()).toBe(200); -}); - -test("sitemap.xml lists both translated locales and every distro page", async ({ page }) => { - const response = await page.request.get("/sitemap.xml"); - expect(response.status()).toBe(200); - expect(response.headers()["content-type"]).toContain("xml"); - - const body = await response.text(); - expect(body).toContain('hreflang="x-default"'); - expect(body).toContain('hreflang="ko"'); - expect(body).toContain("/distro/fedora"); - expect(body).toContain("/ko/distro/fedora"); -}); - -test("the footer's feed links resolve", async ({ page }) => { - await page.goto("/"); - - for (const [name, type] of [ - ["releases.rss", "rss"], - ["releases.atom", "atom"], - ]) { - const href = await page.locator(`footer a[href$="${name}"]`).first().getAttribute("href"); - expect(href, `footer does not link ${name}`).toBeTruthy(); - - const response = await page.request.get(href as string); - expect(response.status(), `${name} is not served`).toBe(200); - expect(response.headers()["content-type"]).toContain(type as string); - expect(await response.text()).toContain(" { - await page.goto("/search?q=fedora"); - const hit = page.locator("a", { hasText: "Fedora" }).first(); - await expect(hit).toBeVisible(); -}); - -test("quiz completes and recommends a distro", async ({ page }) => { - await page.goto("/quiz"); - await page.getByRole("button", { name: "Start" }).click(); - // answer all seven questions with the first option - for (let i = 0; i < 7; i += 1) { - await page - .locator(".boxed .row") - .first() - .click({ timeout: 5_000 }) - .catch(() => {}); - } - await expect(page.getByText("Your top match")).toBeVisible(); - await expect(page.getByRole("link", { name: /^See / })).toBeVisible(); -}); - -test("compare diffs two distros", async ({ page }) => { - await page.goto("/compare?slugs=ubuntu,fedora"); - await expect(page.locator(".spec-table")).toBeVisible(); - await expect(page.locator("th", { hasText: "Ubuntu" })).toBeVisible(); - await expect(page.locator("th", { hasText: "Fedora" })).toBeVisible(); -}); diff --git a/apps/web/e2e/security.spec.ts b/apps/web/e2e/security.spec.ts deleted file mode 100644 index 447de8c..0000000 --- a/apps/web/e2e/security.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { expect, test } from "@playwright/test"; - -// The headers in .ai/security.md § "Headers". They are asserted here rather -// than trusted, because a header that is wrong is invisible: the page looks -// exactly the same either way. - -const EXPECTED: Record = { - "strict-transport-security": "max-age=63072000; includeSubDomains; preload", - "referrer-policy": "strict-origin-when-cross-origin", - "x-content-type-options": "nosniff", - "x-frame-options": "DENY", - "permissions-policy": /camera=\(\)/, - "content-security-policy": /default-src 'self'/, -}; - -test("every response carries the documented security headers", async ({ page }) => { - const response = await page.goto("/"); - expect(response).not.toBeNull(); - const headers = response?.headers() ?? {}; - - for (const [name, expected] of Object.entries(EXPECTED)) { - const actual = headers[name]; - expect(actual, `missing header: ${name}`).toBeTruthy(); - if (typeof expected === "string") expect(actual).toBe(expected); - else expect(actual).toMatch(expected); - } -}); - -test("the policy forbids third-party origins and framing", async ({ page }) => { - const response = await page.goto("/"); - const csp = response?.headers()["content-security-policy"] ?? ""; - - expect(csp).toContain("frame-ancestors 'none'"); - expect(csp).toContain("object-src 'none'"); - expect(csp).toContain("base-uri 'self'"); - // connect-src 'self' is what keeps the BFF the only thing the browser talks - // to — the download selector calls /api/v1/downloads/*, never a mirror. - expect(csp).toContain("connect-src 'self'"); -}); - -test("browsing violates nothing the policy blocks", async ({ page }) => { - const violations: string[] = []; - page.on("console", (message) => { - const text = message.text(); - if (/content security policy/i.test(text)) violations.push(text); - }); - - await page.goto("/"); - await page.goto("/explore"); - await page.locator(".card-grid a").first().click(); - await page.waitForLoadState("networkidle"); - - expect(violations).toEqual([]); -}); - -test("the pre-paint theme script survives the policy", async ({ page }) => { - // It moved out of app.html to satisfy script-src 'self'. If the move broke - // the URL, dark-mode visitors get a flash of light before hydration — a - // regression nothing else here would catch. - const script = await page.request.get("/theme.js"); - expect(script.status()).toBe(200); - expect(await script.text()).toContain("lh-theme"); - - await page.emulateMedia({ colorScheme: "dark" }); - await page.goto("/"); - await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); -}); diff --git a/apps/web/package.json b/apps/web/package.json deleted file mode 100644 index 698a995..0000000 --- a/apps/web/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@linuxhub/web", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "test:e2e": "playwright test", - "test": "vitest run --passWithNoTests" - }, - "dependencies": { - "@linuxhub/i18n": "workspace:*", - "@linuxhub/shared": "workspace:*", - "@linuxhub/ui": "workspace:*" - }, - "devDependencies": { - "@axe-core/playwright": "^4.12.1", - "@playwright/test": "^1.61.1", - "@sveltejs/adapter-vercel": "^5.10.0", - "@sveltejs/kit": "^2.22.0", - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "mdsvex": "^0.12.8", - "svelte": "catalog:", - "svelte-check": "^4.1.0", - "typescript": "catalog:", - "vite": "^6.3.0", - "vitest": "catalog:" - } -} diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts deleted file mode 100644 index 5a80f57..0000000 --- a/apps/web/playwright.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { existsSync } from "node:fs"; -import { defineConfig } from "@playwright/test"; - -// E2E runs against the production build: `bun run build` first, then -// `bun run test:e2e` (the webServer below serves the built output). -// -// Browser resolution has to work in three places: this container ships a -// prebuilt Chromium at a fixed path, CI installs its own via -// `playwright install`, and a dev machine may have either. Point at the -// prebuilt binary only when it actually exists, otherwise let Playwright -// resolve the browser it manages. -const PREBUILT_CHROMIUM = process.env.PLAYWRIGHT_CHROMIUM_PATH ?? "/opt/pw-browsers/chromium"; -const executablePath = existsSync(PREBUILT_CHROMIUM) ? PREBUILT_CHROMIUM : undefined; - -export default defineConfig({ - testDir: "e2e", - fullyParallel: true, - retries: process.env.CI ? 1 : 0, - reporter: [["list"]], - use: { - baseURL: "http://127.0.0.1:4174", - launchOptions: { executablePath }, - }, - webServer: { - command: "bun run preview --port 4174 --host 127.0.0.1", - url: "http://127.0.0.1:4174", - reuseExistingServer: !process.env.CI, - timeout: 60_000, - }, -}); diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts deleted file mode 100644 index 5ca3fdf..0000000 --- a/apps/web/src/app.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -declare global { - namespace App { - // interface Error {} - interface Locals { - /** Active locale resolved in hooks.server.ts (base: "en"). */ - locale: string; - } - // interface PageData {} - // interface PageState {} - // interface Platform {} - } -} - -export {}; diff --git a/apps/web/src/app.html b/apps/web/src/app.html deleted file mode 100644 index 42b4f16..0000000 --- a/apps/web/src/app.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- - diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts deleted file mode 100644 index 88140b2..0000000 --- a/apps/web/src/hooks.server.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks"; -import { BASE_LOCALE, bindLocaleSource, detectLocale, isLocale, localeDir } from "@linuxhub/i18n"; -import { type Handle, redirect } from "@sveltejs/kit"; - -// Per-request locale for the message runtime — AsyncLocalStorage so -// concurrent SSR renders can't leak each other's locale (ADR-0016). -const localeStore = new AsyncLocalStorage(); -bindLocaleSource(() => localeStore.getStore()); - -const LOCALE_COOKIE = "lh-locale"; -const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; - -/** - * Response headers from `.ai/security.md` § "Headers". - * - * Content-Security-Policy is deliberately **not** here — it lives in - * `svelte.config.js`, because only SvelteKit knows the hashes and nonces of - * the scripts it injects. Setting it in both places would mean the stricter of - * two policies wins silently, which is a debugging trap. - * - * Applied in the hook rather than in `vercel.json` so they are identical in - * dev, in `vite preview`, and in production — a header that only exists in - * production is a header nobody tests. - */ -const SECURITY_HEADERS: Record = { - // Two years, with subdomains, preload-eligible. Browsers ignore this over - // plain http, so it is safe to send unconditionally in dev. - "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload", - "Referrer-Policy": "strict-origin-when-cross-origin", - "X-Content-Type-Options": "nosniff", - // Redundant with CSP frame-ancestors for modern browsers, and the only - // protection in the ones that predate it. - "X-Frame-Options": "DENY", - "Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()", -}; - -/** - * Paths that must never be geo-redirected into a locale prefix. - * - * `/robots.txt` and `/sitemap.xml` have exactly one canonical URL each, at the - * root, by specification — a crawler arriving from a Korean IP must not be - * bounced to `/ko/robots.txt`, which does not exist. The BFF is here for the - * same reason: it serves JSON, and a locale prefix would only break the fetch. - */ -const UNLOCALIZED = new Set(["/robots.txt", "/sitemap.xml"]); - -function isUnlocalized(pathname: string): boolean { - return pathname.startsWith("/api/") || UNLOCALIZED.has(pathname); -} - -export const handle: Handle = async ({ event, resolve }) => { - const { pathname, search } = event.url; - const segment = pathname.split("/")[1] ?? ""; - - let locale = BASE_LOCALE; - if (segment && isLocale(segment)) { - // The base locale is canonically unprefixed: /en/explore → /explore. - if (segment === BASE_LOCALE) { - redirect(308, pathname.slice(BASE_LOCALE.length + 1) + search || "/"); - } - locale = segment; - } else if (!isUnlocalized(pathname)) { - // No prefix → geo/cookie detection, redirect once (.ai/i18n.md). - const cookie = event.cookies.get(LOCALE_COOKIE); - const preferred = - cookie && isLocale(cookie) - ? cookie - : detectLocale( - // Vercel edge geo header; absent in dev → Accept-Language tiebreaker. - event.request.headers.get("x-vercel-ip-country"), - event.request.headers.get("accept-language"), - ); - if (!cookie) { - event.cookies.set(LOCALE_COOKIE, preferred, { - path: "/", - maxAge: COOKIE_MAX_AGE, - httpOnly: false, - sameSite: "lax", - }); - } - if (preferred !== BASE_LOCALE) { - redirect(307, `/${preferred}${pathname === "/" ? "" : pathname}${search}` || `/${preferred}`); - } - } - - event.locals.locale = locale; - - const response = await localeStore.run(locale, () => - resolve(event, { - transformPageChunk: ({ html }) => - html.replace("%lh.lang%", locale).replace("%lh.dir%", localeDir(locale)), - }), - ); - - for (const [name, value] of Object.entries(SECURITY_HEADERS)) { - response.headers.set(name, value); - } - return response; -}; diff --git a/apps/web/src/lib/components/AppDrawer.svelte b/apps/web/src/lib/components/AppDrawer.svelte deleted file mode 100644 index 5eb9457..0000000 --- a/apps/web/src/lib/components/AppDrawer.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - diff --git a/apps/web/src/lib/components/AppFooter.svelte b/apps/web/src/lib/components/AppFooter.svelte deleted file mode 100644 index 965256f..0000000 --- a/apps/web/src/lib/components/AppFooter.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/apps/web/src/lib/components/AppHeader.svelte b/apps/web/src/lib/components/AppHeader.svelte deleted file mode 100644 index 39d1e7b..0000000 --- a/apps/web/src/lib/components/AppHeader.svelte +++ /dev/null @@ -1,126 +0,0 @@ - - -{m.skip_to_content} -
-
- - {m.site_name} -
-
diff --git a/apps/web/src/lib/components/CommandPalette.svelte b/apps/web/src/lib/components/CommandPalette.svelte deleted file mode 100644 index 828bd79..0000000 --- a/apps/web/src/lib/components/CommandPalette.svelte +++ /dev/null @@ -1,211 +0,0 @@ - - - - -{#if ui.paletteOpen} - - -{/if} - - diff --git a/apps/web/src/lib/components/DownloadSelector.svelte b/apps/web/src/lib/components/DownloadSelector.svelte deleted file mode 100644 index 1d381c6..0000000 --- a/apps/web/src/lib/components/DownloadSelector.svelte +++ /dev/null @@ -1,447 +0,0 @@ - - -
- - - - -
- -
- {#each options.versions as version, i (version.version)} -
- - -
- {formatDate(version.released_at)} - {#if openVersion === version.version && resolution?.size} - {formatBytes(resolution.size)} - {/if} -
-
- - {#if openVersion === version.version} -
- {#if status === "loading"} -
-
-
-
- {:else if status === "empty"} -
- -
{m.dl_no_files_title}
-

{m.dl_no_files_hint}

- -
- {:else if status === "error"} -
- -
{m.dl_resolve_failed}
-
- - {#if options.mirrors.length > 1} - - {/if} -
-
- {:else if resolution} - {m.dl_mirrors} - - {#each options.mirrors as mirror (mirror.id)} - - {/each} - - {#if resolution.sha256} -
-
- SHA-256 - -
- {resolution.sha256} -
- {/if} - - - -
- - {m.dl_verify_note} -
- {/if} -
- {/if} - {/each} -
- - diff --git a/apps/web/src/lib/content/index.ts b/apps/web/src/lib/content/index.ts deleted file mode 100644 index 65db0a0..0000000 --- a/apps/web/src/lib/content/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Distro MDX loader — resolves content/distros///.md -// (repo root, compiled by mdsvex) to a Svelte component + frontmatter, with -// the English-fallback behavior specified in .ai/i18n.md. - -import type { Component } from "svelte"; - -export type DistroDoc = "description" | "install" | "usage"; - -export type ContentMeta = { - title: string; - summary: string; - distro: string; - doc: DistroDoc; - locale: string; - official_links?: { homepage?: string; docs?: string }; - sources?: string[]; - last_reviewed?: string; - translated?: boolean; -}; - -type ContentModule = { default: Component; metadata: ContentMeta }; - -export type LoadedDoc = { - component: Component; - meta: ContentMeta; - /** false → English fallback is being shown for a non-English locale. */ - translated: boolean; -}; - -const modules = import.meta.glob("../../../../../content/distros/*/*/*.md"); - -const index = new Map Promise>(); -for (const [path, loader] of Object.entries(modules)) { - const match = path.match(/content\/distros\/([^/]+)\/([^/]+)\/([^/]+)\.md$/); - if (match) index.set(`${match[1]}/${match[2]}/${match[3]}`, loader); -} - -export async function loadDoc( - slug: string, - doc: DistroDoc, - locale: string, -): Promise { - const exact = index.get(`${slug}/${locale}/${doc}`); - const loader = exact ?? index.get(`${slug}/en/${doc}`); - if (!loader) return null; - const mod = await loader(); - const authored = exact !== undefined || locale === "en"; - // The YAML parser turns ISO dates into Date objects — normalize back. - const reviewed: unknown = mod.metadata.last_reviewed; - return { - component: mod.default, - meta: { - ...mod.metadata, - last_reviewed: - reviewed instanceof Date - ? reviewed.toISOString().slice(0, 10) - : reviewed - ? String(reviewed).slice(0, 10) - : undefined, - }, - translated: authored && mod.metadata.translated !== false, - }; -} diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts deleted file mode 100644 index 3787913..0000000 --- a/apps/web/src/lib/format.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Presentation helpers — the formatting the API stopped doing (ADR-0020). -// -// The Worker returns facts: a number for `downloads`, a `channel` plus an -// `lts` flag rather than "Long-term support release", an ISO date rather than -// a rendered one. Everything that turns those into text for a human belongs -// here, where it can see the active locale. - -import { getLocale, m } from "@linuxhub/i18n"; -import type { Release } from "@linuxhub/shared"; - -/** Locale-aware compact download count: 1200000 → "1.2M" in en, "120만" in ko. */ -export function formatDownloads(count: number): string { - return new Intl.NumberFormat(getLocale(), { - notation: "compact", - maximumFractionDigits: 1, - }).format(count); -} - -/** Locale-aware date. Invalid or empty input renders as an em dash rather than - * "Invalid Date", which is what a raw `new Date("")` would put on the page. */ -export function formatDate(iso: string | null | undefined): string { - if (!iso) return "—"; - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return "—"; - return new Intl.DateTimeFormat(getLocale(), { dateStyle: "medium" }).format(date); -} - -export function formatBytes(bytes: number | null | undefined): string { - if (bytes === null || bytes === undefined) return "—"; - const units = ["B", "KB", "MB", "GB", "TB"]; - let value = bytes; - let unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit++; - } - return `${new Intl.NumberFormat(getLocale(), { maximumFractionDigits: 1 }).format(value)} ${units[unit]}`; -} - -type ChannelFacts = Pick & { eol?: boolean }; - -/** - * The one place a release channel becomes words. - * - * Precedence matters and is deliberate: a cycle that has passed its EOL date - * reads as end-of-life whatever channel it shipped in, and LTS outranks plain - * stable because it is the more specific claim. - */ -export function releaseChannelLabel(release: ChannelFacts): string { - if (release.eol) return m.release_eol; - if (release.channel === "rolling") return m.release_rolling; - if (release.channel === "beta") return m.release_beta; - if (release.lts) return m.release_lts; - return m.release_stable; -} - -/** "Fedora 44" — a proper noun and a version number, so no translation is - * involved; it is composed here only so no server sends it pre-built. */ -export function releaseTitle(name: string, version: string): string { - return `${name} ${version}`.trim(); -} diff --git a/apps/web/src/lib/server/adapt.ts b/apps/web/src/lib/server/adapt.ts deleted file mode 100644 index a93d380..0000000 --- a/apps/web/src/lib/server/adapt.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Snapshot → facts adapter. -// -// While task 5.4 is in flight the BFF has two sources: the Worker, and the -// committed snapshot in `data.ts` for deploys with no Worker configured. They -// must emit **one** shape, or every page would need to know which branch it -// got — so the snapshot is adapted up to the shared `Distro` here, in one -// place, rather than each route doing it slightly differently. -// -// This file dies with `data.ts` in task 5.7. - -import type { Distro } from "@linuxhub/shared"; -import { HOMEPAGES, type Distro as SnapshotDistro } from "./data"; - -const MULTIPLIER: Record = { K: 1e3, M: 1e6, B: 1e9 }; - -/** - * "1.2M" → 1200000. The snapshot stores downloads pre-formatted, which is the - * exact defect ADR-0020 removes from the API; parsing it back is the price of - * keeping the fallback shape-compatible until the snapshot goes away. - */ -export function parseCompact(value: string): number { - const match = value.trim().match(/^([\d.]+)\s*([KMB])?$/i); - if (!match) return 0; - const amount = Number.parseFloat(match[1] ?? "0"); - if (Number.isNaN(amount)) return 0; - return Math.round(amount * (MULTIPLIER[(match[2] ?? "").toUpperCase()] ?? 1)); -} - -export function toSharedDistro(d: SnapshotDistro): Distro { - return { - slug: d.slug, - name: d.name, - summary: d.summary, - family: d.family, - // The snapshot never carried a machine-readable parent — `familyLine` was - // prose. Lineage arrives with the Wikidata step, which is blocked on QIDs - // (.ai/data-sources.md), so the honest value here is "unknown". - based_on: null, - homepage: HOMEPAGES[d.slug] ?? "", - status: "active", - logo: d.logo, - categories: d.categories, - tags: [], - desktops: [], - downloads: parseCompact(d.downloads), - rank: d.rank, - trend: d.trend, - latest_release: null, - }; -} diff --git a/apps/web/src/lib/server/api.test.ts b/apps/web/src/lib/server/api.test.ts deleted file mode 100644 index 8023c57..0000000 --- a/apps/web/src/lib/server/api.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// The regression this exists for: a page loader that read `.data` off an error -// envelope threw `undefined.slice is not a function`, and a misconfigured -// INTERNAL_API_TOKEN took the whole site to a 500 instead of degrading. - -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { unwrap, unwrapList } from "./api"; - -function envelope(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -let logged: string[] = []; - -beforeEach(() => { - logged = []; - vi.spyOn(console, "log").mockImplementation((line: string) => { - logged.push(String(line)); - }); -}); - -describe("unwrap", () => { - it("returns the payload of a success envelope", async () => { - const data = await unwrap(envelope({ ok: true, data: [1, 2, 3] }), [], "test"); - expect(data).toEqual([1, 2, 3]); - }); - - it("falls back instead of throwing on an error envelope", async () => { - // This is what the Worker answers when INTERNAL_API_TOKEN does not match. - const data = await unwrap( - envelope({ ok: false, error: { code: "UPSTREAM_ERROR", message: "catalog" } }, 502), - [], - "home:trending", - ); - expect(data).toEqual([]); - }); - - it("falls back on a body that is not JSON at all", async () => { - const data = await unwrap( - new Response("gateway timeout", { status: 504 }), - [], - "x", - ); - expect(data).toEqual([]); - }); - - it("logs the failure with its code, so degrading is not the same as hiding", async () => { - await unwrap( - envelope({ ok: false, error: { code: "UPSTREAM_ERROR", message: "catalog" } }, 502), - [], - "home:trending", - ); - expect(logged).toHaveLength(1); - const line = JSON.parse(logged[0] as string); - expect(line).toMatchObject({ - level: "error", - what: "home:trending", - status: 502, - code: "UPSTREAM_ERROR", - }); - }); - - it("does not log when the call succeeded", async () => { - await unwrap(envelope({ ok: true, data: [] }), [], "test"); - expect(logged).toEqual([]); - }); -}); - -describe("unwrapList", () => { - it("prefers meta.total over the page length", async () => { - const result = await unwrapList( - envelope({ ok: true, data: [1, 2], meta: { page: 1, limit: 2, total: 57 } }), - "explore", - ); - expect(result).toEqual({ items: [1, 2], total: 57 }); - }); - - it("falls back to the page length when the endpoint reports no total", async () => { - const result = await unwrapList(envelope({ ok: true, data: [1, 2, 3] }), "explore"); - expect(result.total).toBe(3); - }); - - it("reports zero rather than a total it lost", async () => { - const result = await unwrapList( - envelope({ ok: false, error: { code: "UPSTREAM_ERROR", message: "x" } }, 502), - "explore", - ); - expect(result).toEqual({ items: [], total: 0 }); - }); -}); diff --git a/apps/web/src/lib/server/api.ts b/apps/web/src/lib/server/api.ts deleted file mode 100644 index cd61c4e..0000000 --- a/apps/web/src/lib/server/api.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Reading a BFF envelope from a page loader. -// -// Every loader used to cast the parsed body straight to `ApiSuccess` and -// read `.data`. That cast is a lie whenever the endpoint answers an error -// envelope — `.data` is `undefined`, the next `.slice`/`.map` throws, and the -// whole page becomes a 500. It is exactly the failure a misconfigured -// `INTERNAL_API_TOKEN` produces: the Worker answers 404 to everything, the BFF -// turns that into `UPSTREAM_ERROR`, and the site goes down rather than -// degrading. TypeScript could not catch it, because the cast asserted the -// problem away. - -import type { ApiResponse } from "@linuxhub/shared"; - -/** - * Unwraps an envelope, falling back rather than throwing. - * - * A page that can render with less should render with less: the nav, the - * footer and the editorial content are still worth serving when the catalog is - * briefly unavailable. The failure is logged with its code so it is diagnosable - * from the Vercel logs, and `GET /api/v1/health` reports the same condition on - * demand — degrading quietly is not the same as hiding. - * - * Use `error(404, …)` instead where the page genuinely cannot exist without the - * data, as `distro/[slug]` does. - */ -export async function unwrap(response: Response, fallback: T, what: string): Promise { - let body: ApiResponse | null = null; - try { - body = (await response.json()) as ApiResponse; - } catch { - // A non-JSON body means something in front of the route answered. - } - - if (body?.ok) return body.data; - - console.log( - JSON.stringify({ - ts: new Date().toISOString(), - level: "error", - msg: "BFF request failed, page degraded", - what, - status: response.status, - code: body?.ok === false ? body.error.code : "NON_JSON", - }), - ); - return fallback; -} - -/** `meta.total` when the endpoint reported one, else the page's own length — - * so a degraded list says "0 results" rather than claiming a total it lost. */ -export async function unwrapList( - response: Response, - what: string, -): Promise<{ items: T[]; total: number }> { - let body: ApiResponse | null = null; - try { - body = (await response.json()) as ApiResponse; - } catch { - // as above - } - - if (body?.ok) return { items: body.data, total: body.meta?.total ?? body.data.length }; - - console.log( - JSON.stringify({ - ts: new Date().toISOString(), - level: "error", - msg: "BFF request failed, page degraded", - what, - status: response.status, - code: body?.ok === false ? body.error.code : "NON_JSON", - }), - ); - return { items: [], total: 0 }; -} diff --git a/apps/web/src/lib/server/data.ts b/apps/web/src/lib/server/data.ts deleted file mode 100644 index 913b1fe..0000000 --- a/apps/web/src/lib/server/data.ts +++ /dev/null @@ -1,790 +0,0 @@ -// Catalog data serving the BFF until Phase 5 lands the Hono/D1 backend. -// Release/EOL cycles and mirror lists come from the committed live snapshot -// (live-data.json, produced by `bun packages/ingest/src/live.ts` from -// official/public APIs — see .ai/data-sources.md). Shapes follow .ai/api.md. -// -// ───────────────────────────────────────────────────────────────────────── -// THIS FILE IS A WORK LIST, NOT A DATA STORE. Do not add facts to it. -// -// Everything below that is not derived from live-data.json was invented -// during the frontend build-out. Phase 5 replaces each symbol with data -// fetched from an official API, feed, or structured source, stored with its -// source_url + fetched_at. The per-symbol plan — which source replaces which -// field — is in .ai/data-sources.md ("Replacing the placeholder data"). -// -// Three rules from that doc, repeated here because they are easy to get -// wrong while working in this file: -// -// 1. Facts (versions, editions, desktops, package managers, requirements) -// come from APIs/feeds/Wikidata. Never hand-maintained. -// 2. Popularity (downloads, rank, trend) comes from OUR OWN signals only — -// view/download counters in KV aggregated into D1. Scraping DistroWatch -// or any third-party popularity chart is forbidden. -// 3. Editorial content (QUIZ, HALL_OF_FAME, banner copy) stays authored, -// but moves out of this file — to content/ or D1 — with resolvable -// source URLs, so this file can be deleted. -// ───────────────────────────────────────────────────────────────────────── - -import liveJson from "./live-data.json" with { type: "json" }; - -// Shape of the committed snapshot (mirrors packages/ingest/src/live.ts types); -// typed at the import boundary so optional fields stay optional. -type LiveRelease = { - cycle: string; - latest?: string; - codename?: string; - releaseDate?: string; - eol?: string; - lts: boolean; - supported: boolean; -}; -type LiveMirror = { - name: string; - url: string; - country?: string; - countryCode?: string; - note?: string; -}; -type LiveSnapshot = { - fetched_at: string; - sources: string[]; - releases: Record; - mirrors: Record; -}; - -const live: LiveSnapshot = liveJson; - -export type Distro = { - slug: string; - name: string; - summary: string; - family: string; - familyLine: string; - color: string; - initials: string; - downloads: string; - rank: number; - trend: number; - categories: string[]; - /** Official logo path served from static/distros (see assets/distros/ATTRIBUTION.md). */ - logo: string; -}; - -export const DISTROS: Distro[] = [ - { - slug: "ubuntu", - logo: "/distros/ubuntu.svg", - name: "Ubuntu", - summary: "Predictable LTS releases and a huge software ecosystem", - family: "debian", - familyLine: "Debian family", - color: "#e95420", - initials: "U", - downloads: "1.2M", - rank: 1, - trend: 0, - categories: ["desktop", "beginners", "server"], - }, - { - slug: "fedora", - logo: "/distros/fedora.svg", - name: "Fedora", - summary: "The newest stable GNOME and kernel, twice a year", - family: "rpm", - familyLine: "RPM family", - color: "#51a2da", - initials: "F", - downloads: "842K", - rank: 2, - trend: 1, - categories: ["desktop"], - }, - { - slug: "linux-mint", - logo: "/distros/linux-mint.svg", - name: "Linux Mint", - summary: "A gentle landing for Windows switchers", - family: "debian", - familyLine: "Debian family · Ubuntu-based", - color: "#69b53f", - initials: "M", - downloads: "731K", - rank: 3, - trend: -1, - categories: ["desktop", "beginners"], - }, - { - slug: "arch", - logo: "/distros/arch.svg", - name: "Arch Linux", - summary: "A rolling release you assemble yourself", - family: "arch", - familyLine: "Arch family", - color: "#1793d1", - initials: "A", - downloads: "689K", - rank: 4, - trend: 0, - categories: ["diy"], - }, - { - slug: "debian", - logo: "/distros/debian.svg", - name: "Debian", - summary: "The universal operating system", - family: "debian", - familyLine: "Debian family · root", - color: "#a81d33", - initials: "D", - downloads: "577K", - rank: 5, - trend: 0, - categories: ["desktop", "server"], - }, - { - slug: "opensuse", - logo: "/distros/opensuse.svg", - name: "openSUSE", - summary: "Leap for stability, Tumbleweed for rolling", - family: "suse", - familyLine: "SUSE family", - color: "#73ba25", - initials: "oS", - downloads: "412K", - rank: 6, - trend: 0, - categories: ["desktop", "server"], - }, - { - slug: "manjaro", - logo: "/distros/manjaro.svg", - name: "Manjaro", - summary: "Arch's power with curated updates", - family: "arch", - familyLine: "Arch family", - color: "#35bf5c", - initials: "Mj", - downloads: "398K", - rank: 7, - trend: -2, - categories: ["desktop"], - }, - { - slug: "pop-os", - logo: "/distros/pop-os.svg", - name: "Pop!_OS", - summary: "Developer-focused desktop with tiling windows", - family: "debian", - familyLine: "Debian family · Ubuntu-based", - color: "#48b9c7", - initials: "P!", - downloads: "355K", - rank: 8, - trend: 1, - categories: ["desktop"], - }, - { - slug: "nixos", - logo: "/distros/nixos.svg", - name: "NixOS", - summary: "Declarative, reproducible system configuration with rollbacks", - family: "independent", - familyLine: "Independent", - color: "#5277c3", - initials: "N", - downloads: "287K", - rank: 9, - trend: 3, - categories: ["diy"], - }, - { - slug: "zorin", - logo: "/distros/zorin.svg", - name: "Zorin OS", - summary: "A familiar desktop for Windows and macOS switchers", - family: "debian", - familyLine: "Debian family · Ubuntu-based", - color: "#15a6f0", - initials: "Z", - downloads: "244K", - rank: 10, - trend: 2, - categories: ["desktop", "beginners"], - }, - { - slug: "elementary", - logo: "/distros/elementary.svg", - name: "elementary OS", - summary: "A thoughtful, design-first desktop", - family: "debian", - familyLine: "Debian family · Ubuntu-based", - color: "#64baff", - initials: "e", - downloads: "201K", - rank: 11, - trend: 0, - categories: ["desktop", "beginners"], - }, - { - slug: "endeavouros", - logo: "/distros/endeavouros.svg", - name: "EndeavourOS", - summary: "Arch with a friendly installer and community", - family: "arch", - familyLine: "Arch family", - color: "#7f3fbf", - initials: "E", - downloads: "176K", - rank: 12, - trend: 5, - categories: ["diy", "desktop"], - }, -]; - -export const BANNERS = [ - { - slug: "fedora", - kicker: "home_distro_of_day", - tagline: "The newest stable GNOME, kernel, and toolchains — reliable enough for every day.", - }, - { - slug: "linux-mint", - kicker: "home_editors_pick", - tagline: "The friendliest way off Windows — Cinnamon keeps everything where you expect it.", - }, - { - slug: "nixos", - kicker: "home_rising", - tagline: "Describe your whole system in one file — and roll back when you change your mind.", - }, -] as const; - -export const RECENT_RELEASES = Object.entries(live.releases) - .map(([slug, cycles]) => { - const distro = DISTROS.find((d) => d.slug === slug); - const latest = cycles[0]; - if (!distro || !latest?.releaseDate) return undefined; - return { - slug, - title: `${distro.name} ${latest.cycle}`, - subtitle: latest.lts ? "Long-term support release" : "Stable release", - date: latest.releaseDate, - }; - }) - .filter((r) => r !== undefined) - .sort((a, b) => b.date.localeCompare(a.date)) - .slice(0, 5); - -export type Version = { - version: string; - channel: "release" | "beta" | "eol" | "rolling"; - line: string; - note: string; - date: string; - size?: string; - downloads?: string; -}; - -// --------------------------------------------------------------------------- -// Per-distro detail (releases/mirrors from the live snapshot; identity and -// editions curated here; prose docs live in content/distros/ as mdsvex). -// --------------------------------------------------------------------------- - -export const HOMEPAGES: Record = { - ubuntu: "https://ubuntu.com", - fedora: "https://fedoraproject.org", - "linux-mint": "https://linuxmint.com", - arch: "https://archlinux.org", - debian: "https://www.debian.org", - opensuse: "https://www.opensuse.org", - manjaro: "https://manjaro.org", - "pop-os": "https://system76.com/pop", - nixos: "https://nixos.org", - zorin: "https://zorin.com/os", - elementary: "https://elementary.io", - endeavouros: "https://endeavouros.com", -}; - -const EDITIONS: Record = { - ubuntu: ["Desktop", "Server", "Kubuntu", "Xubuntu", "Ubuntu MATE"], - fedora: ["Workstation (GNOME)", "KDE Plasma spin", "Xfce spin", "Server", "Silverblue (Atomic)"], - "linux-mint": ["Cinnamon", "MATE", "Xfce"], - arch: ["ISO (netinstall)"], - debian: ["netinst", "DVD", "Live GNOME", "Live KDE", "Live Xfce"], - opensuse: ["Leap", "Tumbleweed", "MicroOS"], - manjaro: ["KDE Plasma", "GNOME", "Xfce"], - "pop-os": ["Intel/AMD", "NVIDIA"], - nixos: ["GNOME ISO", "KDE Plasma ISO", "Minimal ISO"], - zorin: ["Core", "Lite", "Pro"], - elementary: ["elementary OS"], - endeavouros: ["ISO (Calamares)"], -}; - -const ROLLING = new Set(["arch", "endeavouros", "manjaro"]); - -function buildVersions(slug: string, name: string): Version[] { - const cycles = live.releases[slug]; - if (cycles && cycles.length > 0) { - return cycles.map((c) => ({ - version: `${name} ${c.cycle}${c.codename ? ` “${c.codename}”` : ""}`, - channel: c.supported ? "release" : "eol", - line: c.latest ? `latest point release ${c.latest}` : "official release", - note: c.eol - ? c.supported - ? `supported until ${c.eol}` - : `end of life ${c.eol}` - : c.supported - ? "actively supported" - : "end of life", - date: c.releaseDate ?? "", - })); - } - if (ROLLING.has(slug)) { - return [ - { - version: `${name} (rolling)`, - channel: "rolling", - line: "continuously updated", - note: "install once, update forever", - date: live.fetched_at.slice(0, 10), - }, - ]; - } - const spec = SPECS[slug]; - return [ - { - version: `${name} ${spec?.latest ?? ""}`.trim(), - channel: "release", - line: spec?.releaseModel ?? "official release", - note: "see the official site for support dates", - date: "", - }, - ]; -} - -type DetailMirror = { - flag: string | null; - name: string; - note: string; - healthy: boolean; - auto: boolean; -}; - -/** "US" → 🇺🇸 (regional indicator pair); null when no country is known. */ -function flagEmoji(countryCode: string | undefined): string | null { - if (countryCode?.length !== 2) return null; - return String.fromCodePoint( - ...[...countryCode.toUpperCase()].map((ch) => 0x1f1a5 + ch.charCodeAt(0)), - ); -} - -function buildMirrors(slug: string): DetailMirror[] { - const auto: DetailMirror = { - flag: null, - name: "Automatic — chosen for your region", - note: "chosen by your region", - healthy: true, - auto: true, - }; - const liveMirrors = live.mirrors[slug]; - if (liveMirrors && liveMirrors.length > 0) { - return [ - auto, - ...liveMirrors.map((mirror) => ({ - flag: flagEmoji(mirror.countryCode), - name: mirror.name, - note: mirror.note ?? mirror.url, - healthy: true, - auto: false, - })), - ]; - } - return [ - auto, - { - flag: null, - name: "Official download server", - note: HOMEPAGES[slug] ?? "official site", - healthy: true, - auto: false, - }, - ]; -} - -const REQUIREMENTS = [ - { row: "Processor", min: "2 GHz dual-core", rec: "Quad-core" }, - { row: "Memory", min: "2 GB", rec: "8 GB" }, - { row: "Storage", min: "20 GB", rec: "40 GB SSD" }, - { row: "Firmware", min: "UEFI or BIOS", rec: "UEFI + Secure Boot" }, -]; - -export function getDetailFor(distro: Distro) { - const versions = buildVersions(distro.slug, distro.name); - const latest = versions[0]; - const spec = SPECS[distro.slug]; - return { - summary: distro.summary, - homepage: HOMEPAGES[distro.slug] ?? "https://distrowatch.com", - badges: { family: distro.familyLine, active: true, translated: true }, - meta: [ - { icon: "download", value: distro.downloads, labelKey: "detail_downloads" }, - { - icon: "calendar", - value: ROLLING.has(distro.slug) - ? "rolling" - : (versions[0]?.version.replace(`${distro.name} `, "").split(" ")[0] ?? "—"), - label: latest?.date ? `Latest · ${latest.date}` : "Latest", - }, - { icon: "chart", value: `#${distro.rank}`, labelKey: "detail_rank_month" }, - { icon: "monitor", value: spec?.desktop ?? "—", label: "Default desktop" }, - { icon: "cpu", value: "x86_64 +", labelKey: "detail_architectures" }, - ], - editions: EDITIONS[distro.slug] ?? ["Official ISO"], - architectures: ["x86_64", "aarch64"], - formats: [".iso", ".iso.torrent", "Checksum", "GPG signature"], - versions, - mirrors: buildMirrors(distro.slug), - requirements: REQUIREMENTS, - fetchedAt: live.fetched_at, - sources: live.sources, - related: [] as Distro[], - }; -} - -export type DetailPayload = ReturnType; - -export function getDistro(slug: string) { - return DISTROS.find((d) => d.slug === slug); -} - -// Compare specs per distro (.ai/api.md #46) — mock until Phase 5. -export const SPECS: Record< - string, - { releaseModel: string; latest: string; desktop: string; pkg: string; minMem: string } -> = { - ubuntu: { - releaseModel: "Fixed — LTS every 2 years", - latest: "26.04 LTS · Apr 2026", - desktop: "GNOME", - pkg: "apt + snap", - minMem: "4 GB", - }, - fedora: { - releaseModel: "Fixed — every 6 months", - latest: "42 · Apr 2026", - desktop: "GNOME", - pkg: "dnf + Flatpak", - minMem: "2 GB", - }, - "linux-mint": { - releaseModel: "Fixed — tracks Ubuntu LTS", - latest: "22.2 · Jan 2026", - desktop: "Cinnamon", - pkg: "apt + Flatpak", - minMem: "2 GB", - }, - arch: { - releaseModel: "Rolling", - latest: "rolling · daily", - desktop: "none — you choose", - pkg: "pacman + AUR", - minMem: "512 MB", - }, - debian: { - releaseModel: "Fixed — ~2 years", - latest: "13 · Aug 2025", - desktop: "GNOME", - pkg: "apt", - minMem: "1 GB", - }, - opensuse: { - releaseModel: "Fixed (Leap) / Rolling (Tumbleweed)", - latest: "Leap 16.0 · Apr 2026", - desktop: "KDE Plasma", - pkg: "zypper + Flatpak", - minMem: "2 GB", - }, - manjaro: { - releaseModel: "Rolling — curated", - latest: "rolling · batched", - desktop: "KDE Plasma", - pkg: "pacman + AUR", - minMem: "2 GB", - }, - "pop-os": { - releaseModel: "Fixed — tracks Ubuntu", - latest: "24.04 · 2024", - desktop: "COSMIC", - pkg: "apt + Flatpak", - minMem: "4 GB", - }, - nixos: { - releaseModel: "Fixed — every 6 months", - latest: "26.05 · May 2026", - desktop: "none — you choose", - pkg: "nix", - minMem: "2 GB", - }, - zorin: { - releaseModel: "Fixed — tracks Ubuntu LTS", - latest: "18 · 2026", - desktop: "Zorin (GNOME)", - pkg: "apt + Flatpak", - minMem: "2 GB", - }, - elementary: { - releaseModel: "Fixed — tracks Ubuntu LTS", - latest: "8 · 2025", - desktop: "Pantheon", - pkg: "apt + Flatpak", - minMem: "4 GB", - }, - endeavouros: { - releaseModel: "Rolling", - latest: "rolling · daily", - desktop: "KDE Plasma (default)", - pkg: "pacman + AUR", - minMem: "2 GB", - }, -}; - -// Hall of Fame — editorial content (.ai/api.md #35). -export const HALL_OF_FAME = [ - { - name: "Slackware", - color: "#4e4e4e", - initials: "S", - era: "1993 — present", - discontinued: false, - why: 'The oldest surviving distribution. Its insistence on simplicity and vanilla upstream software defined a whole philosophy of Linux — one still audible in every "keep it simple" argument today.', - sources: "slackware.com · LWN retrospective (2023)", - }, - { - name: "Debian", - color: "#a81d33", - initials: "D", - era: "1993 — present", - discontinued: false, - why: "The universal operating system and the root of the largest family tree in Linux. Its Social Contract and packaging culture became the backbone for hundreds of derivatives, Ubuntu included.", - sources: "debian.org/social_contract · debian.org/history", - }, - { - name: "Red Hat Linux", - color: "#cc0000", - initials: "RH", - era: "1995 — 2004", - discontinued: true, - why: "Proved that free software could carry an enterprise. RPM, Anaconda, and the support model it pioneered live on in RHEL, CentOS's successors, and Fedora.", - sources: "redhat.com/history · Wikipedia (Red Hat Linux)", - }, - { - name: "Mandrake Linux", - color: "#c78a00", - initials: "Md", - era: "1998 — 2011", - discontinued: true, - why: "The first distribution that treated desktop usability as the product. Graphical installers and friendly defaults that feel obvious today were Mandrake inventions first.", - sources: "Wikipedia (Mandriva) · archive.org press coverage", - }, - { - name: "Gentoo", - color: "#54487a", - initials: "G", - era: "2002 — present", - discontinued: false, - why: 'Source-based and endlessly configurable — the training ground for a generation of kernel and toolchain developers. Portage made "compile everything, your way" a coherent system.', - sources: "gentoo.org · Gentoo Wiki history", - }, - { - name: "Ubuntu", - color: "#e95420", - initials: "U", - era: "2004 — present", - discontinued: false, - why: '"Linux for human beings" — the release that took desktop Linux mainstream, shipped free CDs worldwide, and set the six-month cadence much of the ecosystem still keeps time by.', - sources: "ubuntu.com/about · launch announcement (2004)", - }, -]; - -// Distro-finder question set (.ai/api.md #49) — answers carry per-slug weights. -export type QuizOption = { label: string; sub: string; icon: string; w: Record }; -export type QuizQuestion = { q: string; options: QuizOption[] }; - -export const QUIZ: QuizQuestion[] = [ - { - q: "How do you feel about updates?", - options: [ - { - label: "Set and forget", - sub: "Update twice a year, quietly", - icon: "check", - w: { ubuntu: 3, "linux-mint": 3, debian: 2, zorin: 2 }, - }, - { - label: "Fresh but stable", - sub: "New features soon after release", - icon: "calendar", - w: { fedora: 3, opensuse: 2, "pop-os": 1 }, - }, - { - label: "Always the latest", - sub: "Rolling updates, every day", - icon: "download", - w: { arch: 3, endeavouros: 2, manjaro: 2 }, - }, - { - label: "I'll manage it myself", - sub: "Full manual control", - icon: "sliders", - w: { arch: 2, nixos: 3, debian: 1 }, - }, - ], - }, - { - q: "Have you used Linux before?", - options: [ - { - label: "Never", - sub: "First time", - icon: "help", - w: { "linux-mint": 3, zorin: 3, ubuntu: 2 }, - }, - { - label: "A little", - sub: "Tried a live USB or two", - icon: "check", - w: { ubuntu: 2, fedora: 2, "pop-os": 2 }, - }, - { - label: "Comfortable", - sub: "Daily driver material", - icon: "monitor", - w: { fedora: 2, opensuse: 2, debian: 2 }, - }, - { - label: "Terminal is home", - sub: "I read man pages for fun", - icon: "cpu", - w: { arch: 3, nixos: 3, endeavouros: 2 }, - }, - ], - }, - { - q: "What's the machine?", - options: [ - { - label: "Modern laptop or desktop", - sub: "Bought in the last few years", - icon: "monitor", - w: { fedora: 2, ubuntu: 2, "pop-os": 2 }, - }, - { - label: "Older hardware", - sub: "Give it a second life", - icon: "cpu", - w: { "linux-mint": 2, debian: 2, arch: 1 }, - }, - { - label: "A server or homelab", - sub: "Headless, always on", - icon: "server", - w: { debian: 3, ubuntu: 2, opensuse: 2, nixos: 2 }, - }, - { - label: "Gaming rig", - sub: "GPU matters", - icon: "dice", - w: { "pop-os": 3, manjaro: 2, fedora: 1 }, - }, - ], - }, - { - q: "How should the desktop feel?", - options: [ - { - label: "Like Windows", - sub: "Taskbar, start menu", - icon: "monitor", - w: { "linux-mint": 3, zorin: 3, manjaro: 1 }, - }, - { - label: "Like macOS", - sub: "Dock, clean lines", - icon: "monitor", - w: { elementary: 3, zorin: 2, "pop-os": 1 }, - }, - { - label: "Modern GNOME", - sub: "Gestures, activities", - icon: "monitor", - w: { fedora: 3, ubuntu: 2, debian: 1 }, - }, - { - label: "I'll build my own", - sub: "Window manager life", - icon: "sliders", - w: { arch: 3, nixos: 2, endeavouros: 2 }, - }, - ], - }, - { - q: "How much tinkering do you enjoy?", - options: [ - { - label: "None — it should just work", - sub: "", - icon: "check", - w: { "linux-mint": 3, zorin: 2, ubuntu: 2, "pop-os": 2 }, - }, - { - label: "A weekend project is fun", - sub: "", - icon: "sliders", - w: { fedora: 2, opensuse: 2, manjaro: 2 }, - }, - { - label: "Tinkering is the point", - sub: "", - icon: "cpu", - w: { arch: 3, nixos: 3, endeavouros: 2 }, - }, - ], - }, - { - q: "How important is free (libre) software purity?", - options: [ - { label: "Very — as free as possible", sub: "", icon: "heart", w: { debian: 3, fedora: 2 } }, - { - label: "Practical mix is fine", - sub: "Codecs and drivers included", - icon: "check", - w: { "linux-mint": 2, ubuntu: 2, manjaro: 2, "pop-os": 2 }, - }, - { label: "Haven't thought about it", sub: "", icon: "help", w: { zorin: 1, ubuntu: 1 } }, - ], - }, - { - q: "If something breaks, you…", - options: [ - { - label: "Want it to never break", - sub: "Stability first", - icon: "check", - w: { debian: 3, "linux-mint": 2, ubuntu: 2 }, - }, - { - label: "Search forums and fix it", - sub: "", - icon: "search", - w: { fedora: 2, manjaro: 2, endeavouros: 2 }, - }, - { label: "Read the wiki and enjoy it", sub: "", icon: "info", w: { arch: 3, nixos: 2 } }, - { - label: "Roll back and move on", - sub: "Snapshots save lives", - icon: "chart", - w: { nixos: 3, opensuse: 2 }, - }, - ], - }, -]; diff --git a/apps/web/src/lib/server/feed.test.ts b/apps/web/src/lib/server/feed.test.ts deleted file mode 100644 index 9e9326b..0000000 --- a/apps/web/src/lib/server/feed.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { RecentRelease } from "@linuxhub/shared"; -import { describe, expect, it } from "vitest"; -import { escapeXml, renderAtom, renderRss, toItems } from "./feed"; - -const ORIGIN = "https://linuxhub.kro.kr"; - -const RELEASES: RecentRelease[] = [ - { - slug: "fedora", - name: "Fedora", - version: "44", - channel: "stable", - lts: false, - released_at: "2026-04-28", - notes_url: "https://fedoramagazine.org/announcing-fedora-44/", - }, - { - slug: "arch", - name: "Arch Linux", - version: "2026.08.01", - channel: "rolling", - lts: false, - released_at: "2026-08-01", - // A source that publishes no announcement link. - notes_url: null, - }, -]; - -describe("toItems", () => { - it("links to our page when the source published no announcement", () => { - const [fedora, arch] = toItems(ORIGIN, RELEASES); - expect(fedora?.notesUrl).toBe("https://fedoramagazine.org/announcing-fedora-44/"); - expect(arch?.notesUrl).toBeNull(); - expect(arch?.link).toBe(`${ORIGIN}/distro/arch`); - }); - - it("dates an unreadable date to the epoch, not to now", () => { - // "now" would make an old release resurface as today's news on every - // rebuild, which is how a feed teaches readers to ignore it. - const xml = renderRss( - ORIGIN, - toItems(ORIGIN, [{ ...RELEASES[0], released_at: "not-a-date" } as RecentRelease]), - ORIGIN, - ); - expect(xml).toContain("Thu, 01 Jan 1970 00:00:00 GMT"); - }); -}); - -describe("escapeXml", () => { - it("escapes everything that would break a document", () => { - expect(escapeXml(`Tom & "Jerry" 'x'`)).toBe( - "Tom & "Jerry" <b>'x'</b>", - ); - }); -}); - -describe("renderRss", () => { - const xml = renderRss(ORIGIN, toItems(ORIGIN, RELEASES), `${ORIGIN}/api/v1/feeds/releases.rss`); - - it("emits RFC-822 dates, which is what RSS 2.0 requires", () => { - expect(xml).toContain("Tue, 28 Apr 2026 00:00:00 GMT"); - }); - - it("carries a self link and one item per release", () => { - expect(xml).toContain('rel="self"'); - expect(xml.match(//g)).toHaveLength(2); - }); - - it("prefers the upstream announcement as the item link", () => { - expect(xml).toContain("https://fedoramagazine.org/announcing-fedora-44/"); - expect(xml).toContain(`${ORIGIN}/distro/arch`); - }); -}); - -describe("renderAtom", () => { - const xml = renderAtom(ORIGIN, toItems(ORIGIN, RELEASES), `${ORIGIN}/api/v1/feeds/releases.atom`); - - it("emits RFC-3339 dates, which is what Atom requires", () => { - expect(xml).toContain("2026-04-28T00:00:00.000Z"); - }); - - it("stamps the feed with its newest entry rather than with now", () => { - // The feed-level is the first one in the document, and must be - // the newest entry's date (Arch, 2026-08-01), not Fedora's earlier one. - const feedUpdated = xml.slice(xml.indexOf(""), xml.indexOf("")); - expect(feedUpdated).toContain("2026-08-01"); - }); - - it("gives every entry a stable id", () => { - expect(xml).toContain(`${ORIGIN}/distro/fedora#44`); - }); -}); diff --git a/apps/web/src/lib/server/feed.ts b/apps/web/src/lib/server/feed.ts deleted file mode 100644 index b3f39fd..0000000 --- a/apps/web/src/lib/server/feed.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Release feeds (.ai/api.md #55, #56). -// -// Both formats are built from the same rows the site renders, fetched through -// the BFF's own `releases/recent` — so a feed can never disagree with the page, -// and it works in both the Worker and snapshot modes without knowing which one -// it is in. - -import { m } from "@linuxhub/i18n"; -import type { ApiResponse, RecentRelease } from "@linuxhub/shared"; - -export const FEED_LIMIT = 30; - -export function escapeXml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -export async function recentReleases( - fetchImpl: typeof fetch, - limit = FEED_LIMIT, -): Promise { - const response = await fetchImpl(`/api/v1/releases/recent?limit=${limit}`); - const body = (await response.json()) as ApiResponse; - return body.ok ? body.data : []; -} - -export type FeedItem = { - title: string; - /** Canonical page for the release — our distro page, always resolvable. */ - link: string; - /** The upstream announcement, when the source published one. */ - notesUrl: string | null; - updated: string; - guid: string; -}; - -export function toItems(origin: string, releases: RecentRelease[]): FeedItem[] { - return releases.map((release) => { - const link = `${origin}/distro/${release.slug}`; - return { - // A proper noun and a version number — nothing to translate, and - // composed here rather than sent pre-built by any server (ADR-0020). - title: `${release.name} ${release.version}`.trim(), - link, - notesUrl: release.notes_url, - // Never null: `releases/recent` filters `released_at IS NOT NULL`, - // because a feed entry with no date has nowhere to sort. - updated: release.released_at, - guid: `${link}#${release.version}`, - }; - }); -} - -/** - * RSS 2.0 wants RFC-822 dates; Atom wants RFC-3339. Both start from the same - * `YYYY-MM-DD`, which is all the sources publish. - * - * An unparseable string falls back to the epoch rather than to `now`: a date - * we cannot read must not make an old release resurface as today's news on - * every rebuild, which is how a feed teaches its readers to ignore it. - */ -function asDate(value: string): Date { - const date = new Date(value); - return Number.isNaN(date.getTime()) ? new Date(0) : date; -} - -export function renderRss(origin: string, items: FeedItem[], self: string): string { - const entries = items.map((item) => - [ - " ", - ` ${escapeXml(item.title)}`, - ` ${escapeXml(item.notesUrl ?? item.link)}`, - ` ${escapeXml(item.guid)}`, - ` ${asDate(item.updated).toUTCString()}`, - " ", - ].join("\n"), - ); - - return [ - '', - '', - " ", - ` ${escapeXml(m.site_name)}`, - ` ${escapeXml(origin)}`, - ` ${escapeXml(m.site_tagline)}`, - ` `, - ...entries, - " ", - "", - "", - ].join("\n"); -} - -export function renderAtom(origin: string, items: FeedItem[], self: string): string { - const newest = items.reduce( - (latest, item) => (item.updated > latest ? item.updated : latest), - "1970-01-01", - ); - - const entries = items.map((item) => - [ - " ", - ` ${escapeXml(item.title)}`, - ` `, - ` ${escapeXml(item.guid)}`, - ` ${asDate(item.updated).toISOString()}`, - " ", - ].join("\n"), - ); - - return [ - '', - '', - ` ${escapeXml(m.site_name)}`, - ` ${escapeXml(m.site_tagline)}`, - ` ${escapeXml(`${origin}/`)}`, - ` `, - ` `, - ` ${asDate(newest).toISOString()}`, - ...entries, - "", - "", - ].join("\n"); -} diff --git a/apps/web/src/lib/server/live-data.json b/apps/web/src/lib/server/live-data.json deleted file mode 100644 index 80247c5..0000000 --- a/apps/web/src/lib/server/live-data.json +++ /dev/null @@ -1,462 +0,0 @@ -{ - "fetched_at": "2026-08-04T03:40:33.337Z", - "sources": [ - "https://endoflife.date/api (release cycles, dates, EOL)", - "https://archlinux.org/mirrors/status/json/ (official Arch mirror health)", - "https://mirrors.fedoraproject.org/mirrorlist (official Fedora mirrors)" - ], - "releases": { - "ubuntu": [ - { - "cycle": "26.04", - "latest": "26.04", - "codename": "Resolute Raccoon", - "releaseDate": "2026-04-23", - "eol": "2031-04-30", - "lts": true, - "supported": true - }, - { - "cycle": "25.10", - "latest": "25.10", - "codename": "Questing Quokka", - "releaseDate": "2025-10-09", - "eol": "2026-07-01", - "lts": false, - "supported": false - }, - { - "cycle": "25.04", - "latest": "25.04", - "codename": "Plucky Puffin", - "releaseDate": "2025-04-17", - "eol": "2026-01-17", - "lts": false, - "supported": false - }, - { - "cycle": "24.10", - "latest": "24.10", - "codename": "Oracular Oriole", - "releaseDate": "2024-10-10", - "eol": "2025-07-10", - "lts": false, - "supported": false - }, - { - "cycle": "24.04", - "latest": "24.04.4", - "codename": "Noble Numbat", - "releaseDate": "2024-04-25", - "eol": "2029-05-31", - "lts": true, - "supported": true - }, - { - "cycle": "23.10", - "latest": "23.10", - "codename": "Mantic Minotaur", - "releaseDate": "2023-10-12", - "eol": "2024-07-12", - "lts": false, - "supported": false - } - ], - "fedora": [ - { - "cycle": "44", - "latest": "44", - "releaseDate": "2026-04-28", - "eol": "2027-06-02", - "lts": false, - "supported": true - }, - { - "cycle": "43", - "latest": "43", - "releaseDate": "2025-10-28", - "eol": "2026-12-09", - "lts": false, - "supported": true - }, - { - "cycle": "42", - "latest": "42", - "codename": "Adams", - "releaseDate": "2025-04-15", - "eol": "2026-05-27", - "lts": false, - "supported": false - }, - { - "cycle": "41", - "latest": "41", - "releaseDate": "2024-10-29", - "eol": "2025-12-15", - "lts": false, - "supported": false - }, - { - "cycle": "40", - "latest": "40", - "releaseDate": "2024-04-23", - "eol": "2025-05-13", - "lts": false, - "supported": false - }, - { - "cycle": "39", - "latest": "39", - "releaseDate": "2023-11-07", - "eol": "2024-11-26", - "lts": false, - "supported": false - } - ], - "linux-mint": [ - { - "cycle": "22.3", - "codename": "Zena", - "releaseDate": "2026-01-11", - "eol": "2029-04-30", - "lts": true, - "supported": true - }, - { - "cycle": "lmde7", - "codename": "Gigi", - "releaseDate": "2025-10-14", - "lts": false, - "supported": true - }, - { - "cycle": "22.2", - "codename": "Zara", - "releaseDate": "2025-09-04", - "eol": "2029-04-30", - "lts": true, - "supported": true - }, - { - "cycle": "22.1", - "codename": "Xia", - "releaseDate": "2025-01-16", - "eol": "2029-04-30", - "lts": true, - "supported": true - }, - { - "cycle": "22", - "codename": "Wilma", - "releaseDate": "2024-07-25", - "eol": "2029-04-30", - "lts": true, - "supported": true - }, - { - "cycle": "21.3", - "codename": "Virginia", - "releaseDate": "2024-01-12", - "eol": "2027-04-30", - "lts": true, - "supported": true - } - ], - "debian": [ - { - "cycle": "13", - "latest": "13.6", - "codename": "Trixie", - "releaseDate": "2025-08-09", - "eol": "2028-08-09", - "lts": false, - "supported": true - }, - { - "cycle": "12", - "latest": "12.15", - "codename": "Bookworm", - "releaseDate": "2023-06-10", - "eol": "2026-07-11", - "lts": false, - "supported": false - }, - { - "cycle": "11", - "latest": "11.11", - "codename": "Bullseye", - "releaseDate": "2021-08-14", - "eol": "2024-08-14", - "lts": false, - "supported": false - }, - { - "cycle": "10", - "latest": "10.13", - "codename": "Buster", - "releaseDate": "2019-07-06", - "eol": "2022-09-10", - "lts": false, - "supported": false - }, - { - "cycle": "9", - "latest": "9.13", - "codename": "Stretch", - "releaseDate": "2017-06-17", - "eol": "2020-07-18", - "lts": false, - "supported": false - }, - { - "cycle": "8", - "latest": "8.11", - "codename": "Jessie", - "releaseDate": "2015-04-25", - "eol": "2018-06-17", - "lts": false, - "supported": false - } - ], - "opensuse": [ - { - "cycle": "16.0", - "releaseDate": "2025-10-01", - "eol": "2027-10-31", - "lts": false, - "supported": true - }, - { - "cycle": "15.6", - "releaseDate": "2024-06-12", - "eol": "2026-04-30", - "lts": false, - "supported": false - }, - { - "cycle": "15.5", - "releaseDate": "2023-06-07", - "eol": "2024-12-31", - "lts": false, - "supported": false - }, - { - "cycle": "15.4", - "releaseDate": "2022-06-09", - "eol": "2023-12-07", - "lts": false, - "supported": false - }, - { - "cycle": "15.3", - "releaseDate": "2021-06-02", - "eol": "2022-12-31", - "lts": false, - "supported": false - }, - { - "cycle": "15.2", - "releaseDate": "2020-07-02", - "eol": "2022-01-04", - "lts": false, - "supported": false - } - ], - "pop-os": [ - { - "cycle": "24.04", - "releaseDate": "2025-12-11", - "eol": "2029-05-31", - "lts": true, - "supported": true - }, - { - "cycle": "22.04", - "releaseDate": "2022-04-25", - "eol": "2027-04-25", - "lts": true, - "supported": true - }, - { - "cycle": "21.10", - "releaseDate": "2021-12-14", - "eol": "2022-07-24", - "lts": false, - "supported": false - }, - { - "cycle": "21.04", - "releaseDate": "2021-06-29", - "eol": "2022-03-13", - "lts": false, - "supported": false - }, - { - "cycle": "20.10", - "releaseDate": "2020-10-23", - "eol": "2021-09-28", - "lts": false, - "supported": false - }, - { - "cycle": "20.04", - "releaseDate": "2020-04-30", - "eol": "2025-04-30", - "lts": true, - "supported": false - } - ], - "nixos": [ - { - "cycle": "26.05", - "codename": "Yarara", - "releaseDate": "2026-05-30", - "eol": "2026-12-31", - "lts": false, - "supported": true - }, - { - "cycle": "25.11", - "codename": "Xantusia", - "releaseDate": "2025-11-30", - "eol": "2026-06-30", - "lts": false, - "supported": false - }, - { - "cycle": "25.05", - "codename": "Warbler", - "releaseDate": "2025-05-23", - "eol": "2025-12-31", - "lts": false, - "supported": false - }, - { - "cycle": "24.11", - "codename": "Vicuña", - "releaseDate": "2024-11-30", - "eol": "2025-06-30", - "lts": false, - "supported": false - }, - { - "cycle": "24.05", - "codename": "Uakari", - "releaseDate": "2024-05-31", - "eol": "2024-12-31", - "lts": false, - "supported": false - }, - { - "cycle": "23.11", - "codename": "Tapir", - "releaseDate": "2023-11-29", - "eol": "2024-06-30", - "lts": false, - "supported": false - } - ] - }, - "mirrors": { - "arch": [ - { - "name": "mirror.osbeck.com", - "url": "https://mirror.osbeck.com/archlinux/", - "country": "Sweden", - "countryCode": "SE", - "note": "score 0.1 · https" - }, - { - "name": "frankfurt.mirror.pkgbuild.com", - "url": "https://frankfurt.mirror.pkgbuild.com/", - "country": "Germany", - "countryCode": "DE", - "note": "score 0.1 · https" - }, - { - "name": "berlin.mirror.pkgbuild.com", - "url": "https://berlin.mirror.pkgbuild.com/", - "country": "Germany", - "countryCode": "DE", - "note": "score 0.1 · https" - }, - { - "name": "london.mirror.pkgbuild.com", - "url": "https://london.mirror.pkgbuild.com/", - "country": "United Kingdom", - "countryCode": "GB", - "note": "score 0.2 · https" - }, - { - "name": "al.arch.niranjan.co", - "url": "https://al.arch.niranjan.co/", - "country": "Albania", - "countryCode": "AL", - "note": "score 0.2 · https" - }, - { - "name": "umea.mirror.pkgbuild.com", - "url": "https://umea.mirror.pkgbuild.com/", - "country": "Sweden", - "countryCode": "SE", - "note": "score 0.2 · https" - }, - { - "name": "ams.nl.mirrors.bjg.at", - "url": "https://ams.nl.mirrors.bjg.at/arch/", - "country": "Netherlands", - "countryCode": "NL", - "note": "score 0.2 · https" - }, - { - "name": "uk.arch.niranjan.co", - "url": "https://uk.arch.niranjan.co/", - "country": "United Kingdom", - "countryCode": "GB", - "note": "score 0.2 · https" - } - ], - "fedora": [ - { - "name": "mirror.fcix.net", - "url": "http://mirror.fcix.net/fedora/linux/releases/44/Everything/x86_64/os/", - "note": "via mirrors.fedoraproject.org" - }, - { - "name": "nnenix.mm.fcix.net", - "url": "https://nnenix.mm.fcix.net/fedora/linux/releases/44/Everything/x86_64/os/", - "note": "via mirrors.fedoraproject.org" - }, - { - "name": "ohioix.mm.fcix.net", - "url": "https://ohioix.mm.fcix.net/fedora/linux/releases/44/Everything/x86_64/os/", - "note": "via mirrors.fedoraproject.org" - }, - { - "name": "mirror.web-ster.com", - "url": "http://mirror.web-ster.com/fedora/releases/44/Everything/x86_64/os/", - "note": "via mirrors.fedoraproject.org" - }, - { - "name": "nocix.mm.fcix.net", - "url": "https://nocix.mm.fcix.net/fedora/linux/releases/44/Everything/x86_64/os/", - "note": "via mirrors.fedoraproject.org" - }, - { - "name": "mirrors.rc.rit.edu", - "url": "http://mirrors.rc.rit.edu/fedora/fedora/linux/releases/44/Everything/x86_64/os/", - "note": "via mirrors.fedoraproject.org" - }, - { - "name": "mirror.cs.princeton.edu", - "url": "http://mirror.cs.princeton.edu/pub/mirrors/fedora/linux/releases/44/Everything/x86_64/os/", - "note": "via mirrors.fedoraproject.org" - }, - { - "name": "gigsouth.mm.fcix.net", - "url": "https://gigsouth.mm.fcix.net/fedora/linux/releases/44/Everything/x86_64/os/", - "note": "via mirrors.fedoraproject.org" - } - ] - } -} diff --git a/apps/web/src/lib/server/worker.ts b/apps/web/src/lib/server/worker.ts deleted file mode 100644 index 6d83714..0000000 --- a/apps/web/src/lib/server/worker.ts +++ /dev/null @@ -1,137 +0,0 @@ -// Worker client — the BFF's only way to reach apps/api. -// -// The BFF never touches D1 or KV; it proxies the Worker with the internal -// service token (.ai/architecture.md § "Layers and responsibilities", settled -// in Phase 0 and not reopened). This module is that proxy. -// -// ── Transitional dual mode ───────────────────────────────────────────────── -// Task 5.4 moves the BFF endpoint by endpoint, and the Worker needs a -// Cloudflare account that only exists in production. So a route asks -// `workerConfigured()` first: with LINUXHUB_API_URL set it proxies, without it -// the route keeps serving the committed snapshot in `data.ts`. -// -// This is deliberately temporary and deliberately loud — `workerMode()` is -// logged once per cold start, so "the deploy is serving stale mock data" can -// never look like "the deploy is fine". The branch disappears with `data.ts` -// itself in task 5.7. - -import type { ApiResponse } from "@linuxhub/shared"; -import { env } from "$env/dynamic/private"; - -/** Read at runtime, not build time: these are set in the Vercel dashboard and - * must take effect on redeploy without a rebuild of the app bundle. */ -const apiUrl = () => env.LINUXHUB_API_URL?.replace(/\/+$/, "") ?? ""; -const apiToken = () => env.INTERNAL_API_TOKEN ?? ""; - -export function workerConfigured(): boolean { - return apiUrl().length > 0; -} - -export type WorkerMode = "worker" | "snapshot"; - -let announced = false; - -/** Which data source this instance is serving from. Logged once per cold start - * so the mode is visible in Vercel's logs without a request-by-request trace. */ -export function workerMode(): WorkerMode { - const mode: WorkerMode = workerConfigured() ? "worker" : "snapshot"; - if (!announced) { - announced = true; - console.log( - JSON.stringify({ - ts: new Date().toISOString(), - level: mode === "worker" ? "info" : "warn", - msg: - mode === "worker" - ? "BFF proxying the Worker" - : "BFF serving the committed snapshot — LINUXHUB_API_URL is unset", - }), - ); - } - return mode; -} - -export class WorkerError extends Error { - constructor( - readonly status: number, - message: string, - ) { - super(message); - this.name = "WorkerError"; - } -} - -export type WorkerCall = { - /** JSON body; its presence makes the call a POST. */ - body?: unknown; - /** - * The end user's address, for the Worker's per-IP rate limits. - * - * Without it every request looks like it came from the BFF, and the Worker - * would throttle the entire site as one client. It is passed as a separate - * argument rather than as a caller-supplied header so a route cannot forget - * that this is the user's address and not ours. - */ - clientAddress?: string | null; -}; - -/** - * Calls the Worker and returns its envelope untouched. - * - * The envelope is deliberately passed through rather than re-wrapped: the - * Worker and the BFF share one response shape (`.ai/api.md`), so re-building it - * here would be a second place for it to drift. - */ -export async function callWorker( - path: string, - fetchImpl: typeof fetch = fetch, - call: WorkerCall = {}, -): Promise> { - const base = apiUrl(); - if (!base) throw new WorkerError(503, "LINUXHUB_API_URL is not configured"); - - const token = apiToken(); - const headers: Record = {}; - if (token) headers["X-Internal-Token"] = token; - if (call.clientAddress) headers["X-Client-IP"] = call.clientAddress; - if (call.body !== undefined) headers["Content-Type"] = "application/json"; - - const response = await fetchImpl(`${base}${path}`, { - method: call.body === undefined ? "GET" : "POST", - headers, - body: call.body === undefined ? undefined : JSON.stringify(call.body), - }); - - // A non-JSON body means something in front of the Worker answered — a proxy - // error page, a 502 from the edge. Surfacing that as UPSTREAM_ERROR beats - // letting JSON.parse throw a message nobody can act on. - const body = await response.text(); - try { - return JSON.parse(body) as ApiResponse; - } catch { - throw new WorkerError(502, `Worker returned non-JSON (${response.status})`); - } -} - -/** - * The caller's address, or `null` when the platform cannot supply one. - * - * SvelteKit's `getClientAddress()` throws when the adapter has no way to know - * it. That must never fail a download: without an address the Worker puts the - * request in a shared rate-limit bucket, which is the safe direction to fail. - */ -export function clientAddressOf(getClientAddress: () => string): string | null { - try { - return getClientAddress(); - } catch { - return null; - } -} - -/** `Cache-Control` values per endpoint class (`.ai/frontend-rules.md`). */ -export const CACHE = { - list: "public, s-maxage=60, stale-while-revalidate=300", - detail: "public, s-maxage=300, stale-while-revalidate=600", - search: "public, s-maxage=30, stale-while-revalidate=120", - rankings: "public, s-maxage=600, stale-while-revalidate=1200", -} as const; diff --git a/apps/web/src/lib/state/ui.svelte.ts b/apps/web/src/lib/state/ui.svelte.ts deleted file mode 100644 index af9cafe..0000000 --- a/apps/web/src/lib/state/ui.svelte.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Cross-cutting client UI state (.ai/frontend-rules.md): kept minimal — -// the server is the source of truth for data. - -export const ui = $state({ - drawerOpen: false, - localeOpen: false, - paletteOpen: false, -}); - -export function toggleTheme() { - const html = document.documentElement; - const next = html.dataset.theme === "dark" ? "light" : "dark"; - html.dataset.theme = next; - try { - localStorage.setItem("lh-theme", next); - } catch { - // private mode etc. — theme still applies for this page - } -} diff --git a/apps/web/src/params/locale.ts b/apps/web/src/params/locale.ts deleted file mode 100644 index 96ee351..0000000 --- a/apps/web/src/params/locale.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { isLocale } from "@linuxhub/i18n"; -import type { ParamMatcher } from "@sveltejs/kit"; - -// Validates the optional locale prefix against the registry (.ai/i18n.md); -// unknown prefixes fall through and 404. -export const match: ParamMatcher = (param) => isLocale(param); diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte deleted file mode 100644 index 7bc70c2..0000000 --- a/apps/web/src/routes/+error.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - - - {page.status} — {m.site_name} - - -
-
- -

{notFound ? m.error_404_title : m.error_500_title}

-

{notFound ? m.error_404_body : m.error_500_body}

- {#if notFound} - - {/if} -
- {#if notFound} - {m.error_back_home} - {m.error_random} - {:else} - - {m.error_back_home} - {/if} -
-
-
- - diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte deleted file mode 100644 index 75ff48e..0000000 --- a/apps/web/src/routes/+layout.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - -
- {@render children()} -
- - - diff --git a/apps/web/src/routes/[[locale=locale]]/+layout.ts b/apps/web/src/routes/[[locale=locale]]/+layout.ts deleted file mode 100644 index 83d4876..0000000 --- a/apps/web/src/routes/[[locale=locale]]/+layout.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BASE_LOCALE, setLocale } from "@linuxhub/i18n"; -import type { LayoutLoad } from "./$types"; - -// Resolves the validated locale segment and primes the message runtime — -// on the server this mirrors the AsyncLocalStorage set in hooks.server.ts; -// in the browser it is the per-document locale. -export const load: LayoutLoad = ({ params }) => { - const locale = params.locale ?? BASE_LOCALE; - setLocale(locale); - return { locale }; -}; diff --git a/apps/web/src/routes/[[locale=locale]]/+page.server.ts b/apps/web/src/routes/[[locale=locale]]/+page.server.ts deleted file mode 100644 index 84d174e..0000000 --- a/apps/web/src/routes/[[locale=locale]]/+page.server.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { RecentRelease } from "@linuxhub/shared"; -import { unwrap } from "$lib/server/api"; -import type { Distro } from "$lib/server/data"; -import { BANNERS, getDistro } from "$lib/server/data"; -import type { PageServerLoad } from "./$types"; - -// Home composition: catalog data via the BFF endpoints; banners are -// editorial configuration joined server-side. -// -// Each list degrades to empty on its own. The home page is the first thing a -// visitor sees, and a backend hiccup should cost them a section, not the site. -export const load: PageServerLoad = async ({ fetch }) => { - const [trendingRes, popularRes, recentRes] = await Promise.all([ - fetch("/api/v1/distros?sort=trending"), - fetch("/api/v1/distros?sort=popularity"), - fetch("/api/v1/releases/recent"), - ]); - const trending = (await unwrap(trendingRes, [], "home:trending")).slice(0, 6); - const popular = (await unwrap(popularRes, [], "home:popular")).slice(0, 6); - // Facts only — the page composes the title and the channel label through - // @linuxhub/i18n, and formats the date with Intl (ADR-0020). - const recent = (await unwrap(recentRes, [], "home:recent")).map((release) => ({ - ...release, - distro: getDistro(release.slug), - })); - const banners = BANNERS.map((b) => ({ ...b, distro: getDistro(b.slug) })).filter( - (b) => b.distro !== undefined, - ); - return { banners, trending, popular, recent }; -}; diff --git a/apps/web/src/routes/[[locale=locale]]/+page.svelte b/apps/web/src/routes/[[locale=locale]]/+page.svelte deleted file mode 100644 index 721ee12..0000000 --- a/apps/web/src/routes/[[locale=locale]]/+page.svelte +++ /dev/null @@ -1,151 +0,0 @@ - - - - {m.site_name} - - - -
-
- - -
- - - -

{m.home_trending}

{m.home_see_more}
-
- {#each data.trending as distro (distro.slug)} - - {/each} -
- -

{m.home_popular}

{m.home_see_more}
-
- {#each data.popular as distro (distro.slug)} - - {/each} -
- -

{m.home_recently_updated}

{m.home_see_more}
- - -

{m.home_not_sure}

- -
diff --git a/apps/web/src/routes/[[locale=locale]]/about/+page.svelte b/apps/web/src/routes/[[locale=locale]]/about/+page.svelte deleted file mode 100644 index 4d4b1d9..0000000 --- a/apps/web/src/routes/[[locale=locale]]/about/+page.svelte +++ /dev/null @@ -1,139 +0,0 @@ - - - - {m.about_title} — {m.site_name} - - -
-
-

{m.about_title}

-
- -
-
-
-

{m.about_what}

-

{m.about_what_p1}

-

{m.about_what_p2}

-
-
-

{m.about_rankings}

-

{m.about_rankings_p}

-
-
-

{m.about_data}

-

{m.about_data_p}

-
-
-

{m.about_content}

-

{m.about_content_p}

-
-
-

{m.about_crawler}

-

{m.about_crawler_p}

-
-
-

{m.about_contact}

-

{m.about_contact_p}

- -
-
- - -
-
- - diff --git a/apps/web/src/routes/[[locale=locale]]/compare/+page.server.ts b/apps/web/src/routes/[[locale=locale]]/compare/+page.server.ts deleted file mode 100644 index 9fee580..0000000 --- a/apps/web/src/routes/[[locale=locale]]/compare/+page.server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { DISTROS, SPECS } from "$lib/server/data"; -import type { PageServerLoad } from "./$types"; - -// Compare (.ai/api.md #46) — slugs come from the URL (?slugs=a,b,c, max 4). -export const load: PageServerLoad = ({ url }) => { - const requested = (url.searchParams.get("slugs") ?? "ubuntu,fedora,arch") - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - .slice(0, 4); - const picked = requested - .map((slug) => { - const distro = DISTROS.find((d) => d.slug === slug); - return distro ? { ...distro, specs: SPECS[slug] } : undefined; - }) - .filter((d) => d !== undefined); - const available = DISTROS.filter((d) => !requested.includes(d.slug)).map((d) => ({ - slug: d.slug, - name: d.name, - })); - return { picked, available }; -}; diff --git a/apps/web/src/routes/[[locale=locale]]/compare/+page.svelte b/apps/web/src/routes/[[locale=locale]]/compare/+page.svelte deleted file mode 100644 index 9789004..0000000 --- a/apps/web/src/routes/[[locale=locale]]/compare/+page.svelte +++ /dev/null @@ -1,207 +0,0 @@ - - - - {m.compare_title} — {m.site_name} - - -
-
-

{m.compare_title}

-

{m.compare_lede}

-
- -
- {#each data.picked as distro (distro.slug)} -
- - {distro.name} - -
- {/each} - {#if data.picked.length < 4} - - {/if} -
- - {#if data.picked.length < 2} -
- -
{m.compare_pick_two}
-

{m.compare_pick_hint}

-
- {:else} -
- -
- -
- - - - - {#each data.picked as distro (distro.slug)} - - {/each} - - - - {#each ROWS as row (row.key)} - {#if !diffOnly || isDiff(row)} - - - {#each data.picked as distro (distro.slug)} - - {/each} - - {/if} - {/each} - - - {#each data.picked as distro (distro.slug)} - - {/each} - - - - - - {#each data.picked as distro (distro.slug)} - - {/each} - - -
{distro.name}
{row.label}{valueFor(distro, row)}
{m.compare_popularity}#{distro.rank} · {distro.downloads}
- - {m.detail_download} - -
-
- {/if} -
- - diff --git a/apps/web/src/routes/[[locale=locale]]/contribute/+page.svelte b/apps/web/src/routes/[[locale=locale]]/contribute/+page.svelte deleted file mode 100644 index a4651c6..0000000 --- a/apps/web/src/routes/[[locale=locale]]/contribute/+page.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - - - {m.contribute_title} — {m.site_name} - - -
-
-
-

{m.contribute_title}

-

{m.contribute_lede}

-
- - {#if sent} -
- -
{m.form_thanks}
-
- - {m.nav_explore} -
-
- {:else} -
- {#each TABS as t (t.id)} - - {/each} -
- -
- {#if tab === "suggest"} -
- - -
-
- - -
-
- - -

{m.form_why_hint}

-
-
- - -
- {:else if tab === "report"} -
- - -
-
- - -
-
- - -
- {:else} -
- - -
-
- - -
- {/if} - -
- {m.form_captcha} -
- - {m.form_captcha_note} -
-
- -
- {/if} -
-
- - diff --git a/apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.server.ts b/apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.server.ts deleted file mode 100644 index 5e4a582..0000000 --- a/apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ApiResponse, DownloadOptions } from "@linuxhub/shared"; -import { error } from "@sveltejs/kit"; -import type { DetailPayload, Distro } from "$lib/server/data"; -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ fetch, params }) => { - // Both in flight together: the download matrix is a second round trip, and - // serializing it would add its latency to every distro page. - const [detailRes, optionsRes] = await Promise.all([ - fetch(`/api/v1/distros/${params.slug}`), - fetch(`/api/v1/distros/${params.slug}/download-options`), - ]); - - const body = (await detailRes.json()) as ApiResponse<{ distro: Distro; detail: DetailPayload }>; - if (!body.ok) { - error(404, "distro not found"); - } - - // Downloads are additive: a distro whose artifacts are not sourced yet still - // has a page, so a failure here costs the selector, never the page. - const options = (await optionsRes - .json() - .catch(() => null)) as ApiResponse | null; - const downloads: DownloadOptions = - options?.ok === true ? options.data : { slug: params.slug, versions: [], mirrors: [] }; - - return { ...body.data, downloads }; -}; diff --git a/apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.svelte b/apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.svelte deleted file mode 100644 index 096cdf3..0000000 --- a/apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.svelte +++ /dev/null @@ -1,365 +0,0 @@ - - - - {data.distro.name} — {m.site_name} - - - -
-
-
- -
-

{data.distro.name}

-

{data.detail.summary}

-
- {data.detail.badges.family} - {#if data.detail.badges.active}{m.badge_active}{/if} - {#if data.content.description && !data.content.description.translated}{m.badge_english_fallback}{/if} -
-
- -
- -
- - -
- -
- {#each data.detail.meta as tile (tile.icon)} -
- - {tile.value} - {"labelKey" in tile && tile.labelKey ? m[tile.labelKey as keyof typeof m] : tile.label} -
- {/each} -
- -
-
- - - -
-
- {#if activeDoc} - {@const Doc = activeDoc.component} - {#if !activeDoc.translated} -

{m.badge_english_fallback}

- {/if} - - {#if sourceLinks.length > 0} -
- {m.detail_sources} - {#each sourceLinks as source, i (source.url)} - {i > 0 ? " · " : " "}{source.host} - {/each} - {#if activeDoc.meta.last_reviewed} - · {m.detail_last_reviewed} {activeDoc.meta.last_reviewed} - {/if} -
- {/if} - {:else} -

{m.state_loading}…

- {/if} -
-
- -
-

{m.dl_title}

- - {#if data.downloads.versions.length > 0} - - {#key data.distro.slug} - - {/key} - {:else} -
- - - -
- -
- {#each data.detail.versions as version, i (version.version)} -
- - -
- {version.date} - {#if version.size}{version.size}{/if} - {#if version.downloads}{version.downloads}{/if} -
-
- {#if openVersion === i} -
- {m.dl_mirrors} - {#each data.detail.mirrors as mirrorRow, j (mirrorRow.name)} - - {/each} -
-
- - {m.dl_verify_note} - - {m.dl_how_to_verify} - - -
-
-
- {/if} - {/each} -
- {/if} -

- {m.dl_selection_note} -

-

- {m.detail_data_note} {data.detail.fetchedAt.slice(0, 10)} -

-
- -
-

{m.detail_requirements}

-
- - - - - - {#each data.detail.requirements as req (req.row)} - - {/each} - -
{m.detail_minimum}{m.detail_recommended}
{req.row}{req.min}{req.rec}
-
-
- -
-

{m.detail_links}

- -
- - {#if data.detail.related.length > 0} -
-
-

{m.detail_similar}

- {m.home_see_more} -
-
- {#each data.detail.related as rel (rel.slug)} - - {/each} -
-
- {/if} -
-
- - diff --git a/apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.ts b/apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.ts deleted file mode 100644 index 29403a2..0000000 --- a/apps/web/src/routes/[[locale=locale]]/distro/[slug]/+page.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { loadDoc } from "$lib/content"; -import type { PageLoad } from "./$types"; - -// Server load supplies distro + detail; here we attach the mdsvex content -// components (per-tab docs) in the active locale, English as fallback. -export const load: PageLoad = async ({ data, params, parent }) => { - const { locale } = await parent(); - const [description, install, usage] = await Promise.all([ - loadDoc(params.slug, "description", locale), - loadDoc(params.slug, "install", locale), - loadDoc(params.slug, "usage", locale), - ]); - return { ...data, content: { description, install, usage } }; -}; diff --git a/apps/web/src/routes/[[locale=locale]]/explore/+page.server.ts b/apps/web/src/routes/[[locale=locale]]/explore/+page.server.ts deleted file mode 100644 index dfd1d46..0000000 --- a/apps/web/src/routes/[[locale=locale]]/explore/+page.server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Distro } from "@linuxhub/shared"; -import { unwrapList } from "$lib/server/api"; -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ fetch, url }) => { - const category = url.searchParams.get("category"); - const sort = url.searchParams.get("sort") ?? "popularity"; - const query = new URLSearchParams({ sort }); - if (category) query.set("category", category); - const res = await fetch(`/api/v1/distros?${query}`); - const { items, total } = await unwrapList(res, "explore:distros"); - return { distros: items, total, category, sort }; -}; diff --git a/apps/web/src/routes/[[locale=locale]]/explore/+page.svelte b/apps/web/src/routes/[[locale=locale]]/explore/+page.svelte deleted file mode 100644 index e3c03dc..0000000 --- a/apps/web/src/routes/[[locale=locale]]/explore/+page.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - - {m.explore_title} — {m.site_name} - - -
-
-

{m.explore_title}

-
- - - -
- {data.total} {m.explore_count} - -
- - {#if data.distros.length === 0} -
-
{m.state_empty_catalog}
- {m.dl_clear_filters} -
- {:else} -
- {#each data.distros as distro (distro.slug)} - - {/each} -
- {/if} -
diff --git a/apps/web/src/routes/[[locale=locale]]/hall-of-fame/+page.server.ts b/apps/web/src/routes/[[locale=locale]]/hall-of-fame/+page.server.ts deleted file mode 100644 index 3fe167b..0000000 --- a/apps/web/src/routes/[[locale=locale]]/hall-of-fame/+page.server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { unwrap } from "$lib/server/api"; -import type { HALL_OF_FAME } from "$lib/server/data"; -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ fetch }) => { - const res = await fetch("/api/v1/hall-of-fame"); - const entries = await unwrap( - res, - [] as unknown as typeof HALL_OF_FAME, - "hall-of-fame", - ); - return { entries }; -}; diff --git a/apps/web/src/routes/[[locale=locale]]/hall-of-fame/+page.svelte b/apps/web/src/routes/[[locale=locale]]/hall-of-fame/+page.svelte deleted file mode 100644 index 8ec214c..0000000 --- a/apps/web/src/routes/[[locale=locale]]/hall-of-fame/+page.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - - - {m.hof_title} — {m.site_name} - - -
-
-
-

{m.hof_title}

-

{m.hof_lede} {m.hof_now} {m.nav_rankings}.

-
- -
- {#each data.entries as entry (entry.name)} -
-
- -
-

{entry.name}

-
{entry.era}
-
- {#if entry.discontinued} - {m.hof_discontinued} - {/if} -
-

{entry.why}

-

{m.hof_sources}: {entry.sources}

-
- {/each} -
-
-
- - diff --git a/apps/web/src/routes/[[locale=locale]]/quiz/+page.server.ts b/apps/web/src/routes/[[locale=locale]]/quiz/+page.server.ts deleted file mode 100644 index e292d8d..0000000 --- a/apps/web/src/routes/[[locale=locale]]/quiz/+page.server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { unwrap } from "$lib/server/api"; -import type { Distro, QUIZ } from "$lib/server/data"; -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ fetch }) => { - const [quizRes, distroRes] = await Promise.all([fetch("/api/v1/quiz"), fetch("/api/v1/distros")]); - const quiz = await unwrap(quizRes, [] as unknown as typeof QUIZ, "quiz:questions"); - const distros = await unwrap(distroRes, [], "quiz:distros"); - return { quiz, distros }; -}; diff --git a/apps/web/src/routes/[[locale=locale]]/quiz/+page.svelte b/apps/web/src/routes/[[locale=locale]]/quiz/+page.svelte deleted file mode 100644 index 78302ce..0000000 --- a/apps/web/src/routes/[[locale=locale]]/quiz/+page.svelte +++ /dev/null @@ -1,215 +0,0 @@ - - - - {m.quiz_title} — {m.site_name} - - -
- {#if stage === "intro"} -
-

{m.quiz_title}

-

{m.quiz_intro}

-

{m.quiz_time}

-
- - {m.quiz_browse} -
-
- {:else if stage === "questions"} - {@const question = data.quiz[step]} -
-
-
- - {step + 1} / {total} -
-
-
-
-

{question.q}

-
-
- {#each question.options as option (option.label)} - - {/each} -
- -
- {:else} - {@const top = matches[0]} -
- {#if top} -
- {m.quiz_top_match} - -

{top.name}

-

{top.summary}

- -
- {/if} - {#if matches.length > 1} -
- {#each matches.slice(1) as runner (runner.slug)} - - - - {runner.name} - {runner.summary} - - - - {/each} -
- {/if} - -
- {/if} -
- - diff --git a/apps/web/src/routes/[[locale=locale]]/rankings/+page.server.ts b/apps/web/src/routes/[[locale=locale]]/rankings/+page.server.ts deleted file mode 100644 index d61268b..0000000 --- a/apps/web/src/routes/[[locale=locale]]/rankings/+page.server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { unwrap } from "$lib/server/api"; -import type { PageServerLoad } from "./$types"; - -type Entry = { - rank: number; - slug: string; - name: string; - familyLine: string; - color: string; - initials: string; - logo: string; - trend: number; - spark: string; -}; - -export const load: PageServerLoad = async ({ fetch }) => { - const res = await fetch("/api/v1/rankings"); - return unwrap<{ entries: Entry[]; movers: Entry[] }>( - res, - { entries: [], movers: [] }, - "rankings", - ); -}; diff --git a/apps/web/src/routes/[[locale=locale]]/rankings/+page.svelte b/apps/web/src/routes/[[locale=locale]]/rankings/+page.svelte deleted file mode 100644 index 662c7b7..0000000 --- a/apps/web/src/routes/[[locale=locale]]/rankings/+page.svelte +++ /dev/null @@ -1,106 +0,0 @@ - - - - {m.rankings_title} — {m.site_name} - - -
-
-
-

{m.rankings_title}

-

{m.rankings_lede} {m.rankings_how}

-
- -
-
- {#each PERIODS as p (p.id)} - - {/each} -
- -
- {#each VIEWS as v (v.id)} - - {/each} -
-
- -
    - {#each entries as entry, i (entry.slug)} -
  1. - - {i + 1} - - - {entry.name} - {entry.familyLine} - - - - {#if entry.trend > 0}▲{entry.trend} - {:else if entry.trend < 0}▼{-entry.trend} - {:else}—{/if} - - -
  2. - {/each} -
- -

{m.rankings_movers}

{m.nav_hall_of_fame}
- -
-
- - diff --git a/apps/web/src/routes/[[locale=locale]]/search/+page.server.ts b/apps/web/src/routes/[[locale=locale]]/search/+page.server.ts deleted file mode 100644 index 82379f9..0000000 --- a/apps/web/src/routes/[[locale=locale]]/search/+page.server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Distro } from "@linuxhub/shared"; -import { unwrapList } from "$lib/server/api"; -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ fetch, url }) => { - const q = url.searchParams.get("q") ?? ""; - const res = await fetch(`/api/v1/search?q=${encodeURIComponent(q)}`); - const { items, total } = await unwrapList(res, "search:results"); - return { q, results: items, total }; -}; diff --git a/apps/web/src/routes/[[locale=locale]]/search/+page.svelte b/apps/web/src/routes/[[locale=locale]]/search/+page.svelte deleted file mode 100644 index 3aaa82d..0000000 --- a/apps/web/src/routes/[[locale=locale]]/search/+page.svelte +++ /dev/null @@ -1,95 +0,0 @@ - - - - {data.q} — {m.site_name} - - -
-
-
-

{data.total} {m.search_results_for} “{data.q}”

-
- -
-
- - diff --git a/apps/web/src/routes/api/v1/distros/+server.ts b/apps/web/src/routes/api/v1/distros/+server.ts deleted file mode 100644 index 8d8d75a..0000000 --- a/apps/web/src/routes/api/v1/distros/+server.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { type Distro, err, ok } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { toSharedDistro } from "$lib/server/adapt"; -import { DISTROS } from "$lib/server/data"; -import { CACHE, callWorker, workerConfigured } from "$lib/server/worker"; -import type { RequestHandler } from "./$types"; - -// Catalog list (.ai/api.md #1). -export const GET: RequestHandler = async ({ fetch, url }) => { - if (workerConfigured()) { - // `trending` is not a Worker sort: it only ever meant "order by the - // invented trend column", and there is no trend until we have download - // signals of our own (5.6). Map it to the default rather than handing the - // Worker a value its schema rejects. - const query = new URLSearchParams(url.searchParams); - if (query.get("sort") === "trending") query.set("sort", "popularity"); - - const upstream = await callWorker(`/v1/distros?${query}`, fetch); - if (!upstream.ok) { - return json(err("UPSTREAM_ERROR", "catalog is unavailable"), { status: 502 }); - } - return json(ok(upstream.data, upstream.meta), { headers: { "Cache-Control": CACHE.list } }); - } - - // Snapshot fallback. - const sort = url.searchParams.get("sort") ?? "popularity"; - const category = url.searchParams.get("category"); - - let list = [...DISTROS]; - if (category) list = list.filter((d) => d.categories.includes(category)); - list.sort( - sort === "trending" - ? (a, b) => b.trend - a.trend - : sort === "name" - ? (a, b) => a.name.localeCompare(b.name) - : (a, b) => a.rank - b.rank, - ); - - return json(ok(list.map(toSharedDistro), { page: 1, limit: 24, total: list.length }), { - headers: { "Cache-Control": CACHE.list }, - }); -}; diff --git a/apps/web/src/routes/api/v1/distros/[slug]/+server.ts b/apps/web/src/routes/api/v1/distros/[slug]/+server.ts deleted file mode 100644 index 8922354..0000000 --- a/apps/web/src/routes/api/v1/distros/[slug]/+server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { err, ok } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { DISTROS, getDetailFor, getDistro } from "$lib/server/data"; -import type { RequestHandler } from "./$types"; - -// Distro detail (.ai/api.md #2) — releases/mirrors come from the committed -// live snapshot; related distros are the closest neighbors in the same family. -export const GET: RequestHandler = ({ params }) => { - const distro = getDistro(params.slug); - if (!distro) { - return json(err("NOT_FOUND", "distro not found"), { status: 404 }); - } - const detail = getDetailFor(distro); - detail.related = DISTROS.filter((d) => d.slug !== distro.slug) - .sort((a, b) => { - const aFam = a.family === distro.family ? 0 : 1; - const bFam = b.family === distro.family ? 0 : 1; - return aFam - bFam || a.rank - b.rank; - }) - .slice(0, 3); - return json(ok({ distro, detail })); -}; diff --git a/apps/web/src/routes/api/v1/distros/[slug]/download-options/+server.ts b/apps/web/src/routes/api/v1/distros/[slug]/download-options/+server.ts deleted file mode 100644 index 1c73b8b..0000000 --- a/apps/web/src/routes/api/v1/distros/[slug]/download-options/+server.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type DownloadOptions, err, ok } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { CACHE, callWorker, workerConfigured } from "$lib/server/worker"; -import type { RequestHandler } from "./$types"; - -// The selector's decision tree (.ai/api.md #14). -// -// There is no snapshot fallback: `data.ts` has no artifact paths, sizes or -// checksums — it never did — so with no Worker configured the honest answer is -// an empty matrix, and the page keeps the placeholder rendering it shipped -// with rather than being handed a selection that cannot resolve. -export const GET: RequestHandler = async ({ fetch, params }) => { - if (!workerConfigured()) { - return json(ok({ slug: params.slug, versions: [], mirrors: [] } satisfies DownloadOptions), { - headers: { "Cache-Control": CACHE.detail }, - }); - } - - const upstream = await callWorker( - `/v1/distros/${encodeURIComponent(params.slug)}/download-options`, - fetch, - ); - if (!upstream.ok) { - // A distro with no artifacts sourced yet is a 404 upstream, which is not - // a page error — the distro exists, its downloads do not yet. - if (upstream.error.code === "NOT_FOUND") { - return json(ok({ slug: params.slug, versions: [], mirrors: [] } satisfies DownloadOptions), { - headers: { "Cache-Control": CACHE.detail }, - }); - } - return json(err("UPSTREAM_ERROR", "download options are unavailable"), { status: 502 }); - } - - return json(ok(upstream.data), { headers: { "Cache-Control": CACHE.detail } }); -}; diff --git a/apps/web/src/routes/api/v1/downloads/downloads.test.ts b/apps/web/src/routes/api/v1/downloads/downloads.test.ts deleted file mode 100644 index acab599..0000000 --- a/apps/web/src/routes/api/v1/downloads/downloads.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -// BFF download routes (.ai/api.md #14, #15, #21). -// -// These cover the three things the BFF adds on top of the Worker and that the -// Worker's own suite therefore cannot see: geo comes from the edge header and -// not from the caller, an upstream 404 is a page-safe empty matrix rather than -// an error, and counting a click can never fail the download. - -import type { ApiResponse, DownloadOptions, DownloadResolution } from "@linuxhub/shared"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { env } from "$env/dynamic/private"; -import { GET as downloadOptions } from "../distros/[slug]/download-options/+server"; -import { POST as resolve } from "./resolve/+server"; -import { POST as track } from "./track/+server"; - -type Call = { url: string; init: RequestInit }; - -function stubFetch(response: unknown, calls: Call[] = [], status = 200) { - return Object.assign( - (async (url: string | URL, init: RequestInit = {}) => { - calls.push({ url: String(url), init }); - return new Response(JSON.stringify(response), { - status, - headers: { "Content-Type": "application/json" }, - }); - }) as unknown as typeof fetch, - { calls }, - ); -} - -// The handlers take a RequestEvent; these tests build only the parts they read. -// biome-ignore lint/suspicious/noExplicitAny: narrowing a full RequestEvent here would test SvelteKit, not us. -const event = (parts: Record) => parts as any; - -const RESOLUTION: DownloadResolution = { - url: "https://mirror.test/iso/a.iso", - artifact_id: 7, - size: 100, - sha256: "abc", - sig_url: null, - mirror: { id: 3, name: "mirror.test", country: "SE", base_url: "https://mirror.test/" }, - mirror_choice: "country", -}; - -beforeEach(() => { - env.LINUXHUB_API_URL = "https://api.test"; - env.INTERNAL_API_TOKEN = "secret"; -}); - -describe("GET download-options", () => { - it("passes the Worker's matrix through", async () => { - const matrix: DownloadOptions = { slug: "arch", versions: [], mirrors: [] }; - const response = await downloadOptions( - event({ fetch: stubFetch({ ok: true, data: matrix }), params: { slug: "arch" } }), - ); - const body = (await response.json()) as ApiResponse; - expect(body.ok).toBe(true); - }); - - it("answers an empty matrix — not an error — for a distro with no artifacts yet", async () => { - // Ten of twelve distros are in this state until their artifact sources - // are wired; the page must still render. - const response = await downloadOptions( - event({ - fetch: stubFetch({ ok: false, error: { code: "NOT_FOUND", message: "distro" } }), - params: { slug: "nixos" }, - }), - ); - expect(response.status).toBe(200); - const body = (await response.json()) as ApiResponse; - if (!body.ok) throw new Error("expected success"); - expect(body.data.versions).toEqual([]); - }); - - it("answers an empty matrix when no Worker is configured", async () => { - env.LINUXHUB_API_URL = ""; - const response = await downloadOptions( - event({ fetch: stubFetch({}), params: { slug: "arch" } }), - ); - const body = (await response.json()) as ApiResponse; - if (!body.ok) throw new Error("expected success"); - expect(body.data).toEqual({ slug: "arch", versions: [], mirrors: [] }); - }); -}); - -describe("POST resolve", () => { - it("takes the country from the edge header and ignores one in the body", async () => { - const calls: Call[] = []; - await resolve( - event({ - fetch: stubFetch({ ok: true, data: RESOLUTION }, calls), - getClientAddress: () => "203.0.113.9", - request: new Request("https://web.test/api/v1/downloads/resolve", { - method: "POST", - headers: { "x-vercel-ip-country": "SE" }, - // A caller claiming to be elsewhere must not get to pick a mirror - // by saying so. - body: JSON.stringify({ slug: "arch", version: "1", country: "JP" }), - }), - }), - ); - - const sent = JSON.parse(String(calls[0]?.init.body)) as Record; - expect(sent.country).toBe("SE"); - expect(calls[0]?.init.method).toBe("POST"); - }); - - it("forwards the caller's address so the Worker limits the user, not the BFF", async () => { - const calls: Call[] = []; - await resolve( - event({ - fetch: stubFetch({ ok: true, data: RESOLUTION }, calls), - getClientAddress: () => "203.0.113.9", - request: new Request("https://web.test/api/v1/downloads/resolve", { - method: "POST", - body: JSON.stringify({ slug: "arch" }), - }), - }), - ); - - const headers = calls[0]?.init.headers as Record; - expect(headers["X-Client-IP"]).toBe("203.0.113.9"); - expect(headers["X-Internal-Token"]).toBe("secret"); - }); - - it("keeps the Worker's status for a combination that does not exist", async () => { - const response = await resolve( - event({ - fetch: stubFetch({ ok: false, error: { code: "NOT_FOUND", message: "artifact" } }), - getClientAddress: () => "203.0.113.9", - request: new Request("https://web.test/api/v1/downloads/resolve", { - method: "POST", - body: JSON.stringify({ slug: "arch" }), - }), - }), - ); - expect(response.status).toBe(404); - }); - - it("rejects a body that is not an object", async () => { - const response = await resolve( - event({ - fetch: stubFetch({}), - getClientAddress: () => "203.0.113.9", - request: new Request("https://web.test/api/v1/downloads/resolve", { - method: "POST", - body: "not json", - }), - }), - ); - expect(response.status).toBe(400); - }); -}); - -describe("POST track", () => { - it("never fails the download it is measuring", async () => { - const failing = (async () => { - throw new Error("worker unreachable"); - }) as unknown as typeof fetch; - const spy = vi.spyOn(console, "log").mockImplementation(() => {}); - - const response = await track( - event({ - fetch: failing, - getClientAddress: () => "203.0.113.9", - request: new Request("https://web.test/api/v1/downloads/track", { - method: "POST", - body: JSON.stringify({ artifact_id: 7 }), - }), - }), - ); - - expect(response.status).toBe(200); - const body = (await response.json()) as ApiResponse<{ counted: boolean }>; - if (!body.ok) throw new Error("expected success"); - expect(body.data.counted).toBe(false); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it("reports a counted click when the Worker took it", async () => { - const response = await track( - event({ - fetch: stubFetch({ ok: true, data: { counted: true } }), - getClientAddress: () => "203.0.113.9", - request: new Request("https://web.test/api/v1/downloads/track", { - method: "POST", - body: JSON.stringify({ artifact_id: 7 }), - }), - }), - ); - const body = (await response.json()) as ApiResponse<{ counted: boolean }>; - if (!body.ok) throw new Error("expected success"); - expect(body.data.counted).toBe(true); - }); -}); diff --git a/apps/web/src/routes/api/v1/downloads/resolve/+server.ts b/apps/web/src/routes/api/v1/downloads/resolve/+server.ts deleted file mode 100644 index 602b047..0000000 --- a/apps/web/src/routes/api/v1/downloads/resolve/+server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type DownloadResolution, ERROR_STATUS, err, ok } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { callWorker, clientAddressOf, workerConfigured } from "$lib/server/worker"; -import type { RequestHandler } from "./$types"; - -// Selection → a direct mirror URL with its checksum (.ai/api.md #15). -// -// Never cached at the edge: the answer depends on the caller's country and on -// which mirrors are healthy right now, and a shared cache would pin one -// visitor's mirror onto everyone behind the same POP. The Worker caches the -// artifact lookup itself, which is the expensive half. -export const POST: RequestHandler = async ({ fetch, request, getClientAddress }) => { - if (!workerConfigured()) { - return json(err("UPSTREAM_ERROR", "downloads are unavailable"), { status: 502 }); - } - - const body = (await request.json().catch(() => null)) as Record | null; - if (!body || typeof body !== "object") { - return json(err("VALIDATION_ERROR", "invalid body"), { status: 400 }); - } - - // Geo comes from the edge, not from the client: a country in the request - // body would let anyone pick any mirror by claiming to be there. An explicit - // `mirror_id` is how a visitor overrides the choice, and that one is checked - // against the artifact upstream. - const country = request.headers.get("x-vercel-ip-country"); - const payload = { ...body, country: country ?? undefined }; - - const upstream = await callWorker("/v1/downloads/resolve", fetch, { - body: payload, - clientAddress: clientAddressOf(getClientAddress), - }); - if (!upstream.ok) { - return json(upstream, { status: ERROR_STATUS[upstream.error.code] }); - } - - return json(ok(upstream.data), { headers: { "Cache-Control": "no-store" } }); -}; diff --git a/apps/web/src/routes/api/v1/downloads/track/+server.ts b/apps/web/src/routes/api/v1/downloads/track/+server.ts deleted file mode 100644 index 66c4161..0000000 --- a/apps/web/src/routes/api/v1/downloads/track/+server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ok } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { callWorker, clientAddressOf, workerConfigured } from "$lib/server/worker"; -import type { RequestHandler } from "./$types"; - -// Count a download click (.ai/api.md #21). -// -// This endpoint always answers 200. A click is a measurement, and a -// measurement that can fail the thing it measures is worse than a lost count: -// the browser fires this alongside the navigation to the file, so an error here -// would surface as a broken download for no gain. Failures are logged instead. -export const POST: RequestHandler = async ({ fetch, request, getClientAddress }) => { - const counted = { counted: false }; - if (!workerConfigured()) return json(ok(counted)); - - const body = (await request.json().catch(() => null)) as Record | null; - if (!body) return json(ok(counted)); - - try { - const upstream = await callWorker<{ counted: boolean }>("/v1/downloads/track", fetch, { - body, - clientAddress: clientAddressOf(getClientAddress), - }); - return json(ok({ counted: upstream.ok })); - } catch (error) { - console.log( - JSON.stringify({ - ts: new Date().toISOString(), - level: "warn", - msg: "download click not counted", - detail: String(error), - }), - ); - return json(ok(counted)); - } -}; diff --git a/apps/web/src/routes/api/v1/feeds/releases.atom/+server.ts b/apps/web/src/routes/api/v1/feeds/releases.atom/+server.ts deleted file mode 100644 index dd4fb88..0000000 --- a/apps/web/src/routes/api/v1/feeds/releases.atom/+server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { recentReleases, renderAtom, toItems } from "$lib/server/feed"; -import type { RequestHandler } from "./$types"; - -// Recent releases, Atom (.ai/api.md #56). Linked from the site footer. -export const GET: RequestHandler = async ({ fetch, url }) => { - const releases = await recentReleases(fetch); - const body = renderAtom(url.origin, toItems(url.origin, releases), url.href); - - return new Response(body, { - headers: { - "Content-Type": "application/atom+xml; charset=utf-8", - "Cache-Control": "public, s-maxage=900, stale-while-revalidate=3600", - }, - }); -}; diff --git a/apps/web/src/routes/api/v1/feeds/releases.rss/+server.ts b/apps/web/src/routes/api/v1/feeds/releases.rss/+server.ts deleted file mode 100644 index 8f1b8d0..0000000 --- a/apps/web/src/routes/api/v1/feeds/releases.rss/+server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { recentReleases, renderRss, toItems } from "$lib/server/feed"; -import type { RequestHandler } from "./$types"; - -// Recent releases, RSS 2.0 (.ai/api.md #55). Linked from the site footer. -export const GET: RequestHandler = async ({ fetch, url }) => { - const releases = await recentReleases(fetch); - const body = renderRss(url.origin, toItems(url.origin, releases), url.href); - - return new Response(body, { - headers: { - "Content-Type": "application/rss+xml; charset=utf-8", - "Cache-Control": "public, s-maxage=900, stale-while-revalidate=3600", - }, - }); -}; diff --git a/apps/web/src/routes/api/v1/hall-of-fame/+server.ts b/apps/web/src/routes/api/v1/hall-of-fame/+server.ts deleted file mode 100644 index 42f0988..0000000 --- a/apps/web/src/routes/api/v1/hall-of-fame/+server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ok } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { HALL_OF_FAME } from "$lib/server/data"; -import type { RequestHandler } from "./$types"; - -// Hall of Fame (.ai/api.md #35) — editorial entries with cited rationale. -export const GET: RequestHandler = () => json(ok(HALL_OF_FAME)); diff --git a/apps/web/src/routes/api/v1/health/+server.ts b/apps/web/src/routes/api/v1/health/+server.ts deleted file mode 100644 index 2c45ed7..0000000 --- a/apps/web/src/routes/api/v1/health/+server.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type Distro, type Health, ok } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { callWorker, workerConfigured, workerMode } from "$lib/server/worker"; -import type { RequestHandler } from "./$types"; - -// BFF liveness (.ai/api.md #41). -// -// Reports its own status plus, when configured, the Worker's — so one call -// answers both "is the site up" and "is the backend behind it up", which is -// what a smoke test after a deploy actually needs. `mode` says which data -// source is being served, so a deploy with LINUXHUB_API_URL unset is visible -// rather than merely quiet. -export type BffHealth = Health & { - mode: "worker" | "snapshot"; - worker: Health | null; - /** - * Did a **token-gated** call get past the token check? - * - * `/v1/health` is deliberately exempt from that check, so it answers 200 - * even when the token is wrong — which made this endpoint report a - * perfectly healthy backend while every catalog request 404'd and the site - * rendered empty. - * - * `false` means **refused**, and only that: a bad token is the Worker's - * one reason to answer `NOT_FOUND` on a list route (`.ai/api.md`). A call - * that got in and then failed leaves this `true` and names the failure in - * `catalog_error` — reporting that as "unauthorized" sent one debugging - * session after the token when the real fault was a dead D1 binding. - * `null` in snapshot mode, where there is no token to be wrong. - */ - authorized: boolean | null; - /** The error code the gated call answered with, or `null` if it succeeded. - * `INTERNAL` here with `db:false` above means the D1 binding, not the - * token. */ - catalog_error: string | null; - /** - * How many distros the catalog actually holds. Separates "the Worker is - * fine but nobody has run the ingestion crons yet" from "the Worker is - * refusing us", which look identical from the page: both render empty. - */ - distros: number | null; -}; - -export const GET: RequestHandler = async ({ fetch }) => { - const body: BffHealth = { - service: "linuxhub-web-bff", - status: "up", - version: "0.1.0", - db: false, - kv: false, - db_error: null, - kv_error: null, - mode: workerMode(), - worker: null, - authorized: null, - catalog_error: null, - distros: null, - }; - - if (!workerConfigured()) return json(ok(body), { headers: { "Cache-Control": "no-store" } }); - - try { - const upstream = await callWorker("/v1/health", fetch); - if (upstream.ok) { - body.worker = upstream.data; - body.db = upstream.data.db; - body.kv = upstream.data.kv; - body.db_error = upstream.data.db_error; - body.kv_error = upstream.data.kv_error; - // The BFF is only as healthy as the backend it proxies. - body.status = upstream.data.status; - } else { - body.status = "degraded"; - } - } catch { - // A liveness probe has to answer even when its upstream does not. - body.status = "degraded"; - } - - // The gated probe. Cheapest token-carrying call there is: one row, and the - // real total comes back in `meta`. - try { - const catalog = await callWorker("/v1/distros?limit=1", fetch); - if (catalog.ok) { - body.authorized = true; - body.distros = catalog.meta?.total ?? catalog.data.length; - } else { - // NOT_FOUND on a list route is the Worker's answer to a bad token. - // Anything else got past the token and failed behind it. - body.authorized = catalog.error.code !== "NOT_FOUND"; - body.catalog_error = catalog.error.code; - body.status = "degraded"; - } - } catch { - // The call never completed, so whether the token would have been - // accepted is unknown — saying `false` would be a guess. - body.catalog_error = "UNREACHABLE"; - body.status = "degraded"; - } - - return json(ok(body), { headers: { "Cache-Control": "no-store" } }); -}; diff --git a/apps/web/src/routes/api/v1/health/health.test.ts b/apps/web/src/routes/api/v1/health/health.test.ts deleted file mode 100644 index 26dbbe4..0000000 --- a/apps/web/src/routes/api/v1/health/health.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// The health check exists to make a bad deploy visible. It reported a healthy -// backend while the site rendered empty, because the one call it made — -// `/v1/health` — is exempt from the Worker's internal-token check. These cover -// the three states a first deploy actually lands in. - -import type { ApiResponse } from "@linuxhub/shared"; -import { beforeEach, describe, expect, it } from "vitest"; -import { env } from "$env/dynamic/private"; -import type { BffHealth } from "./+server"; -import { GET } from "./+server"; - -const WORKER_UP = { - ok: true, - data: { service: "linuxhub-api", status: "up", version: "0.1.0", db: true, kv: true }, -}; -const NOT_FOUND = { ok: false, error: { code: "NOT_FOUND", message: "not found" } }; - -/** Answers each path from `routes`; anything unlisted 404s like the Worker - * does for an unauthorized caller. */ -function stubFetch(routes: Record) { - return (async (url: string | URL) => { - const path = String(url); - const key = Object.keys(routes).find((k) => path.includes(k)); - return new Response(JSON.stringify(key ? routes[key] : NOT_FOUND), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }) as unknown as typeof fetch; -} - -// biome-ignore lint/suspicious/noExplicitAny: the handler reads only `fetch`. -const event = (fetchImpl: typeof fetch) => ({ fetch: fetchImpl }) as any; - -async function health(fetchImpl: typeof fetch): Promise { - const response = await GET(event(fetchImpl)); - const body = (await response.json()) as ApiResponse; - if (!body.ok) throw new Error("health must always answer a success envelope"); - return body.data; -} - -beforeEach(() => { - env.LINUXHUB_API_URL = "https://api.test"; - env.INTERNAL_API_TOKEN = "secret"; -}); - -describe("GET /api/v1/health", () => { - it("reports snapshot mode when no Worker is configured", async () => { - env.LINUXHUB_API_URL = ""; - const body = await health(stubFetch({})); - - expect(body.mode).toBe("snapshot"); - // There is no token to be wrong, so the question does not apply. - expect(body.authorized).toBeNull(); - expect(body.distros).toBeNull(); - }); - - it("catches a token mismatch, which /v1/health alone cannot see", async () => { - // The Worker exempts /v1/health from its token check, so it answers 200 - // while every gated route 404s. Before the gated probe this deploy - // reported status "up" with a completely unusable catalog. - const body = await health(stubFetch({ "/v1/health": WORKER_UP })); - - expect(body.worker?.status).toBe("up"); - expect(body.authorized).toBe(false); - expect(body.catalog_error).toBe("NOT_FOUND"); - expect(body.status).toBe("degraded"); - }); - - it("does not blame the token for a failure behind it", async () => { - // A dead D1 binding makes the gated call answer INTERNAL. It got past - // the token; calling that "unauthorized" sent a real debugging session - // after the wrong thing. - const body = await health( - stubFetch({ - "/v1/health": { - ok: true, - data: { ...WORKER_UP.data, status: "degraded", db: false }, - }, - "/v1/distros": { ok: false, error: { code: "INTERNAL", message: "internal error" } }, - }), - ); - - expect(body.db).toBe(false); - expect(body.authorized).toBe(true); - expect(body.catalog_error).toBe("INTERNAL"); - }); - - it("separates an unseeded catalog from an unauthorized one", async () => { - const body = await health( - stubFetch({ - "/v1/health": WORKER_UP, - "/v1/distros": { ok: true, data: [], meta: { page: 1, limit: 1, total: 0 } }, - }), - ); - - expect(body.authorized).toBe(true); - expect(body.distros).toBe(0); // crons have not run yet - expect(body.status).toBe("up"); - }); - - it("reports a healthy, seeded backend", async () => { - const body = await health( - stubFetch({ - "/v1/health": WORKER_UP, - "/v1/distros": { ok: true, data: [{}], meta: { page: 1, limit: 1, total: 12 } }, - }), - ); - - expect(body).toMatchObject({ status: "up", db: true, kv: true, authorized: true, distros: 12 }); - }); - - it("still answers when the Worker is unreachable", async () => { - const unreachable = (async () => { - throw new Error("ECONNREFUSED"); - }) as unknown as typeof fetch; - - const body = await health(unreachable); - expect(body.status).toBe("degraded"); - // Unknown, not refused — the call never reached the token check. - expect(body.authorized).toBeNull(); - expect(body.catalog_error).toBe("UNREACHABLE"); - }); -}); diff --git a/apps/web/src/routes/api/v1/quiz/+server.ts b/apps/web/src/routes/api/v1/quiz/+server.ts deleted file mode 100644 index 457a7d3..0000000 --- a/apps/web/src/routes/api/v1/quiz/+server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ok } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { QUIZ } from "$lib/server/data"; -import type { RequestHandler } from "./$types"; - -// Distro-finder question set (.ai/api.md #49). -export const GET: RequestHandler = () => json(ok(QUIZ)); diff --git a/apps/web/src/routes/api/v1/rankings/+server.ts b/apps/web/src/routes/api/v1/rankings/+server.ts deleted file mode 100644 index de07787..0000000 --- a/apps/web/src/routes/api/v1/rankings/+server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ok } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { DISTROS } from "$lib/server/data"; -import type { RequestHandler } from "./$types"; - -// Rankings (.ai/api.md #32) — deterministic mock sparkline per distro. -function spark(seed: number, trend: number): string { - const pts: string[] = []; - for (let i = 0; i < 9; i++) { - const wobble = ((seed * (i + 3)) % 5) - 2; - const drift = trend === 0 ? 0 : -trend * (i / 8) * 8; - const y = Math.min(21, Math.max(3, 12 + wobble + drift)); - pts.push(`${i * 11},${Math.round(y)}`); - } - return pts.join(" "); -} - -export const GET: RequestHandler = () => { - const entries = [...DISTROS] - .sort((a, b) => a.rank - b.rank) - .map((d, i) => ({ - rank: d.rank, - slug: d.slug, - name: d.name, - familyLine: d.familyLine, - color: d.color, - initials: d.initials, - logo: d.logo, - trend: d.trend, - spark: spark(d.slug.length + i, d.trend), - })); - const movers = [...entries] - .filter((e) => e.trend !== 0) - .sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend)) - .slice(0, 3); - return json(ok({ entries, movers })); -}; diff --git a/apps/web/src/routes/api/v1/releases/recent/+server.ts b/apps/web/src/routes/api/v1/releases/recent/+server.ts deleted file mode 100644 index 0ee29de..0000000 --- a/apps/web/src/routes/api/v1/releases/recent/+server.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { type ApiMeta, ok, type RecentRelease } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { getDistro, RECENT_RELEASES } from "$lib/server/data"; -import { CACHE, callWorker, workerConfigured } from "$lib/server/worker"; -import type { RequestHandler } from "./$types"; - -// Cross-distro recent releases (.ai/api.md #12). -// -// The shape changed with this move, on purpose. It used to return -// `title: "Fedora 44"` and `subtitle: "Long-term support release"` — English -// composed on the server, which no amount of locale routing could translate. -// It now returns facts (`name`, `version`, `channel`, `lts`) and the page -// composes the label through @linuxhub/i18n (ADR-0020). -export const GET: RequestHandler = async ({ fetch, url }) => { - const limit = url.searchParams.get("limit") ?? "10"; - - if (workerConfigured()) { - const upstream = await callWorker( - `/v1/releases/recent?limit=${encodeURIComponent(limit)}`, - fetch, - ); - if (!upstream.ok) return json(upstream, { status: 502 }); - return json(ok(upstream.data, upstream.meta), { - headers: { "Cache-Control": CACHE.list }, - }); - } - - // Snapshot fallback — same shape, so the page cannot tell the two apart. - const rows: RecentRelease[] = RECENT_RELEASES.map((r) => { - const distro = getDistro(r.slug); - return { - slug: r.slug, - name: distro?.name ?? r.slug, - version: r.title.replace(`${distro?.name ?? ""} `, ""), - channel: "stable" as const, - lts: r.subtitle.toLowerCase().includes("long-term"), - released_at: r.date, - notes_url: null, - }; - }).slice(0, Number(limit) || 10); - - const meta: ApiMeta = { limit: Number(limit) || 10, next_cursor: null }; - return json(ok(rows, meta), { headers: { "Cache-Control": CACHE.list } }); -}; diff --git a/apps/web/src/routes/api/v1/search/+server.ts b/apps/web/src/routes/api/v1/search/+server.ts deleted file mode 100644 index 3487921..0000000 --- a/apps/web/src/routes/api/v1/search/+server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { type Distro, ok, searchQuery } from "@linuxhub/shared"; -import { json } from "@sveltejs/kit"; -import { toSharedDistro } from "$lib/server/adapt"; -import { DISTROS } from "$lib/server/data"; -import { CACHE, callWorker, workerConfigured } from "$lib/server/worker"; -import type { RequestHandler } from "./$types"; - -// Search (.ai/api.md #22). -export const GET: RequestHandler = async ({ fetch, url }) => { - const raw = url.searchParams.get("q") ?? ""; - - // An empty query is a legitimate state — the search page and the command - // palette both render before anyone types — so it answers with an empty - // result set rather than the VALIDATION_ERROR the Worker's schema would - // (correctly) raise for a missing `q`. - if (raw.trim().length === 0) { - return json(ok([] as Distro[], { total: 0 }), { headers: { "Cache-Control": CACHE.search } }); - } - - if (workerConfigured()) { - const parsed = searchQuery.safeParse(Object.fromEntries(url.searchParams)); - if (!parsed.success) { - return json(ok([] as Distro[], { total: 0 }), { - headers: { "Cache-Control": CACHE.search }, - }); - } - const upstream = await callWorker( - `/v1/search?q=${encodeURIComponent(parsed.data.q)}&limit=${parsed.data.limit}`, - fetch, - ); - if (!upstream.ok) return json(upstream, { status: 502 }); - return json(ok(upstream.data, upstream.meta), { headers: { "Cache-Control": CACHE.search } }); - } - - // Snapshot fallback — the contains-match equivalent of the LIKE strategy - // (ADR-0010) the Worker runs in SQL. - const q = raw.trim().toLowerCase(); - const results = DISTROS.filter( - (d) => - d.name.toLowerCase().includes(q) || - d.summary.toLowerCase().includes(q) || - d.familyLine.toLowerCase().includes(q), - ).map(toSharedDistro); - - return json(ok(results, { total: results.length }), { - headers: { "Cache-Control": CACHE.search }, - }); -}; diff --git a/apps/web/src/routes/robots.txt/+server.ts b/apps/web/src/routes/robots.txt/+server.ts deleted file mode 100644 index bf2281d..0000000 --- a/apps/web/src/routes/robots.txt/+server.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { RequestHandler } from "./$types"; - -// robots.txt. A site whose own rules demand that *we* respect robots.txt -// (.ai/security.md § "Crawler ethics") should publish one. -// -// The only disallowed prefix is /api/ — the BFF. It is not secret, but it is -// machine surface with no crawl value, and `downloads/resolve` is a POST that -// costs a database lookup and a rate-limit slot per call. -export const GET: RequestHandler = ({ url }) => { - const body = [ - "User-agent: *", - "Allow: /", - "Disallow: /api/", - "", - `Sitemap: ${url.origin}/sitemap.xml`, - "", - ].join("\n"); - - return new Response(body, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "public, max-age=3600", - }, - }); -}; diff --git a/apps/web/src/routes/sitemap.xml/+server.ts b/apps/web/src/routes/sitemap.xml/+server.ts deleted file mode 100644 index 57e6fae..0000000 --- a/apps/web/src/routes/sitemap.xml/+server.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { BASE_LOCALE, TRANSLATED_LOCALES } from "@linuxhub/i18n"; -import type { ApiResponse, Distro } from "@linuxhub/shared"; -import type { RequestHandler } from "./$types"; - -// Sitemap (.ai/api.md #59 — "served by the web app directly"). -// -// Only the locales that actually have a message catalog are listed. The -// registry carries ~57, but a locale with no catalog renders English behind a -// translated URL, and telling a search engine that `/de/explore` is German -// when it is English is the same class of claim as a hand-typed fact. -// Locales join this list by gaining a catalog, not by being added here. - -/** Routes with stable, indexable content. `/search` is query-driven and - * `/distro/` is added per distro below. */ -const STATIC_PATHS = [ - "/", - "/explore", - "/rankings", - "/hall-of-fame", - "/quiz", - "/compare", - "/about", - "/contribute", -] as const; - -function localizedPath(locale: string, path: string): string { - if (locale === BASE_LOCALE) return path; - return path === "/" ? `/${locale}` : `/${locale}${path}`; -} - -function escapeXml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -/** One per path per locale, each carrying the full alternate set — which - * is what xhtml:link requires: every variant must list every variant. */ -function urlEntry(origin: string, path: string, lastmod?: string): string { - const alternates = TRANSLATED_LOCALES.map( - (locale) => - ` `, - ).join("\n"); - - return TRANSLATED_LOCALES.map((locale) => { - const loc = escapeXml(origin + localizedPath(locale, path)); - return [ - " ", - ` ${loc}`, - lastmod ? ` ${escapeXml(lastmod)}` : "", - alternates, - ` `, - " ", - ] - .filter(Boolean) - .join("\n"); - }).join("\n"); -} - -export const GET: RequestHandler = async ({ fetch, url }) => { - // The catalog comes from the BFF, so the sitemap lists what the site - // actually serves rather than a second, drifting list of slugs. - let distros: Distro[] = []; - try { - const response = await fetch("/api/v1/distros?limit=100"); - const body = (await response.json()) as ApiResponse; - if (body.ok) distros = body.data; - } catch { - // A sitemap missing its distro pages beats a 500 on /sitemap.xml: the - // static routes are still worth serving, and crawlers retry. - } - - const entries = [ - ...STATIC_PATHS.map((path) => urlEntry(url.origin, path)), - ...distros.map((distro) => - urlEntry( - url.origin, - `/distro/${distro.slug}`, - distro.latest_release?.released_at ?? undefined, - ), - ), - ]; - - const xml = [ - '', - '', - ...entries, - "", - "", - ].join("\n"); - - return new Response(xml, { - headers: { - "Content-Type": "application/xml; charset=utf-8", - "Cache-Control": "public, max-age=3600", - }, - }); -}; diff --git a/apps/web/static/distros/arch.svg b/apps/web/static/distros/arch.svg deleted file mode 100644 index ca8204c..0000000 --- a/apps/web/static/distros/arch.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/static/distros/debian.svg b/apps/web/static/distros/debian.svg deleted file mode 100644 index 685f632..0000000 --- a/apps/web/static/distros/debian.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/apps/web/static/distros/elementary.svg b/apps/web/static/distros/elementary.svg deleted file mode 100644 index c368af7..0000000 --- a/apps/web/static/distros/elementary.svg +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/apps/web/static/distros/endeavouros.svg b/apps/web/static/distros/endeavouros.svg deleted file mode 100644 index 2feec32..0000000 --- a/apps/web/static/distros/endeavouros.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - EndeavourOS Logo - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/static/distros/fedora.svg b/apps/web/static/distros/fedora.svg deleted file mode 100644 index 40668e2..0000000 --- a/apps/web/static/distros/fedora.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/apps/web/static/distros/linux-mint.svg b/apps/web/static/distros/linux-mint.svg deleted file mode 100644 index afb3524..0000000 --- a/apps/web/static/distros/linux-mint.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - diff --git a/apps/web/static/distros/manjaro.svg b/apps/web/static/distros/manjaro.svg deleted file mode 100644 index 504946e..0000000 --- a/apps/web/static/distros/manjaro.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/static/distros/nixos.svg b/apps/web/static/distros/nixos.svg deleted file mode 100644 index a40c4f7..0000000 --- a/apps/web/static/distros/nixos.svg +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/web/static/distros/opensuse.svg b/apps/web/static/distros/opensuse.svg deleted file mode 100644 index 571d9b3..0000000 --- a/apps/web/static/distros/opensuse.svg +++ /dev/null @@ -1,2 +0,0 @@ - -openSUSE logo diff --git a/apps/web/static/distros/pop-os.svg b/apps/web/static/distros/pop-os.svg deleted file mode 100644 index 8fdef5f..0000000 --- a/apps/web/static/distros/pop-os.svg +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - image/svg+xml - - - - - - - - - - - - - diff --git a/apps/web/static/distros/ubuntu.svg b/apps/web/static/distros/ubuntu.svg deleted file mode 100644 index f217bc8..0000000 --- a/apps/web/static/distros/ubuntu.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/apps/web/static/distros/zorin.svg b/apps/web/static/distros/zorin.svg deleted file mode 100644 index f0f89a9..0000000 --- a/apps/web/static/distros/zorin.svg +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/static/favicon.svg b/apps/web/static/favicon.svg deleted file mode 100644 index 81b4daa..0000000 --- a/apps/web/static/favicon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - Linuxhub - - - - - diff --git a/apps/web/static/theme.js b/apps/web/static/theme.js deleted file mode 100644 index 3378b4d..0000000 --- a/apps/web/static/theme.js +++ /dev/null @@ -1,16 +0,0 @@ -// Theme before first paint: stored choice wins, then system preference. -// -// A separate file rather than an inline - - - -
-
-

Linuxhub design comps

-

Flathub-clone system (ADR-0013) · 15 screens · light/dark · responsive · RTL. Toggle theme and direction with the floating buttons. Each screen ends with its loading/empty/error spec. See design/README.md.

-
- -
- - diff --git a/design/screens/about.html b/design/screens/about.html deleted file mode 100644 index 6d60ebc..0000000 --- a/design/screens/about.html +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - About — Linuxhub design comp - - - - - -
-
-

About Linuxhub

-
- -
-
-
-

What is Linuxhub

-

Linuxhub is a catalog of (almost) every Linux distribution — releases, editions, system requirements, and downloads from trusted mirrors, in one place and in your language.

-

We don't host ISO files. Every download resolves to an official mirror chosen for your region, with the checksum right next to the button.

-
- -
-

How rankings work

-

Rankings come from our own signals only: page views, download clicks, and release recency. We deliberately don't scrape third-party popularity charts — the method is simple enough to explain in one sentence, and that's the point.

-
- -
-

Where the data comes from

-

Release and mirror data is fetched from each project's official sources — release APIs, announcement pages, and mirror manifests — on a regular schedule. Every ingested record keeps its source URL and fetch time.

-
    -
  • Official project APIs and release pages, always preferred
  • -
  • Mirror directories like Fedora MirrorManager and Arch mirror status
  • -
  • Wikidata for structured metadata, checked weekly
  • -
-
- -
-

Content & trademarks

-

Descriptions and guides are written in our own words after reading each project's official documentation, and every page cites its sources. Distro names and logos belong to their projects; logos are used per each project's brand guidelines, with attribution. Spot something wrong? Report it.

-
- -
-

Crawler policy

-

Our fetcher identifies itself as linuxhub-ingest with a link to this section, respects robots.txt, sends conditional requests, and never exceeds one request per second per host. If you run an official distro resource and want different limits, write to crawler@linuxhub.example.

-
- -
-

Contact & contribute

-

Linuxhub is built in the open.

- -
-
- - -
- -
-

Design spec — notes

-
-
-
Anchor highlight
-
-

The jumped-to section tints briefly, then fades. No motion under reduced-motion.

-
-
-
-
Mobile ToC
-
- -

The rail collapses into this menu below 1024px.

-
-
-
-
Prose is canonical
-
-

This page's typography (.prose) is the same system used for distro content — designed once, reused.

-
-
-
-
-
- - diff --git a/design/screens/category-tag.html b/design/screens/category-tag.html deleted file mode 100644 index c830368..0000000 --- a/design/screens/category-tag.html +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - Category page — Linuxhub design comp - - - - - -
-
- -
-

For beginners

-

Installs easily, looks familiar, rarely needs a terminal

-
- 46 distributions -
- - - -
- - - More filters in Explore - - -
- - - - - -
-

Variant — tag header (plainer, no icon)

-
-

#rolling-release

-

Distributions that update continuously instead of shipping numbered versions. 31 distributions.

-
-
- -
-

Design spec — states

-
-
-
Loading
-
-
-
-
-
-
Empty — bare tag
-
-
- -
Nothing tagged yet
- Browse Explore -
-
-
-
-
Error
-
-
- -
Couldn't load this category
- -
-
-
-
-
-
- - diff --git a/design/screens/compare.html b/design/screens/compare.html deleted file mode 100644 index 69c56ca..0000000 --- a/design/screens/compare.html +++ /dev/null @@ -1,205 +0,0 @@ - - - - - - Compare — Linuxhub design comp - - - - - -
-
-

Compare

-

Put 2–4 distributions side by side. Differences are tinted; identical rows fade back.

-
- -
-
- U - Ubuntu - -
-
- F - Fedora - -
-
- A - Arch Linux - -
- -
- -
- - 4 identical rows hidden -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
UbuntuFedoraArch Linux
FamilyDebianRPMArch
Release modelFixed — LTS every 2 yearsFixed — every 6 monthsRolling
Latest version26.04 LTS · Apr 202642 · Apr 2026rolling · daily
Default desktopGNOMEGNOMEnone — you choose
Package managerapt + snapdnf + Flatpakpacman + AUR
Min. memory4 GB2 GB512 MB
Popularity#1 · 1.2M downloads#2 · 842K downloads#4 · 689K downloads
-
- -
-

Design spec — states

-
-
-
Loading
-
-
-
-
-
-
Empty — pick at least two
-
-
- -
Pick at least two distros
-

Try Ubuntu vs Fedora, or Arch vs Debian.

- -
-
-
-
-
One column failed
-
-
- -
Couldn't load Fedora
-

Other columns are unaffected.

- -
-
-
-
-
-
- - diff --git a/design/screens/contribute.html b/design/screens/contribute.html deleted file mode 100644 index e01860d..0000000 --- a/design/screens/contribute.html +++ /dev/null @@ -1,206 +0,0 @@ - - - - - - Contribute — Linuxhub design comp - - - - - -
-
-
-

Contribute

-

Suggest a distro, report something broken, or send feedback. Contributions are anonymous, reviewed by maintainers, and protected by a quick captcha — no account needed.

-
- -
- - - -
- -
-
- - -
-
- - -

- - Enter a full URL, like https://vanillaos.org -

-
-
- - -

A sentence or two is plenty. 0 / 500

-
-
- - -
-
- One quick check -
- - hCaptcha widget renders here — no account needed -
-
- -
- - - - -
- -
-

Design spec — states

-
-
-
Success
-
-
- -
Thanks — we review every submission
-
- - Back to Explore -
-
-
-
-
-
Rate limited (429)
-
-
- - Too many submissions — try again in a minute. -
-
-
-
-
Captcha loading / failed
-
-
-
- - Captcha check failed — please try once more. -
-
-
-
-
-
- - diff --git a/design/screens/distro-detail.html b/design/screens/distro-detail.html deleted file mode 100644 index 8696172..0000000 --- a/design/screens/distro-detail.html +++ /dev/null @@ -1,309 +0,0 @@ - - - - - - Distro detail — Linuxhub design comp - - - - - -
-
-
- F -
-

Fedora

-

Fast-moving, polished, sponsored by Red Hat

-
- RPM family - Active - English fallback -
-
-
- - - Official site - - -
-
- -
- - -
- -
-
- - 842K - Downloads -
-
- - 42 - Latest · Apr 2026 -
-
- - 2.1 GB - Workstation ISO -
-
- - #2 - Rank this month -
-
- - x86_64 +2 - Architectures -
-
- -
-
- - - -
- -
-

Fedora is where much of the Linux desktop's future ships first. Backed by Red Hat and built by a large community, it delivers a new release roughly every six months with the latest stable GNOME, kernel, and developer toolchains — while staying reliable enough for daily work.

-

Who it's for

-

Developers and enthusiasts who want current software without living on a rolling release. If you want this year's GNOME with sensible defaults and SELinux enabled out of the box, Fedora Workstation is the reference experience.

-

Editions

-

Workstation (GNOME) is the flagship. KDE Plasma, Xfce, and other desktops ship as official spins, Server and IoT target headless machines, and the Atomic desktops (Silverblue, Kinoite) offer image-based, rollback-friendly systems.

-
$ sudo dnf upgrade --refresh
-

Written from the official Fedora documentation. Sources: docs.fedoraproject.org · fedoramagazine.org — last reviewed 2026-07-02.

-
- - -
- -
-

Releases

-
-
- - Fedora 42 - Stable · supported until May 2027 - - 2026-04-22 latest -
-
- - Fedora 41 - Stable · end of life Nov 2026 - - 2025-10-29 -
-
- - Fedora 40 - End of life - - 2025-04-23 -
-
-
- -
-

System requirements

-
- - - - - - - - - - -
MinimumRecommended
Processor2 GHz dual-coreQuad-core
Memory2 GB8 GB
Storage15 GB40 GB SSD
FirmwareUEFI or BIOSUEFI + Secure Boot
-
-
- -
-

Links

- -
- -
-

Similar distributions

See more
- -
-
- - - - -
-

Design spec — states

-
-
-
Loading
-
-
-
-
-
-
-
-
Empty — doc not written
-
-
- -
Not written yet
-

Read the official docs meanwhile.

- -
-
-
-
-
Error — section failed
-
-
- -
Couldn't load screenshots
-

The rest of the page is unaffected.

- -
-
-
-
-
-
- - diff --git a/design/screens/download-selector.html b/design/screens/download-selector.html deleted file mode 100644 index 14c2f35..0000000 --- a/design/screens/download-selector.html +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - Downloads — Linuxhub design comp - - - - - -
-
-
- F -
-

Fedora — Downloads

-

Modrinth-style version list (ADR-0014). Lives on the distro page's Downloads section; same layout inside the dialog on mobile.

-
-
- -
- - - - -
- -
- -
- -
- Fedora 42 - release - latest - - - Workstation · x86_64 · .iso - supported until May 2027 - -
-
- 2026-04-22 - 2.1 GB - 412K downloads -
-
-
- Mirrors - - - - -
-
- SHA-256 - -
- a1b6f4de8c2e4b0f6c9d3a5e7f8091b2c3d4e5f60718293a4b5c6d7e8f901a2b -
- -
- -
- -
- Fedora 41 - release - - - Workstation · x86_64 · .iso - end of life Nov 2026 - -
-
- 2025-10-29 - 2.0 GB - 1.1M downloads -
-
- -
- -
- Fedora 43 Beta - beta - - - Workstation · x86_64 · .iso - pre-release — for testing only - -
-
- 2026-09-16 - 2.2 GB - 18K downloads -
-
- -
- -
- Fedora 40 - end of life - - - Workstation · x86_64 · .iso - no security updates — not recommended - -
-
- 2025-04-23 - 2.0 GB - 2.3M downloads -
-
-
- -

- Clicking the round button downloads with the current filters + selected mirror; clicking the row expands mirror/checksum details. The last selection is remembered. -

-
- -
-

Design spec — states

-
-
-
Loading versions
-
-
-
-
-
-
-
-
No match for filters
-
-
- -
No files for this combination
-

Silverblue isn't built for riscv64.

- -
-
-
-
-
Resolve failed
-
-
- -
Couldn't resolve this download
-
- - -
-
-
-
-
-
-
- - diff --git a/design/screens/error-404.html b/design/screens/error-404.html deleted file mode 100644 index 0eedafc..0000000 --- a/design/screens/error-404.html +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - 404 / 500 — Linuxhub design comp - - - - - -
-
-

Variant — 404 not found

-
- -

Page not found

-

The distro may have been renamed, or the link is stale.

- -
- Back to home - -
-
-
- -
-

Variant — 500 something broke

-
- -

Something went wrong

-

It's logged on our side. Trying again usually works.

-
- request id: req_8f4a2c91 - -
-
- - Back to home -
-
-
- -
-

Design spec — notes

-
-
-
Focus on arrival
-
-

The headline (h1) receives focus on navigation so screen readers announce the error immediately.

-
-
-
-
500 independence
-
-

The 500 variant renders with zero data dependencies — it must work when everything else failed.

-
-
-
-
Motion
-
-

One quiet fade on entry. Nothing else.

-
-
-
-
-
- - diff --git a/design/screens/explore.html b/design/screens/explore.html deleted file mode 100644 index 311c446..0000000 --- a/design/screens/explore.html +++ /dev/null @@ -1,251 +0,0 @@ - - - - - - Explore — Linuxhub design comp - - - - - -
-
-

Explore

-
- - - - - -
-

Design spec — states

-
-
-
Loading
-
-
-
-
-
-
-
Empty — no matches
-
-
- -
No distributions match
-

Try removing a filter.

- -
-
-
-
-
Error
-
-
- -
Couldn't load the catalog
- -
-
-
-
-
-
- - diff --git a/design/screens/global-nav.html b/design/screens/global-nav.html deleted file mode 100644 index f0a875f..0000000 --- a/design/screens/global-nav.html +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - Global nav — Linuxhub design comp - - - - - -
-
-

Global navigation

-

The live header (wordmark · search entry · nav · locale · theme) is at the top of every comp page; the dark footer at the bottom. This page specifies the states. Use the floating review buttons to check dark theme and RTL.

-
- - -
- - diff --git a/design/screens/hall-of-fame.html b/design/screens/hall-of-fame.html deleted file mode 100644 index 22658d0..0000000 --- a/design/screens/hall-of-fame.html +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - Hall of Fame — Linuxhub design comp - - - - - -
-
-
-

Hall of Fame

-

The distributions that shaped Linux itself — curated with reasons and sources, not a popularity contest. For what's popular now, see the rankings.

-
- -
-
-
- S -
-

Slackware

-
1993 — present
-
-
-

The oldest surviving distribution. Its insistence on simplicity and vanilla upstream software defined a whole philosophy of Linux — one still audible in every "keep it simple" argument today.

-

Sources: slackware.com · LWN retrospective (2023)

-
- -
-
- D -
-

Debian

-
1993 — present
-
-
-

The universal operating system and the root of the largest family tree in Linux. Its Social Contract and packaging culture became the backbone for hundreds of derivatives, Ubuntu included.

-

Sources: debian.org/social_contract · debian.org/history

-
- -
-
- RH -
-

Red Hat Linux

-
1995 — 2004
-
- Discontinued -
-

Proved that free software could carry an enterprise. RPM, Anaconda, and the support model it pioneered live on in RHEL, CentOS's successors, and Fedora.

-

Sources: redhat.com/history · Wikipedia (Red Hat Linux)

-
- -
-
- Md -
-

Mandrake Linux

-
1998 — 2011
-
- Discontinued -
-

The first distribution that treated desktop usability as the product. Graphical installers and friendly defaults that feel obvious today were Mandrake inventions first.

-

Sources: Wikipedia (Mandriva) · archive.org press coverage

-
- -
-
- G -
-

Gentoo

-
2002 — present
-
-
-

Source-based and endlessly configurable — the training ground for a generation of kernel and toolchain developers. Portage made "compile everything, your way" a coherent system.

-

Sources: gentoo.org · Gentoo Wiki history

-
- -
-
- U -
-

Ubuntu

-
2004 — present
-
-
-

"Linux for human beings" — the release that took desktop Linux mainstream, shipped free CDs worldwide, and set the six-month cadence much of the ecosystem still keeps time by.

-

Sources: ubuntu.com/about · launch announcement (2004)

-
-
-
- -
-

Design spec — states

-
-
-
Loading
-
-
-
-
-
-
Empty
-
-
- -
Curation in progress
-
-
-
-
-
Error
-
-
- -
Couldn't load the hall
- -
-
-
-
-
-
- - diff --git a/design/screens/home.html b/design/screens/home.html deleted file mode 100644 index 5cab546..0000000 --- a/design/screens/home.html +++ /dev/null @@ -1,276 +0,0 @@ - - - - - - Home — Linuxhub design comp - - - - - -
- - - - -

Trending

See more
- - -

Popular

See more
- - -

Recently updated

See more
- - -

Not sure where to start?

- - -
-

Design spec — states

-
-
-
Loading
-
-
-
-
-
-
-
Empty section
-
-

A section with no data is removed entirely. The banner falls back to the top popular distro.

-
-
-
-
Error
-
-
- -
Couldn't load this section
- -
-
-
-
-
-
- - diff --git a/design/screens/mobile-nav.html b/design/screens/mobile-nav.html deleted file mode 100644 index c0f1c17..0000000 --- a/design/screens/mobile-nav.html +++ /dev/null @@ -1,225 +0,0 @@ - - - - - - Mobile nav — Linuxhub design comp - - - - - -
-
-

Mobile navigation

-

Three frames at 320px: the drawer, the shared bottom-sheet pattern (filters + download selector reuse it), and the full-screen search overlay. The drawer is also live — resize under 640px and tap the hamburger. RTL flips the drawer edge.

-
- -
-
-

Drawer open

-
-
- - Linuxhub - -
-
- -
-
- -
-

Bottom sheet — filters

-
-
- - Linuxhub - -
-
-
- -
-
-
-

Family

-
- - - -
-
-
-

Desktop

-
- - -
-
-
-
-
- -
-
-
-
- -
-

Search overlay

-
- -
-
-
- -
-

Design spec — notes

-
-
-
Gestures
-
-

Sheet dismisses by handle-drag or scrim tap — always with a visible Cancel equivalent. Reduced motion: fade only.

-
-
-
-
Focus
-
-

Drawer and sheets trap focus; Esc/back closes; focus returns to the opener. Hamburger carries aria-expanded.

-
-
-
-
RTL
-
-

The drawer enters from the end edge in RTL automatically (logical positioning) — verify with the review toggle.

-
-
-
-
-
- - diff --git a/design/screens/quiz.html b/design/screens/quiz.html deleted file mode 100644 index 08003c0..0000000 --- a/design/screens/quiz.html +++ /dev/null @@ -1,241 +0,0 @@ - - - - - - Distro finder — Linuxhub design comp - - - - - -
-
-

Distro finder — flow frames

-
- -
-
-

Intro

-
-

Find your distro

-

Seven quick questions about how you work. We'll match you with three distributions that fit — no jargon until the results.

-

Takes under a minute.

-
- - Just browse -
-
-
- -
-

Question 3 of 7 — single select, auto-advances

-
-
-
- - 3 / 7 -
-
-
-
-

How do you feel about updates?

-
-
- - - - -
- -
-
- -
-

Result — top match + runner-ups

-
-
- Your top match - F -

Fedora

-

You wanted current software without daily churn, a polished GNOME desktop, and sensible security defaults — Fedora ships exactly that, twice a year.

- -
- -
- - Compare these three -
-
-
-
- -
-

Design spec — states

-
-
-
Matching…
-
-
-
-
Matching…
-
-
-
-
-
No strong match
-
-
- -
No single stand-out
-

Your answers fit many distros — browse with these filters instead.

- Open Explore -
-
-
-
-
Submit failed
-
-
- -
Couldn't compute your match
-

Your answers are kept.

- -
-
-
-
-
-
- - diff --git a/design/screens/rankings.html b/design/screens/rankings.html deleted file mode 100644 index 3d1a1ec..0000000 --- a/design/screens/rankings.html +++ /dev/null @@ -1,211 +0,0 @@ - - - - - - Rankings — Linuxhub design comp - - - - - -
- - -
-

Design spec — states

-
-
-
Loading
-
-
-
-
-
-
-
-
Empty — young period
-
-
- -
Not enough data yet
-

Weekly rankings need a few more days of signals.

-
-
-
-
-
Error
-
-
- -
Couldn't load rankings
- -
-
-
-
-
-
- - diff --git a/design/screens/search-results.html b/design/screens/search-results.html deleted file mode 100644 index 7d93072..0000000 --- a/design/screens/search-results.html +++ /dev/null @@ -1,231 +0,0 @@ - - - - - - Search — Linuxhub design comp - - - - - -
- - -
-

Command palette (⌘K)

-
- -
-
- -
-

Design spec — states

-
-
-
Loading
-
-
-
-
-
-
-
Empty — no matches
-
-
- -
No matches for “xyz”
-

Check the spelling, or suggest this distro.

- -
-
-
-
-
Error
-
-
- -
Search is unavailable
- -
-
-
-
-
-
- - diff --git a/design/shared/design.css b/design/shared/design.css deleted file mode 100644 index 44e8464..0000000 --- a/design/shared/design.css +++ /dev/null @@ -1,2452 +0,0 @@ -/* - * Linuxhub design comps — Flathub-clone component styles (ADR-0013) - * Built strictly on packages/ui tokens. Flat surfaces, pill buttons, - * boxed lists. Logical properties throughout so dir="rtl" mirrors. - */ - -@import url("../../packages/ui/src/tokens.css"); - -/* ---------------------------------------------------------------- base */ - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html { - color-scheme: light dark; -} - -body { - margin: 0; - background: var(--color-bg); - color: var(--color-text); - font-family: var(--font-sans); - font-size: var(--text-base); - line-height: var(--leading-base); - -webkit-font-smoothing: antialiased; -} - -/* Mica — a fixed, desaturated accent wash beneath every layer (WinUI base) */ -body::before { - content: ""; - position: fixed; - inset: 0; - z-index: -1; - pointer-events: none; - background-color: var(--color-bg); - background-image: - var(--mica-noise), radial-gradient(125% 70% at 50% 0%, var(--mica-tint), transparent 72%), - radial-gradient(90% 60% at 85% 100%, var(--mica-tint-2), transparent 70%); - background-size: - 160px 160px, - cover, - cover; -} - -h1, -h2, -h3, -h4, -p { - margin: 0; -} - -h1, -h2, -h3 { - font-family: var(--font-display); - font-weight: var(--weight-strong); - letter-spacing: var(--tracking-tight); -} - -a { - color: var(--color-link); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -:focus-visible { - outline: 2px solid var(--focus-outer); - outline-offset: 2px; - border-radius: var(--radius-sm); -} - -/* Containers that clip their children draw the ring inside instead */ -.boxed :focus-visible, -.vfiles :focus-visible, -.locale-popover :focus-visible { - outline-offset: -3px; -} - -::selection { - background: var(--color-accent-soft); -} - -.container { - max-inline-size: var(--content-max-width); - margin-inline: auto; - padding-inline: var(--space-4); -} - -@media (min-width: 640px) { - .container { - padding-inline: var(--space-5); - } -} - -.visually-hidden { - position: absolute; - inline-size: 1px; - block-size: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} - -.mono { - font-family: var(--font-mono); - font-size: var(--text-sm); -} - -.kbd { - display: inline-block; - padding: 1px var(--space-2); - border-radius: var(--radius-sm); - background: var(--color-well); - border: 1px solid var(--color-stroke); - color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: var(--text-xs); - line-height: 1.4; -} - -.muted { - color: var(--color-text-muted); -} - -/* ---------------------------------------------------------------- icons */ - -.icon { - inline-size: 20px; - block-size: 20px; - stroke: currentColor; - stroke-width: 1.5; - fill: none; - stroke-linecap: round; - stroke-linejoin: round; - flex: none; -} - -.icon--sm { - inline-size: 16px; - block-size: 16px; -} - -[dir="rtl"] .icon--flip { - transform: scaleX(-1); -} - -/* -------------------------------------------------------------- buttons */ -/* Fluent controls: layered fill, hairline stroke with a darker bottom lip, - 6px corners, and a press that dims rather than lifts. */ - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - min-block-size: 38px; - padding-inline: var(--space-4); - border: 1px solid var(--color-stroke); - border-block-end-color: var(--color-stroke-lip); - border-radius: var(--radius-sm); - background: var(--color-control); - color: var(--color-text); - box-shadow: var(--shadow-control); - font: inherit; - font-size: var(--text-sm); - font-weight: var(--weight-strong); - line-height: 1; - cursor: pointer; - user-select: none; - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out), - color var(--motion-fast) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.btn:hover { - background: var(--color-control-hover); - text-decoration: none; - color: var(--color-text); -} - -.btn:active { - background: var(--color-control-active); - border-block-end-color: var(--color-stroke); - color: var(--color-text-muted); -} - -.btn:disabled { - opacity: 0.4; - cursor: not-allowed; - box-shadow: none; -} - -.btn--primary { - background: var(--color-accent-fill); - border-color: transparent; - border-block-end-color: rgb(0 0 0 / 0.2); - color: var(--color-on-accent); -} - -.btn--primary:hover { - background: var(--color-accent-fill-hover); - color: var(--color-on-accent); -} - -.btn--primary:active { - background: var(--color-accent-fill-active); - color: color-mix(in srgb, var(--color-on-accent) 78%, transparent); -} - -.btn--danger { - background: var(--color-danger); - border-color: transparent; - border-block-end-color: rgb(0 0 0 / 0.2); - color: #ffffff; -} - -.btn--ghost { - background: transparent; - border-color: transparent; - box-shadow: none; - color: var(--color-link); -} - -.btn--ghost:hover { - background: var(--color-subtle-hover); - color: var(--color-link); -} - -.btn--lg { - min-block-size: 44px; - padding-inline: var(--space-5); - font-size: var(--text-base); -} - -.btn--sm { - min-block-size: 32px; - padding-inline: var(--space-3); - font-size: var(--text-sm); -} - -.btn--block { - inline-size: 100%; -} - -.btn[aria-busy="true"] { - pointer-events: none; - opacity: 0.8; -} - -.icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - inline-size: 40px; - block-size: 40px; - border: 1px solid transparent; - border-radius: var(--radius-sm); - background: transparent; - color: var(--color-text); - cursor: pointer; - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.icon-btn:hover { - background: var(--color-subtle-hover); - border-color: var(--color-stroke); -} - -.icon-btn:active { - background: var(--color-subtle-active); - color: var(--color-text-muted); -} - -/* --------------------------------------------------------------- fields */ -/* Flat gray entries, focus ring only. */ - -.field { - display: grid; - gap: var(--space-2); -} - -.field__label { - font-size: var(--text-sm); - font-weight: 600; -} - -.field__label .optional { - color: var(--color-text-muted); - font-weight: 400; -} - -.input, -.select, -.textarea { - inline-size: 100%; - min-block-size: 40px; - padding-inline: var(--space-3); - background: var(--color-control); - color: var(--color-text); - border: 1px solid var(--color-stroke); - border-block-end-color: var(--color-stroke-strong); - border-radius: var(--radius-sm); - font: inherit; - font-size: var(--text-sm); - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out); -} - -.input:hover, -.select:hover, -.textarea:hover { - background: var(--color-control-hover); -} - -.textarea { - padding-block: var(--space-3); - min-block-size: 120px; - resize: vertical; -} - -.input::placeholder, -.textarea::placeholder { - color: var(--color-text-muted); -} - -.input:focus-visible, -.select:focus-visible, -.textarea:focus-visible { - background: var(--color-subtle-hover); - border-block-end-color: var(--color-accent); - box-shadow: inset 0 -1px 0 0 var(--color-accent); -} - -.input[aria-invalid="true"], -.textarea[aria-invalid="true"] { - border-block-end-color: var(--color-danger); - box-shadow: inset 0 -1px 0 0 var(--color-danger); -} - -.field__hint { - font-size: var(--text-sm); - color: var(--color-text-muted); -} - -.field__error { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-sm); - color: var(--color-danger); -} - -/* header search entry */ - -.search { - position: relative; - display: flex; - align-items: center; -} - -.search .icon { - position: absolute; - inset-inline-start: var(--space-4); - color: var(--color-text-muted); - pointer-events: none; -} - -.search__input { - inline-size: 100%; - min-block-size: 38px; - padding-inline: var(--space-7) var(--space-4); - border: 1px solid var(--color-stroke); - border-block-end-color: var(--color-stroke-strong); - border-radius: var(--radius-sm); - background: var(--color-control); - color: var(--color-text); - font: inherit; - font-size: var(--text-sm); - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out); -} - -.search__input:hover { - background: var(--color-control-hover); -} - -.search__input:focus-visible { - background: var(--color-surface); - border-block-end-color: var(--color-accent); - box-shadow: inset 0 -1px 0 0 var(--color-accent); -} - -/* -------------------------------------------------------- pills & badges */ - -.pill { - display: inline-flex; - align-items: center; - gap: var(--space-2); - min-block-size: 34px; - padding-inline: var(--space-4); - border: 1px solid var(--color-stroke); - border-block-end-color: var(--color-stroke-lip); - border-radius: var(--radius-full); - background: var(--color-control); - color: var(--color-text); - box-shadow: var(--shadow-control); - font: inherit; - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - white-space: nowrap; - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.pill:hover { - background: var(--color-control-hover); - text-decoration: none; - color: var(--color-text); -} - -.pill:active { - background: var(--color-control-active); - color: var(--color-text-muted); -} - -.pill[aria-pressed="true"], -.pill[aria-checked="true"], -.pill--active { - background: var(--color-accent-fill); - border-color: transparent; - color: var(--color-on-accent); -} - -.pill:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.pill__remove { - display: inline-flex; - margin-inline-end: calc(var(--space-2) * -1); - border: 0; - background: none; - color: inherit; - cursor: pointer; - padding: 2px; - border-radius: var(--radius-full); -} - -.badge { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: 2px var(--space-2); - border-radius: var(--radius-full); - background: var(--color-accent-soft); - color: var(--color-accent-text); - font-size: var(--text-xs); - font-weight: 600; - line-height: var(--leading-xs); - white-space: nowrap; -} - -.badge--neutral { - background: color-mix(in srgb, var(--color-text) 8%, transparent); - color: var(--color-text-muted); -} - -.badge--success { - background: color-mix(in srgb, var(--color-success) 15%, transparent); - color: var(--color-success-text); -} - -.badge--warning { - background: color-mix(in srgb, var(--color-warning) 15%, transparent); - color: var(--color-warning-text); -} - -.badge--danger { - background: color-mix(in srgb, var(--color-danger) 15%, transparent); - color: var(--color-danger-text); -} - -.trend { - font-variant-numeric: tabular-nums; - font-size: var(--text-sm); - font-weight: 600; -} - -.trend--up { - color: var(--color-success-text); -} - -.trend--down { - color: var(--color-danger-text); -} - -.trend--flat { - color: var(--color-text-muted); -} - -/* ------------------------------------------------------------ logo tiles */ -/* Placeholder distro marks for comps only (ADR-0012). */ - -.logo-tile { - --tile: var(--color-accent); - display: inline-flex; - align-items: center; - justify-content: center; - inline-size: 64px; - block-size: 64px; - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--tile) 14%, var(--color-surface)); - box-shadow: inset 0 0 0 1px var(--color-stroke); - color: var(--tile); - font-weight: var(--weight-strong); - font-size: var(--text-xl); - letter-spacing: -0.02em; - flex: none; - user-select: none; -} - -.logo-tile--sm { - inline-size: 40px; - block-size: 40px; - font-size: var(--text-base); - border-radius: var(--radius-sm); -} - -.logo-tile--xs { - inline-size: 24px; - block-size: 24px; - font-size: var(--text-xs); - border-radius: 6px; -} - -.logo-tile--lg { - inline-size: 112px; - block-size: 112px; - font-size: var(--text-3xl); - border-radius: var(--radius-xl); -} - -/* -------------------------------------------------- app cards (layered) */ -/* Flathub's horizontal card (icon start, name + summary after) rendered as a - Fluent card: lighter fill than the Mica page, hairline stroke, soft lift. */ - -.app-card { - display: flex; - align-items: center; - gap: var(--space-4); - padding: var(--space-4); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-surface); - box-shadow: var(--shadow-card); - color: inherit; - transition: - background-color var(--motion-base) var(--ease-out), - border-color var(--motion-base) var(--ease-out), - box-shadow var(--motion-base) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.app-card:hover { - background: var(--color-surface-hover); - border-color: var(--color-stroke-strong); - box-shadow: var(--shadow-card-hover); - text-decoration: none; - color: inherit; -} - -.app-card__body { - display: grid; - gap: 2px; - min-inline-size: 0; -} - -.app-card__name { - font-size: var(--text-base); - font-weight: var(--weight-strong); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.app-card__summary { - font-size: var(--text-sm); - color: var(--color-text-muted); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.app-card__meta { - font-size: var(--text-xs); - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; -} - -.card-grid { - display: grid; - grid-template-columns: 1fr; - gap: var(--space-3); -} - -@media (min-width: 640px) { - .card-grid { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (min-width: 1024px) { - .card-grid { - grid-template-columns: repeat(3, 1fr); - } -} - -/* ------------------------------------------------------------ banner tile */ -/* Distro-of-the-day, Flathub app-banner style: flat brand tint. */ - -.banner-tile { - --tile: var(--color-accent); - display: flex; - align-items: center; - gap: var(--space-6); - padding: var(--space-6) var(--space-7); - border-radius: var(--radius-lg); - background: linear-gradient( - 125deg, - color-mix(in srgb, var(--tile) 32%, var(--color-surface)), - color-mix(in srgb, var(--tile) 10%, var(--color-surface)) 70% - ); - border: 1px solid var(--color-stroke); - box-shadow: var(--shadow-card); - color: inherit; - min-block-size: 200px; - transition: - filter var(--motion-fast) var(--ease-out), - box-shadow var(--motion-base) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.banner-tile:hover { - filter: brightness(1.04); - text-decoration: none; - color: inherit; -} - -.banner-tile__body { - display: grid; - gap: var(--space-2); - min-inline-size: 0; -} - -.banner-tile__kicker { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--color-text-muted); -} - -.banner-tile__name { - font-size: var(--text-3xl); - font-weight: 700; - line-height: var(--leading-3xl); - letter-spacing: -0.01em; -} - -.banner-tile__tagline { - color: var(--color-text-muted); - max-inline-size: 48ch; -} - -@media (max-width: 639px) { - .banner-tile { - flex-direction: column; - align-items: flex-start; - gap: var(--space-4); - padding: var(--space-5); - } - - .banner-tile__name { - font-size: var(--text-2xl); - } -} - -/* ------------------------------------------------------------ boxed list */ -/* libadwaita boxed list: one rounded container, hairline-separated rows. */ - -.boxed { - background: var(--color-surface); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - box-shadow: var(--shadow-card); - overflow: hidden; -} - -.boxed > * + * { - border-block-start: 1px solid var(--color-border); -} - -.row { - display: flex; - align-items: center; - gap: var(--space-4); - inline-size: 100%; - min-block-size: 52px; - padding: var(--space-3) var(--space-4); - background: transparent; - border: 0; - color: var(--color-text); - font: inherit; - text-align: start; - text-decoration: none; - transition: background-color var(--motion-fast) var(--ease-out); -} - -a.row:hover, -button.row:hover { - background: var(--color-subtle-hover); - text-decoration: none; - color: var(--color-text); -} - -a.row:active, -button.row:active { - background: var(--color-subtle-active); -} - -.row__body { - display: grid; - gap: 2px; - min-inline-size: 0; - flex: 1; -} - -.row__title { - font-weight: 600; - font-size: var(--text-base); -} - -.row__subtitle { - font-size: var(--text-sm); - color: var(--color-text-muted); -} - -.row__end { - margin-inline-start: auto; - display: flex; - align-items: center; - gap: var(--space-3); - color: var(--color-text-muted); - font-size: var(--text-sm); - font-variant-numeric: tabular-nums; - flex: none; -} - -.row .chevron { - color: var(--color-text-muted); -} - -/* radio circle for selectable rows (GNOME style) */ - -.radio-dot { - inline-size: 20px; - block-size: 20px; - border-radius: var(--radius-full); - border: 2px solid var(--color-text-muted); - flex: none; - position: relative; - transition: border-color var(--motion-fast) var(--ease-out); -} - -[aria-checked="true"] > .radio-dot { - border-color: var(--color-accent); - background: var(--color-accent); - box-shadow: inset 0 0 0 3px var(--color-surface); -} - -.row[aria-checked="true"] { - background: var(--color-accent-soft); -} - -/* ------------------------------------------------------------- app header */ -/* Flathub-style: wordmark · wide search · nav links · icons. Flat. */ - -.app-header { - position: sticky; - inset-block-start: 0; - z-index: 40; - background: var(--color-bg); - border-block-end: 1px solid var(--color-stroke); -} - -.app-header__inner { - display: flex; - align-items: center; - gap: var(--space-4); - min-block-size: 64px; -} - -.wordmark { - display: inline-flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-lg); - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.01em; - flex: none; -} - -.wordmark:hover { - text-decoration: none; - color: var(--color-text); -} - -.wordmark .icon { - color: var(--color-accent); - border-radius: 6px; -} - -.app-header .search { - flex: 1; - max-inline-size: 400px; - display: none; -} - -@media (min-width: 640px) { - .app-header .search { - display: flex; - } -} - -.app-nav { - display: none; - align-items: center; - gap: var(--space-1); - margin-inline-start: auto; -} - -@media (min-width: 1024px) { - .app-nav { - display: flex; - } -} - -.app-nav a { - position: relative; - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-sm); - color: var(--color-text-muted); - font-size: var(--text-sm); - font-weight: 500; - transition: background-color var(--motion-fast) var(--ease-out); -} - -.app-nav a:hover { - color: var(--color-text); - background: var(--color-subtle-hover); - text-decoration: none; -} - -.app-nav a[aria-current="page"] { - color: var(--color-text); - background: var(--color-subtle-hover); - font-weight: var(--weight-strong); -} - -/* Fluent selection indicator — a short accent bar under the active item */ -.app-nav a[aria-current="page"]::after { - content: ""; - position: absolute; - inset-block-end: 1px; - inset-inline-start: 50%; - translate: -50% 0; - inline-size: 16px; - block-size: 3px; - border-radius: var(--radius-full); - background: var(--color-accent); -} - -.header-actions { - display: flex; - align-items: center; - gap: var(--space-1); - margin-inline-start: auto; -} - -@media (min-width: 1024px) { - .header-actions { - margin-inline-start: 0; - } -} - -.locale-btn { - inline-size: auto; - gap: var(--space-1); - padding-inline: var(--space-3); - font-size: var(--text-sm); - font-weight: 600; -} - -.hamburger { - display: inline-flex; -} - -@media (min-width: 1024px) { - .hamburger { - display: none; - } -} - -.search-mobile-btn { - display: inline-flex; -} - -@media (min-width: 640px) { - .search-mobile-btn { - display: none; - } -} - -@media (max-width: 479px) { - .app-header__inner { - gap: var(--space-1); - } -} - -@media (max-width: 399px) { - .app-header .locale-btn { - display: none; - } -} - -/* locale popover */ - -.popover-anchor { - position: relative; -} - -.locale-popover { - position: absolute; - inset-block-start: calc(100% + var(--space-2)); - inset-inline-end: 0; - z-index: 50; - inline-size: 280px; - max-block-size: 420px; - display: none; - flex-direction: column; - background: var(--color-surface-raised); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - box-shadow: var(--shadow-flyout); - overflow: hidden; -} - -.locale-popover.is-open { - display: flex; -} - -.locale-popover__search { - padding: var(--space-3); - border-block-end: 1px solid var(--color-border); -} - -.locale-popover__search .input { - min-block-size: 36px; - font-size: var(--text-sm); - background: var(--color-control); -} - -[data-theme="dark"] .locale-popover__search .input { - background: var(--color-surface-hover); -} - -.locale-popover__list { - overflow-y: auto; - padding: var(--space-2); - margin: 0; -} - -.locale-option { - display: flex; - align-items: center; - gap: var(--space-3); - inline-size: 100%; - padding: var(--space-2) var(--space-3); - border: 0; - border-radius: var(--radius-sm); - background: none; - color: var(--color-text); - font: inherit; - font-size: var(--text-sm); - cursor: pointer; - text-align: start; -} - -.locale-option:hover { - background: var(--color-surface); -} - -.locale-option .code { - margin-inline-start: auto; - color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: var(--text-xs); - text-transform: uppercase; -} - -.locale-option .icon { - color: var(--color-accent); - visibility: hidden; -} - -.locale-option[aria-selected="true"] .icon { - visibility: visible; -} - -.locale-popover__foot { - padding: var(--space-3); - border-block-start: 1px solid var(--color-border); - font-size: var(--text-xs); - color: var(--color-text-muted); -} - -/* ------------------------------------------------------------- app footer */ -/* Flathub-style: dark band in both themes. */ - -.app-footer { - margin-block-start: var(--space-8); - background: #1c1c1e; - color: #b3b3b8; - font-size: var(--text-sm); -} - -[data-theme="dark"] .app-footer { - background: #1a1a1a; -} - -.app-footer__inner { - display: grid; - gap: var(--space-6); - padding-block: var(--space-7); -} - -@media (min-width: 1024px) { - .app-footer__inner { - grid-template-columns: 2fr 3fr; - } -} - -.app-footer .wordmark { - color: #ffffff; -} - -.app-footer h3 { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: #8a8a8f; - margin-block-end: var(--space-3); -} - -.app-footer ul { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: var(--space-2); -} - -.app-footer a { - color: #b3b3b8; -} - -.app-footer a:hover { - color: #ffffff; -} - -.footer-cols { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-5); -} - -@media (min-width: 640px) { - .footer-cols { - grid-template-columns: repeat(3, 1fr); - } -} - -/* --------------------------------------------------------- mobile drawer */ - -.drawer-scrim { - position: fixed; - inset: 0; - z-index: 60; - background: rgb(0 0 0 / 0.4); - opacity: 0; - pointer-events: none; - transition: opacity var(--motion-slow) var(--ease-emphasized); -} - -.drawer { - position: fixed; - inset-block: 0; - inset-inline-start: 0; - z-index: 61; - inline-size: min(85vw, 360px); - display: flex; - flex-direction: column; - background: var(--color-surface-raised); - box-shadow: var(--shadow-dialog); - transform: translateX(-100%); - transition: transform var(--motion-slow) var(--ease-emphasized); -} - -[dir="rtl"] .drawer { - transform: translateX(100%); -} - -.drawer-open .drawer, -[dir="rtl"] .drawer-open .drawer { - transform: translateX(0); -} - -.drawer-open .drawer-scrim { - opacity: 1; - pointer-events: auto; -} - -.drawer__head { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-3) var(--space-4); -} - -.drawer__nav { - flex: 1; - overflow-y: auto; - padding: var(--space-3); - display: grid; - gap: var(--space-1); - align-content: start; -} - -.drawer__nav a { - display: flex; - align-items: center; - gap: var(--space-3); - min-block-size: 48px; - padding-inline: var(--space-4); - border-radius: var(--radius-sm); - color: var(--color-text); - font-size: var(--text-base); - font-weight: 500; -} - -.drawer__nav a:hover { - background: var(--color-subtle-hover); - text-decoration: none; -} - -.drawer__nav a[aria-current="page"] { - position: relative; - background: var(--color-subtle-hover); - font-weight: 600; -} - -.drawer__divider { - border: 0; - border-block-start: 1px solid var(--color-border); - margin-block: var(--space-2); -} - -.drawer__utility { - display: flex; - align-items: center; - justify-content: space-between; - min-block-size: 48px; - padding-inline: var(--space-4); - border-radius: var(--radius-sm); - color: var(--color-text); - font-size: var(--text-base); - background: none; - border: 0; - font-family: inherit; - cursor: pointer; -} - -.drawer__utility:hover { - background: var(--color-subtle-hover); -} - -.drawer__foot { - padding: var(--space-4); - font-size: var(--text-xs); - color: var(--color-text-muted); - display: flex; - gap: var(--space-4); -} - -/* ---------------------------------------------------------- bottom sheet */ - -.sheet { - position: fixed; - inset-inline: 0; - inset-block-end: 0; - z-index: 61; - max-block-size: 85vh; - display: flex; - flex-direction: column; - background: var(--color-surface-raised); - border-start-start-radius: var(--radius-lg); - border-start-end-radius: var(--radius-lg); - box-shadow: var(--shadow-dialog); -} - -.sheet__handle { - inline-size: 40px; - block-size: 4px; - border-radius: var(--radius-full); - background: var(--color-border); - margin: var(--space-3) auto var(--space-2); -} - -.sheet__body { - overflow-y: auto; - padding: var(--space-2) var(--space-4); -} - -.sheet__foot { - padding: var(--space-3) var(--space-4) var(--space-5); -} - -/* ---------------------------------------------------------- filter column */ - -.filter-col { - display: grid; - gap: var(--space-5); - align-content: start; -} - -.filter-group__title { - font-size: var(--text-sm); - font-weight: 600; - margin-block-end: var(--space-2); -} - -.filter-option { - display: flex; - align-items: center; - gap: var(--space-3); - min-block-size: 40px; - padding-inline: var(--space-4); - font-size: var(--text-sm); - cursor: pointer; -} - -.filter-option:hover { - background: var(--color-subtle-hover); -} - -.filter-option input { - accent-color: var(--color-accent); - inline-size: 16px; - block-size: 16px; - margin: 0; -} - -.filter-option .count { - margin-inline-start: auto; - color: var(--color-text-muted); - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; -} - -/* toolbar above grids */ - -.list-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-3); - margin-block-end: var(--space-4); -} - -.list-toolbar__count { - font-size: var(--text-sm); - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; -} - -.list-toolbar__sort { - margin-inline-start: auto; - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-sm); - color: var(--color-text-muted); -} - -.list-toolbar__sort .select { - min-block-size: 36px; - inline-size: auto; - font-size: var(--text-sm); -} - -/* pill row (categories / quick filters) */ - -.pill-row { - display: flex; - gap: var(--space-2); - overflow-x: auto; - padding-block: var(--space-2); - scroll-snap-type: x proximity; -} - -.pill-row > * { - scroll-snap-align: start; - flex: none; -} - -/* ------------------------------------------------------------------ tabs */ -/* libadwaita view-switcher: pill toggle group. */ - -.switcher { - display: inline-flex; - gap: var(--space-1); - padding: var(--space-1); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-well); - max-inline-size: 100%; - overflow-x: auto; -} - -.switcher .tab { - min-block-size: 32px; - padding-inline: var(--space-4); - border: 0; - border-radius: var(--radius-xs); - background: transparent; - color: var(--color-text-muted); - font: inherit; - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - white-space: nowrap; - transition: background-color var(--motion-fast) var(--ease-out); -} - -.switcher .tab:hover { - color: var(--color-text); -} - -.switcher .tab[aria-selected="true"] { - background: var(--color-surface); - box-shadow: - var(--shadow-control), - inset 0 0 0 1px var(--color-stroke); - color: var(--color-text); - font-weight: var(--weight-strong); -} - -.tab-panel[hidden] { - display: none; -} - -/* ---------------------------------------------------------------- dialog */ - -.modal-scrim { - position: fixed; - inset: 0; - z-index: 70; - display: none; - align-items: center; - justify-content: center; - padding: var(--space-4); - background: var(--scrim); -} - -.modal-scrim.is-open { - display: flex; -} - -.modal { - inline-size: 100%; - max-inline-size: 540px; - max-block-size: min(700px, 92vh); - display: flex; - flex-direction: column; - background: var(--color-surface-raised); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-dialog); - overflow: hidden; -} - -.modal__head { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-4) var(--space-5); -} - -.modal__title { - font-size: var(--text-lg); - font-weight: var(--weight-strong); -} - -.modal__head .icon-btn { - margin-inline-start: auto; - inline-size: 36px; - block-size: 36px; -} - -.modal__body { - flex: 1; - overflow-y: auto; - padding: var(--space-4) var(--space-5) var(--space-5); -} - -@media (max-width: 639px) { - .modal-scrim { - padding: 0; - align-items: flex-end; - } - - .modal { - max-inline-size: none; - max-block-size: 92vh; - border-end-start-radius: 0; - border-end-end-radius: 0; - } -} - -/* ----------------------------------------------------- download stepper */ - -.stepper { - display: flex; - align-items: center; - gap: var(--space-2); - padding: 0 var(--space-5) var(--space-3); - overflow-x: auto; -} - -.step { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-xs); - font-weight: 500; - color: var(--color-text-muted); - white-space: nowrap; -} - -.step__dot { - inline-size: 8px; - block-size: 8px; - border-radius: var(--radius-full); - background: var(--color-border); - flex: none; -} - -.step--done .step__dot, -.step--current .step__dot { - background: var(--color-accent); -} - -.step--current { - color: var(--color-text); - font-weight: 600; -} - -.step--done { - color: var(--color-text); -} - -.step + .step::before { - content: ""; - inline-size: 14px; - block-size: 1px; - background: var(--color-border); - margin-inline-end: var(--space-2); -} - -.mirror-flag { - font-family: var(--font-mono); - font-size: var(--text-xs); - font-weight: 600; - color: var(--color-text-muted); - background: var(--color-well); - border-radius: var(--radius-xs); - padding: 2px var(--space-1); - min-inline-size: 30px; - text-align: center; - flex: none; -} - -.health-dot { - inline-size: 8px; - block-size: 8px; - border-radius: var(--radius-full); - background: var(--color-success); - flex: none; -} - -.health-dot--down { - background: var(--color-danger); -} - -.checksum { - display: grid; - gap: var(--space-2); - padding: var(--space-4); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-sm); - background: var(--color-well); -} - -.checksum__row { - display: flex; - align-items: center; - gap: var(--space-3); -} - -.checksum__hash { - font-family: var(--font-mono); - font-size: var(--text-xs); - color: var(--color-text-muted); - word-break: break-all; - line-height: 1.6; -} - -/* ------------------------------------------------------ carousel & tiles */ - -.carousel { - background: var(--color-surface); - border: 1px solid var(--color-stroke); - box-shadow: var(--shadow-card); - border-radius: var(--radius-lg); - padding: var(--space-5); - display: flex; - align-items: center; - gap: var(--space-4); -} - -.carousel__frame { - flex: 1; - aspect-ratio: 16 / 10; - border-radius: var(--radius-sm); - background: var(--color-well); - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-muted); - font-size: var(--text-sm); - overflow: hidden; -} - -.carousel__frame--peek { - flex: 0 0 12%; - opacity: 0.5; -} - -.carousel-dots { - display: flex; - justify-content: center; - gap: var(--space-2); - margin-block-start: var(--space-3); -} - -.carousel-dots span { - inline-size: 8px; - block-size: 8px; - border-radius: var(--radius-full); - background: var(--color-border); -} - -.carousel-dots .is-active { - background: var(--color-text-muted); -} - -.meta-tiles { - display: grid; - grid-auto-flow: column; - grid-auto-columns: minmax(128px, 1fr); - gap: var(--space-3); - overflow-x: auto; - padding-block: var(--space-1); -} - -.meta-tile { - display: grid; - gap: var(--space-1); - justify-items: center; - text-align: center; - padding: var(--space-4) var(--space-3); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-surface); - box-shadow: var(--shadow-card); -} - -.meta-tile__value { - font-size: var(--text-lg); - font-weight: 700; - font-variant-numeric: tabular-nums; -} - -.meta-tile__label { - font-size: var(--text-xs); - color: var(--color-text-muted); -} - -/* ----------------------------------------------------------------- prose */ - -.prose { - max-inline-size: 68ch; - font-size: var(--text-base); - line-height: var(--leading-base); -} - -.prose h2 { - font-size: var(--text-xl); - font-weight: 700; - line-height: var(--leading-xl); - margin-block: var(--space-6) var(--space-3); -} - -.prose h3 { - font-size: var(--text-lg); - font-weight: 600; - margin-block: var(--space-5) var(--space-2); -} - -.prose p { - margin-block: var(--space-3); -} - -.prose ul, -.prose ol { - padding-inline-start: var(--space-5); - margin-block: var(--space-3); -} - -.prose li { - margin-block: var(--space-1); -} - -.prose pre { - position: relative; - background: var(--color-surface); - border: 1px solid var(--color-stroke); - box-shadow: var(--shadow-card); - border-radius: var(--radius-sm); - padding: var(--space-4); - overflow-x: auto; - font-family: var(--font-mono); - font-size: var(--text-sm); - line-height: 1.6; -} - -.prose code { - font-family: var(--font-mono); - font-size: 0.9em; - background: var(--color-well); - padding: 1px var(--space-1); - border-radius: 6px; -} - -.prose pre code { - background: none; - padding: 0; -} - -.prose .sources { - margin-block-start: var(--space-6); - padding-block-start: var(--space-4); - border-block-start: 1px solid var(--color-border); - font-size: var(--text-sm); - color: var(--color-text-muted); -} - -.copy-btn { - position: absolute; - inset-block-start: var(--space-2); - inset-inline-end: var(--space-2); -} - -/* ----------------------------------------------------------------- tables */ - -.spec-table { - inline-size: 100%; - border-collapse: collapse; - font-size: var(--text-sm); -} - -.spec-table th, -.spec-table td { - padding: var(--space-3) var(--space-4); - text-align: start; - vertical-align: top; -} - -.spec-table tbody tr + tr th, -.spec-table tbody tr + tr td { - border-block-start: 1px solid var(--color-border); -} - -.spec-table thead th { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-muted); - border-block-end: 1px solid var(--color-border); -} - -.spec-table tbody th { - font-weight: 500; - color: var(--color-text-muted); - inline-size: 180px; -} - -.spec-table .num { - text-align: end; - font-variant-numeric: tabular-nums; -} - -.table-scroll { - overflow-x: auto; - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-surface); - box-shadow: var(--shadow-card); -} - -.table-scroll .spec-table th:first-child { - position: sticky; - inset-inline-start: 0; - background: var(--color-surface); -} - -.cell--diff { - background: var(--color-accent-soft); -} - -.cell--same { - color: var(--color-text-muted); -} - -.spec-table tfoot th, -.spec-table tfoot td { - border-block-start: 1px solid var(--color-border); - padding-block: var(--space-4); -} - -/* --------------------------------------------------------------- rankings */ - -.rank-num { - inline-size: 32px; - font-size: var(--text-lg); - font-weight: 700; - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; - text-align: center; - flex: none; -} - -.row--top .rank-num { - color: var(--color-accent); -} - -.sparkline { - inline-size: 88px; - block-size: 24px; - flex: none; -} - -.sparkline polyline { - fill: none; - stroke: var(--color-text-muted); - stroke-width: 1.5; - stroke-linecap: round; - stroke-linejoin: round; - opacity: 0.7; -} - -.sparkline .area { - display: none; -} - -@media (max-width: 639px) { - .sparkline { - display: none; - } -} - -/* ------------------------------------------------------------------- quiz */ - -.quiz-progress { - block-size: 4px; - border-radius: var(--radius-full); - background: var(--color-well); - overflow: hidden; -} - -.quiz-progress__bar { - block-size: 100%; - background: var(--color-accent); - border-radius: inherit; - transition: inline-size var(--motion-base) var(--ease-out); -} - -/* ----------------------------------------------------------- alerts/toast */ - -.alert { - display: flex; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-sm); - font-size: var(--text-sm); -} - -.alert--danger { - background: color-mix(in srgb, var(--color-danger) 10%, var(--color-surface)); - color: var(--color-danger); -} - -.alert--info { - background: var(--color-accent-soft); - color: var(--color-link); -} - -.toast { - position: fixed; - inset-block-end: var(--space-5); - inset-inline-start: 50%; - transform: translateX(-50%); - z-index: 80; - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3) var(--space-5); - border-radius: var(--radius-full); - background: var(--color-text); - color: var(--color-bg); - font-size: var(--text-sm); - font-weight: 500; - box-shadow: var(--shadow-dialog); -} - -/* -------------------------------------------------------------- skeletons */ - -.skeleton { - position: relative; - overflow: hidden; - background: var(--color-well); - border-radius: var(--radius-sm); -} - -.skeleton::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - 90deg, - transparent, - color-mix(in srgb, var(--color-bg) 50%, transparent), - transparent - ); - animation: shimmer 1.6s infinite; -} - -@keyframes shimmer { - from { - transform: translateX(-100%); - } - to { - transform: translateX(100%); - } -} - -/* ------------------------------------------------------- empty/error state */ - -.state-block { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--space-3); - padding: var(--space-7) var(--space-5); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-surface); - box-shadow: var(--shadow-card); - color: var(--color-text-muted); -} - -.state-block .icon { - inline-size: 32px; - block-size: 32px; -} - -.state-block--error .icon { - color: var(--color-danger); -} - -.state-block__title { - font-size: var(--text-lg); - font-weight: 600; - color: var(--color-text); -} - -/* ------------------------------------------------------------- pagination */ - -.pagination { - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-1); - margin-block-start: var(--space-6); -} - -.page-btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-inline-size: 40px; - block-size: 40px; - padding-inline: var(--space-2); - border: 1px solid transparent; - border-radius: var(--radius-sm); - background: none; - color: var(--color-text-muted); - font: inherit; - font-size: var(--text-sm); - font-weight: 500; - font-variant-numeric: tabular-nums; - cursor: pointer; - transition: background-color var(--motion-fast) var(--ease-out); -} - -.page-btn:hover { - background: var(--color-subtle-hover); - border-color: var(--color-stroke); - color: var(--color-text); -} - -.page-btn[aria-current="page"] { - background: var(--color-accent); - color: var(--color-on-accent); -} - -/* ------------------------------------------------------------ page pieces */ - -.page-head { - padding-block: var(--space-6) var(--space-5); -} - -.page-head h1 { - font-size: var(--text-2xl); - font-weight: 700; - line-height: var(--leading-2xl); - letter-spacing: -0.01em; -} - -.page-head .lede { - margin-block-start: var(--space-2); - color: var(--color-text-muted); - max-inline-size: 60ch; -} - -.section-title { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--space-3); - margin-block: var(--space-7) var(--space-4); -} - -.section-title h2 { - font-size: var(--text-xl); - font-weight: 700; - letter-spacing: -0.01em; -} - -.section-title a { - font-size: var(--text-sm); - font-weight: 500; - white-space: nowrap; -} - -/* --------------------------------------------------------------- spec annex */ - -.spec-annex { - margin-block-start: var(--space-8); - padding-block-start: var(--space-6); - border-block-start: 1px dashed var(--color-border); -} - -.spec-annex > h2 { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--color-text-muted); - margin-block-end: var(--space-4); -} - -.spec-frames { - display: grid; - gap: var(--space-4); -} - -@media (min-width: 1024px) { - .spec-frames { - grid-template-columns: repeat(3, 1fr); - } -} - -.spec-frame { - border: 1px dashed var(--color-border); - border-radius: var(--radius-md); - overflow: hidden; -} - -.spec-frame__label { - padding: var(--space-2) var(--space-4); - border-block-end: 1px dashed var(--color-border); - font-size: var(--text-xs); - font-weight: 600; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.spec-frame__body { - padding: var(--space-4); - display: grid; - gap: var(--space-3); -} - -/* ------------------------------------------------- banner carousel (home) */ - -.banner-carousel { - position: relative; -} - -.banner-track { - display: grid; - grid-auto-flow: column; - grid-auto-columns: 100%; - gap: var(--space-4); - overflow-x: auto; - scroll-snap-type: x mandatory; - scrollbar-width: none; - border-radius: var(--radius-lg); -} - -.banner-track::-webkit-scrollbar { - display: none; -} - -.banner-track > * { - scroll-snap-align: start; -} - -.banner-nav { - position: absolute; - inset-block-start: 50%; - transform: translateY(-50%); - z-index: 2; - inline-size: 40px; - block-size: 40px; - border: 1px solid var(--color-stroke); - border-radius: var(--radius-full); - background: var(--color-surface-raised); - color: var(--color-text); - box-shadow: var(--shadow-flyout); - display: none; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.banner-nav--prev { - inset-inline-start: calc(var(--space-3) * -1); -} - -.banner-nav--next { - inset-inline-end: calc(var(--space-3) * -1); -} - -@media (min-width: 1024px) { - .banner-nav { - display: inline-flex; - } -} - -/* ------------------------------------------- category gradient tiles (home) */ -/* Flathub-style colorful category buttons: each carries its own hue pair. */ - -.cat-tiles { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-3); -} - -@media (min-width: 640px) { - .cat-tiles { - grid-template-columns: repeat(4, 1fr); - } -} - -@media (min-width: 1024px) { - .cat-tiles { - grid-template-columns: repeat(8, 1fr); - } -} - -.cat-tile { - --g1: #3584e4; - --g2: #1a5fb4; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--space-2); - min-block-size: 72px; - padding: var(--space-3); - border-radius: var(--radius-md); - background: linear-gradient(135deg, var(--g1), var(--g2)); - box-shadow: - inset 0 1px 0 0 rgb(255 255 255 / 0.22), - inset 0 0 0 1px rgb(0 0 0 / 0.08), - var(--shadow-card); - color: #ffffff; - font-size: var(--text-sm); - font-weight: var(--weight-strong); - text-align: center; - transition: - filter var(--motion-fast) var(--ease-out), - box-shadow var(--motion-base) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.cat-tile:hover { - filter: brightness(1.1); - text-decoration: none; - color: #ffffff; -} - -.cat-tile .icon { - color: #ffffff; -} - -/* --------------------------------------- version table (Modrinth-style) */ -/* Dense version list: filters above, channel-colored rows, per-row round - download button, expandable files/mirror area. ADR-0014. */ - -.vfilters { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin-block-end: var(--space-4); -} - -.vfilters .select { - inline-size: auto; - min-block-size: 40px; - font-size: var(--text-sm); - font-weight: 500; -} - -.vrow { - display: flex; - align-items: center; - gap: var(--space-4); - padding: var(--space-3) var(--space-4); -} - -.vrow__dl { - display: inline-flex; - align-items: center; - justify-content: center; - inline-size: 44px; - block-size: 44px; - border: 0; - border-radius: var(--radius-full); - background: var(--color-accent-fill); - color: var(--color-on-accent); - cursor: pointer; - flex: none; - transition: background-color var(--motion-fast) var(--ease-out); -} - -.vrow__dl:hover { - background: var(--color-accent-fill-hover); -} - -.vrow__main { - display: grid; - gap: 2px; - min-inline-size: 0; - flex: 1; -} - -.vrow__title { - display: flex; - align-items: center; - gap: var(--space-2); - font-weight: 700; - flex-wrap: wrap; -} - -.vrow__meta { - display: flex; - flex-wrap: wrap; - gap: var(--space-1) var(--space-3); - font-size: var(--text-xs); - color: var(--color-text-muted); -} - -.vrow__stats { - display: grid; - gap: 2px; - justify-items: end; - font-size: var(--text-xs); - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; - flex: none; -} - -@media (max-width: 479px) { - .vrow__stats { - display: none; - } -} - -.vrow--open { - background: var(--color-accent-soft); -} - -.vfiles { - padding: var(--space-4); - padding-inline-start: calc(44px + var(--space-4) * 2); - display: grid; - gap: var(--space-3); - background: color-mix(in srgb, var(--color-accent-soft) 40%, var(--color-surface)); -} - -@media (max-width: 639px) { - .vfiles { - padding-inline-start: var(--space-4); - } -} - -.vfiles__title { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-muted); -} - -.vfile { - display: flex; - align-items: center; - gap: var(--space-3); - min-block-size: 44px; - padding-inline: var(--space-3); - border-radius: var(--radius-sm); - background: var(--color-control); - font-size: var(--text-sm); - border: 1px solid var(--color-stroke); - color: var(--color-text); - font-family: inherit; - text-align: start; - cursor: pointer; -} - -.vfile:hover { - background: var(--color-control-hover); - border-color: var(--color-stroke-strong); -} - -.vfile .size { - margin-inline-start: auto; - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; - flex: none; -} - -/* ------------------------------------------------------------ mock toolbar */ -/* Review-only controls — not part of the product design. */ - -.mock-toolbar { - position: fixed; - inset-block-end: var(--space-4); - inset-inline-end: var(--space-4); - z-index: 90; - display: flex; - gap: var(--space-1); - padding: var(--space-1); - border-radius: var(--radius-full); - background: var(--color-surface-raised); - box-shadow: var(--shadow-dialog); - opacity: 0.85; -} - -.mock-toolbar:hover { - opacity: 1; -} - -.mock-toolbar .icon-btn { - inline-size: 36px; - block-size: 36px; -} - -body.bare .mock-toolbar { - display: none; -} - -/* In-text links carry an underline (axe link-in-text-block). */ -.sources a, -.lede a, -.prose a { - text-decoration: underline; -} - -/* ------------------------------------------------------- Fluent press feel */ -/* WinUI controls shrink slightly under the pointer; motion-sensitive users - get the colour change only. */ -@media (prefers-reduced-motion: no-preference) { - .btn:active, - .pill:active, - .icon-btn:active, - .page-btn:active { - transform: scale(0.97); - } - - .app-card:active, - .cat-tile:active, - .banner-tile:active { - transform: scale(0.995); - } -} - -/* Fluent NavigationView selection bar on the mobile drawer */ -.drawer__nav a[aria-current="page"]::before { - content: ""; - position: absolute; - inset-block: 25%; - inset-inline-start: 0; - inline-size: 3px; - border-radius: var(--radius-full); - background: var(--color-accent); -} - -/* Colored tiles lift like any other Fluent card on hover */ -.cat-tile:hover, -.banner-tile:hover { - box-shadow: - inset 0 1px 0 0 rgb(255 255 255 / 0.22), - var(--shadow-card-hover); - text-decoration: none; -} - -/* ------------------------------------------------------------- acrylic */ -/* Fluent acrylic = tint + blur + saturation + grain, layered over whatever - is behind the surface. Every overlay chrome in the app uses it; the tint - opacities stay high enough that text keeps AA contrast over any backdrop. - Without backdrop-filter support each surface falls back to its solid fill - (declared on the rule itself), so nothing depends on this block. */ - -@supports (backdrop-filter: blur(1px)) { - /* In-app acrylic — chrome that sits over scrolling content */ - .app-header { - background-color: var(--acrylic-fill); - background-image: var(--acrylic-noise); - backdrop-filter: var(--acrylic-blur); - } - - /* Flyouts and panes — acrylic over the page, on top of their own shadow */ - /* Flyouts and panes: thick enough that content behind reads as colour and - shape, never as competing text */ - .locale-popover, - .drawer, - .sheet { - background-color: var(--acrylic-pane); - background-image: var(--acrylic-noise); - backdrop-filter: var(--acrylic-blur); - } - - /* Dialogs sit over the smoke layer, so they take the thicker recipe */ - .modal { - background-color: var(--acrylic-thick); - background-image: var(--acrylic-noise); - backdrop-filter: var(--acrylic-blur); - } - - /* Small floating controls get the thin recipe — less blur, less cost */ - .banner-nav, - .mock-toolbar { - background-color: var(--acrylic-flyout); - background-image: var(--acrylic-noise); - backdrop-filter: var(--acrylic-blur-thin); - } - - /* The scrim itself blurs the page behind a dialog (Fluent Smoke) */ - .modal-scrim.is-open { - backdrop-filter: blur(2px); - } -} - -/* The grain must not tile visibly over large flat areas */ -.app-header, -.locale-popover, -.drawer, -.sheet, -.modal, -.banner-nav, -.toast { - background-repeat: repeat; - background-size: 160px 160px; -} diff --git a/design/shared/mock.js b/design/shared/mock.js deleted file mode 100644 index ccba4de..0000000 --- a/design/shared/mock.js +++ /dev/null @@ -1,326 +0,0 @@ -/* - * Linuxhub design comps — shared chrome + review controls (ADR-0012/0013). - * Flathub-style shell: wordmark · wide search entry · nav links · locale · - * theme, plus the dark footer. Injected into every comp page; wires the - * review-only interactions (theme, RTL, tabs, dialog, drawer, popover). - * Not production code. - */ - -/* ------------------------------------------------------------------ icons */ - -const ICONS = ` -`; - -/* ------------------------------------------------------------------ chrome */ - -const NAV_ITEMS = [ - ["explore", "Explore"], - ["rankings", "Rankings"], - ["hall-of-fame", "Hall of Fame"], - ["quiz", "Quiz"], -]; - -const LOCALES = [ - ["English", "en"], - ["한국어", "ko"], - ["日本語", "ja"], - ["简体中文", "zh-Hans"], - ["Deutsch", "de"], - ["español", "es"], - ["français", "fr"], - ["português (Brasil)", "pt-BR"], - ["русский", "ru"], - ["العربية", "ar"], - ["עברית", "he"], - ["Türkçe", "tr"], -]; - -function icon(name, cls = "icon") { - return ``; -} - -function headerHtml(page) { - const nav = NAV_ITEMS.map( - ([id, label]) => - `${label}`, - ).join(""); - const locales = LOCALES.map( - ([label, code]) => - ``, - ).join(""); - return ` - Skip to content -
-
- - ${icon("i-logo")}Linuxhub - - -
- -
- -
- -
${locales}
-
Missing your language? Help translate
-
-
- -
-
-
`; -} - -function footerHtml() { - return ` - `; -} - -function drawerHtml(page) { - const DRAWER_ICONS = { - explore: "i-compass", - rankings: "i-chart", - "hall-of-fame": "i-trophy", - quiz: "i-help", - compare: "i-sliders", - about: "i-info", - contribute: "i-heart", - }; - const nav = [ - ...NAV_ITEMS, - ["compare", "Compare"], - ["about", "About"], - ["contribute", "Contribute"], - ] - .map( - ([id, label]) => - `${icon(DRAWER_ICONS[id], "icon icon--sm")}${label}`, - ) - .join(""); - return ` -
- `; -} - -function toolbarHtml() { - return ` -
- - -
`; -} - -/* ------------------------------------------------------------ interactions */ - -function applyTheme(theme) { - document.documentElement.dataset.theme = theme; - try { - localStorage.setItem("lh-comp-theme", theme); - } catch { - /* ignore */ - } -} - -function init() { - const page = document.body.dataset.page || ""; - document.body.insertAdjacentHTML("afterbegin", ICONS); - if (!document.body.hasAttribute("data-no-chrome")) { - document.body.insertAdjacentHTML("afterbegin", headerHtml(page)); - document.body.insertAdjacentHTML("beforeend", footerHtml()); - document.body.insertAdjacentHTML("beforeend", drawerHtml(page)); - } - document.body.insertAdjacentHTML("beforeend", toolbarHtml()); - - if (location.search.includes("bare")) document.body.classList.add("bare"); - if (location.search.includes("dark")) applyTheme("dark"); - else if (location.search.includes("light")) applyTheme("light"); - else { - try { - const saved = localStorage.getItem("lh-comp-theme"); - if (saved) applyTheme(saved); - } catch { - /* ignore */ - } - } - if (location.search.includes("rtl")) document.documentElement.dir = "rtl"; - - document.addEventListener("click", (e) => { - const t = e.target instanceof Element ? e.target : null; - if (!t) return; - - const themeBtn = t.closest("[data-theme-toggle]"); - if (themeBtn) { - const cur = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; - applyTheme(cur); - return; - } - - if (t.closest("[data-rtl-toggle]")) { - const html = document.documentElement; - html.dir = html.dir === "rtl" ? "ltr" : "rtl"; - return; - } - - if (t.closest("[data-drawer-open]")) { - document.body.classList.add("drawer-open"); - return; - } - if (t.closest("[data-drawer-close]")) { - document.body.classList.remove("drawer-open"); - return; - } - - const toggle = t.closest("[data-toggle]"); - if (toggle) { - const target = document.querySelector(toggle.getAttribute("data-toggle")); - if (target) { - const open = target.classList.toggle("is-open"); - toggle.setAttribute("aria-expanded", String(open)); - } - return; - } - - const opener = t.closest("[data-open-modal]"); - if (opener) { - const modal = document.querySelector(opener.getAttribute("data-open-modal")); - if (modal) modal.classList.add("is-open"); - return; - } - if (t.closest("[data-close-modal]")) { - t.closest(".modal-scrim")?.classList.remove("is-open"); - return; - } - - const group = t.closest("[data-collapse]"); - if (group) { - const open = group.getAttribute("aria-expanded") !== "false"; - group.setAttribute("aria-expanded", String(!open)); - return; - } - - const tab = t.closest('[role="tab"]'); - if (tab) { - const list = tab.closest('[role="tablist"]'); - for (const other of list.querySelectorAll('[role="tab"]')) { - const selected = other === tab; - other.setAttribute("aria-selected", String(selected)); - const panel = document.getElementById(other.getAttribute("aria-controls") || ""); - if (panel) panel.hidden = !selected; - } - return; - } - - const radio = t.closest('[role="radio"]'); - if (radio && !radio.disabled) { - const scope = radio.closest('[role="radiogroup"]'); - for (const other of scope.querySelectorAll('[role="radio"]')) { - other.setAttribute("aria-checked", String(other === radio)); - } - } - }); - - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - document.body.classList.remove("drawer-open"); - for (const el of document.querySelectorAll(".is-open")) el.classList.remove("is-open"); - } - }); -} - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); -} else { - init(); -} diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index bf64bb3..0000000 --- a/docs/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# docs/ - -Human-facing documentation: ADR deep-dives, runbooks, and operational guides. - -| File | Contents | -|---|---| -| `monorepo.md` | Layout, the enforced workspace boundaries, the dependency catalog, and how to add a workspace | -| `deployment.md` | Getting live: Cloudflare resources, secrets, D1 migrations, cron seeding, Vercel, rollback, and where the site origin lives | -| `next-session-prompt.md` | Ready-to-paste continuation prompt for the next agent session | - -Machine/agent-facing project memory lives in `.ai/` — which takes priority over -this directory in the SSOT order (`.ai/` > `docs/` > `prompts/` > code). diff --git a/docs/deployment.md b/docs/deployment.md deleted file mode 100644 index f0b1544..0000000 --- a/docs/deployment.md +++ /dev/null @@ -1,332 +0,0 @@ -# Deployment Runbook - -How to get Linuxhub live: the Hono Worker on Cloudflare, the SvelteKit app on -Vercel. Everything here needs account access, which is why it is a runbook for -a human rather than something an agent can do. - -> **Where the project actually is.** Phase 5 tasks 5.1–5.4 and 5.6 are done. -> The Worker has the schema, cron ingestion and twelve endpoints; the BFF -> proxies it for `health`, `releases/recent`, `search`, `distros` and all three -> download endpoints. `distros/:slug`, `rankings`, `hall-of-fame` and `quiz` -> still read the committed snapshot in `apps/web/src/lib/server/data.ts` — see -> `.ai/handoff.md` § "Which BFF routes have moved" for why each one. -> -> **Set `LINUXHUB_API_URL` and `INTERNAL_API_TOKEN` in Vercel (§6).** Without -> them the deploy silently serves the snapshot for everything and the download -> buttons resolve nothing. `GET /api/v1/health` reports which mode is live. - -Verify commands are marked ✅. Run them; do not assume. - ---- - -## 0. Prerequisites - -```bash -bun install -bunx wrangler login # opens a browser; authorizes the Cloudflare account -``` - -Wrangler is a workspace dependency — `bunx wrangler` inside `apps/api` uses the -pinned version rather than whatever is global. - -> **Windows.** Every command here runs in `cmd.exe` and PowerShell as written. -> Commands elsewhere in the repo use `bun run --filter '@linuxhub/…' - - - - - {name} - {#if summary}{summary}{/if} - - diff --git a/packages/ui/src/components/Icon.svelte b/packages/ui/src/components/Icon.svelte deleted file mode 100644 index 1ab7893..0000000 --- a/packages/ui/src/components/Icon.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/packages/ui/src/components/IconDefs.svelte b/packages/ui/src/components/IconDefs.svelte deleted file mode 100644 index 4de03da..0000000 --- a/packages/ui/src/components/IconDefs.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - - diff --git a/packages/ui/src/components/LogoTile.svelte b/packages/ui/src/components/LogoTile.svelte deleted file mode 100644 index 91a3a0b..0000000 --- a/packages/ui/src/components/LogoTile.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - - {#if src} - - {:else} - {initials} - {/if} - - - diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts deleted file mode 100644 index b2a3a50..0000000 --- a/packages/ui/src/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// @linuxhub/ui — design tokens + shared Svelte components. -// -// Phase 2: tokens implemented from .ai/design-system.md (tokens.css is the -// CSS source; tokens.ts mirrors JS-needed values; motion.ts guards reduced -// motion). Components land in Phase 4 from the approved Stitch design only. -// -// CSS import for apps: `import "@linuxhub/ui/tokens.css";` - -export { - BRAND_COLOR, - BRAND_FALLBACK, - brandColor, - flagEmoji, - initials, -} from "./brand"; -export { motionDuration, prefersReducedMotion } from "./motion"; -export { - type Breakpoint, - breakpoints, - contentMaxWidth, - gridStagger, - motionDurations, -} from "./tokens"; - -export const UI_VERSION = "0.1.0"; diff --git a/packages/ui/src/motion.ts b/packages/ui/src/motion.ts deleted file mode 100644 index 43f520c..0000000 --- a/packages/ui/src/motion.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Reduced-motion helper — every JS-driven animation goes through this so -// prefers-reduced-motion is always honored (.ai/design-system.md). - -export function prefersReducedMotion(): boolean { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return false; - } - return window.matchMedia("(prefers-reduced-motion: reduce)").matches; -} - -/** Returns 0 under reduced motion so transitions become instant. */ -export function motionDuration(ms: number): number { - return prefersReducedMotion() ? 0 : ms; -} diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css deleted file mode 100644 index c8e39b7..0000000 --- a/packages/ui/src/styles.css +++ /dev/null @@ -1,2387 +0,0 @@ -/* - * Linuxhub design system styles — the approved design (ADR-0013/0014). - * Source of truth: design/ comps; this file is the production port and must - * stay visually identical to them. Built on tokens.css; flat surfaces, pill - * buttons, boxed lists; logical properties so dir="rtl" mirrors. - */ - -@import url("./tokens.css"); - -/* ---------------------------------------------------------------- base */ - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html { - color-scheme: light dark; -} - -body { - margin: 0; - background: var(--color-bg); - color: var(--color-text); - font-family: var(--font-sans); - font-size: var(--text-base); - line-height: var(--leading-base); - -webkit-font-smoothing: antialiased; -} - -/* Mica — a fixed, desaturated accent wash beneath every layer (WinUI base) */ -body::before { - content: ""; - position: fixed; - inset: 0; - z-index: -1; - pointer-events: none; - background-color: var(--color-bg); - background-image: - var(--mica-noise), radial-gradient(125% 70% at 50% 0%, var(--mica-tint), transparent 72%), - radial-gradient(90% 60% at 85% 100%, var(--mica-tint-2), transparent 70%); - background-size: - 160px 160px, - cover, - cover; -} - -h1, -h2, -h3, -h4, -p { - margin: 0; -} - -h1, -h2, -h3 { - font-family: var(--font-display); - font-weight: var(--weight-strong); - letter-spacing: var(--tracking-tight); -} - -a { - color: var(--color-link); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -:focus-visible { - outline: 2px solid var(--focus-outer); - outline-offset: 2px; - border-radius: var(--radius-sm); -} - -/* Containers that clip their children draw the ring inside instead */ -.boxed :focus-visible, -.vfiles :focus-visible, -.locale-popover :focus-visible { - outline-offset: -3px; -} - -::selection { - background: var(--color-accent-soft); -} - -.container { - max-inline-size: var(--content-max-width); - margin-inline: auto; - padding-inline: var(--space-4); -} - -@media (min-width: 640px) { - .container { - padding-inline: var(--space-5); - } -} - -.visually-hidden { - position: absolute; - inline-size: 1px; - block-size: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} - -.mono { - font-family: var(--font-mono); - font-size: var(--text-sm); -} - -.kbd { - display: inline-block; - padding: 1px var(--space-2); - border-radius: var(--radius-sm); - background: var(--color-well); - border: 1px solid var(--color-stroke); - color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: var(--text-xs); - line-height: 1.4; -} - -.muted { - color: var(--color-text-muted); -} - -/* ---------------------------------------------------------------- icons */ - -.icon { - inline-size: 20px; - block-size: 20px; - stroke: currentColor; - stroke-width: 1.5; - fill: none; - stroke-linecap: round; - stroke-linejoin: round; - flex: none; -} - -.icon--sm { - inline-size: 16px; - block-size: 16px; -} - -[dir="rtl"] .icon--flip { - transform: scaleX(-1); -} - -/* -------------------------------------------------------------- buttons */ -/* Fluent controls: layered fill, hairline stroke with a darker bottom lip, - 6px corners, and a press that dims rather than lifts. */ - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - min-block-size: 38px; - padding-inline: var(--space-4); - border: 1px solid var(--color-stroke); - border-block-end-color: var(--color-stroke-lip); - border-radius: var(--radius-sm); - background: var(--color-control); - color: var(--color-text); - box-shadow: var(--shadow-control); - font: inherit; - font-size: var(--text-sm); - font-weight: var(--weight-strong); - line-height: 1; - cursor: pointer; - user-select: none; - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out), - color var(--motion-fast) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.btn:hover { - background: var(--color-control-hover); - text-decoration: none; - color: var(--color-text); -} - -.btn:active { - background: var(--color-control-active); - border-block-end-color: var(--color-stroke); - color: var(--color-text-muted); -} - -.btn:disabled { - opacity: 0.4; - cursor: not-allowed; - box-shadow: none; -} - -.btn--primary { - background: var(--color-accent-fill); - border-color: transparent; - border-block-end-color: rgb(0 0 0 / 0.2); - color: var(--color-on-accent); -} - -.btn--primary:hover { - background: var(--color-accent-fill-hover); - color: var(--color-on-accent); -} - -.btn--primary:active { - background: var(--color-accent-fill-active); - color: color-mix(in srgb, var(--color-on-accent) 78%, transparent); -} - -.btn--danger { - background: var(--color-danger); - border-color: transparent; - border-block-end-color: rgb(0 0 0 / 0.2); - color: #ffffff; -} - -.btn--ghost { - background: transparent; - border-color: transparent; - box-shadow: none; - color: var(--color-link); -} - -.btn--ghost:hover { - background: var(--color-subtle-hover); - color: var(--color-link); -} - -.btn--lg { - min-block-size: 44px; - padding-inline: var(--space-5); - font-size: var(--text-base); -} - -.btn--sm { - min-block-size: 32px; - padding-inline: var(--space-3); - font-size: var(--text-sm); -} - -.btn--block { - inline-size: 100%; -} - -.btn[aria-busy="true"] { - pointer-events: none; - opacity: 0.8; -} - -.icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - inline-size: 40px; - block-size: 40px; - border: 1px solid transparent; - border-radius: var(--radius-sm); - background: transparent; - color: var(--color-text); - cursor: pointer; - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.icon-btn:hover { - background: var(--color-subtle-hover); - border-color: var(--color-stroke); -} - -.icon-btn:active { - background: var(--color-subtle-active); - color: var(--color-text-muted); -} - -/* --------------------------------------------------------------- fields */ -/* Flat gray entries, focus ring only. */ - -.field { - display: grid; - gap: var(--space-2); -} - -.field__label { - font-size: var(--text-sm); - font-weight: 600; -} - -.field__label .optional { - color: var(--color-text-muted); - font-weight: 400; -} - -.input, -.select, -.textarea { - inline-size: 100%; - min-block-size: 40px; - padding-inline: var(--space-3); - background: var(--color-control); - color: var(--color-text); - border: 1px solid var(--color-stroke); - border-block-end-color: var(--color-stroke-strong); - border-radius: var(--radius-sm); - font: inherit; - font-size: var(--text-sm); - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out); -} - -.input:hover, -.select:hover, -.textarea:hover { - background: var(--color-control-hover); -} - -.textarea { - padding-block: var(--space-3); - min-block-size: 120px; - resize: vertical; -} - -.input::placeholder, -.textarea::placeholder { - color: var(--color-text-muted); -} - -.input:focus-visible, -.select:focus-visible, -.textarea:focus-visible { - background: var(--color-subtle-hover); - border-block-end-color: var(--color-accent); - box-shadow: inset 0 -1px 0 0 var(--color-accent); -} - -.input[aria-invalid="true"], -.textarea[aria-invalid="true"] { - border-block-end-color: var(--color-danger); - box-shadow: inset 0 -1px 0 0 var(--color-danger); -} - -.field__hint { - font-size: var(--text-sm); - color: var(--color-text-muted); -} - -.field__error { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-sm); - color: var(--color-danger); -} - -/* header search entry */ - -.search { - position: relative; - display: flex; - align-items: center; -} - -.search .icon { - position: absolute; - inset-inline-start: var(--space-4); - color: var(--color-text-muted); - pointer-events: none; -} - -.search__input { - inline-size: 100%; - min-block-size: 38px; - padding-inline: var(--space-7) var(--space-4); - border: 1px solid var(--color-stroke); - border-block-end-color: var(--color-stroke-strong); - border-radius: var(--radius-sm); - background: var(--color-control); - color: var(--color-text); - font: inherit; - font-size: var(--text-sm); - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out); -} - -.search__input:hover { - background: var(--color-control-hover); -} - -.search__input:focus-visible { - background: var(--color-surface); - border-block-end-color: var(--color-accent); - box-shadow: inset 0 -1px 0 0 var(--color-accent); -} - -/* -------------------------------------------------------- pills & badges */ - -.pill { - display: inline-flex; - align-items: center; - gap: var(--space-2); - min-block-size: 34px; - padding-inline: var(--space-4); - border: 1px solid var(--color-stroke); - border-block-end-color: var(--color-stroke-lip); - border-radius: var(--radius-full); - background: var(--color-control); - color: var(--color-text); - box-shadow: var(--shadow-control); - font: inherit; - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - white-space: nowrap; - transition: - background-color var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.pill:hover { - background: var(--color-control-hover); - text-decoration: none; - color: var(--color-text); -} - -.pill:active { - background: var(--color-control-active); - color: var(--color-text-muted); -} - -.pill[aria-pressed="true"], -.pill[aria-checked="true"], -.pill--active { - background: var(--color-accent-fill); - border-color: transparent; - color: var(--color-on-accent); -} - -.pill:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.pill__remove { - display: inline-flex; - margin-inline-end: calc(var(--space-2) * -1); - border: 0; - background: none; - color: inherit; - cursor: pointer; - padding: 2px; - border-radius: var(--radius-full); -} - -.badge { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: 2px var(--space-2); - border-radius: var(--radius-full); - background: var(--color-accent-soft); - color: var(--color-accent-text); - font-size: var(--text-xs); - font-weight: 600; - line-height: var(--leading-xs); - white-space: nowrap; -} - -.badge--neutral { - background: color-mix(in srgb, var(--color-text) 8%, transparent); - color: var(--color-text-muted); -} - -.badge--success { - background: color-mix(in srgb, var(--color-success) 15%, transparent); - color: var(--color-success-text); -} - -.badge--warning { - background: color-mix(in srgb, var(--color-warning) 15%, transparent); - color: var(--color-warning-text); -} - -.badge--danger { - background: color-mix(in srgb, var(--color-danger) 15%, transparent); - color: var(--color-danger-text); -} - -.trend { - font-variant-numeric: tabular-nums; - font-size: var(--text-sm); - font-weight: 600; -} - -.trend--up { - color: var(--color-success-text); -} - -.trend--down { - color: var(--color-danger-text); -} - -.trend--flat { - color: var(--color-text-muted); -} - -/* ------------------------------------------------------------ logo tiles */ -/* Placeholder distro marks for comps only (ADR-0012). */ - -.logo-tile { - --tile: var(--color-accent); - display: inline-flex; - align-items: center; - justify-content: center; - inline-size: 64px; - block-size: 64px; - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--tile) 14%, var(--color-surface)); - box-shadow: inset 0 0 0 1px var(--color-stroke); - color: var(--tile); - font-weight: var(--weight-strong); - font-size: var(--text-xl); - letter-spacing: -0.02em; - flex: none; - user-select: none; -} - -.logo-tile--sm { - inline-size: 40px; - block-size: 40px; - font-size: var(--text-base); - border-radius: var(--radius-sm); -} - -.logo-tile--xs { - inline-size: 24px; - block-size: 24px; - font-size: var(--text-xs); - border-radius: 6px; -} - -.logo-tile--lg { - inline-size: 112px; - block-size: 112px; - font-size: var(--text-3xl); - border-radius: var(--radius-xl); -} - -/* -------------------------------------------------- app cards (layered) */ -/* Flathub's horizontal card (icon start, name + summary after) rendered as a - Fluent card: lighter fill than the Mica page, hairline stroke, soft lift. */ - -.app-card { - display: flex; - align-items: center; - gap: var(--space-4); - padding: var(--space-4); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-surface); - box-shadow: var(--shadow-card); - color: inherit; - transition: - background-color var(--motion-base) var(--ease-out), - border-color var(--motion-base) var(--ease-out), - box-shadow var(--motion-base) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.app-card:hover { - background: var(--color-surface-hover); - border-color: var(--color-stroke-strong); - box-shadow: var(--shadow-card-hover); - text-decoration: none; - color: inherit; -} - -.app-card__body { - display: grid; - gap: 2px; - min-inline-size: 0; -} - -.app-card__name { - font-size: var(--text-base); - font-weight: var(--weight-strong); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.app-card__summary { - font-size: var(--text-sm); - color: var(--color-text-muted); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.app-card__meta { - font-size: var(--text-xs); - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; -} - -.card-grid { - display: grid; - grid-template-columns: 1fr; - gap: var(--space-3); -} - -@media (min-width: 640px) { - .card-grid { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (min-width: 1024px) { - .card-grid { - grid-template-columns: repeat(3, 1fr); - } -} - -/* ------------------------------------------------------------ banner tile */ -/* Distro-of-the-day, Flathub app-banner style: flat brand tint. */ - -.banner-tile { - --tile: var(--color-accent); - display: flex; - align-items: center; - gap: var(--space-6); - padding: var(--space-6) var(--space-7); - border-radius: var(--radius-lg); - background: linear-gradient( - 125deg, - color-mix(in srgb, var(--tile) 32%, var(--color-surface)), - color-mix(in srgb, var(--tile) 10%, var(--color-surface)) 70% - ); - border: 1px solid var(--color-stroke); - box-shadow: var(--shadow-card); - color: inherit; - min-block-size: 200px; - transition: - filter var(--motion-fast) var(--ease-out), - box-shadow var(--motion-base) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.banner-tile:hover { - filter: brightness(1.04); - text-decoration: none; - color: inherit; -} - -.banner-tile__body { - display: grid; - gap: var(--space-2); - min-inline-size: 0; -} - -.banner-tile__kicker { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--color-text-muted); -} - -.banner-tile__name { - font-size: var(--text-3xl); - font-weight: 700; - line-height: var(--leading-3xl); - letter-spacing: -0.01em; -} - -.banner-tile__tagline { - color: var(--color-text-muted); - max-inline-size: 48ch; -} - -@media (max-width: 639px) { - .banner-tile { - flex-direction: column; - align-items: flex-start; - gap: var(--space-4); - padding: var(--space-5); - } - - .banner-tile__name { - font-size: var(--text-2xl); - } -} - -/* ------------------------------------------------------------ boxed list */ -/* libadwaita boxed list: one rounded container, hairline-separated rows. */ - -.boxed { - background: var(--color-surface); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - box-shadow: var(--shadow-card); - overflow: hidden; -} - -.boxed > * + * { - border-block-start: 1px solid var(--color-border); -} - -.row { - display: flex; - align-items: center; - gap: var(--space-4); - inline-size: 100%; - min-block-size: 52px; - padding: var(--space-3) var(--space-4); - background: transparent; - border: 0; - color: var(--color-text); - font: inherit; - text-align: start; - text-decoration: none; - transition: background-color var(--motion-fast) var(--ease-out); -} - -a.row:hover, -button.row:hover { - background: var(--color-subtle-hover); - text-decoration: none; - color: var(--color-text); -} - -a.row:active, -button.row:active { - background: var(--color-subtle-active); -} - -.row__body { - display: grid; - gap: 2px; - min-inline-size: 0; - flex: 1; -} - -.row__title { - font-weight: 600; - font-size: var(--text-base); -} - -.row__subtitle { - font-size: var(--text-sm); - color: var(--color-text-muted); -} - -.row__end { - margin-inline-start: auto; - display: flex; - align-items: center; - gap: var(--space-3); - color: var(--color-text-muted); - font-size: var(--text-sm); - font-variant-numeric: tabular-nums; - flex: none; -} - -.row .chevron { - color: var(--color-text-muted); -} - -/* radio circle for selectable rows (GNOME style) */ - -.radio-dot { - inline-size: 20px; - block-size: 20px; - border-radius: var(--radius-full); - border: 2px solid var(--color-text-muted); - flex: none; - position: relative; - transition: border-color var(--motion-fast) var(--ease-out); -} - -[aria-checked="true"] > .radio-dot { - border-color: var(--color-accent); - background: var(--color-accent); - box-shadow: inset 0 0 0 3px var(--color-surface); -} - -.row[aria-checked="true"] { - background: var(--color-accent-soft); -} - -/* ------------------------------------------------------------- app header */ -/* Flathub-style: wordmark · wide search · nav links · icons. Flat. */ - -.app-header { - position: sticky; - inset-block-start: 0; - z-index: 40; - background: var(--color-bg); - border-block-end: 1px solid var(--color-stroke); -} - -.app-header__inner { - display: flex; - align-items: center; - gap: var(--space-4); - min-block-size: 64px; -} - -.wordmark { - display: inline-flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-lg); - font-weight: 700; - color: var(--color-text); - letter-spacing: -0.01em; - flex: none; -} - -.wordmark:hover { - text-decoration: none; - color: var(--color-text); -} - -.wordmark .icon { - color: var(--color-accent); - border-radius: 6px; -} - -.app-header .search { - flex: 1; - max-inline-size: 400px; - display: none; -} - -@media (min-width: 640px) { - .app-header .search { - display: flex; - } -} - -.app-nav { - display: none; - align-items: center; - gap: var(--space-1); - margin-inline-start: auto; -} - -@media (min-width: 1024px) { - .app-nav { - display: flex; - } -} - -.app-nav a { - position: relative; - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-sm); - color: var(--color-text-muted); - font-size: var(--text-sm); - font-weight: 500; - transition: background-color var(--motion-fast) var(--ease-out); -} - -.app-nav a:hover { - color: var(--color-text); - background: var(--color-subtle-hover); - text-decoration: none; -} - -.app-nav a[aria-current="page"] { - color: var(--color-text); - background: var(--color-subtle-hover); - font-weight: var(--weight-strong); -} - -/* Fluent selection indicator — a short accent bar under the active item */ -.app-nav a[aria-current="page"]::after { - content: ""; - position: absolute; - inset-block-end: 1px; - inset-inline-start: 50%; - translate: -50% 0; - inline-size: 16px; - block-size: 3px; - border-radius: var(--radius-full); - background: var(--color-accent); -} - -.header-actions { - display: flex; - align-items: center; - gap: var(--space-1); - margin-inline-start: auto; -} - -@media (min-width: 1024px) { - .header-actions { - margin-inline-start: 0; - } -} - -.locale-btn { - inline-size: auto; - gap: var(--space-1); - padding-inline: var(--space-3); - font-size: var(--text-sm); - font-weight: 600; -} - -.hamburger { - display: inline-flex; -} - -@media (min-width: 1024px) { - .hamburger { - display: none; - } -} - -.search-mobile-btn { - display: inline-flex; -} - -@media (min-width: 640px) { - .search-mobile-btn { - display: none; - } -} - -@media (max-width: 479px) { - .app-header__inner { - gap: var(--space-1); - } -} - -@media (max-width: 399px) { - .app-header .locale-btn { - display: none; - } -} - -/* locale popover */ - -.popover-anchor { - position: relative; -} - -.locale-popover { - position: absolute; - inset-block-start: calc(100% + var(--space-2)); - inset-inline-end: 0; - z-index: 50; - inline-size: 280px; - max-block-size: 420px; - display: none; - flex-direction: column; - background: var(--color-surface-raised); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - box-shadow: var(--shadow-flyout); - overflow: hidden; -} - -.locale-popover.is-open { - display: flex; -} - -.locale-popover__search { - padding: var(--space-3); - border-block-end: 1px solid var(--color-border); -} - -.locale-popover__search .input { - min-block-size: 36px; - font-size: var(--text-sm); - background: var(--color-control); -} - -[data-theme="dark"] .locale-popover__search .input { - background: var(--color-surface-hover); -} - -.locale-popover__list { - overflow-y: auto; - padding: var(--space-2); - margin: 0; -} - -.locale-option { - display: flex; - align-items: center; - gap: var(--space-3); - inline-size: 100%; - padding: var(--space-2) var(--space-3); - border: 0; - border-radius: var(--radius-sm); - background: none; - color: var(--color-text); - font: inherit; - font-size: var(--text-sm); - cursor: pointer; - text-align: start; -} - -.locale-option:hover { - background: var(--color-surface); -} - -.locale-option .code { - margin-inline-start: auto; - color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: var(--text-xs); - text-transform: uppercase; -} - -.locale-option .icon { - color: var(--color-accent); - visibility: hidden; -} - -.locale-option[aria-selected="true"] .icon { - visibility: visible; -} - -.locale-popover__foot { - padding: var(--space-3); - border-block-start: 1px solid var(--color-border); - font-size: var(--text-xs); - color: var(--color-text-muted); -} - -/* ------------------------------------------------------------- app footer */ -/* Flathub-style: dark band in both themes. */ - -.app-footer { - margin-block-start: var(--space-8); - background: #1c1c1e; - color: #b3b3b8; - font-size: var(--text-sm); -} - -[data-theme="dark"] .app-footer { - background: #1a1a1a; -} - -.app-footer__inner { - display: grid; - gap: var(--space-6); - padding-block: var(--space-7); -} - -@media (min-width: 1024px) { - .app-footer__inner { - grid-template-columns: 2fr 3fr; - } -} - -.app-footer .wordmark { - color: #ffffff; -} - -.app-footer h3 { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: #8a8a8f; - margin-block-end: var(--space-3); -} - -.app-footer ul { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: var(--space-2); -} - -.app-footer a { - color: #b3b3b8; -} - -.app-footer a:hover { - color: #ffffff; -} - -.footer-cols { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-5); -} - -@media (min-width: 640px) { - .footer-cols { - grid-template-columns: repeat(3, 1fr); - } -} - -/* --------------------------------------------------------- mobile drawer */ - -.drawer-scrim { - position: fixed; - inset: 0; - z-index: 60; - background: rgb(0 0 0 / 0.4); - opacity: 0; - pointer-events: none; - transition: opacity var(--motion-slow) var(--ease-emphasized); -} - -.drawer { - position: fixed; - inset-block: 0; - inset-inline-start: 0; - z-index: 61; - inline-size: min(85vw, 360px); - display: flex; - flex-direction: column; - background: var(--color-surface-raised); - box-shadow: var(--shadow-dialog); - transform: translateX(-100%); - transition: transform var(--motion-slow) var(--ease-emphasized); -} - -[dir="rtl"] .drawer { - transform: translateX(100%); -} - -.drawer-open .drawer, -[dir="rtl"] .drawer-open .drawer { - transform: translateX(0); -} - -.drawer-open .drawer-scrim { - opacity: 1; - pointer-events: auto; -} - -.drawer__head { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-3) var(--space-4); -} - -.drawer__nav { - flex: 1; - overflow-y: auto; - padding: var(--space-3); - display: grid; - gap: var(--space-1); - align-content: start; -} - -.drawer__nav a { - display: flex; - align-items: center; - gap: var(--space-3); - min-block-size: 48px; - padding-inline: var(--space-4); - border-radius: var(--radius-sm); - color: var(--color-text); - font-size: var(--text-base); - font-weight: 500; -} - -.drawer__nav a:hover { - background: var(--color-subtle-hover); - text-decoration: none; -} - -.drawer__nav a[aria-current="page"] { - position: relative; - background: var(--color-subtle-hover); - font-weight: 600; -} - -.drawer__divider { - border: 0; - border-block-start: 1px solid var(--color-border); - margin-block: var(--space-2); -} - -.drawer__utility { - display: flex; - align-items: center; - justify-content: space-between; - min-block-size: 48px; - padding-inline: var(--space-4); - border-radius: var(--radius-sm); - color: var(--color-text); - font-size: var(--text-base); - background: none; - border: 0; - font-family: inherit; - cursor: pointer; -} - -.drawer__utility:hover { - background: var(--color-subtle-hover); -} - -.drawer__foot { - padding: var(--space-4); - font-size: var(--text-xs); - color: var(--color-text-muted); - display: flex; - gap: var(--space-4); -} - -/* ---------------------------------------------------------- bottom sheet */ - -.sheet { - position: fixed; - inset-inline: 0; - inset-block-end: 0; - z-index: 61; - max-block-size: 85vh; - display: flex; - flex-direction: column; - background: var(--color-surface-raised); - border-start-start-radius: var(--radius-lg); - border-start-end-radius: var(--radius-lg); - box-shadow: var(--shadow-dialog); -} - -.sheet__handle { - inline-size: 40px; - block-size: 4px; - border-radius: var(--radius-full); - background: var(--color-border); - margin: var(--space-3) auto var(--space-2); -} - -.sheet__body { - overflow-y: auto; - padding: var(--space-2) var(--space-4); -} - -.sheet__foot { - padding: var(--space-3) var(--space-4) var(--space-5); -} - -/* ---------------------------------------------------------- filter column */ - -.filter-col { - display: grid; - gap: var(--space-5); - align-content: start; -} - -.filter-group__title { - font-size: var(--text-sm); - font-weight: 600; - margin-block-end: var(--space-2); -} - -.filter-option { - display: flex; - align-items: center; - gap: var(--space-3); - min-block-size: 40px; - padding-inline: var(--space-4); - font-size: var(--text-sm); - cursor: pointer; -} - -.filter-option:hover { - background: var(--color-subtle-hover); -} - -.filter-option input { - accent-color: var(--color-accent); - inline-size: 16px; - block-size: 16px; - margin: 0; -} - -.filter-option .count { - margin-inline-start: auto; - color: var(--color-text-muted); - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; -} - -/* toolbar above grids */ - -.list-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-3); - margin-block-end: var(--space-4); -} - -.list-toolbar__count { - font-size: var(--text-sm); - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; -} - -.list-toolbar__sort { - margin-inline-start: auto; - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-sm); - color: var(--color-text-muted); -} - -.list-toolbar__sort .select { - min-block-size: 36px; - inline-size: auto; - font-size: var(--text-sm); -} - -/* pill row (categories / quick filters) */ - -.pill-row { - display: flex; - gap: var(--space-2); - overflow-x: auto; - padding-block: var(--space-2); - scroll-snap-type: x proximity; -} - -.pill-row > * { - scroll-snap-align: start; - flex: none; -} - -/* ------------------------------------------------------------------ tabs */ -/* libadwaita view-switcher: pill toggle group. */ - -.switcher { - display: inline-flex; - gap: var(--space-1); - padding: var(--space-1); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-well); - max-inline-size: 100%; - overflow-x: auto; -} - -.switcher .tab { - min-block-size: 32px; - padding-inline: var(--space-4); - border: 0; - border-radius: var(--radius-xs); - background: transparent; - color: var(--color-text-muted); - font: inherit; - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - white-space: nowrap; - transition: background-color var(--motion-fast) var(--ease-out); -} - -.switcher .tab:hover { - color: var(--color-text); -} - -.switcher .tab[aria-selected="true"] { - background: var(--color-surface); - box-shadow: - var(--shadow-control), - inset 0 0 0 1px var(--color-stroke); - color: var(--color-text); - font-weight: var(--weight-strong); -} - -.tab-panel[hidden] { - display: none; -} - -/* ---------------------------------------------------------------- dialog */ - -.modal-scrim { - position: fixed; - inset: 0; - z-index: 70; - display: none; - align-items: center; - justify-content: center; - padding: var(--space-4); - background: var(--scrim); -} - -.modal-scrim.is-open { - display: flex; -} - -.modal { - inline-size: 100%; - max-inline-size: 540px; - max-block-size: min(700px, 92vh); - display: flex; - flex-direction: column; - background: var(--color-surface-raised); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-dialog); - overflow: hidden; -} - -.modal__head { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-4) var(--space-5); -} - -.modal__title { - font-size: var(--text-lg); - font-weight: var(--weight-strong); -} - -.modal__head .icon-btn { - margin-inline-start: auto; - inline-size: 36px; - block-size: 36px; -} - -.modal__body { - flex: 1; - overflow-y: auto; - padding: var(--space-4) var(--space-5) var(--space-5); -} - -@media (max-width: 639px) { - .modal-scrim { - padding: 0; - align-items: flex-end; - } - - .modal { - max-inline-size: none; - max-block-size: 92vh; - border-end-start-radius: 0; - border-end-end-radius: 0; - } -} - -/* ----------------------------------------------------- download stepper */ - -.stepper { - display: flex; - align-items: center; - gap: var(--space-2); - padding: 0 var(--space-5) var(--space-3); - overflow-x: auto; -} - -.step { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-xs); - font-weight: 500; - color: var(--color-text-muted); - white-space: nowrap; -} - -.step__dot { - inline-size: 8px; - block-size: 8px; - border-radius: var(--radius-full); - background: var(--color-border); - flex: none; -} - -.step--done .step__dot, -.step--current .step__dot { - background: var(--color-accent); -} - -.step--current { - color: var(--color-text); - font-weight: 600; -} - -.step--done { - color: var(--color-text); -} - -.step + .step::before { - content: ""; - inline-size: 14px; - block-size: 1px; - background: var(--color-border); - margin-inline-end: var(--space-2); -} - -.mirror-flag { - font-family: var(--font-mono); - font-size: var(--text-xs); - font-weight: 600; - color: var(--color-text-muted); - background: var(--color-well); - border-radius: var(--radius-xs); - padding: 2px var(--space-1); - min-inline-size: 30px; - text-align: center; - flex: none; -} - -.health-dot { - inline-size: 8px; - block-size: 8px; - border-radius: var(--radius-full); - background: var(--color-success); - flex: none; -} - -.health-dot--down { - background: var(--color-danger); -} - -.checksum { - display: grid; - gap: var(--space-2); - padding: var(--space-4); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-sm); - background: var(--color-well); -} - -.checksum__row { - display: flex; - align-items: center; - gap: var(--space-3); -} - -.checksum__hash { - font-family: var(--font-mono); - font-size: var(--text-xs); - color: var(--color-text-muted); - word-break: break-all; - line-height: 1.6; -} - -/* ------------------------------------------------------ carousel & tiles */ - -.carousel { - background: var(--color-surface); - border: 1px solid var(--color-stroke); - box-shadow: var(--shadow-card); - border-radius: var(--radius-lg); - padding: var(--space-5); - display: flex; - align-items: center; - gap: var(--space-4); -} - -.carousel__frame { - flex: 1; - aspect-ratio: 16 / 10; - border-radius: var(--radius-sm); - background: var(--color-well); - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-muted); - font-size: var(--text-sm); - overflow: hidden; -} - -.carousel__frame--peek { - flex: 0 0 12%; - opacity: 0.5; -} - -.carousel-dots { - display: flex; - justify-content: center; - gap: var(--space-2); - margin-block-start: var(--space-3); -} - -.carousel-dots span { - inline-size: 8px; - block-size: 8px; - border-radius: var(--radius-full); - background: var(--color-border); -} - -.carousel-dots .is-active { - background: var(--color-text-muted); -} - -.meta-tiles { - display: grid; - grid-auto-flow: column; - grid-auto-columns: minmax(128px, 1fr); - gap: var(--space-3); - overflow-x: auto; - padding-block: var(--space-1); -} - -.meta-tile { - display: grid; - gap: var(--space-1); - justify-items: center; - text-align: center; - padding: var(--space-4) var(--space-3); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-surface); - box-shadow: var(--shadow-card); -} - -.meta-tile__value { - font-size: var(--text-lg); - font-weight: 700; - font-variant-numeric: tabular-nums; -} - -.meta-tile__label { - font-size: var(--text-xs); - color: var(--color-text-muted); -} - -/* ----------------------------------------------------------------- prose */ - -.prose { - max-inline-size: 68ch; - font-size: var(--text-base); - line-height: var(--leading-base); -} - -.prose h2 { - font-size: var(--text-xl); - font-weight: 700; - line-height: var(--leading-xl); - margin-block: var(--space-6) var(--space-3); -} - -.prose h3 { - font-size: var(--text-lg); - font-weight: 600; - margin-block: var(--space-5) var(--space-2); -} - -.prose p { - margin-block: var(--space-3); -} - -.prose ul, -.prose ol { - padding-inline-start: var(--space-5); - margin-block: var(--space-3); -} - -.prose li { - margin-block: var(--space-1); -} - -.prose pre { - position: relative; - background: var(--color-surface); - border: 1px solid var(--color-stroke); - box-shadow: var(--shadow-card); - border-radius: var(--radius-sm); - padding: var(--space-4); - overflow-x: auto; - font-family: var(--font-mono); - font-size: var(--text-sm); - line-height: 1.6; -} - -.prose code { - font-family: var(--font-mono); - font-size: 0.9em; - background: var(--color-well); - padding: 1px var(--space-1); - border-radius: 6px; -} - -.prose pre code { - background: none; - padding: 0; -} - -.prose .sources { - margin-block-start: var(--space-6); - padding-block-start: var(--space-4); - border-block-start: 1px solid var(--color-border); - font-size: var(--text-sm); - color: var(--color-text-muted); -} - -.copy-btn { - position: absolute; - inset-block-start: var(--space-2); - inset-inline-end: var(--space-2); -} - -/* ----------------------------------------------------------------- tables */ - -.spec-table { - inline-size: 100%; - border-collapse: collapse; - font-size: var(--text-sm); -} - -.spec-table th, -.spec-table td { - padding: var(--space-3) var(--space-4); - text-align: start; - vertical-align: top; -} - -.spec-table tbody tr + tr th, -.spec-table tbody tr + tr td { - border-block-start: 1px solid var(--color-border); -} - -.spec-table thead th { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-muted); - border-block-end: 1px solid var(--color-border); -} - -.spec-table tbody th { - font-weight: 500; - color: var(--color-text-muted); - inline-size: 180px; -} - -.spec-table .num { - text-align: end; - font-variant-numeric: tabular-nums; -} - -.table-scroll { - overflow-x: auto; - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-surface); - box-shadow: var(--shadow-card); -} - -.table-scroll .spec-table th:first-child { - position: sticky; - inset-inline-start: 0; - background: var(--color-surface); -} - -.cell--diff { - background: var(--color-accent-soft); -} - -.cell--same { - color: var(--color-text-muted); -} - -.spec-table tfoot th, -.spec-table tfoot td { - border-block-start: 1px solid var(--color-border); - padding-block: var(--space-4); -} - -/* --------------------------------------------------------------- rankings */ - -.rank-num { - inline-size: 32px; - font-size: var(--text-lg); - font-weight: 700; - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; - text-align: center; - flex: none; -} - -.row--top .rank-num { - color: var(--color-accent); -} - -.sparkline { - inline-size: 88px; - block-size: 24px; - flex: none; -} - -.sparkline polyline { - fill: none; - stroke: var(--color-text-muted); - stroke-width: 1.5; - stroke-linecap: round; - stroke-linejoin: round; - opacity: 0.7; -} - -.sparkline .area { - display: none; -} - -@media (max-width: 639px) { - .sparkline { - display: none; - } -} - -/* ------------------------------------------------------------------- quiz */ - -.quiz-progress { - block-size: 4px; - border-radius: var(--radius-full); - background: var(--color-well); - overflow: hidden; -} - -.quiz-progress__bar { - block-size: 100%; - background: var(--color-accent); - border-radius: inherit; - transition: inline-size var(--motion-base) var(--ease-out); -} - -/* ----------------------------------------------------------- alerts/toast */ - -.alert { - display: flex; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-sm); - font-size: var(--text-sm); -} - -.alert--danger { - background: color-mix(in srgb, var(--color-danger) 10%, var(--color-surface)); - color: var(--color-danger); -} - -.alert--info { - background: var(--color-accent-soft); - color: var(--color-link); -} - -.toast { - position: fixed; - inset-block-end: var(--space-5); - inset-inline-start: 50%; - transform: translateX(-50%); - z-index: 80; - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3) var(--space-5); - border-radius: var(--radius-full); - background: var(--color-text); - color: var(--color-bg); - font-size: var(--text-sm); - font-weight: 500; - box-shadow: var(--shadow-dialog); -} - -/* -------------------------------------------------------------- skeletons */ - -.skeleton { - position: relative; - overflow: hidden; - background: var(--color-well); - border-radius: var(--radius-sm); -} - -.skeleton::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - 90deg, - transparent, - color-mix(in srgb, var(--color-bg) 50%, transparent), - transparent - ); - animation: shimmer 1.6s infinite; -} - -@keyframes shimmer { - from { - transform: translateX(-100%); - } - to { - transform: translateX(100%); - } -} - -/* ------------------------------------------------------- empty/error state */ - -.state-block { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--space-3); - padding: var(--space-7) var(--space-5); - border: 1px solid var(--color-stroke); - border-radius: var(--radius-md); - background: var(--color-surface); - box-shadow: var(--shadow-card); - color: var(--color-text-muted); -} - -.state-block .icon { - inline-size: 32px; - block-size: 32px; -} - -.state-block--error .icon { - color: var(--color-danger); -} - -.state-block__title { - font-size: var(--text-lg); - font-weight: 600; - color: var(--color-text); -} - -/* ------------------------------------------------------------- pagination */ - -.pagination { - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-1); - margin-block-start: var(--space-6); -} - -.page-btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-inline-size: 40px; - block-size: 40px; - padding-inline: var(--space-2); - border: 1px solid transparent; - border-radius: var(--radius-sm); - background: none; - color: var(--color-text-muted); - font: inherit; - font-size: var(--text-sm); - font-weight: 500; - font-variant-numeric: tabular-nums; - cursor: pointer; - transition: background-color var(--motion-fast) var(--ease-out); -} - -.page-btn:hover { - background: var(--color-subtle-hover); - border-color: var(--color-stroke); - color: var(--color-text); -} - -.page-btn[aria-current="page"] { - background: var(--color-accent); - color: var(--color-on-accent); -} - -/* ------------------------------------------------------------ page pieces */ - -.page-head { - padding-block: var(--space-6) var(--space-5); -} - -.page-head h1 { - font-size: var(--text-2xl); - font-weight: 700; - line-height: var(--leading-2xl); - letter-spacing: -0.01em; -} - -.page-head .lede { - margin-block-start: var(--space-2); - color: var(--color-text-muted); - max-inline-size: 60ch; -} - -.section-title { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--space-3); - margin-block: var(--space-7) var(--space-4); -} - -.section-title h2 { - font-size: var(--text-xl); - font-weight: 700; - letter-spacing: -0.01em; -} - -.section-title a { - font-size: var(--text-sm); - font-weight: 500; - white-space: nowrap; -} - -/* ------------------------------------------------- banner carousel (home) */ - -.banner-carousel { - position: relative; -} - -.banner-track { - display: grid; - grid-auto-flow: column; - grid-auto-columns: 100%; - gap: var(--space-4); - overflow-x: auto; - scroll-snap-type: x mandatory; - scrollbar-width: none; - border-radius: var(--radius-lg); -} - -.banner-track::-webkit-scrollbar { - display: none; -} - -.banner-track > * { - scroll-snap-align: start; -} - -.banner-nav { - position: absolute; - inset-block-start: 50%; - transform: translateY(-50%); - z-index: 2; - inline-size: 40px; - block-size: 40px; - border: 1px solid var(--color-stroke); - border-radius: var(--radius-full); - background: var(--color-surface-raised); - color: var(--color-text); - box-shadow: var(--shadow-flyout); - display: none; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.banner-nav--prev { - inset-inline-start: calc(var(--space-3) * -1); -} - -.banner-nav--next { - inset-inline-end: calc(var(--space-3) * -1); -} - -@media (min-width: 1024px) { - .banner-nav { - display: inline-flex; - } -} - -/* ------------------------------------------- category gradient tiles (home) */ -/* Flathub-style colorful category buttons: each carries its own hue pair. */ - -.cat-tiles { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-3); -} - -@media (min-width: 640px) { - .cat-tiles { - grid-template-columns: repeat(4, 1fr); - } -} - -@media (min-width: 1024px) { - .cat-tiles { - grid-template-columns: repeat(8, 1fr); - } -} - -.cat-tile { - --g1: #3584e4; - --g2: #1a5fb4; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--space-2); - min-block-size: 72px; - padding: var(--space-3); - border-radius: var(--radius-md); - background: linear-gradient(135deg, var(--g1), var(--g2)); - box-shadow: - inset 0 1px 0 0 rgb(255 255 255 / 0.22), - inset 0 0 0 1px rgb(0 0 0 / 0.08), - var(--shadow-card); - color: #ffffff; - font-size: var(--text-sm); - font-weight: var(--weight-strong); - text-align: center; - transition: - filter var(--motion-fast) var(--ease-out), - box-shadow var(--motion-base) var(--ease-out), - transform var(--motion-fast) var(--ease-out); -} - -.cat-tile:hover { - filter: brightness(1.1); - text-decoration: none; - color: #ffffff; -} - -.cat-tile .icon { - color: #ffffff; -} - -/* --------------------------------------- version table (Modrinth-style) */ -/* Dense version list: filters above, channel-colored rows, per-row round - download button, expandable files/mirror area. ADR-0014. */ - -.vfilters { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin-block-end: var(--space-4); -} - -.vfilters .select { - inline-size: auto; - min-block-size: 40px; - font-size: var(--text-sm); - font-weight: 500; -} - -.vrow { - display: flex; - align-items: center; - gap: var(--space-4); - padding: var(--space-3) var(--space-4); -} - -.vrow__dl { - display: inline-flex; - align-items: center; - justify-content: center; - inline-size: 44px; - block-size: 44px; - border: 0; - border-radius: var(--radius-full); - background: var(--color-accent-fill); - color: var(--color-on-accent); - cursor: pointer; - flex: none; - transition: background-color var(--motion-fast) var(--ease-out); -} - -.vrow__dl:hover { - background: var(--color-accent-fill-hover); -} - -.vrow__main { - display: grid; - gap: 2px; - min-inline-size: 0; - flex: 1; -} - -.vrow__title { - display: flex; - align-items: center; - gap: var(--space-2); - font-weight: 700; - flex-wrap: wrap; -} - -.vrow__meta { - display: flex; - flex-wrap: wrap; - gap: var(--space-1) var(--space-3); - font-size: var(--text-xs); - color: var(--color-text-muted); -} - -.vrow__stats { - display: grid; - gap: 2px; - justify-items: end; - font-size: var(--text-xs); - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; - flex: none; -} - -@media (max-width: 479px) { - .vrow__stats { - display: none; - } -} - -.vrow--open { - background: var(--color-accent-soft); -} - -.vfiles { - padding: var(--space-4); - padding-inline-start: calc(44px + var(--space-4) * 2); - display: grid; - gap: var(--space-3); - background: color-mix(in srgb, var(--color-accent-soft) 40%, var(--color-surface)); -} - -@media (max-width: 639px) { - .vfiles { - padding-inline-start: var(--space-4); - } -} - -.vfiles__title { - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-muted); -} - -.vfile { - display: flex; - align-items: center; - gap: var(--space-3); - min-block-size: 44px; - padding-inline: var(--space-3); - border-radius: var(--radius-sm); - background: var(--color-control); - font-size: var(--text-sm); - border: 1px solid var(--color-stroke); - color: var(--color-text); - font-family: inherit; - text-align: start; - cursor: pointer; -} - -.vfile:hover { - background: var(--color-control-hover); - border-color: var(--color-stroke-strong); -} - -.vfile .size { - margin-inline-start: auto; - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; - flex: none; -} - -/* -------------------------------------------------------- view transitions */ - -::view-transition-old(root), -::view-transition-new(root) { - animation-duration: var(--motion-base); - animation-timing-function: var(--ease-out); -} - -@media (prefers-reduced-motion: reduce) { - ::view-transition-old(root), - ::view-transition-new(root) { - animation-duration: 0.01ms; - } -} - -/* In-text links carry an underline (axe link-in-text-block). */ -.sources a, -.lede a, -.prose a { - text-decoration: underline; -} - -/* ------------------------------------------------------- Fluent press feel */ -/* WinUI controls shrink slightly under the pointer; motion-sensitive users - get the colour change only. */ -@media (prefers-reduced-motion: no-preference) { - .btn:active, - .pill:active, - .icon-btn:active, - .page-btn:active { - transform: scale(0.97); - } - - .app-card:active, - .cat-tile:active, - .banner-tile:active { - transform: scale(0.995); - } -} - -/* Fluent NavigationView selection bar on the mobile drawer */ -.drawer__nav a[aria-current="page"]::before { - content: ""; - position: absolute; - inset-block: 25%; - inset-inline-start: 0; - inline-size: 3px; - border-radius: var(--radius-full); - background: var(--color-accent); -} - -/* Colored tiles lift like any other Fluent card on hover */ -.cat-tile:hover, -.banner-tile:hover { - box-shadow: - inset 0 1px 0 0 rgb(255 255 255 / 0.22), - var(--shadow-card-hover); - text-decoration: none; -} - -/* ------------------------------------------------------------- acrylic */ -/* Fluent acrylic = tint + blur + saturation + grain, layered over whatever - is behind the surface. Every overlay chrome in the app uses it; the tint - opacities stay high enough that text keeps AA contrast over any backdrop. - Without backdrop-filter support each surface falls back to its solid fill - (declared on the rule itself), so nothing depends on this block. */ - -@supports (backdrop-filter: blur(1px)) { - /* In-app acrylic — chrome that sits over scrolling content */ - .app-header { - background-color: var(--acrylic-fill); - background-image: var(--acrylic-noise); - backdrop-filter: var(--acrylic-blur); - } - - /* Flyouts and panes — acrylic over the page, on top of their own shadow */ - /* Flyouts and panes: thick enough that content behind reads as colour and - shape, never as competing text */ - .locale-popover, - .drawer, - .sheet { - background-color: var(--acrylic-pane); - background-image: var(--acrylic-noise); - backdrop-filter: var(--acrylic-blur); - } - - /* Dialogs sit over the smoke layer, so they take the thicker recipe */ - .modal { - background-color: var(--acrylic-thick); - background-image: var(--acrylic-noise); - backdrop-filter: var(--acrylic-blur); - } - - /* Small floating controls get the thin recipe — less blur, less cost */ - .banner-nav { - background-color: var(--acrylic-flyout); - background-image: var(--acrylic-noise); - backdrop-filter: var(--acrylic-blur-thin); - } - - /* The scrim itself blurs the page behind a dialog (Fluent Smoke) */ - .modal-scrim.is-open { - backdrop-filter: blur(2px); - } -} - -/* The grain must not tile visibly over large flat areas */ -.app-header, -.locale-popover, -.drawer, -.sheet, -.modal, -.banner-nav, -.toast { - background-repeat: repeat; - background-size: 160px 160px; -} diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css deleted file mode 100644 index 9df3df3..0000000 --- a/packages/ui/src/tokens.css +++ /dev/null @@ -1,274 +0,0 @@ -/* - * Linuxhub design tokens — source of truth: .ai/design-system.md (ADR-0017) - * Flathub structure rendered in WinUI 3 (Fluent) materials: layered surfaces - * (Mica base → card fill → flyout), control strokes with a bottom lip, real - * elevation, acrylic chrome, Fluent type ramp and motion. - * Any change here must update that doc in the same commit. - * - * Theming: the app sets data-theme="light|dark" on (user override, - * persisted). The prefers-color-scheme block below is the no-JS/first-paint - * fallback and must stay in sync with the [data-theme="dark"] block. - */ - -:root { - /* --- Color: light. Layer model — bg (Mica) sits *under* cards --------- */ - --color-bg: #f3f3f3; - --color-well: #eeeeee; /* nested wells inside a card */ - --color-surface: #ffffff; /* card / boxed-list fill */ - --color-surface-hover: #fafafa; - --color-surface-active: #f4f4f4; - --color-surface-raised: #ffffff; /* flyouts, dialogs */ - - /* Controls (buttons, inputs, pills) */ - --color-control: #fdfdfd; - --color-control-hover: #f6f6f6; - --color-control-active: #f0f0f0; - --color-subtle-hover: rgb(0 0 0 / 0.04); - --color-subtle-active: rgb(0 0 0 / 0.07); - - /* Strokes — Fluent keeps hairlines translucent so they read on any layer */ - --color-stroke: rgb(0 0 0 / 0.06); - --color-stroke-lip: rgb(0 0 0 / 0.13); /* bottom edge of a control */ - --color-stroke-strong: rgb(0 0 0 / 0.16); - --color-border: rgb(0 0 0 / 0.08); /* separators inside a card */ - - --color-text: #1a1a1a; - --color-text-muted: #5c5c5c; - --color-text-subtle: #6e6e6e; - - /* Accent ramp — GNOME blue family in Fluent's light1..dark3 structure */ - --color-accent-light3: #c3dbf7; - --color-accent-light2: #99c1f1; - --color-accent-light1: #62a0ea; - --color-accent: #3584e4; - --color-accent-dark1: #1c71d8; - --color-accent-dark2: #1a5fb4; - --color-accent-dark3: #15487f; - - /* Accent *fill* is the theme-correct one for text-bearing surfaces */ - --color-accent-fill: var(--color-accent-dark2); - --color-accent-fill-hover: var(--color-accent-dark1); - --color-accent-fill-active: var(--color-accent-dark3); - --color-on-accent: #ffffff; - --color-accent-soft: #e8f1fc; - --color-accent-text: #1a5fb4; - --color-link: #1a5fb4; - - /* Status — `-text` variants are the AA-safe on-tint colors */ - --color-success: #26a269; - --color-warning: #c78a00; - --color-danger: #c01c28; - --color-success-text: #156b41; - --color-warning-text: #7a5200; - --color-danger-text: #8f1521; - - /* --- Materials ------------------------------------------------------- */ - /* Mica: two soft washes so the base reads as a material, not a flat grey */ - --mica-tint: color-mix(in srgb, var(--color-accent) 8%, transparent); - --mica-tint-2: color-mix(in srgb, var(--color-accent-light1) 5%, transparent); - - /* Acrylic = tint + blur + saturation + grain. The grain is what sells it: - Fluent's acrylic is never a clean blur. Inline SVG so it stays offline. */ - --acrylic-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.42'/%3E%3C/svg%3E"); - --mica-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.18'/%3E%3C/svg%3E"); - --acrylic-fill: color-mix(in srgb, var(--color-bg) 70%, transparent); - --acrylic-flyout: color-mix(in srgb, var(--color-surface-raised) 76%, transparent); - /* Panes carry a lot of text, so they sit thicker than a small flyout */ - --acrylic-pane: color-mix(in srgb, var(--color-surface-raised) 85%, transparent); - --acrylic-thick: color-mix(in srgb, var(--color-surface-raised) 90%, transparent); - --acrylic-blur: blur(40px) saturate(180%); - --acrylic-blur-thin: blur(20px) saturate(150%); - --scrim: rgb(0 0 0 / 0.35); - - /* --- Elevation — Fluent's soft, layered shadows ----------------------- */ - --shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 4px rgb(0 0 0 / 0.03); - --shadow-card-hover: 0 2px 4px rgb(0 0 0 / 0.06), 0 6px 14px rgb(0 0 0 / 0.07); - --shadow-control: 0 1px 1px rgb(0 0 0 / 0.04); - --shadow-flyout: 0 8px 16px rgb(0 0 0 / 0.14); - --shadow-dialog: 0 32px 64px rgb(0 0 0 / 0.19), 0 2px 21px rgb(0 0 0 / 0.14); - - /* --- Typography — Fluent ramp; Segoe UI Variable where it exists ------ */ - --font-sans: "Segoe UI Variable Text", "Segoe UI", "Inter", "Noto Sans", system-ui, sans-serif; - --font-display: - "Segoe UI Variable Display", "Segoe UI", "Inter", "Noto Sans", system-ui, sans-serif; - --font-mono: "Cascadia Code", "JetBrains Mono", ui-monospace, monospace; - --text-xs: 0.75rem; /* 12 — caption */ - --text-sm: 0.875rem; /* 14 — body / control label */ - --text-base: 1rem; /* 16 — prose body */ - --text-lg: 1.125rem; /* 18 — body large */ - --text-xl: 1.25rem; /* 20 — subtitle */ - --text-2xl: 1.75rem; /* 28 — title */ - --text-3xl: 2.5rem; /* 40 — title large */ - --text-4xl: 3.25rem; /* 52 — display (banner only) */ - --leading-xs: 1.34; - --leading-sm: 1.43; - --leading-base: 1.6; - --leading-lg: 1.5; - --leading-xl: 1.4; - --leading-2xl: 1.29; - --leading-3xl: 1.2; - --leading-4xl: 1.08; - --tracking-tight: -0.015em; - --weight-strong: 600; /* Fluent SemiBold carries all emphasis */ - - /* --- Spacing — 4px base grid ----------------------------------------- */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 24px; - --space-6: 32px; - --space-7: 48px; - --space-8: 64px; - - /* --- Radius — Fluent: 4px controls, 8px cards/flyouts ----------------- */ - --radius-xs: 4px; - --radius-sm: 6px; /* buttons, inputs, small controls */ - --radius-md: 8px; /* cards, boxed lists, rows */ - --radius-lg: 12px; /* dialogs, banners, panels */ - --radius-xl: 16px; /* hero + screenshot frames, large logo tiles */ - --radius-full: 9999px; /* badges, dots, avatars */ - - /* --- Motion — Fluent: strong decelerate, point-to-point 250ms --------- */ - --motion-fast: 150ms; - --motion-base: 250ms; - --motion-slow: 350ms; - --ease-out: cubic-bezier(0, 0, 0, 1); - --ease-standard: cubic-bezier(0.33, 0, 0.67, 1); - --ease-emphasized: cubic-bezier(0.22, 1, 0.36, 1); - - /* --- Layout & focus --------------------------------------------------- */ - --content-max-width: 1176px; - --focus-inner: #ffffff; - --focus-outer: #1a1a1a; - --focus-ring: 0 0 0 1px var(--focus-inner), 0 0 0 3px var(--focus-outer); -} - -/* Dark — Fluent dark; keep in sync with the media-query fallback below */ -:root[data-theme="dark"] { - --color-bg: #202020; - --color-well: #272727; - --color-surface: #2b2b2b; - --color-surface-hover: #323232; - --color-surface-active: #292929; - --color-surface-raised: #2d2d2d; - - --color-control: #333333; - --color-control-hover: #3a3a3a; - --color-control-active: #2f2f2f; - --color-subtle-hover: rgb(255 255 255 / 0.06); - --color-subtle-active: rgb(255 255 255 / 0.04); - - --color-stroke: rgb(255 255 255 / 0.08); - --color-stroke-lip: rgb(255 255 255 / 0.06); - --color-stroke-strong: rgb(255 255 255 / 0.16); - --color-border: rgb(255 255 255 / 0.09); - - --color-text: #ffffff; - --color-text-muted: #c7c7c7; - --color-text-subtle: #a4a4a4; - - /* Fluent dark flips the accent: a light fill carrying near-black text */ - --color-accent-fill: var(--color-accent-light1); - --color-accent-fill-hover: var(--color-accent-light2); - --color-accent-fill-active: var(--color-accent); - --color-on-accent: #06182b; - --color-accent-soft: #1c3348; - --color-accent-text: #99c1f1; - --color-link: #99c1f1; - - --color-success: #2ec27e; - --color-warning: #e5a50a; - --color-danger: #f66151; - --color-success-text: #78e9a8; - --color-warning-text: #f9c440; - --color-danger-text: #ff9186; - - --mica-tint: color-mix(in srgb, var(--color-accent) 14%, transparent); - --mica-tint-2: color-mix(in srgb, var(--color-accent-light1) 7%, transparent); - --acrylic-fill: color-mix(in srgb, var(--color-bg) 68%, transparent); - --acrylic-flyout: color-mix(in srgb, var(--color-surface-raised) 74%, transparent); - --acrylic-pane: color-mix(in srgb, var(--color-surface-raised) 84%, transparent); - --acrylic-thick: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); - --scrim: rgb(0 0 0 / 0.5); - - --shadow-card: 0 1px 2px rgb(0 0 0 / 0.2), 0 2px 4px rgb(0 0 0 / 0.14); - --shadow-card-hover: 0 2px 4px rgb(0 0 0 / 0.24), 0 6px 14px rgb(0 0 0 / 0.26); - --shadow-control: 0 1px 1px rgb(0 0 0 / 0.16); - --shadow-flyout: 0 8px 16px rgb(0 0 0 / 0.26); - --shadow-dialog: 0 32px 64px rgb(0 0 0 / 0.37), 0 2px 21px rgb(0 0 0 / 0.26); - - --focus-inner: #000000; - --focus-outer: #ffffff; -} - -/* No-JS / first-paint fallback: system dark unless the user chose light */ -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - --color-bg: #202020; - --color-well: #272727; - --color-surface: #2b2b2b; - --color-surface-hover: #323232; - --color-surface-active: #292929; - --color-surface-raised: #2d2d2d; - - --color-control: #333333; - --color-control-hover: #3a3a3a; - --color-control-active: #2f2f2f; - --color-subtle-hover: rgb(255 255 255 / 0.06); - --color-subtle-active: rgb(255 255 255 / 0.04); - - --color-stroke: rgb(255 255 255 / 0.08); - --color-stroke-lip: rgb(255 255 255 / 0.06); - --color-stroke-strong: rgb(255 255 255 / 0.16); - --color-border: rgb(255 255 255 / 0.09); - - --color-text: #ffffff; - --color-text-muted: #c7c7c7; - --color-text-subtle: #a4a4a4; - - --color-accent-fill: var(--color-accent-light1); - --color-accent-fill-hover: var(--color-accent-light2); - --color-accent-fill-active: var(--color-accent); - --color-on-accent: #06182b; - --color-accent-soft: #1c3348; - --color-accent-text: #99c1f1; - --color-link: #99c1f1; - - --color-success: #2ec27e; - --color-warning: #e5a50a; - --color-danger: #f66151; - --color-success-text: #78e9a8; - --color-warning-text: #f9c440; - --color-danger-text: #ff9186; - - --mica-tint: color-mix(in srgb, var(--color-accent) 14%, transparent); - --mica-tint-2: color-mix(in srgb, var(--color-accent-light1) 7%, transparent); - --acrylic-fill: color-mix(in srgb, var(--color-bg) 68%, transparent); - --acrylic-flyout: color-mix(in srgb, var(--color-surface-raised) 74%, transparent); - --acrylic-pane: color-mix(in srgb, var(--color-surface-raised) 84%, transparent); - --acrylic-thick: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); - --scrim: rgb(0 0 0 / 0.5); - - --shadow-card: 0 1px 2px rgb(0 0 0 / 0.2), 0 2px 4px rgb(0 0 0 / 0.14); - --shadow-card-hover: 0 2px 4px rgb(0 0 0 / 0.24), 0 6px 14px rgb(0 0 0 / 0.26); - --shadow-control: 0 1px 1px rgb(0 0 0 / 0.16); - --shadow-flyout: 0 8px 16px rgb(0 0 0 / 0.26); - --shadow-dialog: 0 32px 64px rgb(0 0 0 / 0.37), 0 2px 21px rgb(0 0 0 / 0.26); - - --focus-inner: #000000; - --focus-outer: #ffffff; - } -} - -/* Global reduced-motion kill-switch (CSS side; JS side is motion.ts) */ -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} diff --git a/packages/ui/src/tokens.test.ts b/packages/ui/src/tokens.test.ts deleted file mode 100644 index 2671dce..0000000 --- a/packages/ui/src/tokens.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { motionDuration } from "./motion"; -import { breakpoints, contentMaxWidth, gridStagger, motionDurations } from "./tokens"; - -describe("tokens", () => { - it("breakpoints ascend mobile → wide", () => { - const values = [breakpoints.mobile, breakpoints.tablet, breakpoints.desktop, breakpoints.wide]; - expect([...values].sort((a, b) => a - b)).toEqual(values); - expect(breakpoints.mobile).toBe(0); - }); - - it("motion durations ascend fast → slow", () => { - expect(motionDurations.fast).toBeLessThan(motionDurations.base); - expect(motionDurations.base).toBeLessThan(motionDurations.slow); - }); - - it("stagger caps at 12 cards", () => { - expect(gridStagger.maxCards).toBe(12); - }); - - it("motionDuration passes through outside a browser", () => { - // No window in the test env → helper must not throw and returns input. - expect(motionDuration(200)).toBe(200); - }); -}); - -describe("tokens.css contract", () => { - const css = readFileSync(new URL("./tokens.css", import.meta.url), "utf8"); - - function block(pattern: RegExp): Record { - const match = css.match(pattern); - if (!match) throw new Error(`token block not found: ${pattern}`); - return Object.fromEntries( - [...match[1].matchAll(/(--[a-z0-9-]+):\s*([^;]+);/g)].map(([, k, v]) => [k, v.trim()]), - ); - } - - const root = block(/:root \{([\s\S]*?)\n\}/); - const dark = block(/:root\[data-theme="dark"\] \{([\s\S]*?)\n\}/); - const fallback = block(/:root:not\(\[data-theme="light"\]\) \{([\s\S]*?)\n\t\}/); - - it("keeps the dark block and the no-JS fallback identical", () => { - // Two hand-maintained copies; drift here silently breaks first paint. - expect(fallback).toEqual(dark); - }); - - it("mirrors the CSS motion durations in the JS tokens", () => { - expect(root["--motion-fast"]).toBe(`${motionDurations.fast}ms`); - expect(root["--motion-base"]).toBe(`${motionDurations.base}ms`); - expect(root["--motion-slow"]).toBe(`${motionDurations.slow}ms`); - }); - - it("mirrors the CSS content width in the JS tokens", () => { - expect(root["--content-max-width"]).toBe(`${contentMaxWidth}px`); - }); - - it("layers surfaces above the Mica base in both themes (ADR-0017)", () => { - // Fluent reads depth by lightness: cards sit *above* the page. - expect(root["--color-bg"]).toBe("#f3f3f3"); - expect(root["--color-surface"]).toBe("#ffffff"); - expect(dark["--color-bg"]).toBe("#202020"); - expect(dark["--color-surface"]).toBe("#2b2b2b"); - }); - - it("resolves the accent fill per theme so components never branch", () => { - expect(root["--color-accent-fill"]).toBe("var(--color-accent-dark2)"); - expect(root["--color-on-accent"]).toBe("#ffffff"); - // WinUI dark: light accent fill carrying near-black text - expect(dark["--color-accent-fill"]).toBe("var(--color-accent-light1)"); - expect(dark["--color-on-accent"]).not.toBe("#ffffff"); - }); -}); diff --git a/packages/ui/src/tokens.ts b/packages/ui/src/tokens.ts deleted file mode 100644 index a5a5296..0000000 --- a/packages/ui/src/tokens.ts +++ /dev/null @@ -1,23 +0,0 @@ -// JS-side mirror of the tokens needed outside CSS. -// Source of truth: .ai/design-system.md + tokens.css — keep all three in sync. - -export const breakpoints = { - mobile: 0, - tablet: 640, - desktop: 1024, - wide: 1440, -} as const; - -export type Breakpoint = keyof typeof breakpoints; - -/** Milliseconds — pair with motionDuration() so reduced-motion zeroes them. */ -export const motionDurations = { - fast: 150, - base: 250, - slow: 350, -} as const; - -/** Staggered grid reveal: 30ms per card, first 12 cards only. */ -export const gridStagger = { perCardMs: 30, maxCards: 12 } as const; - -export const contentMaxWidth = 1176; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json deleted file mode 100644 index ed34f2e..0000000 --- a/packages/ui/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/prompts/README.md b/prompts/README.md deleted file mode 100644 index 5eb0986..0000000 --- a/prompts/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# prompts/ - -AI prompts used by the project. - -## `stitch/` — Google Stitch design briefs (Phase 2 output) - -Per-screen briefs derived from `.ai/design-system.md`. Each brief states: -purpose, layout per breakpoint, tokens, components to reuse, content/states -(loading/empty/error), interaction + motion, accessibility + reduced-motion -requirements, and the Flathub/Modrinth reference. - -**How to use (the human runs Stitch):** - -1. In [Google Stitch](https://stitch.withgoogle.com), start a Linuxhub project. -2. For each screen, paste **`_foundation.md` first, then the screen brief** - into the same prompt — briefs assume the foundation's tokens/components. -3. Generate per breakpoint noted in the brief (mobile + desktop minimum), - in **both light and dark themes**; iterate with follow-up prompts until it - matches the brief. Stitch designs layout/visuals/motion only — never - accept architecture, API, or data-model inventions. -4. Review against `.ai/design-system.md`, export to Figma, and mark the - design **approved**. Only then does frontend implementation (Phase 4) - begin. Token values changed during design review must be written back to - `.ai/design-system.md` + `packages/ui` in the same change. - -Suggested order: `global-nav` → `mobile-nav` → `explore` → `distro-detail` -→ `download-selector` → `home` → the rest (search-results, rankings, -category-tag, compare, quiz, hall-of-fame, contribute, about, error-404). -The first five establish the shared components everything else reuses. diff --git a/prompts/stitch/_foundation.md b/prompts/stitch/_foundation.md deleted file mode 100644 index 6c041c8..0000000 --- a/prompts/stitch/_foundation.md +++ /dev/null @@ -1,98 +0,0 @@ -# Linuxhub Design Foundation (paste this before every screen brief) - -You are designing **Linuxhub** — a modern, fast, multilingual catalog for -browsing almost every Linux distribution, with a guided download experience. -Think "Flathub meets Modrinth": Flathub's calm, spacious, content-first -browsing combined with Modrinth's playful micro-interactions, dense-but-legible -data views, and confident accent color. - -**Your scope:** layout, responsive behavior, component layout, visual -hierarchy, interaction and motion design. **Do not** invent app architecture, -APIs, data structures, or new features — design exactly the screens and states -each brief describes, using only the tokens and components below. - -## Design language - -Modern, spacious, card-based. Neutral calm shell; distro brand logos provide -the color inside cards. Confident Linux-blue accent. Generous whitespace, -soft large radii, quiet borders. Dark and light themes are equal citizens — -design both for every screen. - -## Color tokens - -| Token | Light | Dark | Use | -|---|---|---|---| -| bg | `#fafafa` | `#101214` | page background | -| surface | `#ffffff` | `#16191d` | cards, panels | -| surface-raised | `#ffffff` | `#1d2126` | modals, popovers, hover-lift | -| border | `#e4e7eb` | `#2a2f36` | hairlines, dividers | -| text | `#17191c` | `#e8eaed` | primary text | -| text-muted | `#5c6470` | `#9aa3ad` | secondary text | -| accent | `#1793d1` (hover `#0f7fb8`) | `#31a8e0` (hover `#5cbce8`) | links, primary buttons, focus | -| accent-contrast | `#ffffff` | `#0b1014` | text on accent | -| accent-soft | `#e8f4fb` | `#12303f` | tinted chips/backgrounds | -| success | `#1a7f37` | `#3fb950` | verified checksum, active status | -| warning | `#9a6700` | `#d29922` | EOL-soon, untranslated badge | -| danger | `#cf222e` | `#f85149` | broken mirror, errors, discontinued | - -All text/background pairs must meet WCAG AA (4.5:1 body, 3:1 large/UI). - -## Typography - -Inter (falling back to Noto Sans for CJK/Arabic/Indic); JetBrains Mono for -checksums/terminal snippets. Scale: xs 12px/1.4 · sm 14px/1.5 · base 16px/1.6 -· lg 18px/1.5 · xl 22px/1.4 · 2xl 28px/1.3 · 3xl 36px/1.2 · 4xl 48px/1.1 -(hero only). Weights: 400 body, 500 UI labels, 600 headings, 700 hero. -Never justify text. - -## Spacing, radius, elevation - -4px grid: 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64. Radii: 6px chips+inputs, -10px buttons+small cards, 16px distro cards+panels, pill 9999px. -Shadows (light): resting `0 1px 2px rgb(0 0 0 / .06)`, hover -`0 4px 12px rgb(0 0 0 / .08)`, modal `0 12px 32px rgb(0 0 0 / .14)`. -Dark mode uses lighter raised surfaces + subtle borders instead of heavy shadow. - -## Motion - -Fast 120ms ease-out (hovers, toggles) · base 200ms ease-out (card lift, -dropdowns, tabs) · slow 320ms cubic-bezier(.22,1,.36,1) (route transitions, -modals) · springy press feedback on buttons/toggles. Patterns: route -cross-fade + 8px slide; card hover = 2px lift + hover shadow; skeleton loaders -(never spinners for content); staggered grid reveal (30ms/card, first 12); -selector steps slide horizontally. **Always honor reduced-motion: every -animation has a no-motion variant.** - -## Breakpoints - -mobile 0+ (1-col cards, bottom-sheet filters, hamburger) · tablet 640+ -(2-col, collapsible filter rail) · desktop 1024+ (3-col + persistent filter -rail) · wide 1440+ (4-col, content max-width 1320px centered). - -## Components (use these names as layer/frame names so they map to code) - -AppHeader, AppFooter, MobileNav, CommandPalette, LocaleSwitcher, ThemeToggle, -DistroCard, CardGrid, FilterRail, FilterSheet, FacetChip, SortSelect, -SearchInput, Pagination, DistroHero, ContentTabs, DownloadPanel, -DownloadSelector, ChecksumBlock, ScreenshotGallery, RequirementsTable, -RelatedDistros, RankSparkline, ReleaseTimeline, RankingList, TrendBadge, -HallOfFameCard, CompareTable, QuizStepper, RandomButton, Badge, Button -(primary/secondary/ghost/danger), Chip, Tabs, Modal, Tooltip, Toast, -SkeletonCard, SkeletonRow, EmptyState, ErrorState, CaptchaGate, CopyButton. - -Reuse these across screens — never design two different treatments for the -same component. - -## Global rules - -- Every screen: design **loading (skeleton), empty, and error** states. -- Keyboard-visible focus ring: 2px accent, 2px offset, on every interactive - element. -- Touch targets ≥44px. Icons: single consistent set, 1.5px stroke, 20/24px. -- The UI ships in ~57 languages including RTL (Arabic, Hebrew, Farsi, Central - Kurdish): layouts must mirror cleanly — avoid direction-baked compositions; - leave room for longer strings (German, Tamil). -- Distro logos are official brand SVGs on neutral surfaces — never recolored, - distorted, or cropped. -- Sample distro content for mocks: Ubuntu, Fedora, Arch Linux, Debian, Linux - Mint, openSUSE, Manjaro, Pop!_OS, EndeavourOS, Zorin OS, NixOS, elementary OS. diff --git a/prompts/stitch/about.md b/prompts/stitch/about.md deleted file mode 100644 index bdec950..0000000 --- a/prompts/stitch/about.md +++ /dev/null @@ -1,50 +0,0 @@ -# Stitch Brief — About - -*(Use together with `_foundation.md`.)* - -## Purpose -Explains what Linuxhub is, how rankings are computed, where data comes from, -content licensing/attribution policy, the crawler policy (with contact -anchor), and how to contribute. Mostly prose — typography does the design. - -## Layout -Single centered column (max 68ch) with a slim sticky table-of-contents rail -on desktop (right side; collapses into a "On this page" dropdown on mobile). - -Sections (each with anchored h2): -1. **What is Linuxhub** — 2 paragraphs + the three stat chips from Home. -2. **How rankings work** — plain-language method (our own signals: views, - download clicks, release recency) + a small illustrative RankSparkline. -3. **Where the data comes from** — official APIs/pages, refresh cadence, - a note that every source is recorded; link style for external sources. -4. **Content & trademarks** — paraphrase-with-citation policy, logo - attribution, "report an issue" link to Contribute. -5. **Crawler policy** (`#crawler` anchor — our bot's UA links here) — - robots.txt compliance, rate limits, contact. -6. **Contact / contribute** — buttons to Contribute form + GitHub repo. - -Prose: base/1.6, headings 600, generous 48px section spacing, hairline -dividers. Blockquote/list/link styles designed here become the canonical -long-form styles (shared with distro MDX content). - -## Components -ContentTabs typography styles (shared prose), Chip, Badge, Button, -RankSparkline (illustrative), AppFooter. - -## Content & states -Static content — loading/error states are trivial (instant render), but -design the anchor-highlight state (target section briefly tinted -accent-soft) and the ToC active-section indicator. - -## Interaction & motion -ToC tracks scroll position with a sliding indicator (base); anchor jumps -smooth-scroll (disabled under reduced motion); external links get a subtle -↗ affordance. - -## Accessibility -Proper heading hierarchy (single h1); ToC is a labeled nav; smooth scroll -honors reduced motion; link text descriptive (no "here"). - -## Reference -Flathub's about page calm; documentation-quality typography as on modern -docs sites, kept warm. diff --git a/prompts/stitch/category-tag.md b/prompts/stitch/category-tag.md deleted file mode 100644 index 2530fc7..0000000 --- a/prompts/stitch/category-tag.md +++ /dev/null @@ -1,43 +0,0 @@ -# Stitch Brief — Category / Tag Page - -*(Use together with `_foundation.md`.)* - -## Purpose -Landing page for one taxonomy node — a category (e.g. "For beginners", -"Server", "Gaming") or a tag (e.g. "rolling-release", "immutable") — showing -its distros with the same grid language as Explore. One design serves both; -tags render a slightly plainer header. - -## Layout -1. **Taxonomy header** — accent-soft tinted band (subtle): category icon - (24px stroke glyph), name (2xl/600), description (base muted, max 60ch), - count Chip ("38 distros"). Tag variant: no icon, "#tag-name" styling on a - plain background. -2. **Sibling rail** — horizontal Chip scroller of other categories/tags for - lateral discovery (current one highlighted, accent-soft). -3. **CardGrid** of DistroCards — identical to Explore (3-col desktop, 2 tablet, - 1 mobile) with SortSelect (popularity default) and Pagination. No full - FilterRail here — a single "Refine in Explore →" ghost Button links to - Explore with this facet pre-applied. - -## Components -Chip, Badge, CardGrid, DistroCard, SortSelect, Pagination, Button, -SkeletonCard, EmptyState, ErrorState. - -## Content & states -- Loading: header instant (from route), grid SkeletonCards. -- Empty (tag with no distros): EmptyState "Nothing tagged yet" + button to - Explore. -- Error: grid ErrorState with retry; header and sibling rail stay. - -## Interaction & motion -Grid stagger-reveal (30ms/card, cap 12); sibling chips scroll with snap and -edge-fade hints; card hover lift. Reduced motion: fades only. - -## Accessibility -Header is the h1; sibling rail is a labeled nav, keyboard-scrollable with -visible focus; the tinted band keeps AA contrast for description text. - -## Reference -Flathub category pages (tinted headers, same-grid consistency); Modrinth tag -chips for the sibling rail. diff --git a/prompts/stitch/compare.md b/prompts/stitch/compare.md deleted file mode 100644 index 2a0c296..0000000 --- a/prompts/stitch/compare.md +++ /dev/null @@ -1,54 +0,0 @@ -# Stitch Brief — Compare - -*(Use together with `_foundation.md`.)* - -## Purpose -Side-by-side comparison of 2–4 distros (family, release model, desktop -options, architectures, requirements, popularity). Decision-support: the -differences should pop, the sameness should recede. - -## Layout -1. **Picker bar** — 2–4 slots as cards: filled slot = logo + name + remove ×; - empty slot = dashed-border "Add a distro" opening an inline SearchInput - dropdown (same anatomy as search autocomplete). Sticky on scroll - (condensed to logos when stuck). -2. **CompareTable** — first column: attribute labels (500 weight); one - column per distro (equal width, min 200px). Row groups with subheads: - Overview (family, based on, status, first release) · Release model - (channel, cadence, latest version + date) · Desktops offered (glyph - chips) · Architectures (pills) · System requirements (min/rec) · - Popularity (rank + RankSparkline + downloads). - **Difference emphasis:** cells whose value differs from the row's others - get accent-soft background; identical rows render muted. A "differences - only" toggle Chip above the table hides identical rows. -3. Row hover highlights the full row across columns. Table scrolls - horizontally inside its own container on overflow (never the page). -4. **Mobile:** columns become swipeable panes with a sticky attribute - label column (first column pinned); or stacked per-attribute rows at - <400px — design the pinned-column pattern as primary. -5. Footer: "Download" primary Button per column + link to each detail page. - -## Components -CompareTable, SearchInput, Chip, Badge, Button, RankSparkline, SkeletonRow, -EmptyState, ErrorState, Tooltip. - -## Content & states -- Loading: table skeleton preserving column count. -- Empty (0–1 slots filled): friendly EmptyState explaining "pick at least - two" with popular pairing suggestions (Ubuntu vs Fedora, Arch vs Debian). -- Error: per-column ErrorState (one distro failing doesn't kill the table). - -## Interaction & motion -Adding a distro animates its column in with a base slide+fade; removing -collapses it; the differences toggle cross-fades rows. Reduced motion: -instant column swaps. - -## Accessibility -Real table semantics with row/column headers; difference highlighting is -paired with a dot marker (not color alone); picker slots are buttons with -clear labels; horizontal scroll region is keyboard-operable and announced. -RTL mirrors column order. - -## Reference -Modrinth's version-comparison density; classic spec-sheet tables done with -Flathub's whitespace and calm borders. diff --git a/prompts/stitch/contribute.md b/prompts/stitch/contribute.md deleted file mode 100644 index 2493c70..0000000 --- a/prompts/stitch/contribute.md +++ /dev/null @@ -1,57 +0,0 @@ -# Stitch Brief — Contribute / Report - -*(Use together with `_foundation.md`.)* - -## Purpose -Anonymous, captcha-gated community forms: suggest a distro, report a broken -mirror/link, send feedback. Low-friction, trustworthy, spam-resistant. -No accounts exist — say so plainly. - -## Layout -1. Page header: title (2xl/600) + one paragraph: contributions are anonymous, - reviewed by maintainers, protected by a captcha. -2. **Form type Tabs** — "Suggest a distro" · "Report a problem" · - "Feedback". One shared card layout (max 560px, surface, radius 16, - padding 24): - - **Suggest:** distro name (input), homepage URL (input), why it belongs - (textarea, counter 500), optional email for follow-up (input, marked - optional). - - **Report:** what's broken (select: mirror down / wrong link / bad - checksum / outdated info / other), which page/mirror (input, pre-filled - when arriving from a distro page), details (textarea). - - **Feedback:** topic (select), message (textarea). -3. **CaptchaGate** sits between fields and submit: bordered slot with - explanatory microcopy ("One quick check — no account needed"). -4. Primary Button full-width "Send"; after success the card swaps to a - thank-you state: success-colored check, "Thanks — we review every - submission", buttons "Send another" / "Back to Explore". - -Labels above fields (never placeholder-as-label); helper text sm muted; -validation errors danger text + danger border below the field. - -## Components -Tabs, CaptchaGate, Button, Badge, Toast, ErrorState, form inputs (design -input/select/textarea styles here — they become the canonical form styles). - -## Content & states -- Loading: captcha slot shows a skeleton shimmer while the widget loads. -- Empty: n/a (forms start empty by nature). -- Error: submit failure → inline danger alert above the button, input - preserved; rate-limited (429) → specific message "Too many submissions — - try again in a minute."; captcha failed → error on the CaptchaGate slot. - -## Interaction & motion -Tab switch cross-fades forms (base) preserving typed input per tab; invalid -submit shakes the offending field 2px (fast, once); success state swaps with -a slow fade + check draw-in. Reduced motion: fades only, no shake (use -border flash). - -## Accessibility -Every field labeled + described (aria-describedby for helpers/errors); -errors announced assertively and focus moves to the first invalid field; -captcha has an accessible fallback note; success state gets focus so it's -announced. - -## Reference -Flathub's quiet form language; keep the captcha visually integrated, not -bolted on. diff --git a/prompts/stitch/distro-detail.md b/prompts/stitch/distro-detail.md deleted file mode 100644 index e8043e8..0000000 --- a/prompts/stitch/distro-detail.md +++ /dev/null @@ -1,61 +0,0 @@ -# Stitch Brief — Distro Detail (MDX content + download panel) - -*(Use together with `_foundation.md`.)* - -## Purpose -The heart of the product: everything about one distro (e.g. Fedora) — -description, install and usage docs, screenshots, requirements — with a -Modrinth-grade download panel that never makes the user leave the page. - -## Layout -1. **DistroHero** — logo (96px on neutral tile), name (3xl/700), family + - status Badges, one-line summary, quick actions right-aligned: primary - Button "Download", ghost buttons (official site ↗, share badge). If the - viewed locale's content is English fallback, show the warning - "untranslated" Badge here. -2. **Two-column body (desktop 1024+):** - - **Main (≈2/3):** **ContentTabs** — Description / Install / Usage. - Long-form prose typography (base/1.6, headings with anchors, code - blocks in mono with CopyButton, cited-sources footer). Below tabs: - **ScreenshotGallery** (16:10 cards, lightbox), **RequirementsTable** - (min vs recommended columns), **RelatedDistros** (DistroCard row). - - **Sidebar (≈1/3, sticky):** **DownloadPanel** — latest stable version - prominent (xl/600 + release date), "Download" primary button opening the - **DownloadSelector** (see its brief), compact **ReleaseTimeline** (last - 5 releases with channel Badges), **RankSparkline** with current rank, - download count, links (docs, community). -3. **Tablet:** sidebar drops below hero, above tabs, non-sticky. - **Mobile:** single column — hero, DownloadPanel (collapsed to the version - + Download button), tabs, gallery, requirements, related; sticky bottom - "Download" bar appears after scrolling past the panel. - -## Components -DistroHero, Badge, Button, ContentTabs, CopyButton, ScreenshotGallery, -RequirementsTable, RelatedDistros, DistroCard, DownloadPanel, -DownloadSelector (trigger), ReleaseTimeline, RankSparkline, ChecksumBlock, -SkeletonRow, EmptyState, ErrorState, Toast. - -## Content & states -- Loading: hero renders first; tabs/panel as SkeletonRows; gallery skeleton - cards. -- Empty: no screenshots → hide gallery; no related → hide row; missing - doc tab → EmptyState inside the tab ("Not written yet — read the official - docs ↗"). -- Error: per-section ErrorState with retry; hero + panel independent of tab - failures. - -## Interaction & motion -Tab switch: 200ms cross-fade + underline slide. Sticky sidebar gently -shadows on scroll. Copy actions confirm via Toast + button check morph. -Gallery lightbox: slow fade + scale from thumbnail. Reduced motion: cross-fades -only, no scale/slide. - -## Accessibility -Tabs follow the ARIA tabs pattern (arrow keys, automatic activation). -Anchored headings get visible focus. Lightbox traps focus, Esc closes. -Checksums in mono with an explicit "copy" label. RTL: sidebar mirrors to the -left; prose direction follows content locale. - -## Reference -Modrinth project page (sidebar version panel, file rows, badge density); -Flathub app page (calm hero, screenshot gallery, content-first prose). diff --git a/prompts/stitch/download-selector.md b/prompts/stitch/download-selector.md deleted file mode 100644 index 840e9db..0000000 --- a/prompts/stitch/download-selector.md +++ /dev/null @@ -1,62 +0,0 @@ -# Stitch Brief — Download Selector (modal) - -*(Use together with `_foundation.md`.)* - -## Purpose -The signature interaction: a guided, Modrinth-style stepper that resolves -Version → Edition → Architecture → File format → Mirror into a direct -download with checksum and verify instructions — all inside one modal. - -## Layout -**Modal** on surface-raised, radius 16, modal shadow; max-width 560px -desktop; full-screen sheet on mobile. Header: distro logo (24px) + "Download -Fedora" (xl/600) + close. Below: **step indicator** — 5 small dots/labels -(Version · Edition · Arch · Format · Mirror) with done/current/todo states. - -**Steps (one visible at a time):** -1. **Version/channel** — radio cards: version number (lg/600) + channel Badge - (stable/LTS/beta/rolling) + release date (muted). Latest stable preselected. -2. **Edition** — radio cards with tiny desktop-environment glyphs (GNOME, - KDE, Xfce, minimal, server…). -3. **Architecture** — compact radio pills (x86_64 preselected, aarch64, - riscv64…). -4. **File format** — radio rows with file size right-aligned: .iso · - .iso.torrent · magnet · checksum · signature. -5. **Mirror** — list sorted nearest-first (country flag + mirror name + - sponsor muted + health dot success/danger); "auto (nearest)" default on top. - -**Result panel** (replaces steps after resolve): big primary Button -"Download • 2.1 GB", **ChecksumBlock** (sha256 in mono + CopyButton + -"verify how?" expandable instructions), mirror + sponsor line, secondary -links (torrent, signature). Footer note: "Selection remembered for next time." - -Steps auto-advance on selection; back via step dots or back arrow. -Single-option steps are skipped automatically but shown as done. - -## Components -Modal, DownloadSelector, Badge, Button, Chip, ChecksumBlock, CopyButton, -Tooltip, SkeletonRow, ErrorState, Toast. - -## Content & states -- Loading: options load as 3–4 SkeletonRows per step; resolve shows an - inline progress state on the button ("Resolving…"). -- Empty: a combination with no artifact → the incompatible option is - disabled with a Tooltip ("Not available for riscv64"). -- Error: resolve failure → compact ErrorState in the result area with - retry + "try another mirror". - -## Interaction & motion -Steps slide horizontally (base, ease-out; direction follows forward/back and -mirrors in RTL); step dots fill with a fast tick animation; result panel -enters with slow fade+rise; button press has springy feedback. Reduced -motion: instant step swaps, fade-only result. - -## Accessibility -Focus trapped; Esc closes (with confirm only if mid-resolve). Each step is a -labeled radiogroup, arrow-key navigable; auto-advance announced politely. -Step indicator exposes progress ("step 3 of 5"). Checksum copy has a text -label, not icon-only. Touch targets ≥44px. - -## Reference -Modrinth version/file download flow (density + clarity of file rows); -platform-picker steppers on Flathub setup pages for tone. diff --git a/prompts/stitch/error-404.md b/prompts/stitch/error-404.md deleted file mode 100644 index 5d4ef58..0000000 --- a/prompts/stitch/error-404.md +++ /dev/null @@ -1,46 +0,0 @@ -# Stitch Brief — 404 / Error Page - -*(Use together with `_foundation.md`.)* - -## Purpose -Dead ends that keep people in the product. Two variants sharing one layout: -**404** (not found — light, a touch witty) and **500** (something broke — -apologetic, steady). Terminal-flavored humor is on-brand if restrained. - -## Layout -Centered composition (max 480px), vertically centered in the viewport minus -header/footer: -1. Small monospace flourish (xs, muted): 404 → `$ linuxhub: page not found`, - 500 → `$ linuxhub: unexpected error` — a single quiet mono line, not a - fake terminal window. -2. Big "404" / "500" numerals (4xl/700, low-contrast tint — decorative). -3. Headline (xl/600): 404 "This page drifted out of the repo." / - 500 "Something broke on our side." -4. One supporting line (base muted): 404 offers help; 500 says it's logged + - shows an opaque request id in mono (with CopyButton) for reporting. -5. Actions: primary Button "Back to home"; 404 adds a **SearchInput** - ("Looking for a distro?") and a **RandomButton** ("Surprise me") — the - playful escape hatch; 500 adds ghost "Try again". -6. AppHeader + AppFooter render normally around it. - -## Components -Button, RandomButton, SearchInput, CopyButton, AppHeader, AppFooter. - -## Content & states -This page *is* the error state. Design both variants in both themes. The -500 variant must not depend on any data loading (it renders when everything -else failed). - -## Interaction & motion -Numerals settle with one slow fade+rise on entry; RandomButton has the -springiest press in the product (small dice-roll wiggle). Reduced motion: -static entry, no wiggle. - -## Accessibility -Headline is the h1 and receives focus on navigation so the error is -announced; request id is selectable text with a labeled copy button; humor -never replaces the plain statement of what happened. - -## Reference -Modrinth's playful-but-tidy error tone; keep Flathub calm — one flourish -maximum. diff --git a/prompts/stitch/explore.md b/prompts/stitch/explore.md deleted file mode 100644 index 89910b3..0000000 --- a/prompts/stitch/explore.md +++ /dev/null @@ -1,56 +0,0 @@ -# Stitch Brief — Explore / Browse (grid + filters) - -*(Use together with `_foundation.md`.)* - -## Purpose -The main catalog: a Flathub-style card grid with powerful but unintimidating -facet filtering and sorting. Users skim dozens of distros fast and narrow -with facets. - -## Layout -- **Desktop 1024+:** persistent left **FilterRail** (260px) + **CardGrid** - (3-col; 4-col at wide 1440+). Above the grid: result count ("312 distros"), - active FacetChips (dismissible), and **SortSelect** (popularity / name / - latest release / newest) right-aligned. -- **Tablet 640+:** collapsible FilterRail (toggle button with active-filter - count badge); 2-col grid. -- **Mobile:** "Filters" button opens **FilterSheet** (bottom sheet, ~85vh, - drag handle, sticky "Show 41 results" apply button); 1-col grid. - -**FilterRail facets** (collapsible groups, checkboxes with counts): -Category, Tag, Family (Debian/Arch/RPM/SUSE/independent…), Desktop -environment, Architecture, Based on, Status (active/discontinued). - -**DistroCard** (the canonical card — designed once, reused everywhere): -logo (48px, neutral surface), name (lg/600), one-line summary (sm, muted, -truncated), footer row: family Badge + download count (xs muted). 16px -radius, resting shadow, hover lift. - -Pagination: numbered **Pagination** at the grid bottom (page/limit model). - -## Components -FilterRail, FilterSheet, FacetChip, SortSelect, CardGrid, DistroCard, Badge, -Pagination, SkeletonCard, EmptyState, ErrorState, Button. - -## Content & states -- Loading: grid of 12 SkeletonCards; rail renders instantly. -- Empty (no matches): EmptyState — friendly illustration-light message - ("No distros match these filters"), button clearing all facets. -- Error: ErrorState with retry replacing the grid only; rail stays usable. -- Filters reflect in chips immediately; count updates live. - -## Interaction & motion -Grid stagger-reveal on load and on filter change (30ms/card, cap 12); -checkbox → chip appears with a fast fade+scale; card hover lift (base); -sheet slides up (slow easing) with scrim fade. Reduced motion: instant -swaps, fade-only sheet. - -## Accessibility -Rail is a labeled `filters` region; groups are expandable buttons with -aria-expanded; result-count updates announced politely. Sheet traps focus, -Esc closes, returns focus to the Filters button. Grid is a list semantically; -cards are single links. - -## Reference -Flathub app grid + category filtering for calm density; Modrinth search page -for facet-chip behavior and result-count feedback. diff --git a/prompts/stitch/global-nav.md b/prompts/stitch/global-nav.md deleted file mode 100644 index a5a5b4a..0000000 --- a/prompts/stitch/global-nav.md +++ /dev/null @@ -1,62 +0,0 @@ -# Stitch Brief — Global Nav (AppHeader + language switcher + footer) - -*(Use together with `_foundation.md`.)* - -## Purpose -The persistent shell on every screen: AppHeader (wordmark, primary nav, -search, locale, theme), and AppFooter. Must scale from `en` to `ta` and -mirror perfectly in RTL. This brief defines the desktop/tablet header; -the mobile drawer is in `mobile-nav.md`. - -## Layout — AppHeader (64px, sticky) -Left → right (mirrors in RTL): -1. **Wordmark** — Linuxhub mark (theme-swapped light/dark SVG) + name - (lg/600); links home. -2. **Primary nav** — Explore · Rankings · Hall of Fame · Compare · Quiz - (base/500, muted → text on hover, active = text + 2px accent underline). - At tablet width, overflow items collapse into a "More ▾" menu. -3. Spacer. -4. **Search trigger** — input-shaped button (240px desktop: magnifier + - "Search distros…" + `⌘K` kbd chip; icon-only at tablet) opening the - CommandPalette. -5. **LocaleSwitcher** — globe icon + current code ("KO"). Opens a popover - (surface-raised, radius 10, max-height ~60vh): search field on top, then - locales as **native labels** ("한국어", "العربية") with BCP-47 code muted; - current one checked; RTL locales render their label RTL in the list. - Footnote: "Missing your language? Help translate ↗". -6. **ThemeToggle** — icon button (sun/moon morph), tooltip. - -Default: solid surface + hairline bottom border; on Home it starts -transparent over the hero and gains surface + border after ~64px scroll. - -## Layout — AppFooter -Muted, 3-column desktop (1-col mobile): ① wordmark + one-liner + theme -credit; ② link columns (Explore, Rankings, About, Contribute, API/feeds: -RSS · Atom · badges); ③ LocaleSwitcher (compact) + "data from official -sources" note. Hairline top border, xs/sm muted text. - -## Components -AppHeader, AppFooter, LocaleSwitcher, ThemeToggle, CommandPalette (trigger), -Tooltip, Badge, Chip. - -## Content & states -- Locale popover loading: instant (bundled) — but design the searched-empty - state ("No matching language"). -- Active-route state for every nav item; keyboard focus state distinct from - hover. -- Design LTR and RTL versions of the full header, both themes. - -## Interaction & motion -Popovers fade+drop 4px (fast); theme toggle cross-morphs sun/moon (base) and -the page theme cross-fades (slow); nav underline slides between items -(base). Reduced motion: instant swaps, no underline slide. - -## Accessibility -Skip-link ("Skip to content") appears on first Tab before the wordmark. -Nav is a labeled landmark; popovers trap focus and close on Esc; -LocaleSwitcher is a listbox with typeahead; locale change announces itself. -The ⌘K hint has a visible-text equivalent for non-keyboard users. - -## Reference -Flathub's header restraint; Modrinth's search-forward header behavior; -Wikipedia-grade language switcher ergonomics (native labels, searchable). diff --git a/prompts/stitch/hall-of-fame.md b/prompts/stitch/hall-of-fame.md deleted file mode 100644 index 8b7227d..0000000 --- a/prompts/stitch/hall-of-fame.md +++ /dev/null @@ -1,45 +0,0 @@ -# Stitch Brief — Hall of Fame - -*(Use together with `_foundation.md`.)* - -## Purpose -An editorial, museum-like page honoring historically significant distros -(e.g. Slackware, Debian, Red Hat Linux, Mandrake). Distinct mood from -rankings: timeless, curated, story-driven — not a leaderboard. - -## Layout -1. Header: title (3xl/700) + short curatorial intro paragraph (lg, muted, - max 60ch) explaining inclusion criteria and that rationales cite sources. -2. **HallOfFameCard** grid (desktop 2-col, wide 3-col, mobile 1-col) — - larger than DistroCard: logo 64px, name (xl/600), era line (sm muted, - "1993 — present"), 2–3 sentence rationale (base), "why it matters" - citations as small footnote links, status Badge if discontinued. - Optional slim "inducted 2026" caption. Cards feel like plaques: extra - padding (24), 16 radius, hairline border, minimal shadow. -3. Ordering is editorial (no numbers, no scores). A quiet timeline strip at - top (decade markers) may anchor the era context — optional, keep subtle. -4. Footer band: link to Rankings ("Looking for what's popular now?"). - -## Components -HallOfFameCard, Badge, Chip, SkeletonCard, EmptyState, ErrorState, Button. - -## Content & states -- Loading: 4 large SkeletonCards. -- Empty: not expected in production; still design the EmptyState - ("Curation in progress"). -- Error: full-width ErrorState with retry. - -## Interaction & motion -Cards reveal with a slow, dignified fade+rise stagger on scroll into view; -hover is a border-color deepen + minimal lift (less playful than elsewhere — -this page is reverent). Citation footnotes open in tooltips on hover/focus. -Reduced motion: fade only. - -## Accessibility -Each card is an article with a heading; citations are real links with -descriptive text. Timeline strip (if used) is decorative and aria-hidden. -AA contrast on all muted era text. - -## Reference -Flathub's calm editorial tone pushed further toward a museum plaque -aesthetic; typography-led, restrained color. diff --git a/prompts/stitch/home.md b/prompts/stitch/home.md deleted file mode 100644 index 4e4f6cf..0000000 --- a/prompts/stitch/home.md +++ /dev/null @@ -1,52 +0,0 @@ -# Stitch Brief — Home / Landing - -*(Use together with `_foundation.md`.)* - -## Purpose -First impression + fastest paths in: search, explore, and "help me choose". -Communicates scale (hundreds of distros, all mirrors, every language) without -clutter. Flathub-calm hero; Modrinth-lively rows underneath. - -## Layout -1. **AppHeader** (see global-nav brief) — transparent over hero, solid on scroll. -2. **Hero** — 4xl headline ("Every Linux, one place" tone), one-line - subhead (muted), and a large centered **SearchInput** (pill, prominent, - with keyboard-shortcut hint "⌘K"). Below: three quiet stat chips - (distros · downloads served · languages). No background illustration - heavier than a subtle tint/gradient of accent-soft. -3. **Featured rows** (Flathub-style horizontal sections, each with title + - "See all →" link): "Trending this week" (DistroCard row + TrendBadge), - "Recently released" (compact cards with version + date), "Categories" - (grid of category tiles with icon + count). -4. **"Not sure where to start?"** band — accent-soft background, short copy, - two Buttons: primary → distro-finder quiz, secondary → Explore. -5. **Hall of Fame teaser** — 3 HallOfFameCards, link to full page. -6. **AppFooter**. - -Breakpoints: mobile stacks everything 1-col, rows become horizontal snap -scroll; tablet 2-col category tiles; desktop full rows (4 cards visible); -wide max-width 1320 centered. - -## Components -AppHeader, SearchInput, DistroCard, TrendBadge, HallOfFameCard, Button, -Chip, CardGrid (row variant), SkeletonCard, AppFooter. - -## Content & states -- Loading: skeleton hero stats + SkeletonCard rows (no spinners). -- Empty (a row has no data): hide the row entirely — never an empty rail. -- Error (rows fail): quiet inline ErrorState per row with retry; hero + - search always render. - -## Interaction & motion -Hero search focuses with a soft glow (fast); rows stagger-reveal on scroll -into view (30ms/card, first 12); card hover lift; "See all" arrow nudges 2px -on hover. Reduced motion: fades only, no slides/stagger. - -## Accessibility -Search is the first tab stop after skip-link + header. Rows are labeled -regions with headings. Horizontal scrollers keyboard-navigable with visible -focus. AA contrast over the hero tint. - -## Reference -Flathub home (calm hero + curated rows) for structure; Modrinth home for the -lively trending row + badge language. diff --git a/prompts/stitch/mobile-nav.md b/prompts/stitch/mobile-nav.md deleted file mode 100644 index 0b4ff0d..0000000 --- a/prompts/stitch/mobile-nav.md +++ /dev/null @@ -1,56 +0,0 @@ -# Stitch Brief — Mobile Nav - -*(Use together with `_foundation.md`.)* - -## Purpose -The <640px navigation shell: compact header + slide-in drawer + the -bottom-sheet pattern reused by filters and the download selector. One-hand -reachability first. - -## Layout -**Compact AppHeader (56px, sticky):** hamburger (44px target) · centered -wordmark · search icon (opens full-screen search overlay, see below). - -**Drawer (MobileNav):** slides from the start edge (left LTR / right RTL), -~85% width, max 360px, surface background, scrim over content: -1. Header row: wordmark + close ×. -2. Primary links (lg/500, 48px rows): Explore · Rankings · Hall of Fame · - Compare · Quiz · About · Contribute; active item = accent-soft pill. -3. Divider. -4. **Utility row:** ThemeToggle (labeled "Theme") and LocaleSwitcher row - ("Language · 한국어 ▾") opening the locale list as a nested sheet with - search — same anatomy as the desktop popover, full-height. -5. Footer (xs muted): RSS · GitHub · "data from official sources". - -**Full-screen search overlay:** input pinned top with cancel; results list -identical to search autocomplete anatomy; recent searches beneath when empty. - -**Bottom sheet (shared pattern):** drag handle, rounded top (16), sticky -action button, ~85vh max with internal scroll — Stitch designs it once here; -filters and the download selector reuse it. - -## Components -MobileNav, AppHeader (compact), LocaleSwitcher (sheet variant), ThemeToggle, -SearchInput, FilterSheet (pattern), Badge, Chip, Button. - -## Content & states -- Drawer opens instantly (no loading state). -- Search overlay: skeleton rows while querying; empty state with recent - searches; inline error with retry. -- Design LTR + RTL drawer (edge swaps) and both themes. - -## Interaction & motion -Drawer slides in (slow easing) with scrim fade; nested locale sheet pushes -over it (base); sheet dismisses by handle-drag or scrim tap with a soft -spring settle; search overlay fades up (base). Reduced motion: fades only, -no slides/springs. - -## Accessibility -Drawer and sheets trap focus, Esc/back closes, focus returns to the opener; -hamburger has aria-expanded + label; drag-dismiss always has a visible -close/cancel equivalent; 44px minimum targets throughout; scrim tap targets -don't overlap content controls. - -## Reference -Flathub mobile drawer simplicity; Modrinth mobile filter sheet for the -bottom-sheet mechanics. diff --git a/prompts/stitch/quiz.md b/prompts/stitch/quiz.md deleted file mode 100644 index e33eb15..0000000 --- a/prompts/stitch/quiz.md +++ /dev/null @@ -1,55 +0,0 @@ -# Stitch Brief — Distro-Finder Quiz - -*(Use together with `_foundation.md`.)* - -## Purpose -A friendly "which distro is for you?" flow for newcomers: 6–8 questions → -3 scored recommendations. Personality-quiz warmth with product-grade polish; -zero jargon until results. - -## Layout -**Intro screen:** centered card (max 560px): playful title (2xl/600, e.g. -"Find your Linux"), 2-line explainer, time hint ("under a minute"), primary -Button "Start", ghost "Skip to Explore". - -**Question screens (QuizStepper):** progress bar (thin, accent, animated -width) + "3 of 7" (xs muted); question (xl/600, max 30ch); answers as large -option cards (radius 10, icon + short label + optional sm muted example), -2-col grid desktop / 1-col mobile. Single-select advances after a brief -confirm tint; a multi-select question uses checkboxes + "Next" button. -Back arrow top-left; "skip question" ghost link bottom. - -**Result screen:** headline "Your matches"; **top match** as a large -celebratory card — logo 96px, name (2xl/700), match reasoning in 2–3 plain -sentences ("You wanted stability and a gentle start — Mint tracks Ubuntu -LTS…"), primary "See Fedora" + secondary "Download". Below: 2 runner-up -DistroCards with one-line reasons. Footer actions: "Retake" ghost, "Compare -these three →" link. - -## Components -QuizStepper, Button, Chip, DistroCard, Badge, SkeletonCard, ErrorState, -Toast. - -## Content & states -- Loading: question set loads behind the intro screen; result computes with - a brief "Matching…" state (max ~1s feel) using a subtle logo shuffle - animation — design its static variant too. -- Empty: n/a (question set is static) — but design the "no strong match" - result variant: neutral tone + link to Explore. -- Error: submitting fails → inline ErrorState with retry, answers preserved. - -## Interaction & motion -Questions slide horizontally (base; mirrors in RTL); progress bar animates -width (base); selected option springs slightly; result card enters with slow -rise + a single soft confetti-free flourish (e.g. accent ring pulse). -Reduced motion: cross-fades, static progress, no pulse. - -## Accessibility -One question per screen, question as heading; options are radiogroup/ -checkbox group, fully arrow-key navigable; progress announced ("question 3 -of 7"); auto-advance has enough delay to not disorient screen readers; -result reasoning is real text, not tooltip-hidden. - -## Reference -Modrinth's playfulness for option cards and the result moment; Flathub calm -for pacing — friendly, never childish. diff --git a/prompts/stitch/rankings.md b/prompts/stitch/rankings.md deleted file mode 100644 index 9b86f76..0000000 --- a/prompts/stitch/rankings.md +++ /dev/null @@ -1,50 +0,0 @@ -# Stitch Brief — Rankings - -*(Use together with `_foundation.md`.)* - -## Purpose -Popularity standings over week / month / year / all-time, plus Trending and -Rising movers. Data-dense but scannable; movement is the story. - -## Layout -1. Page header: title (2xl/600), one-line explanation of methodology (sm - muted, "based on views, downloads and release activity") linking to About. -2. **Period Tabs** — Week · Month · Year · All time; secondary toggle chips - for view: "Top" (default) · "Trending" · "Rising". -3. **RankingList** — numbered rows on surface cards (radius 10, tight 12px - vertical padding): rank number (xl/600, fixed width), logo 32px, name - (base/500) + family Badge, **RankSparkline** (12-week mini line), score - (muted), **TrendBadge** with movement (▲3 success / ▼2 danger / — muted). - Top 3 rows get subtly larger numbers + accent-soft tint — celebratory but - calm, no podium kitsch. -4. Right rail (desktop only): "Biggest movers this period" compact list + - link to Hall of Fame. -5. Optional filter: category SortSelect ("All categories") above the list. - -Mobile: single column; sparkline hides below 400px width; rail content moves -below the list. Rows stay one-line — truncate names. - -## Components -Tabs, Chip, RankingList, TrendBadge, RankSparkline, Badge, SortSelect, -SkeletonRow, EmptyState, ErrorState, Pagination. - -## Content & states -- Loading: 10 SkeletonRows preserving the row anatomy. -- Empty (young periods with no data): EmptyState "Not enough data yet — - check back soon." -- Error: ErrorState with retry replacing the list. - -## Interaction & motion -Period switch cross-fades rows (base) with rank numbers counting to their -new values (fast, tabular figures); movement badges pop in with a small -spring; row hover raises surface slightly and reveals a "view distro →" -affordance. Reduced motion: static swap, no counting. - -## Accessibility -The list is a real ordered list. Movement is conveyed by text ("+3") not -color alone. Tabs follow the ARIA pattern. Sparklines are decorative -(aria-hidden) with score text carrying the data. - -## Reference -Modrinth's project stat rows for density; Flathub's restraint for keeping a -leaderboard calm rather than gamified. diff --git a/prompts/stitch/search-results.md b/prompts/stitch/search-results.md deleted file mode 100644 index 15b60bf..0000000 --- a/prompts/stitch/search-results.md +++ /dev/null @@ -1,49 +0,0 @@ -# Stitch Brief — Search Results (+ autocomplete & command palette) - -*(Use together with `_foundation.md`.)* - -## Purpose -Fast fuzzy search over names/aliases/summaries across locales. Three -surfaces share one visual language: inline autocomplete (under any -SearchInput), the full results page, and the keyboard **CommandPalette** (⌘K). - -## Layout -**Autocomplete dropdown** (under SearchInput): surface-raised, radius 10, -modal shadow; up to 10 rows — logo 24px, name with **matched substring -bolded**, family Badge; footer row "Search for 'ubu' →" going to the full -page. Keyboard: ↑↓ + Enter. - -**Results page:** header "12 results for 'arch'" (lg/600) + SearchInput -(pre-filled, focused); slim FacetChip row (category/family/desktop quick -filters — not the full rail); results as **list rows** (not cards): logo -40px, name (base/600) with match highlight, one-line summary (sm muted, -highlight there too), family Badge + downloads right-aligned. Desktop rows -max-width ~800px centered. Pagination below. - -**CommandPalette (⌘K):** centered overlay 560px, top-anchored at ~20vh; -input on top, results identical anatomy to autocomplete; footer hint bar -(↑↓ navigate · ↵ open · esc close). Scrim blurs page slightly. - -## Components -SearchInput, CommandPalette, FacetChip, Badge, Pagination, SkeletonRow, -EmptyState, ErrorState. - -## Content & states -- Loading: 5 SkeletonRows (page); palette shows a subtle inline pulse. -- Empty: EmptyState "No matches for 'xyz'" + suggestions: check spelling, - browse Explore, suggest this distro (link to Contribute). -- Error: ErrorState with retry (page); palette shows a compact inline error. - -## Interaction & motion -Dropdown fades+drops 4px (fast); highlight follows arrow keys; palette -opens with slow fade+scale-from-98%; result rows have no lift — selection -is a full-row accent-soft highlight. Reduced motion: fades only. - -## Accessibility -Combobox pattern: aria-expanded, aria-activedescendant, results as a -listbox; result count announced politely. Palette traps focus, Esc closes, -restores focus to the trigger. Highlighted match uses bold, not color alone. - -## Reference -Modrinth search-as-you-type responsiveness; Raycast/Linear-style palette -restraint, kept in Flathub calm. diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts deleted file mode 100644 index 9b32698..0000000 --- a/scripts/check-boundaries.ts +++ /dev/null @@ -1,163 +0,0 @@ -// Workspace boundary check. -// -// bun scripts/check-boundaries.ts -// -// `.ai/architecture.md` states two rules that nothing enforced until now, so -// breaking either typechecked cleanly and failed later — at deploy, or at -// runtime in production: -// -// 1. Runtime boundary. apps/api runs on workerd. Bun and Node APIs do not -// exist there, and wrangler.toml sets no `nodejs_compat` flag, so a -// `node:` import or a `Bun.` global reaching the Worker bundle is a -// deploy-time failure. packages/ingest is the live hazard: the Worker -// imports its fetchers, while its CLI half legitimately uses Bun.file and -// node:fs — only the import graph keeps them apart. -// -// 2. Dependency direction. `packages/*` are libraries; they must not import -// an app. And the two apps must not import each other — they are separate -// deployments that talk over HTTP. -// -// This scans import specifiers with a regex rather than a full AST. That is -// enough to be exact about *what a module imports*, which is all these rules -// are about, and it keeps the check dependency-free. It deliberately does not -// try to judge how an import is used. - -import { readdir } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -// `fileURLToPath`, not `.pathname`: on Windows a file URL's pathname is -// `/C:/Users/...`, whose leading slash makes it an invalid path — every -// script here failed with ENOENT on a Windows checkout. -const ROOT = fileURLToPath(new URL("..", import.meta.url)).replace(/[/\\]$/, ""); - -type Violation = { file: string; line: number; rule: string; detail: string }; - -const SOURCE = /\.(ts|tsx|svelte|js)$/; -const SKIP_DIRS = new Set(["node_modules", ".svelte-kit", "dist", ".wrangler", "build"]); - -/** `import … from "x"`, `export … from "x"`, `import("x")`, `require("x")`. */ -const IMPORT = /(?:\bfrom\s*|\bimport\s*\(|\brequire\s*\()\s*["']([^"']+)["']/g; -/** A bare `import "x"` side-effect statement, which the pattern above misses. */ -const BARE_IMPORT = /^\s*import\s+["']([^"']+)["']/gm; - -async function* walk(dir: string): AsyncGenerator { - for (const entry of await readdir(dir, { withFileTypes: true })) { - if (entry.name.startsWith(".") && entry.name !== ".") { - if (SKIP_DIRS.has(entry.name)) continue; - } - if (SKIP_DIRS.has(entry.name)) continue; - const full = `${dir}/${entry.name}`; - if (entry.isDirectory()) yield* walk(full); - else if (SOURCE.test(entry.name)) yield full; - } -} - -function importsOf(source: string): Array<{ spec: string; line: number }> { - const found: Array<{ spec: string; line: number }> = []; - const lineOf = (index: number) => source.slice(0, index).split("\n").length; - for (const pattern of [IMPORT, BARE_IMPORT]) { - pattern.lastIndex = 0; - let match = pattern.exec(source); - while (match !== null) { - if (match[1]) found.push({ spec: match[1], line: lineOf(match.index) }); - match = pattern.exec(source); - } - } - return found; -} - -/** Type-only imports are erased before anything runs, so they cannot drag a - * runtime dependency into the Worker bundle. */ -function isTypeOnly(source: string, line: number): boolean { - const text = source.split("\n")[line - 1] ?? ""; - return /^\s*(?:import|export)\s+type\b/.test(text); -} - -const violations: Violation[] = []; - -for await (const file of walk(`${ROOT}/apps`)) { - const rel = file.slice(ROOT.length + 1); - const source = await Bun.file(file).text(); - // Scoped to what wrangler actually bundles (`main` is src/index.ts). Tests - // run under Vitest on Bun and never reach workerd, so harness.ts reading - // the migration files with node:fs is correct, not a violation. - const isApi = rel.startsWith("apps/api/src/"); - const isWeb = rel.startsWith("apps/web/"); - - for (const { spec, line } of importsOf(source)) { - if (isApi && spec.startsWith("node:") && !isTypeOnly(source, line)) { - violations.push({ - file: rel, - line, - rule: "workers-runtime", - detail: `imports "${spec}" — workerd has no Node builtins and wrangler.toml sets no nodejs_compat flag`, - }); - } - if (isApi && spec === "@linuxhub/web") { - violations.push({ - file: rel, - line, - rule: "app-isolation", - detail: "apps/api imports apps/web", - }); - } - if (isWeb && spec === "@linuxhub/api") { - violations.push({ - file: rel, - line, - rule: "app-isolation", - detail: "apps/web imports apps/api — they are separate deployments and talk over HTTP", - }); - } - } - - // `Bun.` is a global, not an import, so it needs its own scan. Comments are - // stripped first so the many mentions of Bun in prose do not trip it. - if (isApi) { - const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, ""); - const bun = code.match(/\bBun\.\w+/); - if (bun) { - violations.push({ - file: rel, - line: source.split("\n").findIndex((l) => l.includes(bun[0])) + 1, - rule: "workers-runtime", - detail: `uses the Bun global "${bun[0]}" — not available on workerd`, - }); - } - } -} - -for await (const file of walk(`${ROOT}/packages`)) { - const rel = file.slice(ROOT.length + 1); - const source = await Bun.file(file).text(); - for (const { spec, line } of importsOf(source)) { - if (spec.startsWith("@linuxhub/web") || spec.startsWith("@linuxhub/api")) { - violations.push({ - file: rel, - line, - rule: "dependency-direction", - detail: `imports "${spec}" — packages are libraries and must not depend on an app`, - }); - } - if (/(^|\/)\.\.\/\.\.\/\.\.\/apps\//.test(spec)) { - violations.push({ - file: rel, - line, - rule: "dependency-direction", - detail: `reaches into apps/ via "${spec}"`, - }); - } - } -} - -if (violations.length > 0) { - console.error(`\n${violations.length} workspace boundary violation(s):\n`); - for (const v of violations) { - console.error(` ${v.file}:${v.line}`); - console.error(` [${v.rule}] ${v.detail}\n`); - } - console.error('Rules are stated in .ai/architecture.md § "Runtime boundaries".\n'); - process.exit(1); -} - -console.log("workspace boundaries ok"); diff --git a/scripts/check-deploy-config.test.ts b/scripts/check-deploy-config.test.ts deleted file mode 100644 index ebdea4e..0000000 --- a/scripts/check-deploy-config.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// The guard exists because both faults it catches deploy *successfully* and -// fail only in production. Both fixtures below are the real thing: the second -// is the config that took this project's first deploy down. - -import { describe, expect, it } from "vitest"; -import { declaredBindings, isPlaceholder, requiredBindings } from "./check-deploy-config"; - -const ENV_TS = ` -export type Env = { - DB: D1Database; - KV_CACHE: KVNamespace; - KV_RATE: KVNamespace; - KV_GEO: KVNamespace; - INTERNAL_API_TOKEN?: string; - SITE_ORIGIN?: string; -};`; - -describe("requiredBindings", () => { - it("takes the names from Env rather than a hardcoded list", () => { - expect(requiredBindings(ENV_TS)).toEqual(["DB", "KV_CACHE", "KV_RATE", "KV_GEO"]); - }); - - it("ignores secrets and vars, which are not bindings", () => { - expect(requiredBindings(ENV_TS)).not.toContain("INTERNAL_API_TOKEN"); - expect(requiredBindings(ENV_TS)).not.toContain("SITE_ORIGIN"); - }); -}); - -describe("declaredBindings", () => { - it("reads every binding the config declares, CRLF included", () => { - // The config came back from a Windows machine with CRLF endings. - const toml = - '[[d1_databases]]\r\nbinding = "DB"\r\n\r\n[[kv_namespaces]]\r\nbinding = "KV_CACHE"\r\n'; - expect(declaredBindings(toml)).toEqual(["DB", "KV_CACHE"]); - }); - - it("catches the binding wrangler suggests instead of ours", () => { - const toml = '[[d1_databases]]\nbinding = "linuxhub"\ndatabase_name = "linuxhub"\n'; - const declared = declaredBindings(toml); - expect(declared).toEqual(["linuxhub"]); - expect(requiredBindings(ENV_TS).filter((n) => !declared.includes(n))).toContain("DB"); - }); -}); - -describe("isPlaceholder", () => { - it("catches the committed placeholders, including the cute ones", () => { - // "All zeros" would pass three of these four — it did, on the first try. - expect(isPlaceholder("00000000-0000-0000-0000-000000000000")).toBe(true); - expect(isPlaceholder("0000000000000000000000000000cace")).toBe(true); - expect(isPlaceholder("0000000000000000000000000000ea7e")).toBe(true); - expect(isPlaceholder("00000000000000000000000000000e60")).toBe(true); - }); - - it("passes real ids", () => { - expect(isPlaceholder("f25e9e31-8851-48e5-a36c-d374db28ef4c")).toBe(false); - expect(isPlaceholder("c11a899ae51b4bfd9e8b2ffd29b86471")).toBe(false); - }); -}); diff --git a/scripts/check-deploy-config.ts b/scripts/check-deploy-config.ts deleted file mode 100644 index 98e764e..0000000 --- a/scripts/check-deploy-config.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Pre-deploy guard for the Worker. -// -// `apps/api/wrangler.toml` ships with zeroed D1 and KV ids so that local dev, -// tests and `--dry-run` builds work without a Cloudflare account. Those -// placeholders are also perfectly valid TOML, so `wrangler deploy` will happily -// upload a Worker whose bindings point at nothing — and the failure only shows -// up as 500s in production, one request at a time. -// -// It also checks the **binding names**, which is the other half of the same -// trap. `wrangler d1 create ` prints a ready-to-paste block whose -// `binding` is the database name, not ours — pasting it whole renames `DB` to -// something else, `env.DB` becomes undefined, and every query dies with -// "Cannot read properties of undefined (reading 'prepare')" while the deploy -// itself succeeds. That happened on the first real deploy of this project. -// -// This runs before `wrangler deploy` (see the `deploy` script in -// apps/api/package.json) and refuses when it finds either fault. - -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -// `fileURLToPath`, not `.pathname`: on Windows a file URL's pathname is -// `/C:/Users/...`, whose leading slash makes it an invalid path — every -// script here failed with ENOENT on a Windows checkout. -const CONFIG = fileURLToPath(new URL("../apps/api/wrangler.toml", import.meta.url)); -const ENV_TYPE = fileURLToPath(new URL("../apps/api/src/env.ts", import.meta.url)); - -/** - * A placeholder is an id with a long run of zeros. - * - * Not "all zeros": the committed KV placeholders end in `cace`, `ea7e` and - * `0e60`, so a stricter rule silently passes three of the four bindings — it - * did, on the first attempt. A real id is 32 random hex characters, where a run - * of twelve zeros is a 1-in-2^48 event; the zeroed UUID's longest run is - * exactly twelve. - */ -export function isPlaceholder(id: string): boolean { - return /0{12,}/.test(id); -} - -type Binding = { line: number; key: string; id: string }; - -function bindings(toml: string): Binding[] { - const found: Binding[] = []; - toml.split("\n").forEach((line, index) => { - const match = line.match(/^\s*(database_id|id)\s*=\s*"([^"]*)"/); - if (match?.[1] && match[2] !== undefined) { - found.push({ line: index + 1, key: match[1], id: match[2] }); - } - }); - return found; -} - -/** - * The binding names the Worker actually reads, taken from `Env` rather than - * listed here — a hardcoded copy is one that drifts the first time a binding - * is added. - */ -export function requiredBindings(envSource: string): string[] { - const names: string[] = []; - for (const line of envSource.split("\n")) { - const match = line.match(/^\s*(\w+):\s*(D1Database|KVNamespace);/); - if (match?.[1]) names.push(match[1]); - } - return names; -} - -/** Every `binding = "…"` the config declares. */ -export function declaredBindings(toml: string): string[] { - return [...toml.matchAll(/^\s*binding\s*=\s*"([^"]*)"/gm)].map((m) => m[1] as string); -} - -/** The check itself. Guarded below so importing this module for its helpers - * does not read files or exit the process. */ -function main(): void { - const toml = readFileSync(CONFIG, "utf8"); - const problems: string[] = []; - - const placeholders = bindings(toml).filter((b) => isPlaceholder(b.id)); - if (placeholders.length > 0) { - problems.push( - "Placeholder binding ids are still in place:\n" + - placeholders - .map(({ line, key, id }) => ` wrangler.toml:${line} ${key} = "${id}"`) - .join("\n") + - "\n These are valid TOML, so wrangler would deploy a Worker whose D1 and KV\n" + - " bindings point at nothing — visible only as 500s in production.\n" + - " Create the resources and paste the real ids: docs/deployment.md §§ 1-2.", - ); - } - - const declared = declaredBindings(toml); - const required = requiredBindings(readFileSync(ENV_TYPE, "utf8")); - const missing = required.filter((name) => !declared.includes(name)); - if (missing.length > 0) { - problems.push( - `Bindings the Worker reads are not declared: ${missing.join(", ")}\n` + - ` wrangler.toml declares: ${declared.join(", ") || "(none)"}\n` + - " `env.` is undefined at runtime, so every use dies with\n" + - ' "Cannot read properties of undefined" while the deploy still succeeds.\n' + - ' Note `wrangler d1 create` suggests `binding = ""` —\n' + - " keep our names (src/env.ts) and replace only the id.", - ); - } - - if (problems.length > 0) { - console.error("Refusing to deploy — apps/api/wrangler.toml:\n"); - for (const problem of problems) console.error(` ${problem}\n`); - process.exit(1); - } - - console.log(`deploy config ok — ids look real, bindings present: ${required.join(", ")}`); -} - -if (import.meta.main) main(); diff --git a/tsconfig.base.json b/tsconfig.base.json deleted file mode 100644 index 861aa11..0000000 --- a/tsconfig.base.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "docs": [ - "Shared TypeScript settings for every workspace (.ai/architecture.md).", - "", - "Packages extend this and declare only what actually differs: their own", - "`types` (Workers vs. Bun), any extra `lib`, and `include`. Keeping the", - "common set here means a compiler-option change is one edit rather than", - "six, and no workspace can quietly drift to weaker settings.", - "", - "apps/web deliberately does NOT extend this: it extends the config", - "SvelteKit generates into .svelte-kit/, which owns paths and rootDirs.", - "Changing that file's compilerOptions is how you break `$lib` imports." - ], - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - } -} diff --git a/vitest.scripts.config.ts b/vitest.scripts.config.ts deleted file mode 100644 index 00f1fd8..0000000 --- a/vitest.scripts.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vitest/config"; - -// Root-level tests: the repo scripts. `bun run test` delegates to the -// workspaces, and `scripts/` is not one — so a test file there ran under no -// runner at all, which is a test that rots without ever failing. -// -// `defineConfig` comes from `vitest/config`, not `vite`: the root has no vite -// dependency and does not need one for this. -// -// Named `vitest.scripts.config.ts`, not `vitest.config.ts`, and passed with an -// explicit `--config`. Vitest walks *up* from its working directory looking for -// `vitest.config.*`, so a config at the repo root silently overrode -// `apps/api`'s include glob and left it reporting "No test files found". -export default defineConfig({ - test: { - include: ["scripts/**/*.test.ts"], - }, -}); From 61302b6578f02b194f924d2bcb10771122445f52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:20:13 +0000 Subject: [PATCH 02/16] chore: scaffold astro 7 project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single Astro 7 app (static output, Vercel adapter with static headers and CSP, MDX, sitemap with ko/en, Pagefind) on Bun, TypeScript strictest, Biome, Vitest and Playwright. Adds the new CLAUDE.md, the approved plan (docs/plan.md), the decision log, content guide, vercel.json with security and cache headers, CI, and a SessionStart hook for cloud sessions. Refs: docs/plan.md §2, §9, §10 (phase 0) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8A7WuJeLxG7aBvK6xfgC9 --- .claude/hooks/session-start.sh | 25 + .claude/settings.json | 14 + .editorconfig | 12 + .github/workflows/ci.yml | 50 ++ .gitignore | 25 + CLAUDE.md | 70 ++ LICENSE | 29 + README.md | 31 + astro.config.mjs | 59 ++ biome.json | 33 + bun.lock | 1258 ++++++++++++++++++++++++++++++++ docs/content-guide.md | 29 + docs/data-sources.md | 15 + docs/decisions.md | 55 ++ docs/plan.md | 352 +++++++++ e2e/smoke.spec.ts | 6 + package.json | 46 ++ playwright.config.ts | 37 + scripts/sync/run.ts | 2 + scripts/sync/validate.ts | 2 + src/pages/index.astro | 14 + tests/smoke.test.ts | 7 + tsconfig.json | 11 + tsconfig.scripts.json | 11 + vercel.json | 40 + vitest.config.ts | 9 + 26 files changed, 2242 insertions(+) create mode 100755 .claude/hooks/session-start.sh create mode 100644 .claude/settings.json create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 astro.config.mjs create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 docs/content-guide.md create mode 100644 docs/data-sources.md create mode 100644 docs/decisions.md create mode 100644 docs/plan.md create mode 100644 e2e/smoke.spec.ts create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 scripts/sync/run.ts create mode 100644 scripts/sync/validate.ts create mode 100644 src/pages/index.astro create mode 100644 tests/smoke.test.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.scripts.json create mode 100644 vercel.json create mode 100644 vitest.config.ts diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 0000000..1417240 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# SessionStart hook: prepares a cold Claude Code on the web container so the +# quality gates (lint / check / test / build / test:e2e) work on the first try. +# Safe to re-run; skipped on local machines. +set -euo pipefail + +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +cd "${CLAUDE_PROJECT_DIR:-$(dirname "$0")/../..}" + +bun install + +# Generates .astro/types.d.ts (content collection + astro:* module types) so +# `bun run check` and editor tooling work on a fresh clone. +bunx astro sync >/dev/null + +# The image ships a prebuilt Chromium; playwright.config.ts picks it up. +if [ -x /opt/pw-browsers/chromium ] && [ -n "${CLAUDE_ENV_FILE:-}" ]; then + echo 'export PLAYWRIGHT_CHROMIUM_PATH="/opt/pw-browsers/chromium"' >> "$CLAUDE_ENV_FILE" + echo 'export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1' >> "$CLAUDE_ENV_FILE" +fi + +echo "linuxhub: dependencies installed, Astro types synced." diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e06b033 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b793fa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - run: bun install --frozen-lockfile + - run: bun run lint + - run: bun run check + - run: bun run test + - run: bun run build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist + retention-days: 3 + + e2e: + needs: checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - run: bun install --frozen-lockfile + - uses: actions/download-artifact@v5 + with: + name: dist + path: dist + - run: bunx playwright install --with-deps chromium + - run: bun run test:e2e diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89c9347 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# build output +dist/ +.vercel/ +.astro/ + +# dependencies +node_modules/ + +# logs & env +*.log +.env +.env.* +!.env.example + +# test output +test-results/ +playwright-report/ +coverage/ +.lighthouseci/ + +# sync http cache (ETag/Last-Modified), regenerated per run +scripts/sync/.cache/ + +# os +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..410cfce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md — Linuxhub + +Linuxhub is a fast, bilingual (Korean default at `/`, English at `/en/`) catalog +of ~50 popular Linux distributions, built with **Astro 7** and a **GNOME +libadwaita** look (Flathub-like). Every distro has a short overview, an +official-doc-level install guide, its official logo, and a guided download +(version → edition → arch → format → mirror) that only uses **official +mirrors/CDNs** and recommends the nearest mirror from the visitor's IP. +Release data refreshes automatically every 6 hours via GitHub Actions. + +The approved plan lives in `docs/plan.md` (Korean). Decisions are logged in +`docs/decisions.md`. Read both before changing architecture. + +## Stack (changes need a new ADR in `docs/decisions.md`) + +| Layer | Choice | +|---|---| +| Framework | Astro 7, `output: "static"`; the only on-demand route is `src/pages/api/geo.ts` | +| Package manager / scripts | Bun (single app, no monorepo). Astro itself runs on Node ≥ 22.12 | +| Content | Content Layer collections in `src/content.config.ts` (`z` from `astro/zod`, Zod 4) + `@astrojs/mdx` | +| Styling | Plain CSS with libadwaita tokens (`src/styles/`), Astro scoped styles. No Tailwind, no UI framework runtime | +| Client JS | Small vanilla TS islands only where needed (download wizard, filters, search, quiz, compare, copy, theme) | +| Search | `astro-pagefind` (index built in `astro:build:done`) with a custom Adwaita dialog | +| Deploy | Vercel via `@astrojs/vercel` (`imageService: false`, `staticHeaders: true`), region `icn1` | +| Data refresh | `scripts/sync/` run by `.github/workflows/sync.yml`, writes `src/data/**` and commits | +| Lint / tests | Biome, Vitest (`tests/`, `scripts/**/*.test.ts`), Playwright + axe (`e2e/`) | + +## Hard rules + +1. **Near-zero JS.** Pages are static HTML. Add client JS only for real + interaction, as a small vanilla module. Never add React/Svelte/Vue runtimes. +2. **CSP is on** (`security.csp`). No inline `style=""` attributes, no inline + event handlers, no Shiki (Prism only), no ``. Use native CSS + cross-document view transitions. +3. **Adwaita design only.** Use the tokens in `src/styles/tokens.css` and the + components in `src/components/adw/`. Do not invent colors, radii or shadows; + do not restyle a component per page. New visual patterns go into the + component set first. +4. **i18n.** No hardcoded UI strings: use `src/i18n/` dictionaries (`ko`, + `en`). Every page exists in both locales via the `[...locale]` route param. +5. **Official sources only.** Downloads, checksums and mirrors come from the + official project. Checksums are always shown from the official origin host. + `src/data/**` is generated by `bun run sync` — never edit it by hand. +6. **Polite fetching.** Sync respects robots.txt and Crawl-delay, sends the + `LinuxhubBot` User-Agent, paces per host, uses ETag/If-Modified-Since. + Never scrape DistroWatch; the distro list is editorial. +7. **Content.** Paraphrase official docs — never copy verbatim. Every + overview/install doc lists `sources` and `lastReviewed` in frontmatter. + Versions, filenames and checksums inside docs come from data components, + never hand-typed. Korean and English ship together. +8. **Logos.** Official, unmodified (no recoloring). Record source URL, + license and trademark policy per logo. +9. **Privacy.** `/api/geo` returns country/coordinates with + `Cache-Control: private, no-store` and never logs them. + +## Commands (run before every commit) + +```bash +bun run lint # Biome (bun run format to autofix) +bun run check # astro check + scripts typecheck +bun run test # vitest +bun run build # astro build (+ pagefind, sitemap, vercel output) +bun run test:e2e # Playwright + axe against the production build (UI changes) +``` + +## Git + +One completed task = one commit, Conventional Commits +(`feat(web): …`, `feat(sync): …`, `docs: …`, `chore(data): sync …`). +Develop on the assigned branch; production deploys from `main`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4d70012 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +MIT License + +Copyright (c) 2026 Linuxhub contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Written content under src/content/ is licensed under the Creative Commons +Attribution-ShareAlike 4.0 International License (CC BY-SA 4.0): +https://creativecommons.org/licenses/by-sa/4.0/ + +Third-party assets keep their own licenses: Adwaita Sans / Adwaita Mono +(SIL Open Font License 1.1), Adwaita icon theme (CC BY-SA 3.0 / LGPL-3.0), +and distribution logos (trademarks of their owners; see the About page). diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbb6a2f --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Linuxhub + +리눅스 배포판을 둘러보고, 공식 미러에서 내려받고, 설치까지 따라 할 수 있는 +빠르고 깔끔한 카탈로그입니다. A fast, clean catalog of Linux distributions with +official downloads and install guides (Korean / English). + +- **Astro 7** static site with a **GNOME libadwaita** look +- ~50 popular distributions, official logos, short overviews, step-by-step install guides +- Guided downloads from **official mirrors/CDNs**, nearest mirror suggested from your IP +- Release data refreshed every 6 hours by GitHub Actions +- A complete Linux guide, distro finder, comparisons, family tree and release news + +## Development + +```bash +bun install +bun run dev # http://localhost:4321 +bun run build # production build (dist/ + .vercel/output) +bun run sync # refresh release + mirror data from official sources +``` + +Quality gates: `bun run lint`, `bun run check`, `bun run test`, `bun run build`, +`bun run test:e2e`. + +See `docs/plan.md` for the project plan and `docs/decisions.md` for decisions. + +## License + +Code: MIT (see `LICENSE`). Written content in `src/content/`: CC BY-SA 4.0. +Distribution names and logos are trademarks of their respective owners and are +used only to identify the projects; see the About page for sources. diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..7f77a63 --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,59 @@ +// @ts-check +import mdx from "@astrojs/mdx"; +import sitemap from "@astrojs/sitemap"; +import vercel from "@astrojs/vercel"; +import { defineConfig } from "astro/config"; +import pagefind from "astro-pagefind"; + +/** + * The canonical origin. Set SITE_URL in Vercel once a custom domain exists; + * until then the production deployment URL Vercel exposes at build time is + * used, and local builds fall back to the dev server origin. + */ +const site = + process.env.SITE_URL ?? + (process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : "http://localhost:4321"); + +export default defineConfig({ + site, + output: "static", + trailingSlash: "always", + adapter: vercel({ + imageService: false, + maxDuration: 10, + staticHeaders: true, + }), + integrations: [ + mdx(), + sitemap({ + i18n: { defaultLocale: "ko", locales: { ko: "ko-KR", en: "en-US" } }, + }), + pagefind(), + ], + i18n: { + locales: ["ko", "en"], + defaultLocale: "ko", + routing: { prefixDefaultLocale: false }, + }, + prefetch: { prefetchAll: true, defaultStrategy: "hover" }, + build: { inlineStylesheets: "always", format: "directory" }, + markdown: { syntaxHighlight: "prism" }, + security: { + csp: { + directives: [ + "default-src 'self'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ], + // Pagefind compiles its search index with WebAssembly. + scriptDirective: { resources: ["'self'", "'wasm-unsafe-eval'"] }, + styleDirective: { resources: ["'self'"] }, + }, + }, +}); diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..e349a53 --- /dev/null +++ b/biome.json @@ -0,0 +1,33 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": [ + "**", + "!dist", + "!.vercel", + "!.astro", + "!src/data/**/*.json", + "!test-results", + "!playwright-report" + ] + }, + "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, + "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, + "linter": { + "enabled": true, + "rules": { "preset": "recommended" } + }, + "overrides": [ + { + "includes": ["**/*.astro"], + "linter": { + "rules": { + "correctness": { "noUnusedVariables": "off", "noUnusedImports": "off" }, + "style": { "useConst": "off", "useImportType": "off" } + } + } + } + ], + "assist": { "actions": { "source": { "organizeImports": "on" } } } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..b20de86 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1258 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "linuxhub", + "dependencies": { + "@astrojs/markdown-remark": "^7.3.1", + "@astrojs/mdx": "^8.0.2", + "@astrojs/rss": "^4.0.19", + "@astrojs/sitemap": "^3.7.4", + "@astrojs/vercel": "^11.0.11", + "@vercel/functions": "^3.9.11", + "astro": "^7.3.5", + "astro-pagefind": "^2.0.1", + }, + "devDependencies": { + "@astrojs/check": "^0.9.10", + "@axe-core/playwright": "^4.13.0", + "@biomejs/biome": "^2.5.15", + "@playwright/test": "^1.63.0", + "@types/node": "^24", + "robots-parser": "^3.0.1", + "svgo": "^4.1.0", + "typescript": "^6.0.3", + "vitest": "^5.0.3", + "yaml": "^2.9.1", + }, + }, + }, + "packages": { + "@astrojs/astro2tsx": ["@astrojs/astro2tsx@0.1.2", "", { "dependencies": { "@emnapi/core": "1.11.3", "@napi-rs/wasm-runtime": "1.2.4" } }, "sha512-Uj/M9zbrhu5cIZEvWAGTbQJRKSxXWJ/E59BZwt7F+chrKhbfttW+9rkS/l+1QxoJP1KU3UnpM62M7yszfAI0WA=="], + + "@astrojs/check": ["@astrojs/check@0.9.10", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^18.0.0" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "./bin/astro-check.js" } }, "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ=="], + + "@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.5.1", "", { "optionalDependencies": { "@astrojs/compiler-binding-android-arm64": "0.5.1", "@astrojs/compiler-binding-darwin-arm64": "0.5.1", "@astrojs/compiler-binding-darwin-x64": "0.5.1", "@astrojs/compiler-binding-linux-arm64-gnu": "0.5.1", "@astrojs/compiler-binding-linux-arm64-musl": "0.5.1", "@astrojs/compiler-binding-linux-x64-gnu": "0.5.1", "@astrojs/compiler-binding-linux-x64-musl": "0.5.1", "@astrojs/compiler-binding-wasm32-wasi": "0.5.1", "@astrojs/compiler-binding-win32-arm64-msvc": "0.5.1", "@astrojs/compiler-binding-win32-x64-msvc": "0.5.1" } }, "sha512-oczyolLUl9mNrtuBuNjuvn5thpHrFLXAHAWGTRg0JDHQ8cGVsVeFekOu2uSoQGgainJIi+A3/9m0DyYwz3Nlnw=="], + + "@astrojs/compiler-binding-android-arm64": ["@astrojs/compiler-binding-android-arm64@0.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-nMb2nMIJtcWLNH+fV75Enmi+jp4tIOYOfsv4XtpgdOWkqc9P4uSSdK5u1xb340AzrXo5V18SxLl/nc3vcqtamQ=="], + + "@astrojs/compiler-binding-darwin-arm64": ["@astrojs/compiler-binding-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5k4+nZ/WRt+dcVow4z7rBuI7iv9A58h/uQ91YJe1PrTivbcCkq3u2Ynx/AZJwII4CsZawBCeGpojkFjutZBhBQ=="], + + "@astrojs/compiler-binding-darwin-x64": ["@astrojs/compiler-binding-darwin-x64@0.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-0Buw+YST9Ff2EqQyhRMQujuFq/dijLw3zXWhsvI0dJ2pGrT38iVoxTEca2eBX/PfhlwkVWSRJEKO5gMqKHyeHg=="], + + "@astrojs/compiler-binding-linux-arm64-gnu": ["@astrojs/compiler-binding-linux-arm64-gnu@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-x5LRpyCoMKuu3INr18cn42YhwSOaRpp9b5Ny3eJJ/sWVeejC16Zeshu1JlvlSmEZACLeWxU5JM0RX6T6nI+YyA=="], + + "@astrojs/compiler-binding-linux-arm64-musl": ["@astrojs/compiler-binding-linux-arm64-musl@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mL7tQEuDk3rGfaKmrO63nw6WBy1TPVioQGmVyjcAr+C+a/k3SVMYBytNSMqH9mZPT/Swj30JaZ2N842Z1NnOEQ=="], + + "@astrojs/compiler-binding-linux-x64-gnu": ["@astrojs/compiler-binding-linux-x64-gnu@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-uLGc9jtxwiDe4cQyhNPQim4ECiB/YzbWlXssfLAv8r374kArfTwBHjwR2+j+XVyF71yCx5mF9YNRclRqdNBgeg=="], + + "@astrojs/compiler-binding-linux-x64-musl": ["@astrojs/compiler-binding-linux-x64-musl@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-G0cywinaQUc8v1MrytvYKUX7svTDMmYtOCZfxjwKPbJi59zIqM+Ll0rWgO0GQWLZH3XoPYo19ZwlP3Xt9ep9YA=="], + + "@astrojs/compiler-binding-wasm32-wasi": ["@astrojs/compiler-binding-wasm32-wasi@0.5.1", "", { "dependencies": { "@emnapi/core": "1.11.3", "@emnapi/runtime": "1.11.3", "@napi-rs/wasm-runtime": "^1.2.4" }, "cpu": "none" }, "sha512-yMhLL8asmLydoAdnEZxAw6eLfNDs3YJl6jqn0pjPt6zrb2U5qIDLnii4/twThpIFODKkQ6YDpBRTOz/vSfZCxw=="], + + "@astrojs/compiler-binding-win32-arm64-msvc": ["@astrojs/compiler-binding-win32-arm64-msvc@0.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aClIG6poFt3Wf/bZykrEAX2nAnKvBHW6RXiKUa69QjBtWH0/FOlOAjnFcxRNlhej2AMWNYFx0o+LGjwMcH8P7Q=="], + + "@astrojs/compiler-binding-win32-x64-msvc": ["@astrojs/compiler-binding-win32-x64-msvc@0.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-/E6uBCagY70YpWqssBC+/FuniNSloeQPRgj7l3LDWiqjxIyTLnIgX1sTt18FU7jrigetpSnC9cHAM6b6IxSgPw=="], + + "@astrojs/compiler-rs": ["@astrojs/compiler-rs@0.5.1", "", { "dependencies": { "@astrojs/compiler-binding": "0.5.1" } }, "sha512-1k1acnX7DepIiea9U9/Y+d1hdAFfylu1ZQQkkYgRSwBOBAsXInolyZuh/2tax+83vu/A+hOYP6P3+265RvMTvw=="], + + "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.11.0", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA=="], + + "@astrojs/language-server": ["@astrojs/language-server@2.17.1", "", { "dependencies": { "@astrojs/astro2tsx": "^0.1.0", "@astrojs/yaml2ts": "^0.2.4", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.17", "volar-service-css": "0.0.71", "volar-service-emmet": "0.0.71", "volar-service-html": "0.0.71", "volar-service-prettier": "0.0.71", "volar-service-typescript": "0.0.71", "volar-service-typescript-twoslash-queries": "0.0.71", "volar-service-yaml": "0.0.71", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.2.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "./bin/nodeServer.js" } }, "sha512-gG7bvMVNIXgoSe1pM9dI1HsSDLU1EIc/dSAYQ0DuReLyLkOBjsiqvaRYgQltWaldLxh0bNEqjgreLfjd9OnUxQ=="], + + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.3.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.11.0", "@astrojs/prism": "4.0.2", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "estree-util-visit": "^2.0.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.5", "hast-util-to-text": "^4.0.2", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-QCjsjZQl0+Cgb91+T3Ip9hyUe1r5aLm4udi584rML9YO3syupbAYE+jWpAxYg+z4lFLNUugPrylFxZ+8qFTClg=="], + + "@astrojs/markdown-satteri": ["@astrojs/markdown-satteri@0.4.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.11.0", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "satteri": "^0.10.3" } }, "sha512-g4QehXnB/MzxQWnSf17NciFInMqEJy9948p8cc7zGg/r7995m0Ln5PMr3Xlnj24iey1LchaoZ3SzTOa3D4P/3w=="], + + "@astrojs/mdx": ["@astrojs/mdx@8.0.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.11.0", "@astrojs/markdown-satteri": "0.4.2", "es-module-lexer": "^2.0.0" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0", "astro": "^7.2.10" }, "optionalPeers": ["@astrojs/markdown-remark"] }, "sha512-WedIVP2Bu8fXSzxb3ZYZ8xDrWWjf2SRKCneh52Llhoa+O3lcpHPhoZVP3FerlivUaH/0LJz2yVaCNvMdg0u8Bw=="], + + "@astrojs/prism": ["@astrojs/prism@4.0.2", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA=="], + + "@astrojs/rss": ["@astrojs/rss@4.0.19", "", { "dependencies": { "fast-xml-parser": "^5.5.7", "piccolore": "^0.1.3", "zod": "^4.3.6" } }, "sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA=="], + + "@astrojs/sitemap": ["@astrojs/sitemap@3.7.4", "", { "dependencies": { "sitemap": "^9.0.0", "zod": "^4.3.6" } }, "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA=="], + + "@astrojs/telemetry": ["@astrojs/telemetry@3.3.3", "", { "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", "package-manager-detector": "^1.6.0" } }, "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ=="], + + "@astrojs/vercel": ["@astrojs/vercel@11.0.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.11.0", "@vercel/analytics": "^2.0.1", "@vercel/functions": "^3.9.8", "@vercel/nft": "^1.11.0", "@vercel/routing-utils": "^6.6.0", "esbuild": "^0.28.0", "tinyglobby": "^0.2.17" }, "peerDependencies": { "astro": "^7.0.0" } }, "sha512-OAh1CzR9eKlDBBpmp1VY95tFnAWDO46Ue7qxHjx0/2hdjHILvcZa6pnjjLOAOihuuR3CYpsZSPOscfJRKf8dOQ=="], + + "@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.4", "", { "dependencies": { "yaml": "^2.8.3" } }, "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A=="], + + "@axe-core/playwright": ["@axe-core/playwright@4.13.0", "", { "dependencies": { "axe-core": "~4.13.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], + + "@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.10.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA=="], + + "@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.10.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q=="], + + "@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w=="], + + "@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA=="], + + "@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw=="], + + "@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg=="], + + "@bruits/satteri-wasm32-wasi": ["@bruits/satteri-wasm32-wasi@0.10.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.2.3" }, "cpu": "none" }, "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw=="], + + "@bruits/satteri-win32-arm64-msvc": ["@bruits/satteri-win32-arm64-msvc@0.10.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw=="], + + "@bruits/satteri-win32-x64-msvc": ["@bruits/satteri-win32-x64-msvc@0.10.5", "", { "os": "win32", "cpu": "x64" }, "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ=="], + + "@capsizecss/unpack": ["@capsizecss/unpack@4.0.1", "", { "dependencies": { "fontkitten": "^1.0.3" } }, "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ=="], + + "@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="], + + "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], + + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], + + "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], + + "@emmetio/css-parser": ["@emmetio/css-parser@0.4.1", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ=="], + + "@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="], + + "@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="], + + "@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="], + + "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], + + "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.3", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg=="], + + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + + "@nodable/entities": ["@nodable/entities@3.1.0", "", {}, "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], + + "@pagefind/component-ui": ["@pagefind/component-ui@1.5.2", "", { "dependencies": { "adequate-little-templates": "^1.0.2", "bcp-47": "^2.1.0" } }, "sha512-t8/aE0tan4JiKa6cyhhSt/5qrEVwAK/qlYBHFpnRoq+qaFFVrhmXFFMY+r6n4GJtVIFCN2A5nUpeLN68cYjEjw=="], + + "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ=="], + + "@pagefind/darwin-x64": ["@pagefind/darwin-x64@1.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw=="], + + "@pagefind/freebsd-x64": ["@pagefind/freebsd-x64@1.5.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA=="], + + "@pagefind/linux-arm64": ["@pagefind/linux-arm64@1.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw=="], + + "@pagefind/linux-x64": ["@pagefind/linux-x64@1.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA=="], + + "@pagefind/windows-arm64": ["@pagefind/windows-arm64@1.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g=="], + + "@pagefind/windows-x64": ["@pagefind/windows-x64@1.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg=="], + + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + + "@shikijs/core": ["@shikijs/core@4.5.0", "", { "dependencies": { "@shikijs/primitive": "4.5.0", "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw=="], + + "@shikijs/langs": ["@shikijs/langs@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0" } }, "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA=="], + + "@shikijs/primitive": ["@shikijs/primitive@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg=="], + + "@shikijs/themes": ["@shikijs/themes@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0" } }, "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q=="], + + "@shikijs/types": ["@shikijs/types@4.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], + + "@types/node": ["@types/node@24.19.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ=="], + + "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + + "@vercel/analytics": ["@vercel/analytics@2.0.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "nuxt": ">= 3", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "nuxt", "react", "svelte", "vue", "vue-router"] }, "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g=="], + + "@vercel/cli-config": ["@vercel/cli-config@0.3.1", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-sISbygqlFXYb8qfPu/+2Cf/A8IjrlAuZePdag3EDQr2PRA455s7UZtl9Ro674CWsB/saBRmz3jEkw0YsQs8OoQ=="], + + "@vercel/cli-exec": ["@vercel/cli-exec@1.0.1", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ=="], + + "@vercel/functions": ["@vercel/functions@3.9.11", "", { "dependencies": { "@vercel/oidc": "4.0.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-DbqKqcbUHGBTYgHZieSNDuF1/L2iSPZV0OGm0Xkm/t+s61lFk4VX8yGQ22Og3WYGxypW1l4Elx1NP8BL928u8Q=="], + + "@vercel/nft": ["@vercel/nft@1.11.0", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.4", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-m1QFg+U+3yPOnP1xSYJ73UIRxLOXdts1JOhiOiyPYqEsALgrXFFINvgUaD6R6iNvaBFAjHllBCbkfx4FuOdpaA=="], + + "@vercel/oidc": ["@vercel/oidc@4.0.0", "", { "dependencies": { "@vercel/cli-config": "0.3.1", "@vercel/cli-exec": "1.0.1", "jose": "^5.9.6" } }, "sha512-ko6ATeRUlHZMB5hLFdC7+/Q1dI2f3NX3gZHxtpALdkxurwjD5HFHGWYF+4o+4SF0E47jg6KzcHknmRpHcKgJ5A=="], + + "@vercel/routing-utils": ["@vercel/routing-utils@6.6.0", "", { "dependencies": { "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0" }, "optionalDependencies": { "ajv": "^6.12.3" } }, "sha512-Z74zwk1ixcIsMN7Sp788SsZuOEqmJZ4N36WHusr6Vtck5eFAcjyD4oBpjDYRhkxovsh8t13s0L+uVbJYxUPa5w=="], + + "@vitest/mocker": ["@vitest/mocker@5.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg=="], + + "@vitest/spy": ["@vitest/spy@5.0.3", "", {}, "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg=="], + + "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/language-server": ["@volar/language-server@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw=="], + + "@volar/language-service": ["@volar/language-service@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="], + + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "adequate-little-templates": ["adequate-little-templates@1.0.2", "", {}, "sha512-d0tFFG538l3Y4CElRKtticzrMr/OGohs32/nf3Ewn8rbTMBYwkVNe8mPdXWrDnMlz+ZVUFQjsTmsBD5zCtU4wg=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + + "ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="], + + "am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="], + + "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + + "astro": ["astro@7.3.5", "", { "dependencies": { "@astrojs/compiler-rs": "^0.5.0", "@astrojs/internal-helpers": "0.11.0", "@astrojs/markdown-satteri": "0.4.2", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^5.8.1", "diff": "^9.0.0", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "find-proc": "0.2.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.3.2", "jsonc-parser": "^3.3.1", "magic-string": "^1.0.0", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^3.0.0", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "shiki": "^4.0.2", "smol-toml": "^1.8.0", "svgo": "^4.1.0", "tinyclip": "^1.0.1", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.5", "unstorage": "^1.17.5", "verkit": "^0.4.0", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.5.4" }, "optionalDependencies": { "sharp": "^0.35.4" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-1p/ernaq/RuESbM5NMjWzbO6SiebMGrGcYlGjMfBhzzgM+n/X7AUmVyw8BqW2QYCdivpsHwJBcD1fqmlTZjZHg=="], + + "astro-pagefind": ["astro-pagefind@2.0.1", "", { "dependencies": { "@pagefind/component-ui": "^1.5.0", "pagefind": "^1.5.0", "sirv": "^3.0.0" }, "peerDependencies": { "astro": "^2.0.4 || ^3 || ^4 || ^5 || ^6 || ^7" } }, "sha512-zhMTctuVuKrYH1C0Xl+p8mhexEuxVrqfT8paQOAbKZFtgUIWAMtR2nCeU11ti4Rfeit16LhiCxZFwjOqYoiaNQ=="], + + "async-sema": ["async-sema@3.1.1", "", {}, "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg=="], + + "axe-core": ["axe-core@4.13.0", "", {}, "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "bcp-47": ["bcp-47@2.1.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="], + + "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + + "css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="], + + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], + + "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], + + "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], + + "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-util-scope": ["estree-util-scope@1.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA=="], + + "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], + + "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], + + "fast-xml-parser": ["fast-xml-parser@5.11.2", "", { "dependencies": { "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "find-proc": ["find-proc@0.2.0", "", {}, "sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w=="], + + "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], + + "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], + + "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="], + + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + + "magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="], + + "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="], + + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "micromark": ["micromark@4.0.3", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.4", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.3" } }, "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], + + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], + + "micromark-factory-space": ["micromark-factory-space@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-edit-map": ["micromark-util-edit-map@1.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "neotraverse": ["neotraverse@1.0.1", "", {}, "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w=="], + + "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="], + + "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + + "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + + "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], + + "p-limit": ["p-limit@7.3.3", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ=="], + + "p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="], + + "p-timeout": ["p-timeout@7.0.2", "", {}, "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw=="], + + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + + "pagefind": ["pagefind@1.5.2", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.5.2", "@pagefind/darwin-x64": "1.5.2", "@pagefind/freebsd-x64": "1.5.2", "@pagefind/linux-arm64": "1.5.2", "@pagefind/linux-x64": "1.5.2", "@pagefind/windows-arm64": "1.5.2", "@pagefind/windows-x64": "1.5.2" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "path-to-regexp": ["path-to-regexp@6.1.0", "", {}, "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw=="], + + "path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "prettier": ["prettier@3.9.9", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "process-ancestry": ["process-ancestry@0.1.0", "", {}, "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], + + "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], + + "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], + + "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + + "rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-smartypants": ["remark-smartypants@3.0.3", "", { "dependencies": { "retext": "^9.0.0", "retext-smartypants": "^6.0.0", "unified": "^11.0.4", "unist-util-visit": "^5.0.0" } }, "sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], + + "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], + + "retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="], + + "retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="], + + "robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="], + + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], + + "satteri": ["satteri@0.10.5", "", { "dependencies": { "@types/estree-jsx": "^1.0.5", "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3" }, "optionalDependencies": { "@bruits/satteri-darwin-arm64": "0.10.5", "@bruits/satteri-darwin-x64": "0.10.5", "@bruits/satteri-linux-arm64-gnu": "0.10.5", "@bruits/satteri-linux-arm64-musl": "0.10.5", "@bruits/satteri-linux-x64-gnu": "0.10.5", "@bruits/satteri-linux-x64-musl": "0.10.5", "@bruits/satteri-wasm32-wasi": "0.10.5", "@bruits/satteri-win32-arm64-msvc": "0.10.5", "@bruits/satteri-win32-x64-msvc": "0.10.5" } }, "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shiki": ["shiki@4.5.0", "", { "dependencies": { "@shikijs/core": "4.5.0", "@shikijs/engine-javascript": "4.5.0", "@shikijs/engine-oniguruma": "4.5.0", "@shikijs/langs": "4.5.0", "@shikijs/themes": "4.5.0", "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "sitemap": ["sitemap@9.0.1", "", { "dependencies": { "@types/node": "^24.9.2", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/esm/cli.js" } }, "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ=="], + + "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + + "string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "svgo": ["svgo@4.1.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^6.0.0", "css-tree": "^3.0.1", "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" } }, "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q=="], + + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "tinybench": ["tinybench@6.2.0", "", {}, "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q=="], + + "tinyclip": ["tinyclip@1.0.3", "", {}, "sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + + "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unifont": ["unifont@0.7.5", "", { "dependencies": { "css-tree": "^3.1.0", "ohash": "^2.0.11", "undici": "^8.0.0" } }, "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-modify-children": ["unist-util-modify-children@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "array-iterate": "^2.0.0" } }, "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-children": ["unist-util-visit-children@3.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "verkit": ["verkit@0.4.1", "", {}, "sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "vitest": ["vitest@5.0.3", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.3", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "^6.1.4", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.3", "@vitest/browser-preview": "5.0.3", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.3", "@vitest/coverage-v8": "5.0.3", "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ=="], + + "volar-service-css": ["volar-service-css@0.0.71", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw=="], + + "volar-service-emmet": ["volar-service-emmet@0.0.71", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA=="], + + "volar-service-html": ["volar-service-html@0.0.71", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw=="], + + "volar-service-prettier": ["volar-service-prettier@0.0.71", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg=="], + + "volar-service-typescript": ["volar-service-typescript@0.0.71", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw=="], + + "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.71", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A=="], + + "volar-service-yaml": ["volar-service-yaml@0.0.71", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.23.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q=="], + + "vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="], + + "vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="], + + "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.15", "", {}, "sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.18.4", "", {}, "sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg=="], + + "vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="], + + "vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="], + + "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], + + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + + "yaml-language-server": ["yaml-language-server@1.23.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-i18n": "^4.2.0", "prettier": "^3.8.1", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.8.3" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A=="], + + "yargs": ["yargs@18.2.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@astrojs/compiler-binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], + + "@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + + "ajv-i18n/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "astro/obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "vscode-css-languageservice/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "yaml-language-server/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], + + "yaml-language-server/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + + "@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "ajv-i18n/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + + "unstorage/chokidar/readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + + "yaml-language-server/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + } +} diff --git a/docs/content-guide.md b/docs/content-guide.md new file mode 100644 index 0000000..ac3db48 --- /dev/null +++ b/docs/content-guide.md @@ -0,0 +1,29 @@ +# 콘텐츠 작성 가이드 + +## 원칙 +1. **베끼지 않는다.** 공식 문서를 그대로 옮기지 말고 이해한 내용을 자기 말로 다시 쓴다. 명령어와 고유명사는 그대로 써도 된다. +2. **출처를 남긴다.** frontmatter `sources`에 참고한 공식 문서 URL을 모두 적는다. +3. **검토일을 적는다.** frontmatter `lastReviewed`에 마지막으로 확인한 날짜(YYYY-MM-DD)를 적는다. +4. **바뀌는 값은 데이터 컴포넌트로 넣는다.** 버전, 파일명, 체크섬, 크기를 손으로 쓰지 않는다. ``, ``, `` 같은 데이터 컴포넌트를 쓴다. +5. **두 언어를 함께 낸다.** 한국어와 영어를 같은 커밋에 넣는다. 영어는 번역투가 아니라 자연스러운 영어로 쓴다. +6. **겹치는 단계는 공용 partial을 쓴다.** USB 만들기, 체크섬과 서명 검증, UEFI와 Secure Boot, 듀얼부팅처럼 여러 배포판에 공통인 단계는 `src/content/partials/`의 공용 partial로 처리한다. + +## 배포판 소개 (`overview.mdx`) +- 2~4개 문단, 한국어 기준 300~600자. +- 담을 내용: 무엇인지, 누구에게 맞는지, 특징(릴리스 모델, 기본 데스크톱 환경, 패키지 관리), 장점과 주의할 점. + +## 설치 가이드 (`install.mdx`), 공식 문서 수준 +아래 순서를 지킨다. +1. **시작하기 전에:** 시스템 요구사항, 백업, 듀얼부팅 여부 +2. **다운로드와 검증:** 다운로드 마법사 링크, 체크섬, 가능하면 GPG 서명까지 +3. **부팅 USB 만들기:** Windows, macOS, Linux별로 안내 +4. **펌웨어 설정:** UEFI/BIOS 부팅 순서, Secure Boot 지원 여부 +5. **설치:** 설치 프로그램 화면 순서대로 단계별로 설명. 파티션은 자동/수동, 암호화 옵션을 다룬다 +6. **설치 후 할 일:** 업데이트, 드라이버(GPU, Wi-Fi), 코덱, Flatpak 등 +7. **문제 해결:** 자주 막히는 지점 +8. **공식 문서와 커뮤니티 링크** + +## 문체 +- 한국어는 "~합니다" 체로 쓴다. 군더더기 수식어를 빼고, 명령형 지시는 짧게 쓴다. +- 코드 블록에는 언어를 표기한다(`bash`, `text` 등). 프롬프트 기호(`$`)는 넣지 않는다. +- 위험한 작업(디스크 지우기 등)은 ``으로 경고한다. diff --git a/docs/data-sources.md b/docs/data-sources.md new file mode 100644 index 0000000..d20c720 --- /dev/null +++ b/docs/data-sources.md @@ -0,0 +1,15 @@ +# 데이터 소스 + +이 문서는 3단계(동기화 파이프라인)에서 배포판마다 채운다. 각 행에 담을 내용은 다음과 같다. +- 공식 소스 URL과 유형(api, checksum-dir, sourceforge-rss, github-release, manual) +- 다운로드 전략(redirector, mirrors, sourceforge, official-page) +- 미러 목록 소스 +- robots.txt 확인 결과와 확인 날짜 +- 로고 출처, 라이선스, 상표 정책 + +## 공통 규칙 +- User-Agent: `LinuxhubBot/1.0 (+/about/#bot)` +- robots.txt를 확인하고 캐시하며, `Crawl-delay`를 지킨다. +- 같은 호스트에는 1초 이상 간격을 둔다. +- ETag와 If-Modified-Since를 쓴다. +- DistroWatch는 사용하지 않는다(ADR-0008). diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..df914ee --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,55 @@ +# 결정 기록 (ADR) + +새 결정은 아래에 번호를 이어서 추가한다. 이미 내린 결정을 뒤집을 때도 기존 항목을 고치지 않고 새 ADR을 쓴다. + +## ADR-0001 — 처음부터 다시 시작 (2026-10-03) +- **결정:** 기존 SvelteKit + Hono/Workers 모노레포를 모두 지우고 Astro 단일 앱으로 새로 시작한다. +- **이유:** 소유자의 지시다. 목표는 Adwaita 디자인, 아주 빠른 로딩, 단순한 기능 구성이다. +- **유지하는 것:** git 히스토리만 남기고, 코드와 문서는 이어받지 않는다. + +## ADR-0002 — Astro 7 정적 출력과 Vercel +- **결정:** `output: "static"`으로 빌드한다. 서버에서 실행되는 경로는 `/api/geo` 하나뿐이다(`prerender = false`). +- **어댑터 설정:** `@astrojs/vercel` 11을 쓴다. + - `imageService: false`로 런타임 이미지 변환 비용을 없앤다. + - `staticHeaders: true`로 CSP를 HTTP 헤더로 보낸다. +- **리전:** 어댑터에는 리전 옵션이 없으므로 `vercel.json`의 `regions: ["icn1"]`로 지정한다. + +## ADR-0003 — 데이터는 git에 커밋된 JSON, 갱신은 GitHub Actions +- **결정:** `scripts/sync/`가 공식 소스에서 데이터를 가져와 `src/data/**`에 JSON으로 저장한다. + - `.github/workflows/sync.yml`이 6시간마다(미러는 매일) 실행한다. + - 데이터가 바뀌었을 때만 커밋하고, Vercel이 자동으로 재배포한다. +- **이유:** + - 페이지가 완전히 정적이라 가장 빠르다. + - 변경 이력과 롤백을 git으로 관리할 수 있다. + - 런타임 데이터베이스나 KV가 필요 없다. +- **실패 처리:** 어댑터 하나가 실패하면 그 배포판만 마지막 정상 데이터를 유지한다. 3번 연속 실패하면 이슈를 연다. + +## ADR-0004 — 미러 추천 방식 +- **결정:** `/api/geo`가 Vercel의 `geolocation()`으로 국가와 좌표를 알려 주면, 브라우저가 정적 미러 JSON을 받아 순위를 매긴다. + - **정렬 기준:** 같은 국가 → 하버사인 거리(미러 좌표가 없으면 국가 중심 좌표) → HTTPS → 공식 점수 + - **대체 경로:** 실패하면 브라우저 시간대에서 위치를 추정한다(IANA zone1970 좌표). 그것도 안 되면 공식 기본 CDN을 쓴다. +- **공식 리다이렉터가 있는 배포판:** Fedora, openSUSE, Kali처럼 공식 리다이렉터·CDN이 있으면 그것을 기본으로 고르고, 화면에 "자동으로 가까운 서버"라고 표시한다. +- **개인정보:** 위치 정보는 `no-store`로 응답하고 기록하지 않는다. 브라우저 Geolocation API는 쓰지 않는다. + +## ADR-0005 — 다국어: 한국어 기본, 영어 `/en/` +- **결정:** 모든 페이지를 `src/pages/[...locale]/` 아래 하나의 파일로 두 언어용으로 생성한다. +- **자동 리다이렉트 없음:** 언어를 자동으로 바꾸지 않는다. hreflang과 언어 전환 버튼만 둔다(SEO 친화적이고 서버 비용이 없다). +- **UI 문자열:** `src/i18n/{ko,en}.ts`에 둔다. + +## ADR-0006 — Adwaita 디자인 토큰 +- **결정:** libadwaita 1.10의 `_colors.scss` 값을 CSS 변수로 그대로 옮긴다. 모서리 반경도 libadwaita를 따른다(버튼 9px, 카드 12px, 다이얼로그 15px). +- **폰트:** + - Adwaita Sans/Mono 51.0(OFL)의 라틴 subset을 woff2로 직접 호스팅한다. + - 한글은 시스템 폰트를 쓴다. 웹폰트 0바이트로 Lighthouse 100을 노린다. +- **아이콘:** Adwaita symbolic 아이콘(CC BY-SA 3.0)을 쓴다. + +## ADR-0007 — CSP와 클라이언트 JS 규칙 +- **결정:** Astro `security.csp`로 inline script와 style의 해시를 자동으로 넣는다. + - Pagefind 때문에 `'wasm-unsafe-eval'`을 허용한다. + - 코드 하이라이트는 Prism을 쓴다. Shiki는 inline style을 써서 CSP와 맞지 않는다. + - 페이지 전환은 CSS `@view-transition`으로 하고 ClientRouter는 쓰지 않는다. +- **meta CSP의 한계:** `frame-ancestors`는 meta 태그로 보낼 수 없으므로 `vercel.json`의 `X-Frame-Options: DENY`로 대신한다. + +## ADR-0008 — 배포판 목록은 편집자가 정한다 +- **결정:** DistroWatch를 크롤링하지 않는다(robots.txt가 AI 봇을 금지한다). 수록 목록(약 50개)은 편집자가 정한다. +- **정렬:** 인기 순위를 다른 사이트에서 가져와 표시하지 않는다. 정렬은 추천순(편집), 이름순, 최근 업데이트순만 둔다. diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..7f5a3b8 --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,352 @@ +# Linuxhub 재구축 계획: Astro + Adwaita 리눅스 배포판 웹앱 + +## 1. 배경 + +- **목표:** Flathub처럼 GNOME Adwaita 디자인을 쓴 리눅스 배포판 웹앱을 **Astro로 처음부터 새로** 만든다. +- **요구사항:** + - 빠른 로딩, 깔끔하고 단순한 화면(쓸데없는 기능 없음) + - 공식 로고 + - 공식 미러/CDN에서 다운로드, IP 위치 기반 최적 미러 추천, 상시 자동 업데이트 + - 리눅스 종합 가이드, 공식 문서 수준의 설치 가이드 + - 오래 머물고 싶은 사이트, Vercel 배포 +- **기존 저장소는 참고하지 않는다.** 사용자 지시에 따라 SvelteKit/Hono 모노레포 전체를 지운다. 기존 `CLAUDE.md`, `.ai/`, `design/`, `apps/`, `packages/` 등 추적 파일 308개가 대상이다. git 히스토리는 남기고 새로 시작한다. 기존 `CLAUDE.md`의 스택 고정 규칙은 사용자의 명시적 지시로 대체되며, 0단계에서 새 `CLAUDE.md`로 교체한다. +- **작업 브랜치:** `claude/linux-distro-webapp-astro-5dn8qn`. 단계마다 커밋하고 이 브랜치에 푸시한다. + +### 사용자가 확정한 사항 +| 항목 | 결정 | +|---|---| +| 언어 | 한국어 + 영어. 한국어가 기본(`/`), 영어는 `/en/` | +| 배포판 수 | 약 50개. 모든 배포판에 개별 상세 설치 가이드를 쓴다 | +| 부가 기능 | 배포판 찾기 퀴즈, 비교, 계보도·역사 타임라인, 최근 릴리스 소식 + RSS | +| 자동 업데이트 | GitHub Actions 크론 → 검증 → JSON 커밋 → Vercel 자동 재배포 | + +--- + +## 2. 기술 스택 (검증 완료: 2026-10 기준 최신) + +| 영역 | 선택 | 이유 | +|---|---|---| +| 프레임워크 | **Astro 7.3** (`output: 'static'`), Node ≥ 22.12 | 페이지는 모두 정적 HTML이고 JS는 거의 없다 | +| 패키지 매니저 | Bun (단일 앱, 모노레포 아님) | 단순화 | +| 언어 | TypeScript strict | | +| 콘텐츠 | Content Layer(`src/content.config.ts`, `glob`/`file` 로더, `astro/zod`의 Zod 4) + `@astrojs/mdx` 8 | 빌드할 때 스키마로 검증한다 | +| 검색 | `astro-pagefind` 2 (Pagefind 1.5), 인터페이스는 Adwaita 스타일로 직접 만든다 | 정적 인덱스. 검색을 열 때만 로드한다 | +| 배포 | `@astrojs/vercel` 11. 서버 함수는 `/api/geo` 하나뿐이다 | 나머지는 전부 CDN에서 정적 파일로 제공한다 | +| 스타일 | 순수 CSS. libadwaita 토큰과 Astro 스코프 스타일을 쓰고 Tailwind는 쓰지 않는다 | 가장 가볍고 Adwaita를 충실하게 재현할 수 있다 | +| 폰트 | Adwaita Sans/Mono(OFL)의 라틴 글자만 woff2로 잘라 Astro Fonts API로 preload. 한글은 시스템 폰트(Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR)를 쓴다 | 한글 웹폰트를 0바이트로 해서 Lighthouse 100을 노린다 | +| 아이콘 | Adwaita symbolic 아이콘 중 쓰는 것만 인라인 SVG 스프라이트로 넣는다(CC BY-SA 3.0, 출처 표기) | | +| 린트·테스트 | Biome, Vitest, Playwright + axe, Lighthouse CI | | + +**Astro 7에서 주의할 점** + +- **HTML 문법:** 새 Rust 컴파일러는 잘못된 HTML을 에러로 처리한다. +- **Markdown 파이프라인:** Markdown은 Sätteri로 처리된다. remark 플러그인이 필요하면 `@astrojs/markdown-remark`를 써야 한다. +- **예약된 파일명:** `src/fetch.ts`는 Astro가 예약했으므로 동기화 스크립트는 `scripts/`에 둔다. +- **CSP와의 충돌:** ``와 Shiki는 Astro CSP와 충돌한다. 그래서 다음과 같이 한다. + - 화면 전환은 JS 없이 CSS `@view-transition { navigation: auto; }`로 한다. + - 코드 하이라이트는 빌드할 때 Prism으로 한다. + +--- + +## 3. 사이트 구조 (페이지) + +모든 페이지는 `src/pages/[...locale]/…` 하나로 두 언어를 만든다. `locale`이 없으면 한국어 `/…`, `en`이면 영어 `/en/…`가 된다. 언어는 자동으로 리다이렉트하지 않는다. 헤더의 언어 전환 버튼과 hreflang만 둔다. + +| 경로 | 내용 | 클라이언트 JS | +|---|---|---| +| `/` | 홈: 짧은 소개와 검색, 추천 배포판, 용도별 진입(입문·게이밍·개발·서버·보안·경량·롤링), 최근 릴리스, 리눅스 가이드 소개 | 없음 | +| `/distros/` | 전체 목록(Flathub식 카드 그리드). 필터는 계열·용도·데스크톱·릴리스 모델·난이도, 정렬은 추천순·이름순·최근 업데이트순. 필터 상태는 URL 쿼리에 저장하고, JS가 없어도 전체 목록이 보인다 | 필터 약 2KB | +| `/distros/[slug]/` | 상세: 로고·이름·태그라인·배지, 짧은 설명, 핵심 정보 목록(최신 버전·출시일·지원 종료·아키텍처·기본 데스크톱·패키지 관리자·설치 프로그램·공식 링크), **다운로드 마법사**, 설치 가이드 링크, 관련 배포판, 데이터 마지막 확인 시각 | 다운로드 약 6KB | +| `/distros/[slug]/install/` | 공식 문서 수준의 설치 가이드. 목차와 단계를 보여 주고, 버전·파일명·체크섬은 동기화 데이터로 자동 반영한다 | 복사 버튼 1KB 미만 | +| `/learn/`, `/learn/[chapter]/` | 리눅스 가이드: 약 20개 장, 사이드바 목차, 이전/다음 장 | 없음 | +| `/finder/` | 배포판 찾기 퀴즈(6~7문항, 결과 상위 3개와 그 이유) | 약 3KB | +| `/compare/?d=ubuntu,fedora` | 2~3개 배포판 비교, 링크로 공유 가능 | 약 3KB | +| `/family/` | 계보도(계열별 트리)와 역사 타임라인. 빌드할 때 SVG로 만든다 | 없음 | +| `/releases/`, `/rss.xml` | 최근 릴리스 소식(동기화 기록에서 생성) | 없음 | +| `/about/` | 데이터 출처, 로고 상표 고지, 위치 정보 처리 안내, 연락처 | 없음 | +| `/404` | Adwaita StatusPage 스타일 | 없음 | +| `/api/geo` | **유일한 서버 함수**. 방문자 국가와 좌표를 JSON으로 돌려준다 | — | + +공통 헤더(AdwHeaderBar 스타일): 로고, 배포판, 리눅스 가이드, 찾기, 계보, 검색(Ctrl+K), 언어, 테마(시스템/라이트/다크). 모바일에서는 하단 시트 메뉴로 바뀐다. + +### 체류 시간 전략 (팝업이나 다크 패턴 없이, 콘텐츠로 연결) +- **상세 페이지 하단:** 같은 계열이나 비슷한 용도의 배포판을 보여 주고, 이 배포판에 쓰인 기술(데스크톱 환경, 패키지 관리자)을 가이드의 해당 장으로 연결한다. +- **용어 링크:** 가이드와 설치 문서에 처음 나오는 용어를 용어사전과 가이드 장에 링크한다. +- **흐름 연결:** 다운로드를 시작하면 "다음 단계: 부팅 USB 만들기"로, 퀴즈 결과는 상세와 비교로, 비교 결과는 각 상세로 이어진다. +- **가이드 읽기 경험:** 리눅스 가이드는 장 단위로 나누고 이전/다음 장, 읽기 시간, 관련 배포판을 보여 준다. + +--- + +## 4. Adwaita 디자인 시스템 + +- **색상 토큰:** libadwaita 1.10의 `_colors.scss` 값을 CSS 변수로 그대로 옮긴다. 라이트·다크 모드는 `prefers-color-scheme`와 수동 토글로 바꾼다. + - `--accent-bg-color` `#3584e4`. accent 글자색은 라이트 `#0461be`, 다크 `#81d0ff`(oklab 계산식을 그대로 쓴다). + - `--window-bg-color` `#fafafb` / `#222226`, `--view-bg-color` `#fff` / `#1d1d20`. + - `--headerbar-bg-color` `#fff` / `#2e2e32`, `--card-bg-color` `#fff` / `rgb(255 255 255 / 8%)`, `--card-shade-color` 등. + - destructive, success, warning 색상, 테두리(`currentColor` 15% 혼합), hover/active/selected 7%/16%/10%. +- **모서리 반경:** 버튼 9px, 카드 12px, 다이얼로그·팝오버 15px. 간격은 6px 배수로 맞춘다(Adwaita 기준). +- **컴포넌트**(`src/components/adw/`, 기본적으로 JS 없음): + - 화면 구조: HeaderBar, ViewSwitcher(탭), StatusPage, Banner + - 버튼과 칩: Button(suggested, flat, pill, destructive), LinkedButtons, Chip + - 목록과 카드: Card(클릭 가능), BoxedList, ActionRow, ExpanderRow(네이티브 `
`) + - 상호작용(JS 조금): Dialog(네이티브 ``), Toast + - 문서용: Callout, Steps, CodeBlock(복사 버튼), Kbd, Icon +- **도메인 컴포넌트**(`src/components/`): DistroCard(Flathub 앱 타일 스타일), DistroLogo, DownloadWizard, MirrorList, FilterBar, SearchDialog, Quiz, CompareTable, FamilyTree, Timeline, Toc, PrevNext, LangSwitcher, ThemeSwitcher +- **접근성:** WCAG 2.2 AA를 지킨다. + - Adwaita 스타일의 포커스 링과 본문 바로가기 링크를 둔다. + - `prefers-reduced-motion`를 존중하고, 터치 영역은 44px 이상으로 한다. +- **승인 절차:** 1단계를 마치면 컴포넌트 쇼케이스 페이지를 스크린샷으로 공유한다. 라이트와 다크, 모바일과 데스크톱을 모두 보여 주고, 사용자 확인을 받은 뒤 페이지 구현에 들어간다. + +--- + +## 5. 데이터와 콘텐츠 모델 + +### 5.1 배포판 목록 (51개 초안, 2단계에서 확정) +DistroWatch는 **크롤링하지 않는다**(robots.txt가 AI 봇을 금지한다). 목록은 편집자가 직접 정한다. +- **Debian/Ubuntu 계열(26):** Debian, Ubuntu, Kubuntu, Xubuntu, Lubuntu, Ubuntu MATE, Linux Mint, Pop!_OS, Zorin OS, elementary OS, MX Linux, antiX, KDE neon, Linux Lite, Peppermint, Q4OS, Bodhi, SparkyLinux, Devuan, Kali, Parrot, Tails, deepin, TUXEDO OS, Raspberry Pi OS, Vanilla OS +- **Arch 계열(6):** Arch, Manjaro, EndeavourOS, CachyOS, Garuda, Artix +- **Fedora/RHEL 계열(7):** Fedora, Bazzite, Nobara, AlmaLinux, Rocky Linux, CentOS Stream, Oracle Linux +- **SUSE(1):** openSUSE(Tumbleweed/Leap) · **Mandriva 계열(3):** Mageia, OpenMandriva, PCLinuxOS +- **독립(8):** Slackware, Gentoo, Void, Alpine, NixOS, Solus, Puppy, Qubes OS + +### 5.2 파일 구조 +``` +src/content/distros/.yaml # 편집 메타데이터. 사람이 작성하고 스키마로 검증한다 + name, family, basedOn, releaseModel, desktops[], packageManagers[], installer, + useCases[], difficulty, architectures[], origin{country, firstRelease}, + links{home, docs, forum, download, releaseNotes}, tagline{ko, en}, + logo{file, sourceUrl, license, trademarkPolicyUrl}, sources[], + sync{adapter, options}, download{strategy: redirector|mirrors|sourceforge|official-page} +src/content/distro-docs//{ko,en}/{overview,install}.mdx # 짧은 소개와 설치 가이드 +src/content/partials/{ko,en}/*.mdx # 공용 단계: USB 만들기, 체크섬·서명 검증, + # UEFI·Secure Boot, 듀얼부팅·BitLocker, + # 설치 프로그램별(Calamares, Anaconda, Ubuntu 설치 프로그램, + # debian-installer, archinstall, Agama/YaST …) +src/content/learn/{ko,en}/*.mdx # 리눅스 가이드 각 장 +src/content/glossary/{ko,en}.yaml # 용어사전 +src/data/ # ← 동기화가 생성하고 커밋하는 파일 (사람이 수정하지 않는다) + releases/.json # 버전·채널·출시일·지원 종료일, 에디션 → 아키텍처 → 형식별 파일 + # (상대 경로, 크기, sha256, 서명·토렌트 URL) + mirrors/.json # 공식 미러 목록 {url, country, lat?, lon?, https, score?} + history.json # 버전 변경 기록 (릴리스 소식과 RSS에 쓴다) + status.json # 소스별 마지막 성공 시각과 상태 +src/data/geo/countries.json, timezones.json # 국가 중심 좌표(Natural Earth, 퍼블릭 도메인), + # IANA zone1970.tab 좌표(퍼블릭 도메인) +src/assets/logos/.svg # 공식 로고. 색을 바꾸지 않고 SVGO로 최적화만 한다 +``` +- **설치 가이드도 자동 업데이트된다.** MDX 안에 ``, ``, `` 같은 데이터 컴포넌트를 넣는다. 버전이 바뀌면 문서도 다음 빌드에서 자동으로 갱신된다. +- **글쓰기 규칙**(`docs/content-guide.md`): + - 공식 문서를 **그대로 베끼지 않고 의역**한다. + - frontmatter에 출처(`sources`)와 검토일(`lastReviewed`)을 적는다. + - 한국어로 먼저 쓰고 영어를 같은 배치에서 쓴다. + +### 5.3 로고 +- **출처 우선순위:** 공식 사이트의 브랜드·프레스 키트를 먼저 쓰고, 공식 파일을 그대로 올린 Wikimedia Commons를 그다음으로 쓴다. +- **기록:** 배포판마다 출처 URL, 라이선스, 상표 정책 URL을 YAML에 기록한다. `/about`에 크레딧과 삭제 요청 연락처를 둔다. +- **사용 규칙:** 색을 바꾸지 않는다. 공식 다크 변형이 있으면 그것을 쓰고, 없으면 밝은 배경의 타일 위에 표시한다. + +--- + +## 6. 다운로드와 미러 추천 + +**다운로드 흐름:** 버전 → 에디션 → 아키텍처 → 형식(ISO, 토렌트, 체크섬, 서명) → 미러 → 다운로드. +- 다운로드 화면에서 SHA256을 복사하고 검증 명령을 볼 수 있다. +- 마지막으로 고른 값은 `localStorage`에 기억한다. + +배포판마다 공식 소스의 형태에 따라 전략을 나눈다(검증 결과 반영). + +| 전략 | 대상 | 동작 | +|---|---|---| +| `redirector` | Fedora, openSUSE, Kali, KDE neon, Tails, Zorin, NixOS, Alpine, Rocky, Alma, Manjaro, Pop!_OS, CachyOS, Bazzite, Slackware | 기본값은 "공식 CDN, 자동으로 가까운 서버". 공식 리다이렉터가 IP를 보고 연결한다. 국가별 목록이 있으면 함께 보여 준다 | +| `mirrors` | Arch, Ubuntu와 공식 변형판, Debian, Gentoo, Devuan, Mageia, Artix, Mint, EndeavourOS | 공식 미러 목록을 **우리가 IP 기준으로 순위를 매겨** 추천 3~5개를 보여 준다 | +| `sourceforge` | MX, antiX, Linux Lite, OpenMandriva, Peppermint, Q4OS, Bodhi, Sparky | 공식 SourceForge 프로젝트. SourceForge가 가까운 미러를 고른다 | +| `official-page` | elementary(링크 만료), PCLinuxOS, Oracle, Puppy 등 | 공식 다운로드 페이지와 검증 방법으로 안내한다 | + +**미러 추천 흐름** + +1. 다운로드 마법사를 열면 `/api/geo`를 호출한다. + - Vercel Function(`prerender = false`)이 `@vercel/functions`의 `geolocation()`으로 국가·위도·경도를 읽어 JSON으로 돌려준다. + - `Cache-Control: private, no-store`로 캐시하지 않고, 위치 정보를 기록하지 않는다. + - 함수 리전은 `vercel.json`의 `regions: ["icn1"]`(서울)로 정한다. +2. 정적 파일 `mirrors/.json`을 가져와 브라우저에서 순위를 매긴다(`src/lib/mirrors.ts`, 테스트 대상). + - **정렬 순서:** 같은 국가 → 하버사인 거리(미러 좌표가 있으면 그 값, Mageia만 해당; 없으면 국가 중심 좌표) → HTTPS → 공식 점수(예: Arch score). +3. `/api/geo`가 실패하면 브라우저 시간대(`Intl` timeZone)를 IANA 좌표로 바꿔 대신 쓴다. 그것도 실패하면 공식 기본 CDN을 쓴다. +4. 사용자는 미러를 직접 바꿀 수 있다. + - 체크섬은 **항상 공식 원본 호스트 기준**으로 보여 주고, 미러에서 받지 않는다. + +--- + +## 7. 상시 자동 업데이트 (GitHub Actions) + +`scripts/sync/` (Bun과 TS로 작성, 실제 소스를 기록한 fixture로 테스트한다) + +- **`http.ts`:** 모든 요청이 지키는 공통 규칙 + - User-Agent `LinuxhubBot/1.0 (+https://<도메인>/about#bot)` + - `robots-parser`로 robots.txt를 확인하고 캐시한다. `Crawl-delay`를 지킨다(Arch는 2초). + - 같은 호스트에는 1초 이상 간격을 두고, 429/5xx는 백오프 후 재시도한다. + - ETag와 If-Modified-Since를 쓴다. +- **범용 어댑터:** `endoflife`(18개 배포판의 버전과 지원 종료일), `json-api`, `checksum-dir`, `sourceforge-rss`, `github-release`, `manual` +- **배포판 전용 어댑터:** fedora `releases.json`, arch `releng`, ubuntu Launchpad, opensuse, pop-os, manjaro `iso-info`, alpine, tails, raspberrypi 등 +- **미러 수집기:** + - Arch `mirrors/status/json`, Ubuntu `cdimage_mirrors`, Debian `Mirrors.masterlist` + - Fedora/Rocky/CentOS `mirrorlist?country=`, Gentoo `distfiles.xml`, Mageia API, Devuan, Artix, Manjaro `status.json` +- **`validate.ts`:** 쓰기 전에 데이터를 검사한다. + - Zod 스키마 검사 + - sha256 형식(16진수 64자) 검사 + - 다운로드 URL은 배포판별 공식 호스트 허용 목록에 있는 https 주소만 허용 + - 버전이 내려가면 거부 + - robots.txt가 허용하는 곳만 HEAD 요청으로 파일이 있는지 확인 +- **`write.ts`:** 결과를 저장한다. + - 키를 정렬하고 가져온 시각을 빼서, 데이터가 실제로 바뀔 때만 diff가 생기게 한다. + - 어댑터가 실패하면 **그 배포판만 마지막 정상 데이터를 유지**하고 `status.json`에 "오래됨" 표시를 남긴다. +- **`history.json`:** 버전이 바뀌면 기록을 추가한다. 이 기록으로 릴리스 소식과 RSS를 만든다. + +**`.github/workflows/sync.yml`** + +- **실행 주기와 동시 실행:** + - 릴리스는 `17 */6 * * *`(6시간마다), 미러는 `43 3 * * *`(매일), 수동 실행(`workflow_dispatch`)도 가능하다. + - `concurrency`로 겹쳐 실행되지 않게 한다. +- **실행 순서:** + 1. `bun install` + 2. `bun run sync` + 3. `bun run sync:validate` + 4. `astro sync` + `astro check`로 스키마를 검사한다. + 5. 바뀐 내용이 있을 때만 `chore(data): sync YYYY-MM-DD` 커밋을 `main`에 푸시한다. Vercel이 Git 연동으로 자동 배포한다. +- **안전장치:** + - `GITHUB_TOKEN`으로 한 푸시는 다른 워크플로를 실행하지 않으므로 무한 루프가 생기지 않는다. + - 저장소는 public이라 Hobby 플랜의 봇 커밋 배포 제한에 걸리지 않을 것으로 본다. 대비책으로 `VERCEL_DEPLOY_HOOK` 시크릿이 있으면 Deploy Hook도 호출한다. + - 같은 소스가 3번 연속 실패하면 `sync-failure` 이슈를 자동으로 열거나 갱신한다. + - 크론은 60일 동안 활동이 없으면 꺼진다. 데이터 커밋이 보통 그보다 자주 생기지만, 대비해 keep-alive 단계를 둔다. + +--- + +## 8. 성능 최적화 (목표: Lighthouse 100 / 100 / 100 / 100) + +- **JS와 CSS:** + - 정적 HTML이 먼저이고, 기본 JS는 0KB다. 기능이 필요한 페이지에서만 작은 바닐라 TS 아일랜드를 쓰고, UI 프레임워크 런타임은 없다. + - 테마 초기화는 head의 인라인 스크립트 1개(1KB 미만, CSP 해시)로 해서 화면 깜빡임을 막는다. + - CSS는 gzip 20KB 이하로 만들고 `build.inlineStylesheets: 'always'`로 인라인한다. 첫 페인트에 필요한 요청이 HTML 하나뿐이다. +- **폰트:** 라틴 subset woff2 1개(약 35KB)를 preload하고, Fonts API가 만드는 대체 폰트 메트릭으로 레이아웃 이동(CLS)을 없앤다. 한글은 시스템 폰트를 쓴다. +- **이미지:** + - 로고는 SVGO로 최적화한 SVG를 `width`/`height`를 지정해 넣는다. 첫 화면 밖은 `loading="lazy"`, 상세 상단 로고는 `fetchpriority="high"`로 한다. + - Vercel 이미지 최적화(`imageService`)는 쓰지 않는다. +- **페이지 이동:** + - `prefetch: { prefetchAll: true, defaultStrategy: 'hover' }`, 그리고 Speculation Rules 기반 사전 렌더링(`clientPrerender`, 실험적 기능)을 켠다. + - JS 없는 네이티브 view transition을 쓴다. +- **캐시:** + - `/_astro/*`와 폰트는 1년 동안 immutable로 캐시한다. + - HTML은 Vercel CDN에 두고, 배포할 때 캐시가 자동으로 무효화된다. + - Pagefind 인덱스는 필요한 조각만 불러온다. +- **CI 예산:** + - Lighthouse CI: 성능 0.98 이상, 접근성·권장사항·SEO 1.0 + - 페이지당 JS gzip 15KB 이하(검색은 동적 로드라 제외) + - 상세 페이지 전체 전송량 120KB 이하 +- **SEO:** + - 모든 페이지에 canonical과 hreflang(ko, en, x-default)을 넣고, `@astrojs/sitemap`의 i18n 설정과 `robots.txt`를 쓴다. + - JSON-LD: WebSite+SearchAction, BreadcrumbList, 배포판은 SoftwareApplication, 설치 가이드는 HowTo/TechArticle + - OG 이미지는 빌드할 때 배포판별로 만든다(로고 + 이름). + +--- + +## 9. Vercel 배포 설정 + +`astro.config.mjs` (핵심): +```js +output: 'static', +adapter: vercel({ imageService: false, maxDuration: 10, staticHeaders: true }), +i18n: { locales: ['ko', 'en'], defaultLocale: 'ko', routing: { prefixDefaultLocale: false } }, +security: { csp: { /* script hashes 자동, Pagefind용 'wasm-unsafe-eval' */ } }, +``` +`vercel.json`: +```json +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "astro", + "installCommand": "bun install --frozen-lockfile", + "buildCommand": "bun run build", + "regions": ["icn1"], + "trailingSlash": true, + "headers": [ + { "source": "/_astro/(.*)", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] }, + { "source": "/pagefind/(.*)", "headers": [{ "key": "Cache-Control", "value": "public, max-age=3600, stale-while-revalidate=86400" }] }, + { "source": "/(.*)", "headers": [ + { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }, + { "key": "Content-Security-Policy", "value": "frame-ancestors 'none'" } ] } + ] +} +``` +- **배포 브랜치:** 프로덕션은 `main`, 그 밖의 브랜치와 PR은 프리뷰 배포다. 지금 작업 브랜치는 프리뷰로 확인한다. +- **Vercel 프로젝트 생성:** Vercel MCP로 팀 `starfect`에 프로젝트를 만들고 `neramc/linuxhub`를 연결한다. 아직 프로젝트가 없는 것을 확인했다. 외부 작업이므로 **9단계에서 사용자 승인을 받은 뒤** 진행한다. +- **도메인:** 사용자가 정한다. `astro.config`의 `site` 값으로 둔다. +- **플랜 제한:** Hobby 플랜은 비상업적 용도만 허용한다(참고로 고지한다). + +--- + +## 10. 진행 단계 (단계마다 독립 커밋 후 푸시) + +| 단계 | 내용 | 완료 기준 | +|---|---|---| +| **0. 초기화** | ① `chore: remove legacy monorepo`: 추적 파일 전부 `git rm`. ② `chore: scaffold astro 7 project`: Bun, TS strict, Biome, Vitest, Playwright, `vercel.json` 초안, `ci.yml`, 새 `CLAUDE.md`와 `docs/`(plan·decisions·data-sources·content-guide), `.claude` SessionStart 훅을 Astro용으로 교체, LICENSE(코드 MIT, 문서 CC BY-SA 4.0 기본안), README | `bun run build`, `lint`, `check`, `test` 통과 | +| **1. 디자인 시스템** | 토큰, 폰트 subset, 아이콘 스프라이트, adw 컴포넌트, 테마 전환, 개발용 쇼케이스 페이지 | 라이트/다크와 모바일/데스크톱 스크린샷을 공유하고 **사용자 확인** | +| **2. 데이터 모델·카탈로그** | `content.config.ts` 스키마, 배포판 YAML 51개, 공식 로고와 크레딧, ko/en UI 사전, geo 데이터셋 | 빌드할 때 스키마 검증 통과 | +| **3. 동기화 파이프라인** | http/robots, 어댑터, 미러 수집기, 검증과 저장, fixture 테스트, 첫 실행 데이터 커밋, `sync.yml` | `bun run sync`가 실제 소스로 성공하고, 두 번째 실행에서 diff가 없다 | +| **4. 핵심 페이지** | 4-1 레이아웃·헤더·404, 4-2 홈·목록·필터, 4-3 상세, 4-4 다운로드 마법사 + `/api/geo` + 미러 순위, 4-5 Pagefind 검색(한국어 조사 정규화) | e2e(탐색, 다운로드, 언어 전환)와 axe 통과 | +| **5. 배포판 콘텐츠** | 공용 partials, 그다음 5a Debian·Ubuntu 계열, 5b Arch, 5c Fedora·RHEL·SUSE, 5d 나머지. 소개와 설치 가이드를 ko/en으로 쓴다 | 모든 문서에 출처와 검토일이 있고, 데이터 컴포넌트로 버전이 자동 반영된다 | +| **6. 리눅스 가이드** | 약 20개 장 ko/en(아래 목록), 용어사전, 자동 용어 링크 | 장 사이 탐색, 관련 배포판 링크 | +| **7. 부가 기능** | 퀴즈(배포판 속성으로 점수를 매기고 근거를 표시), 비교, 계보도·타임라인 SVG, 릴리스 소식 + RSS | 단위 테스트(퀴즈 점수)와 e2e | +| **8. 최적화·SEO** | CSP, Speculation Rules, JSON-LD, OG 이미지, sitemap/hreflang, Lighthouse CI 예산 | Lighthouse 목표 달성 | +| **9. Vercel 배포** | 프로젝트 생성(승인 후), 도메인, 프리뷰 → 프로덕션, 스모크 테스트, `docs/deploy.md` 런북 | 실제 URL에서 페이지, `/api/geo`, 동기화 → 재배포 흐름 확인 | + +**리눅스 가이드 장 목록(초안):** +1. 리눅스란(커널, GNU, 라이선스) +2. 역사(Unix → GNU → 1991 → 배포판의 탄생) +3. 배포판 이해(계열, 릴리스 모델, 불변 배포판) +4. 데스크톱 환경과 윈도우 매니저 +5. X11과 Wayland +6. 패키지 관리(apt, dnf, pacman, zypper, apk, nix / Flatpak, Snap, AppImage) +7. 파일 시스템 계층(FHS)과 파일시스템(ext4, Btrfs, XFS, ZFS) +8. 터미널과 셸 +9. 필수 명령어 치트시트 +10. 사용자, 권한, sudo +11. 프로세스, systemd, 로그 +12. 부팅 과정(UEFI, GRUB/systemd-boot, initramfs) +13. 하드웨어와 드라이버(GPU, Wi-Fi, 펌웨어) +14. 네트워킹과 SSH +15. 보안(업데이트, 방화벽, SELinux/AppArmor, LUKS) +16. 설치 준비(USB, 듀얼부팅, Secure Boot, BitLocker, 백업) +17. Windows/macOS에서 넘어오기 +18. 리눅스 게이밍(Steam, Proton) +19. 개발·서버·컨테이너·가상화 +20. 문제 해결과 도움 받기 +21. 용어사전 + +--- + +## 11. 검증 방법 +- **매 커밋 전:** `bun run lint`, `bun run check`(astro check + tsc), `bun run test`, `bun run build` +- **단위 테스트:** + - 각 어댑터를 실제 응답 fixture로 검증한다. + - 미러 순위(하버사인, 국가 우선순위, 시간대 대체 경로) + - 퀴즈 점수, 스키마 +- **e2e(Playwright + `/opt/pw-browsers/chromium`):** 빌드한 결과물을 대상으로 다음을 확인한다. + - 목록 필터 → 상세 → 다운로드 마법사 단계. `/api/geo`는 모의 응답(KR, US)으로 추천 미러 순서를 확인한다. + - 언어 전환과 hreflang, 검색, 퀴즈, 비교 + - axe(WCAG AA)와 키보드 탐색 +- **동기화 실전 검증:** `bun run sync`를 실제 소스로 실행하고, 결과 URL 몇 개를 robots가 허용하는 곳에서 HEAD로 확인한다. 같은 명령을 다시 실행해 diff가 없는지 본다. +- **성능:** `@lhci/cli`로 홈, 목록, 상세, 가이드 페이지를 측정하고 예산을 넘으면 CI를 실패시킨다. +- **화면 확인:** 주요 화면의 라이트/다크와 모바일/데스크톱 스크린샷을 공유한다. +- **배포 후:** 프리뷰 URL에서 `curl /api/geo`, 보안 헤더, sitemap, RSS를 확인한다. + +## 12. 위험 요소와 대응 +| 위험 | 대응 | +|---|---| +| 콘텐츠 분량이 큼(51개 × 2개 언어 설치 가이드 + 21장 × 2) | 공용 partials로 중복을 줄이고 계열별로 나눠 커밋한다. 버전 같은 수치는 데이터 컴포넌트로 넣어 문서가 낡지 않게 한다 | +| 공식 소스 구조 변경 | 어댑터를 서로 격리하고, 실패하면 마지막 정상 데이터를 유지하며 이슈를 자동으로 연다. fixture 테스트로 잡는다 | +| 일부 배포판은 기계가 읽을 수 있는 소스가 없음(elementary, PCLinuxOS 등) | `official-page` 전략과 수동 버전 관리를 쓰고, 오래되면 표시한다 | +| 로고 상표 | 공식 파일을 수정하지 않고 쓰며, 출처와 정책 URL을 기록하고 크레딧과 삭제 요청 창구를 둔다 | +| 한국어 검색 품질(Pagefind는 한국어 어간 처리를 지원하지 않음) | 검색어에서 조사를 제거하고, 로마자·영문 별칭을 `data-pagefind-meta`로 넣는다 | +| Astro CSP의 제약 | Prism을 쓰고 인라인 `style` 속성을 금지한다. `frame-ancestors`는 `vercel.json` 헤더로 보낸다 | +| 위치 정보와 개인정보보호법 | 위치 정보를 기록하지 않고 `no-store`로 응답하며, `/about`에 처리 방식을 고지한다. 브라우저 Geolocation API는 쓰지 않는다 | diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..6e0d434 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,6 @@ +import { expect, test } from "@playwright/test"; + +test("home page renders", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("h1")).toBeVisible(); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..67c9d37 --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "linuxhub", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "A fast, bilingual (ko/en) catalog of Linux distributions with official downloads and install guides.", + "license": "MIT", + "packageManager": "bun@1.3.14", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "check": "astro check && tsc --noEmit -p tsconfig.scripts.json", + "lint": "biome check .", + "format": "biome check --write .", + "test": "vitest run", + "test:e2e": "playwright test", + "sync": "bun scripts/sync/run.ts", + "sync:validate": "bun scripts/sync/validate.ts" + }, + "dependencies": { + "@astrojs/markdown-remark": "^7.3.1", + "@astrojs/mdx": "^8.0.2", + "@astrojs/rss": "^4.0.19", + "@astrojs/sitemap": "^3.7.4", + "@astrojs/vercel": "^11.0.11", + "@vercel/functions": "^3.9.11", + "astro": "^7.3.5", + "astro-pagefind": "^2.0.1" + }, + "devDependencies": { + "@astrojs/check": "^0.9.10", + "@axe-core/playwright": "^4.13.0", + "@biomejs/biome": "^2.5.15", + "@playwright/test": "^1.63.0", + "@types/node": "^24", + "robots-parser": "^3.0.1", + "svgo": "^4.1.0", + "typescript": "^6.0.3", + "vitest": "^5.0.3", + "yaml": "^2.9.1" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..72a0173 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { existsSync } from "node:fs"; +import { defineConfig, devices } from "@playwright/test"; + +// The cloud image ships a prebuilt Chromium; use it when present so no +// browser download is needed. Everywhere else Playwright's own browser runs. +const prebuilt = "/opt/pw-browsers/chromium"; +const executablePath = + process.env.PLAYWRIGHT_CHROMIUM_PATH ?? (existsSync(prebuilt) ? prebuilt : undefined); + +const PORT = 4322; + +export default defineConfig({ + testDir: "e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [["github"], ["list"]] : "list", + use: { + baseURL: `http://localhost:${PORT}`, + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + ...(executablePath ? { launchOptions: { executablePath } } : {}), + }, + }, + ], + webServer: { + // Serves the production build in dist/ (run `bun run build` first). + command: `bunx astro preview --port ${PORT} --ignore-lock`, + port: PORT, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/scripts/sync/run.ts b/scripts/sync/run.ts new file mode 100644 index 0000000..96c65e0 --- /dev/null +++ b/scripts/sync/run.ts @@ -0,0 +1,2 @@ +// Placeholder: the sync pipeline lands in Phase 3 (docs/plan.md §7). +console.log("sync: not implemented yet"); diff --git a/scripts/sync/validate.ts b/scripts/sync/validate.ts new file mode 100644 index 0000000..b0f283d --- /dev/null +++ b/scripts/sync/validate.ts @@ -0,0 +1,2 @@ +// Placeholder: the sync pipeline lands in Phase 3 (docs/plan.md §7). +console.log("sync:validate: not implemented yet"); diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..52cba7c --- /dev/null +++ b/src/pages/index.astro @@ -0,0 +1,14 @@ +--- +// Placeholder until the Phase 4 home page replaces it. +--- + + + + + + Linuxhub + + +

Linuxhub

+ + diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts new file mode 100644 index 0000000..1c8a8c7 --- /dev/null +++ b/tests/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from "vitest"; + +describe("toolchain", () => { + it("runs vitest", () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8f43471 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/strictest", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist", ".vercel", "node_modules", "scripts"], + "compilerOptions": { + "paths": { + "~/*": ["./src/*"] + }, + "verbatimModuleSyntax": true + } +} diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..d7b9cb8 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/strictest", + "include": ["scripts/**/*.ts"], + "compilerOptions": { + "noEmit": true, + "types": ["node"], + "paths": { + "~/*": ["./src/*"] + } + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..d9a0a76 --- /dev/null +++ b/vercel.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "astro", + "installCommand": "bun install --frozen-lockfile", + "buildCommand": "bun run build", + "regions": ["icn1"], + "headers": [ + { + "source": "/_astro/(.*)", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + }, + { + "source": "/fonts/(.*)", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + }, + { + "source": "/pagefind/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=3600, stale-while-revalidate=86400" } + ] + }, + { + "source": "/(.*)", + "headers": [ + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains; preload" + }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=(), interest-cohort=()" + } + ] + } + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fe6d7d9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +/// +import { getViteConfig } from "astro/config"; + +export default getViteConfig({ + test: { + include: ["tests/**/*.test.ts", "scripts/**/*.test.ts"], + environment: "node", + }, +}); From 5e4760dc41c9df667cba0ea5829f20c0a177ed04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:35:44 +0000 Subject: [PATCH 03/16] feat(ui): adwaita design system, site chrome and catalog schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - libadwaita 1.10 tokens ported to CSS with light-dark() (system / light / dark via data-theme); two documented AA deviations (ADR-0009) - Adwaita Sans/Mono 51.0 subsets via the Astro Fonts API, Hangul from system fonts; Adwaita symbolic icons inlined at build (ADR-0010) - adw components: Button, Card, BoxedList, ActionRow, ExpanderRow, StatusPage, Banner, Callout, Badge, Kbd, ViewSwitcher, Icon, toast - site chrome: header bar with GNOME-style primary menu (Popover API, style switcher, language), mobile view-switcher bottom bar, footer - ko/en dictionaries and locale-aware routing helpers - distro catalog schema + taxonomy, Fedora reference entry, validator - prose/Prism styles, copy-code; CSP hashes for every inline script/style (ADR-0011); dev-only /design showcase Refs: docs/plan.md §4, §5.2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8A7WuJeLxG7aBvK6xfgC9 --- astro.config.mjs | 56 +++- biome.json | 56 +++- docs/decisions.md | 20 ++ public/favicon.svg | 1 + scripts/assets/svgo.config.mjs | 23 ++ scripts/validate-catalog.ts | 53 ++++ src/assets/fonts/AdwaitaMono-Bold-latin.woff2 | Bin 0 -> 17096 bytes src/assets/fonts/AdwaitaMono-latin.woff2 | Bin 0 -> 17120 bytes .../fonts/AdwaitaSans-Italic-latin.woff2 | Bin 0 -> 34744 bytes src/assets/fonts/AdwaitaSans-latin.woff2 | Bin 0 -> 33424 bytes src/assets/fonts/OFL.txt | 96 ++++++ src/assets/icons/README.md | 46 +++ src/assets/icons/about.svg | 1 + src/assets/icons/add.svg | 1 + src/assets/icons/app-grid.svg | 1 + src/assets/icons/bookmark.svg | 1 + src/assets/icons/calendar.svg | 1 + src/assets/icons/check.svg | 1 + src/assets/icons/close.svg | 1 + src/assets/icons/community.svg | 1 + src/assets/icons/compare.svg | 1 + src/assets/icons/computer.svg | 1 + src/assets/icons/contents.svg | 1 + src/assets/icons/copy.svg | 1 + src/assets/icons/dictionary.svg | 1 + src/assets/icons/disc.svg | 1 + src/assets/icons/display.svg | 1 + src/assets/icons/docs.svg | 1 + src/assets/icons/document.svg | 1 + src/assets/icons/download.svg | 1 + src/assets/icons/engineering.svg | 1 + src/assets/icons/external.svg | 1 + src/assets/icons/faq.svg | 1 + src/assets/icons/gaming.svg | 1 + src/assets/icons/go-next.svg | 1 + src/assets/icons/go-previous.svg | 1 + src/assets/icons/go-up.svg | 1 + src/assets/icons/graphics.svg | 1 + src/assets/icons/grid.svg | 1 + src/assets/icons/harddisk.svg | 1 + src/assets/icons/important.svg | 1 + src/assets/icons/info.svg | 1 + src/assets/icons/keyboard.svg | 1 + src/assets/icons/language.svg | 1 + src/assets/icons/link.svg | 1 + src/assets/icons/list.svg | 1 + src/assets/icons/location.svg | 1 + src/assets/icons/lock.svg | 1 + src/assets/icons/menu.svg | 1 + src/assets/icons/moon.svg | 1 + src/assets/icons/more.svg | 1 + src/assets/icons/package.svg | 1 + src/assets/icons/paged.svg | 1 + src/assets/icons/pan-down.svg | 1 + src/assets/icons/pan-end.svg | 1 + src/assets/icons/pan-start.svg | 1 + src/assets/icons/pin.svg | 1 + src/assets/icons/privacy.svg | 1 + src/assets/icons/recent.svg | 1 + src/assets/icons/refresh.svg | 1 + src/assets/icons/remove.svg | 1 + src/assets/icons/reveal.svg | 1 + src/assets/icons/science.svg | 1 + src/assets/icons/search.svg | 1 + src/assets/icons/security.svg | 1 + src/assets/icons/server.svg | 1 + src/assets/icons/settings.svg | 1 + src/assets/icons/sort-desc.svg | 1 + src/assets/icons/sort.svg | 1 + src/assets/icons/star.svg | 1 + src/assets/icons/sun.svg | 1 + src/assets/icons/system.svg | 1 + src/assets/icons/terminal.svg | 1 + src/assets/icons/time.svg | 1 + src/assets/icons/update.svg | 1 + src/assets/icons/usb.svg | 1 + src/assets/icons/utilities.svg | 1 + src/assets/icons/warning.svg | 1 + src/assets/icons/website.svg | 1 + src/assets/logos/fedora.svg | 1 + src/components/DistroCard.astro | 74 +++++ src/components/DistroLogo.astro | 44 +++ src/components/adw/ActionRow.astro | 143 +++++++++ src/components/adw/Badge.astro | 61 ++++ src/components/adw/Banner.astro | 31 ++ src/components/adw/BoxedList.astro | 80 +++++ src/components/adw/Button.astro | 197 ++++++++++++ src/components/adw/Callout.astro | 75 +++++ src/components/adw/Card.astro | 74 +++++ src/components/adw/ExpanderRow.astro | 62 ++++ src/components/adw/Icon.astro | 45 +++ src/components/adw/Kbd.astro | 21 ++ src/components/adw/StatusPage.astro | 59 ++++ src/components/adw/ViewSwitcher.astro | 72 +++++ src/components/site/BottomBar.astro | 74 +++++ src/components/site/BrandMark.astro | 31 ++ src/components/site/HeaderBar.astro | 170 +++++++++++ src/components/site/MainMenu.astro | 213 +++++++++++++ src/components/site/SiteFooter.astro | 86 ++++++ src/components/site/nav.ts | 26 ++ src/content.config.ts | 10 + src/content/distros/fedora.yaml | 50 +++ src/i18n/en.ts | 74 +++++ src/i18n/index.ts | 80 +++++ src/i18n/ko.ts | 70 +++++ src/layouts/Base.astro | 131 ++++++++ src/lib/csp.ts | 6 + src/lib/logos.ts | 15 + src/lib/schemas.ts | 87 ++++++ src/lib/taxonomy.ts | 143 +++++++++ src/pages/[...locale]/design.astro | 189 ++++++++++++ src/pages/[...locale]/index.astro | 15 + src/pages/index.astro | 14 - src/scripts/copy-code.ts | 44 +++ src/scripts/theme-init.ts | 6 + src/scripts/theme.ts | 36 +++ src/scripts/toast.ts | 22 ++ src/styles/base.css | 288 ++++++++++++++++++ src/styles/prose.css | 202 ++++++++++++ src/styles/tokens.css | 168 ++++++++++ 120 files changed, 3684 insertions(+), 23 deletions(-) create mode 100644 public/favicon.svg create mode 100644 scripts/assets/svgo.config.mjs create mode 100644 scripts/validate-catalog.ts create mode 100644 src/assets/fonts/AdwaitaMono-Bold-latin.woff2 create mode 100644 src/assets/fonts/AdwaitaMono-latin.woff2 create mode 100644 src/assets/fonts/AdwaitaSans-Italic-latin.woff2 create mode 100644 src/assets/fonts/AdwaitaSans-latin.woff2 create mode 100644 src/assets/fonts/OFL.txt create mode 100644 src/assets/icons/README.md create mode 100644 src/assets/icons/about.svg create mode 100644 src/assets/icons/add.svg create mode 100644 src/assets/icons/app-grid.svg create mode 100644 src/assets/icons/bookmark.svg create mode 100644 src/assets/icons/calendar.svg create mode 100644 src/assets/icons/check.svg create mode 100644 src/assets/icons/close.svg create mode 100644 src/assets/icons/community.svg create mode 100644 src/assets/icons/compare.svg create mode 100644 src/assets/icons/computer.svg create mode 100644 src/assets/icons/contents.svg create mode 100644 src/assets/icons/copy.svg create mode 100644 src/assets/icons/dictionary.svg create mode 100644 src/assets/icons/disc.svg create mode 100644 src/assets/icons/display.svg create mode 100644 src/assets/icons/docs.svg create mode 100644 src/assets/icons/document.svg create mode 100644 src/assets/icons/download.svg create mode 100644 src/assets/icons/engineering.svg create mode 100644 src/assets/icons/external.svg create mode 100644 src/assets/icons/faq.svg create mode 100644 src/assets/icons/gaming.svg create mode 100644 src/assets/icons/go-next.svg create mode 100644 src/assets/icons/go-previous.svg create mode 100644 src/assets/icons/go-up.svg create mode 100644 src/assets/icons/graphics.svg create mode 100644 src/assets/icons/grid.svg create mode 100644 src/assets/icons/harddisk.svg create mode 100644 src/assets/icons/important.svg create mode 100644 src/assets/icons/info.svg create mode 100644 src/assets/icons/keyboard.svg create mode 100644 src/assets/icons/language.svg create mode 100644 src/assets/icons/link.svg create mode 100644 src/assets/icons/list.svg create mode 100644 src/assets/icons/location.svg create mode 100644 src/assets/icons/lock.svg create mode 100644 src/assets/icons/menu.svg create mode 100644 src/assets/icons/moon.svg create mode 100644 src/assets/icons/more.svg create mode 100644 src/assets/icons/package.svg create mode 100644 src/assets/icons/paged.svg create mode 100644 src/assets/icons/pan-down.svg create mode 100644 src/assets/icons/pan-end.svg create mode 100644 src/assets/icons/pan-start.svg create mode 100644 src/assets/icons/pin.svg create mode 100644 src/assets/icons/privacy.svg create mode 100644 src/assets/icons/recent.svg create mode 100644 src/assets/icons/refresh.svg create mode 100644 src/assets/icons/remove.svg create mode 100644 src/assets/icons/reveal.svg create mode 100644 src/assets/icons/science.svg create mode 100644 src/assets/icons/search.svg create mode 100644 src/assets/icons/security.svg create mode 100644 src/assets/icons/server.svg create mode 100644 src/assets/icons/settings.svg create mode 100644 src/assets/icons/sort-desc.svg create mode 100644 src/assets/icons/sort.svg create mode 100644 src/assets/icons/star.svg create mode 100644 src/assets/icons/sun.svg create mode 100644 src/assets/icons/system.svg create mode 100644 src/assets/icons/terminal.svg create mode 100644 src/assets/icons/time.svg create mode 100644 src/assets/icons/update.svg create mode 100644 src/assets/icons/usb.svg create mode 100644 src/assets/icons/utilities.svg create mode 100644 src/assets/icons/warning.svg create mode 100644 src/assets/icons/website.svg create mode 100644 src/assets/logos/fedora.svg create mode 100644 src/components/DistroCard.astro create mode 100644 src/components/DistroLogo.astro create mode 100644 src/components/adw/ActionRow.astro create mode 100644 src/components/adw/Badge.astro create mode 100644 src/components/adw/Banner.astro create mode 100644 src/components/adw/BoxedList.astro create mode 100644 src/components/adw/Button.astro create mode 100644 src/components/adw/Callout.astro create mode 100644 src/components/adw/Card.astro create mode 100644 src/components/adw/ExpanderRow.astro create mode 100644 src/components/adw/Icon.astro create mode 100644 src/components/adw/Kbd.astro create mode 100644 src/components/adw/StatusPage.astro create mode 100644 src/components/adw/ViewSwitcher.astro create mode 100644 src/components/site/BottomBar.astro create mode 100644 src/components/site/BrandMark.astro create mode 100644 src/components/site/HeaderBar.astro create mode 100644 src/components/site/MainMenu.astro create mode 100644 src/components/site/SiteFooter.astro create mode 100644 src/components/site/nav.ts create mode 100644 src/content.config.ts create mode 100644 src/content/distros/fedora.yaml create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/ko.ts create mode 100644 src/layouts/Base.astro create mode 100644 src/lib/csp.ts create mode 100644 src/lib/logos.ts create mode 100644 src/lib/schemas.ts create mode 100644 src/lib/taxonomy.ts create mode 100644 src/pages/[...locale]/design.astro create mode 100644 src/pages/[...locale]/index.astro delete mode 100644 src/pages/index.astro create mode 100644 src/scripts/copy-code.ts create mode 100644 src/scripts/theme-init.ts create mode 100644 src/scripts/theme.ts create mode 100644 src/scripts/toast.ts create mode 100644 src/styles/base.css create mode 100644 src/styles/prose.css create mode 100644 src/styles/tokens.css diff --git a/astro.config.mjs b/astro.config.mjs index 7f77a63..ab128ab 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,7 +2,7 @@ import mdx from "@astrojs/mdx"; import sitemap from "@astrojs/sitemap"; import vercel from "@astrojs/vercel"; -import { defineConfig } from "astro/config"; +import { defineConfig, fontProviders } from "astro/config"; import pagefind from "astro-pagefind"; /** @@ -37,6 +37,60 @@ export default defineConfig({ defaultLocale: "ko", routing: { prefixDefaultLocale: false }, }, + fonts: [ + { + provider: fontProviders.local(), + name: "Adwaita Sans", + cssVariable: "--font-adwaita-sans", + // Adwaita Sans has no Hangul: Korean text falls through to system fonts. + fallbacks: [ + "Apple SD Gothic Neo", + "Malgun Gothic", + "Noto Sans KR", + "Noto Sans CJK KR", + "system-ui", + "sans-serif", + ], + options: { + variants: [ + { + src: ["./src/assets/fonts/AdwaitaSans-latin.woff2"], + weight: "400 800", + style: "normal", + display: "swap", + }, + { + src: ["./src/assets/fonts/AdwaitaSans-Italic-latin.woff2"], + weight: "400 700", + style: "italic", + display: "swap", + }, + ], + }, + }, + { + provider: fontProviders.local(), + name: "Adwaita Mono", + cssVariable: "--font-adwaita-mono", + fallbacks: ["ui-monospace", "SFMono-Regular", "Consolas", "monospace"], + options: { + variants: [ + { + src: ["./src/assets/fonts/AdwaitaMono-latin.woff2"], + weight: "400", + style: "normal", + display: "swap", + }, + { + src: ["./src/assets/fonts/AdwaitaMono-Bold-latin.woff2"], + weight: "700", + style: "normal", + display: "swap", + }, + ], + }, + }, + ], prefetch: { prefetchAll: true, defaultStrategy: "hover" }, build: { inlineStylesheets: "always", format: "directory" }, markdown: { syntaxHighlight: "prism" }, diff --git a/biome.json b/biome.json index e349a53..68e9bb6 100644 --- a/biome.json +++ b/biome.json @@ -1,6 +1,10 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, "files": { "includes": [ "**", @@ -9,25 +13,61 @@ "!.astro", "!src/data/**/*.json", "!test-results", - "!playwright-report" + "!playwright-report", + "!**/*.svg", + "!src/assets/fonts" ] }, - "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, - "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always" + } + }, "linter": { "enabled": true, - "rules": { "preset": "recommended" } + "rules": { + "preset": "recommended" + } }, "overrides": [ { "includes": ["**/*.astro"], "linter": { "rules": { - "correctness": { "noUnusedVariables": "off", "noUnusedImports": "off" }, - "style": { "useConst": "off", "useImportType": "off" } + "correctness": { + "noUnusedVariables": "off", + "noUnusedImports": "off" + }, + "style": { + "useConst": "off", + "useImportType": "off" + } + } + } + }, + { + "includes": ["**/*.css", "**/*.astro"], + "linter": { + "rules": { + "complexity": { + "noImportantStyles": "off" + } } } } ], - "assist": { "actions": { "source": { "organizeImports": "on" } } } + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } } diff --git a/docs/decisions.md b/docs/decisions.md index df914ee..15e3f89 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -53,3 +53,23 @@ ## ADR-0008 — 배포판 목록은 편집자가 정한다 - **결정:** DistroWatch를 크롤링하지 않는다(robots.txt가 AI 봇을 금지한다). 수록 목록(약 50개)은 편집자가 정한다. - **정렬:** 인기 순위를 다른 사이트에서 가져와 표시하지 않는다. 정렬은 추천순(편집), 이름순, 최근 업데이트순만 둔다. + +## ADR-0009 — 접근성을 위해 libadwaita 값을 일부 바꾼다 +- **결정:** libadwaita 토큰을 그대로 쓰되, WCAG 2.2 AA를 지키기 위해 아래 두 값만 바꾼다. + - **`--accent-bg-color`:** `#3584e4`(blue_3) 대신 `#1c71d8`(blue_4)를 쓴다. 흰 글자와의 대비가 3.9:1에서 4.9:1로 올라간다. + - **`--dim-opacity`:** 55% 대신 70%를 쓴다. 흐리게 표시한 본문 글자도 4.5:1 이상을 유지한다. +- **그대로 두는 값:** 링크와 강조 글자색 `--accent-color`(#0461be / #81d0ff)는 원래 값으로 AA를 통과하므로 바꾸지 않는다. +- **적용 방식:** 모든 색은 `light-dark()`로 한 번만 선언한다. `data-theme` 속성으로 라이트/다크를 강제할 수 있다. + +## ADR-0010 — 아이콘과 폰트 출처 +- **아이콘:** Adwaita icon theme 51.0과 libadwaita 1.10.0의 symbolic 아이콘을 `currentColor`로 바꾸고 SVGO로 최적화해 `src/assets/icons/`에 둔다. 출처는 그 폴더의 `README.md`에 적는다. + - 빌드할 때 인라인 SVG로 넣으므로 추가 요청이 없다. +- **폰트:** Adwaita Sans와 Mono 51.0을 쓴다. + - **Adwaita Sans:** `opsz=14`로 고정하고 굵기 범위를 400–800으로 줄인 뒤 라틴 subset만 남겨 woff2로 만든다(약 33KB). + - **Adwaita Mono:** 굵기 400과 700을 ASCII만 남겨 각 17KB로 만든다. + - 라이선스는 OFL이며 `src/assets/fonts/OFL.txt`에 둔다. 예약 폰트명(RFN)이 없어 subset을 만들어도 이름을 바꿀 필요가 없다. + +## ADR-0011 — CSP와 인라인 스크립트 +- **문제:** Astro CSP는 `is:inline` 스크립트의 해시를 자동으로 넣지 않는다. +- **해결:** 테마 초기화 스크립트는 `src/scripts/theme-init.ts`의 문자열 하나로 관리한다. `Base.astro`에서 `Astro.csp.insertScriptHash()`로 그 해시를 등록한다. +- **검증:** 빌드 결과의 모든 인라인 script와 style 해시가 `.vercel/output/config.json`의 CSP 헤더에 들어 있는지 확인한다. diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..194a81f --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/scripts/assets/svgo.config.mjs b/scripts/assets/svgo.config.mjs new file mode 100644 index 0000000..836e8d7 --- /dev/null +++ b/scripts/assets/svgo.config.mjs @@ -0,0 +1,23 @@ +// SVGO config for distro logos (bunx svgo --config scripts/assets/svgo.config.mjs ). +// Logos render through , so scripts never run, but we strip them anyway +// and drop editor metadata. Shapes and colors are left untouched: official +// logos must not be altered beyond lossless optimization. +export default { + multipass: true, + floatPrecision: 3, + plugins: [ + { + name: "preset-default", + params: { + overrides: { + // Keep colors and shapes exactly as published. + convertColors: false, + mergePaths: false, + }, + }, + }, + "removeScripts", + "removeXlink", + "removeDimensions", + ], +}; diff --git a/scripts/validate-catalog.ts b/scripts/validate-catalog.ts new file mode 100644 index 0000000..03d63e0 --- /dev/null +++ b/scripts/validate-catalog.ts @@ -0,0 +1,53 @@ +/** + * Validates src/content/distros/*.yaml against the catalog schema without a + * full Astro sync, plus cross-entry rules the schema cannot express: + * unique `order`, `basedOn` pointing at an existing slug, logo file present, + * and a sane logo SVG. Usage: bun scripts/validate-catalog.ts [slug ...] + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { distroSchema } from "../src/lib/schemas"; + +const DIR = "src/content/distros"; +const LOGOS = "src/assets/logos"; +const only = new Set(process.argv.slice(2)); +const files = readdirSync(DIR).filter((f) => f.endsWith(".yaml")); +const slugs = new Set(files.map((f) => f.replace(/\.yaml$/, ""))); +const orders = new Map(); +let failed = 0; + +for (const file of files) { + const slug = file.replace(/\.yaml$/, ""); + const raw = parse(readFileSync(join(DIR, file), "utf8")); + const res = distroSchema.safeParse(raw); + const errors: string[] = []; + if (!res.success) { + for (const issue of res.error.issues) errors.push(`${issue.path.join(".")}: ${issue.message}`); + } else { + const d = res.data; + const prev = orders.get(d.order); + if (prev) errors.push(`order ${d.order} duplicates ${prev}`); + orders.set(d.order, slug); + if (d.basedOn && !slugs.has(d.basedOn)) + errors.push(`basedOn "${d.basedOn}" is not a catalog slug`); + const logoPath = join(LOGOS, d.logo.file); + if (!existsSync(logoPath)) errors.push(`logo ${logoPath} missing`); + else { + const svg = readFileSync(logoPath, "utf8"); + if (!/]/.test(svg)) errors.push("logo is not an SVG"); + if (!/viewBox=/.test(svg)) errors.push("logo has no viewBox"); + if (/ 60_000) errors.push(`logo is large (${svg.length} bytes)`); + } + } + if (only.size && !only.has(slug)) continue; + if (errors.length) { + failed++; + console.error(`✗ ${slug}\n ${errors.join("\n ")}`); + } else console.log(`✓ ${slug}`); +} + +console.log(`${files.length} entries, ${failed} with errors`); +process.exit(failed ? 1 : 0); diff --git a/src/assets/fonts/AdwaitaMono-Bold-latin.woff2 b/src/assets/fonts/AdwaitaMono-Bold-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2df045d66497e66f74abdf35bf81f6218059cbcf GIT binary patch literal 17096 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

skN`u1`y61b&-3C>8C`N zXHfos8>@{xg-76pv2Aj2yJXT-_fz)r8)Riz5mslbnMZZY^lIuk%5<9vW`NnoQO|t2 zJ-&fAs+F|FB{$O>4>0j2QvTNTIv}ii2^xY=)#rr?;bgD>=~~iPY3yCJ*kQXuM)kji zeZ^&KCSth9`|LDh`5yH08)9aeQD#@Og`=iv`Zc{scIinkJEdzYoHTRKIQp6d2Lk{A DET8|| literal 0 HcmV?d00001 diff --git a/src/assets/fonts/AdwaitaMono-latin.woff2 b/src/assets/fonts/AdwaitaMono-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..aa8713ca7631d47444552f8cb73e9a28b69d1ec7 GIT binary patch literal 17120 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

wK^k@0Q z$S!cd2GeNIGutmJVd6u>hg&d8^!n-_gQDsFQU0yH6aP(LU2gt=m!7-)+|K=q4t@B! zra4Hz`)qFgotqeUv7$I0SCV1es(MH!CKLDR-PJgQ)}^|ACun~SzMEK*Werdcfnh-S zCMXFy>v3^NrD~Vf1Gz)=vUc15?^nF$-Yxtlkn`E53zfRG%aPx*SEi>>PkN3Rk>2={KJg}xhS|(wK3A^1 z3`w%fs40VN3tyQbSIz*lk+q#Xf|o~nWF#a1BgIZ(j-6W;x){j#R;FM~FguyTPg$TROM8;?ZX5gy8GxQlWU``UCz6TEbkPPt8 zuieJpbUVOU2-J5apxKA@0Kft2x4X)~{o_xSDF$%k*g@Gh6+qwYA(pB3ZupZXA;LiX zBIq?A4`?&|0IU(=UnO=sb|ZFbsbNI=*23c_k%SgYVIo#ef$YxeR#ymUvI|Y5JfZta zFDpwB>3S0jmrExNWLMP5LL?OR8Ov!u)fn6@B`U3^18(vSP(CdU_5_mwE_By~t&)xr7 zqc8L@qyUzRp9t>KjZcOMhQZl{zuj5sK2Sc%U$OQtXPHPm3*d5KNbw^g&UBd$$ldf~ zQ-+4VEAH|9yKDaSQ9A7z#qBOJ^eH=wH%1{KUKRGoK|Z54n{80_i)(SELkEWhfnx6M z3bAv#^q>!Em8d;>1lP6}V>T`1(S)y_iU94)fOyt@f-oG>wWTfKTFZn?9rc=~{x7}z zz>g_^ZJr*kr8A0PJ9ADlnn8NGMqUz|93@ zP??8N!4B0A$i(vsaA$yFh#$R05nEmLw6-Zp)4)xl$T|~f{Xn!I(b}0;qjYSgHlZy4 z`E_-9W4C_QoJ7;GGAr3AcPB>2?&&<;A1296`{QbNWc*xn-(KiR<--#!yV{HJUKQtB`V*j?DhY z0t8E!Te;_33C~MkH~#vrZLv5x+8tgD^95_NC*cH5YWjVbEv*;30XzdUjZ)v%CgFL9H+}9cz%S zAbm%+QM)Q-bG;xjHld*lCw|jaF}-_TNFpaqJo0)e2o_!gISphS6~PJwH%lC@@@}*P zYxivVac`$dw*U@K!3vHgd5M;Ad%CJ}M&{Ieu7d*Ka~-IkgnfI&I;}tMp z)5%a-oIL7uioq8k6Ac|5F7i?sSyo2Z;bo252!tioFq`84B&V1Gu?WnsT}t;cO=}0TomjvgmD+HkpQl0@eOsncZH^9Q)jRB|bq*=Mmk54NXL*@~`5@*r+MlUQtgTcaXHWeg^j1;C!4b}EW zLV^)WqkZsPk&g5wqt3N=qpU9H882+up&ieOrxY%c9#LMiR%OudYL#9w6_ne{(MTpi zkXKqj&Xbv*SDRZ?Rfn+EJXH;R`1R#dN`B`isHjk_C>U2Rt61k-A*gF)n*Esw zya04#CdL`8F5}R9;_oqU(O&e|gFU;3@&owzBO)owO^(YVQ$Qhtt}329?~#pX z1}8)qe{@poc)9gejD!E3Y)r*Ls6Z0z3X>vBR5ChayN zMEE2g2*eIWmEb|F>)(xc$S2=f&y4J(v&_1aKGDM7i>DJIP}Om5d8%Kz2c309;Ue=9mW}e`8>JDJwJ!v zw=a%}gR@57KW3iJ?hi^UP9d$0^H^c65TvP;pJX!g{PL}NUfm}3F$8-lw1#d?@UVep zlp$g5Ud=x?v~rHiC6Nm({SFq+Nudcj!|*PomXxhE>MHC@qcjhGt7e*LE+W98jp^X8 z+WEyE>q@0@9I8Wym{Nz@(6g=G1dD@!{)+vZjzo~KbT8&9g%xtFRzbP6EPXUWvsUza zEP6Lf)!0Yav$b0pUF!P#V7xRy^c~07Lf({@R zS4%5eiyv9U`2barn(}!5pN+lqgc-yl;-7ux3ucw=^;gYxT=ig$xaj)g(M_ zIIv?HfTq=Kz-Uq1hzZw|V->4Y5>ld>je9x&utq+@NFge5hs#8v1*OxF0EZi;nQPN6 zeAUjwB}Z^$%TOJddTcwkBOnQjWNTKZ_bj$*K~*Q8D*5)3NP$3f|D7rYq>p>L+8geS4l~4Y;*l2#WK>|3s-MOxynr3@P2S#{J z2M}Q5bvV-(;repUleN{R1}0GE-AG(I(`Lxg;}C^7EWpqvAm|4OdTnoIX%a(2*M#gm zwseL1N)1UbAphVhOZ$7*R5KDeB}sxeaZQF$dX(&m5i@IM`fAD93k$`5*JZa8<5;4I zq--=?3U}+VYFPk1^g5xg?!Yir=XH&vV9BAyBhxJWp&MP*W~;TWR{)l=TmlloKrtq# zhdKqY^&O}sYh}gzrol25Q|uJZ8$^G&_={*7j|n@UzY52Ejlkw^0d*oh& zy94OOhkpl6MqoH)aNv48$RJ(Ah!s;aaDntD?D52-t&O@&BINk!6WP8h5hG308^MhT zIw>Z$DPjpn!J@J7aUT36tQdM5`XvaRi~|y0WjmnXpw)c{2v8S+xg~Cg!tDge zZ{wAho_ps0-K)#f z*=qq;jsWEz+>0owhuK9T?_SfciA@1^|9`4AXoNSbtvXpg(3 zXMgkWR9fW3O2lc_`#GSt0WAK1j_E=wfVwbr=7(fBsE#W$Xh9YB_Yq_3J2Nx-vp%d zVX^`&`6qS6)|DeVe{O@kT}Om?s$Bz)6llqajD@qZNBEA06@LgORn~E-v<-k#rNp$B6CHFR-Uh4Am4(jJk@5w8o3A3 zKBMK9rrWfn$w6p)PYiEsOxa-^ZjCE$Y&JLki3U?G5Bzz9b%$Vu*U|5if2$9la>mVO z+*dUG8vP+-Gj5LS>9^jg|LVW)JxY70uMZC?_wSwFDLrltuLhSF=Vyl}+kVGKOT8ot zymU_DG_&)79t)~1H(dz92Vda;$E8^N$%y<3kQ6B^OFapT58iwCM*08$(7oHdc_zJz zS>^GNE*A|44$H9>{85!3<#Rp8(;VKtoWG&r>%G&O<{BM~9)#EkCgMa6eRlQPFm`PY2`at{?Qz7PXK1KA0tZYq?`y0sC zMjCq#nY+hJ#$@F%f-!Thp~70O)8ca;XKF%`m;}j!3*!AlVqlTSG?}NEm_FL>4T-5W zv5&8JwyLto1JAYb+7sEIKO^NW#RGyM&$FfRURU4S>hK1uvkn2t#1_v78Ci~M{~+Kl z95<+Kw+6`JMuqZ)L-g~NT^3KzO2tF|fYE)WN3`TOnef&dFFg0eqq}#`PWIE??XD%M z?;85A$KR&6+~Q9KSc=CcJQJQIJfEjOI?q~Y8DCkRwRdy*O>_1mHZ0MSoa78tgd$$o zh`uMVAN5qfgldJwL|aX@)R!g}8C4B9g@YL-I-OPZmASu-4xqMP2B`L-PJU;r<3}XJ z2xx*QL*|~SYA=8wkCqNn2IxXx3xMZ5cZ*k>hx-C9J+Xi|lFS5KjO${{3ffwSAG`j21DH=LWc;*&&gPW31TsiDo+Ei|GA>iCDvN?KU38DNc* zDVES2eSu_qn`%-Erknk;)UeW!y1@G?+qbA3_?K)a8?c+c83*Wg8Tnl&4}bu~dxe0? z(Beru_nu78aC&E_A#jt~-Pfp{xe``jjc@UGzhE45h~5r!T z))&Azcub`2vjkdI+gGw3jU??MI4nkaz%afy3nCAph5+3UDEVrRLLaInH>VT`2guJ| z{z?)kWabamLg7L>3TVBazXK#r-hnJoa}}SRG-VhJLju(P5}wtdPzgH?LK1~r625W_ zY*eXg+nh-;A($Y5LT%dXgyX_DkH(0@eCh_KQ&oR)aRj0STlhXZ4v3Z<{XRCtkHzlB_B?hn9`wqK; z^uKZePmzHHwxSUjww*bwlLj0G0O?~HWNt|BygFo9rrg(1NaF)K&s4}7T#KQw!#JmI zx$sT~KsL3>KC>_o*1`#y7a_&j^$Ibkq$W1$0oc7+^IH%q0sk7s3bzpNUOK3i;BL#| zge1o?^X9%%tiNRr^z4DNitwxm65q+lKpt5TB9Rbfp~;ZP={WF%BN`Jr7qWT+HG-9@ zh~yVjScg$#;EbGOGcEH0>_ zW*yxYv_c1&8yPp*XkoW5s+qts)oTOfZ@fKRx-_d^hWIz*=bXgiH8jvoUL3ey=MeL+ zWxtdM_ooGNxMdtnsL@#xJHMpM#AH#-FgcJu$Qi!U9ix!Y=lVt^F7Pq$>klySmCGB|${MeI zGjD`CwfG11*82V(4Y}FL#kLE#<%s_o``A$wo&*e8n0Bg+a5^-R`7X=tXeLkd5!xv7 zBb>Zx5lPjB4bX zypk$!{gbHk&#>zIZcASgzYJmioS&TAS+`PV-J5V8KD&Q>(;wzOY@cHlcm11x#0Ug% zK+EK^BK!0|6Y0Jm!s&#U0O0vw={n_@|MlE&6aQg8*0>+gZ}bwloL2|POmCry0_tn) z>CEO4^Z~L0P5l`F7~us}0Uy*Lgh==a9Z>GdoZK4&I; z7!BUw*Q$4fq}EC;?1n&hrLi^0sWlr7YFDEHKUmO;1>kn0V51u#+pS?N8m&?zq`J#k zY$;4A&;z=5c95zjS!d%2;0{3S1q6VqML=rcB#;9BpDNRSqPT_7m5zCd0j_&lf2FQ&ejO6u$5lVfz z1Ht5wODXv*XDQ1l#1dCrSbzskiL!`H~_Uk&2seB{>_JlSnj& bGwj^{tHj1H{Ma$Bk`n%5lqSH?p#T5?RmAsQ literal 0 HcmV?d00001 diff --git a/src/assets/fonts/AdwaitaSans-Italic-latin.woff2 b/src/assets/fonts/AdwaitaSans-Italic-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..04852a344538f50702b27ea357a109628e5b7bef GIT binary patch literal 34744 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

dn2}DF9F(?%# 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 \ No newline at end of file diff --git a/src/assets/icons/add.svg b/src/assets/icons/add.svg new file mode 100644 index 0000000..8ef64ec --- /dev/null +++ b/src/assets/icons/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/app-grid.svg b/src/assets/icons/app-grid.svg new file mode 100644 index 0000000..bcb3702 --- /dev/null +++ b/src/assets/icons/app-grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/bookmark.svg b/src/assets/icons/bookmark.svg new file mode 100644 index 0000000..495b578 --- /dev/null +++ b/src/assets/icons/bookmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/calendar.svg b/src/assets/icons/calendar.svg new file mode 100644 index 0000000..ba53d66 --- /dev/null +++ b/src/assets/icons/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg new file mode 100644 index 0000000..a377841 --- /dev/null +++ b/src/assets/icons/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/close.svg b/src/assets/icons/close.svg new file mode 100644 index 0000000..9ddc798 --- /dev/null +++ b/src/assets/icons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/community.svg b/src/assets/icons/community.svg new file mode 100644 index 0000000..8fcfde3 --- /dev/null +++ b/src/assets/icons/community.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/compare.svg b/src/assets/icons/compare.svg new file mode 100644 index 0000000..b369269 --- /dev/null +++ b/src/assets/icons/compare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/computer.svg b/src/assets/icons/computer.svg new file mode 100644 index 0000000..fada5da --- /dev/null +++ b/src/assets/icons/computer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/contents.svg b/src/assets/icons/contents.svg new file mode 100644 index 0000000..78dbe65 --- /dev/null +++ b/src/assets/icons/contents.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/copy.svg b/src/assets/icons/copy.svg new file mode 100644 index 0000000..6976959 --- /dev/null +++ b/src/assets/icons/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/dictionary.svg b/src/assets/icons/dictionary.svg new file mode 100644 index 0000000..b3268d7 --- /dev/null +++ b/src/assets/icons/dictionary.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/disc.svg b/src/assets/icons/disc.svg new file mode 100644 index 0000000..e7c031c --- /dev/null +++ b/src/assets/icons/disc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/display.svg b/src/assets/icons/display.svg new file mode 100644 index 0000000..fada5da --- /dev/null +++ b/src/assets/icons/display.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/docs.svg b/src/assets/icons/docs.svg new file mode 100644 index 0000000..fddf80a --- /dev/null +++ b/src/assets/icons/docs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/document.svg b/src/assets/icons/document.svg new file mode 100644 index 0000000..be29959 --- /dev/null +++ b/src/assets/icons/document.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/download.svg b/src/assets/icons/download.svg new file mode 100644 index 0000000..32b2692 --- /dev/null +++ b/src/assets/icons/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/engineering.svg b/src/assets/icons/engineering.svg new file mode 100644 index 0000000..d289b71 --- /dev/null +++ b/src/assets/icons/engineering.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/external.svg b/src/assets/icons/external.svg new file mode 100644 index 0000000..163f589 --- /dev/null +++ b/src/assets/icons/external.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/faq.svg b/src/assets/icons/faq.svg new file mode 100644 index 0000000..f3701f1 --- /dev/null +++ b/src/assets/icons/faq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/gaming.svg b/src/assets/icons/gaming.svg new file mode 100644 index 0000000..6dc7b86 --- /dev/null +++ b/src/assets/icons/gaming.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/go-next.svg b/src/assets/icons/go-next.svg new file mode 100644 index 0000000..6494d70 --- /dev/null +++ b/src/assets/icons/go-next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/go-previous.svg b/src/assets/icons/go-previous.svg new file mode 100644 index 0000000..a268904 --- /dev/null +++ b/src/assets/icons/go-previous.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/go-up.svg b/src/assets/icons/go-up.svg new file mode 100644 index 0000000..4624b2f --- /dev/null +++ b/src/assets/icons/go-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/graphics.svg b/src/assets/icons/graphics.svg new file mode 100644 index 0000000..f888905 --- /dev/null +++ b/src/assets/icons/graphics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/grid.svg b/src/assets/icons/grid.svg new file mode 100644 index 0000000..985a78a --- /dev/null +++ b/src/assets/icons/grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/harddisk.svg b/src/assets/icons/harddisk.svg new file mode 100644 index 0000000..faf0d83 --- /dev/null +++ b/src/assets/icons/harddisk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/important.svg b/src/assets/icons/important.svg new file mode 100644 index 0000000..2c43bdd --- /dev/null +++ b/src/assets/icons/important.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/info.svg b/src/assets/icons/info.svg new file mode 100644 index 0000000..e5cce39 --- /dev/null +++ b/src/assets/icons/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/keyboard.svg b/src/assets/icons/keyboard.svg new file mode 100644 index 0000000..15719b0 --- /dev/null +++ b/src/assets/icons/keyboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/language.svg b/src/assets/icons/language.svg new file mode 100644 index 0000000..0a0a243 --- /dev/null +++ b/src/assets/icons/language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/link.svg b/src/assets/icons/link.svg new file mode 100644 index 0000000..df5f4e8 --- /dev/null +++ b/src/assets/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/list.svg b/src/assets/icons/list.svg new file mode 100644 index 0000000..0866175 --- /dev/null +++ b/src/assets/icons/list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/location.svg b/src/assets/icons/location.svg new file mode 100644 index 0000000..e64a7e0 --- /dev/null +++ b/src/assets/icons/location.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/lock.svg b/src/assets/icons/lock.svg new file mode 100644 index 0000000..3345afe --- /dev/null +++ b/src/assets/icons/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/menu.svg b/src/assets/icons/menu.svg new file mode 100644 index 0000000..5125164 --- /dev/null +++ b/src/assets/icons/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/moon.svg b/src/assets/icons/moon.svg new file mode 100644 index 0000000..9a79437 --- /dev/null +++ b/src/assets/icons/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/more.svg b/src/assets/icons/more.svg new file mode 100644 index 0000000..8433e30 --- /dev/null +++ b/src/assets/icons/more.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/package.svg b/src/assets/icons/package.svg new file mode 100644 index 0000000..8d03268 --- /dev/null +++ b/src/assets/icons/package.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/paged.svg b/src/assets/icons/paged.svg new file mode 100644 index 0000000..af6ad50 --- /dev/null +++ b/src/assets/icons/paged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/pan-down.svg b/src/assets/icons/pan-down.svg new file mode 100644 index 0000000..40e07f8 --- /dev/null +++ b/src/assets/icons/pan-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/pan-end.svg b/src/assets/icons/pan-end.svg new file mode 100644 index 0000000..2475730 --- /dev/null +++ b/src/assets/icons/pan-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/pan-start.svg b/src/assets/icons/pan-start.svg new file mode 100644 index 0000000..2a1d7c1 --- /dev/null +++ b/src/assets/icons/pan-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/pin.svg b/src/assets/icons/pin.svg new file mode 100644 index 0000000..b30c53b --- /dev/null +++ b/src/assets/icons/pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/privacy.svg b/src/assets/icons/privacy.svg new file mode 100644 index 0000000..3ae35d5 --- /dev/null +++ b/src/assets/icons/privacy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/recent.svg b/src/assets/icons/recent.svg new file mode 100644 index 0000000..a6fb433 --- /dev/null +++ b/src/assets/icons/recent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/refresh.svg b/src/assets/icons/refresh.svg new file mode 100644 index 0000000..9a21b75 --- /dev/null +++ b/src/assets/icons/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/remove.svg b/src/assets/icons/remove.svg new file mode 100644 index 0000000..6e08a22 --- /dev/null +++ b/src/assets/icons/remove.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/reveal.svg b/src/assets/icons/reveal.svg new file mode 100644 index 0000000..5ce6f96 --- /dev/null +++ b/src/assets/icons/reveal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/science.svg b/src/assets/icons/science.svg new file mode 100644 index 0000000..a0d26c5 --- /dev/null +++ b/src/assets/icons/science.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/search.svg b/src/assets/icons/search.svg new file mode 100644 index 0000000..c6a3782 --- /dev/null +++ b/src/assets/icons/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/security.svg b/src/assets/icons/security.svg new file mode 100644 index 0000000..5543990 --- /dev/null +++ b/src/assets/icons/security.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/server.svg b/src/assets/icons/server.svg new file mode 100644 index 0000000..cf967fb --- /dev/null +++ b/src/assets/icons/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/settings.svg b/src/assets/icons/settings.svg new file mode 100644 index 0000000..04e4fe8 --- /dev/null +++ b/src/assets/icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/sort-desc.svg b/src/assets/icons/sort-desc.svg new file mode 100644 index 0000000..5daf08b --- /dev/null +++ b/src/assets/icons/sort-desc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/sort.svg b/src/assets/icons/sort.svg new file mode 100644 index 0000000..ed6c028 --- /dev/null +++ b/src/assets/icons/sort.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/star.svg b/src/assets/icons/star.svg new file mode 100644 index 0000000..d075b7a --- /dev/null +++ b/src/assets/icons/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/sun.svg b/src/assets/icons/sun.svg new file mode 100644 index 0000000..92de2d7 --- /dev/null +++ b/src/assets/icons/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/system.svg b/src/assets/icons/system.svg new file mode 100644 index 0000000..729ecef --- /dev/null +++ b/src/assets/icons/system.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/terminal.svg b/src/assets/icons/terminal.svg new file mode 100644 index 0000000..a2f78fc --- /dev/null +++ b/src/assets/icons/terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/time.svg b/src/assets/icons/time.svg new file mode 100644 index 0000000..c178b50 --- /dev/null +++ b/src/assets/icons/time.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/update.svg b/src/assets/icons/update.svg new file mode 100644 index 0000000..55d24b6 --- /dev/null +++ b/src/assets/icons/update.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/usb.svg b/src/assets/icons/usb.svg new file mode 100644 index 0000000..1687812 --- /dev/null +++ b/src/assets/icons/usb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/utilities.svg b/src/assets/icons/utilities.svg new file mode 100644 index 0000000..4f253f3 --- /dev/null +++ b/src/assets/icons/utilities.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/warning.svg b/src/assets/icons/warning.svg new file mode 100644 index 0000000..19d649e --- /dev/null +++ b/src/assets/icons/warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/website.svg b/src/assets/icons/website.svg new file mode 100644 index 0000000..50cc74c --- /dev/null +++ b/src/assets/icons/website.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/fedora.svg b/src/assets/logos/fedora.svg new file mode 100644 index 0000000..d5080a3 --- /dev/null +++ b/src/assets/logos/fedora.svg @@ -0,0 +1 @@ +Fedora logo (2021) \ No newline at end of file diff --git a/src/components/DistroCard.astro b/src/components/DistroCard.astro new file mode 100644 index 0000000..fb2bc77 --- /dev/null +++ b/src/components/DistroCard.astro @@ -0,0 +1,74 @@ +--- +/** + * Flathub-style app tile for a distribution: logo, name, one-line tagline and + * a couple of facet badges. The whole card is one link. + */ +import type { CollectionEntry } from "astro:content"; +import { type Locale, localizePath } from "~/i18n"; +import { FAMILIES, RELEASE_MODELS } from "~/lib/taxonomy"; +import Badge from "./adw/Badge.astro"; +import Card from "./adw/Card.astro"; +import DistroLogo from "./DistroLogo.astro"; + +interface Props { + distro: CollectionEntry<"distros">; + locale: Locale; + eager?: boolean; + headingLevel?: 2 | 3; +} + +const { distro, locale, eager = false, headingLevel = 3 } = Astro.props; +const d = distro.data; +const Heading = `h${headingLevel}` as "h2" | "h3"; +--- + + + +

+ {d.name} +

{d.tagline[locale]}

+
+ {FAMILIES[d.family][locale]} + {RELEASE_MODELS[d.releaseModel][locale]} +
+
+ + + diff --git a/src/components/DistroLogo.astro b/src/components/DistroLogo.astro new file mode 100644 index 0000000..54be131 --- /dev/null +++ b/src/components/DistroLogo.astro @@ -0,0 +1,44 @@ +--- +/** + * Official distro logo on a neutral "thumbnail" tile, like app icons on + * Flathub. Rendered as (cached, never inlined) with fixed dimensions to + * avoid layout shift. + */ +import { logoUrl } from "~/lib/logos"; + +interface Props { + file: string; + name: string; + size?: number; + eager?: boolean; + /** Decorative when the name is already next to it (default). */ + decorative?: boolean; +} + +const { file, name, size = 64, eager = false, decorative = true } = Astro.props; +--- + + + + diff --git a/src/components/adw/ActionRow.astro b/src/components/adw/ActionRow.astro new file mode 100644 index 0000000..23cd5ae --- /dev/null +++ b/src/components/adw/ActionRow.astro @@ -0,0 +1,143 @@ +--- +/** + * libadwaita AdwActionRow inside a boxed list: optional prefix icon, title, + * subtitle, and a suffix slot. With `href` the whole row is a link (activatable) + * and shows a trailing chevron or external-link icon. + */ +import Icon from "./Icon.astro"; + +interface Props { + title: string; + subtitle?: string; + icon?: string; + href?: string; + external?: boolean; + /** Render the subtitle above the title, like AdwActionRow's `subtitle-lines` property style used for property rows. */ + property?: boolean; + monospaceSubtitle?: boolean; +} + +const { + title, + subtitle, + icon, + href, + external = false, + property = false, + monospaceSubtitle = false, +} = Astro.props; +const Tag = href ? "a" : "div"; +const hasSubtitleSlot = Astro.slots.has("subtitle"); +--- + +
  • + + {icon && } + + {title} + { + (subtitle || hasSubtitleSlot) && ( + + {subtitle} + + + ) + } + + + + {href && } + + +
  • + + diff --git a/src/components/adw/Badge.astro b/src/components/adw/Badge.astro new file mode 100644 index 0000000..db0e243 --- /dev/null +++ b/src/components/adw/Badge.astro @@ -0,0 +1,61 @@ +--- +/** + * Small pill label for facets (family, release model, desktop…). Neutral by + * default; `accent`/`success`/`warning`/`error` use libadwaita semantic colors. + * With `href` it becomes a link (e.g. to a filtered catalog). + */ +interface Props { + tone?: "neutral" | "accent" | "success" | "warning" | "error"; + href?: string; + class?: string; +} + +const { tone = "neutral", href, class: className } = Astro.props; +const Tag = href ? "a" : "span"; +--- + + + + diff --git a/src/components/adw/Banner.astro b/src/components/adw/Banner.astro new file mode 100644 index 0000000..0b575bc --- /dev/null +++ b/src/components/adw/Banner.astro @@ -0,0 +1,31 @@ +--- +/** libadwaita AdwBanner: a full-width accent-tinted strip with an optional action. */ +interface Props { + title: string; + class?: string; +} + +const { title, class: className } = Astro.props; +--- + +
    +

    {title}

    + +
    + + diff --git a/src/components/adw/BoxedList.astro b/src/components/adw/BoxedList.astro new file mode 100644 index 0000000..5e32bc4 --- /dev/null +++ b/src/components/adw/BoxedList.astro @@ -0,0 +1,80 @@ +--- +/** + * libadwaita `list.boxed-list` (widgets/_lists.scss): a card containing + * separated rows. Put ActionRow / ExpanderRow children inside. Optional + * heading + description render like an AdwPreferencesGroup. + */ +interface Props { + title?: string; + description?: string; + /** Heading level for the group title. */ + level?: 2 | 3 | 4; + class?: string; + id?: string; +} + +const { title, description, level = 3, class: className, id } = Astro.props; +const Heading = `h${level}` as "h2" | "h3" | "h4"; +const headingId = id ? `${id}-title` : undefined; +--- + +
    + { + (title || Astro.slots.has("suffix")) && ( +
    +
    + {title && ( + + {title} + + )} + {description &&

    {description}

    } +
    + +
    + ) + } +
      + +
    +
    + + diff --git a/src/components/adw/Button.astro b/src/components/adw/Button.astro new file mode 100644 index 0000000..1fd67ee --- /dev/null +++ b/src/components/adw/Button.astro @@ -0,0 +1,197 @@ +--- +/** + * libadwaita button (widgets/_buttons.scss): raised by default, plus + * `suggested`, `destructive`, `flat` and `opaque` variants, and `pill` / + * `circular` shapes. Renders when `href` is set, otherwise + + + + + + diff --git a/src/components/site/MainMenu.astro b/src/components/site/MainMenu.astro new file mode 100644 index 0000000..081d20f --- /dev/null +++ b/src/components/site/MainMenu.astro @@ -0,0 +1,213 @@ +--- +/** + * GNOME-style primary menu (the "hamburger" popover in a header bar): + * style switcher, language, and secondary links. Built on the HTML Popover API, + * so opening/closing needs no JS; only the style switcher has a tiny module. + */ +import { + LOCALE_META, + LOCALES, + type Locale, + localizePath, + stripLocale, + useTranslations, +} from "~/i18n"; +import Icon from "../adw/Icon.astro"; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const t = useTranslations(locale); +const basePath = stripLocale(Astro.url.pathname); +const links = [ + { href: localizePath("/releases/", locale), label: t("nav.releases"), icon: "update" }, + { href: localizePath("/compare/", locale), label: t("nav.compare"), icon: "compare" }, + { href: localizePath("/about/", locale), label: t("nav.about"), icon: "info" }, +]; +const themes = [ + { value: "system", label: t("theme.system") }, + { value: "light", label: t("theme.light") }, + { value: "dark", label: t("theme.dark") }, +] as const; +--- + + + + + + + + diff --git a/src/components/site/SiteFooter.astro b/src/components/site/SiteFooter.astro new file mode 100644 index 0000000..e8697ca --- /dev/null +++ b/src/components/site/SiteFooter.astro @@ -0,0 +1,86 @@ +--- +import { type Locale, localizePath, useTranslations } from "~/i18n"; +import BrandMark from "./BrandMark.astro"; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const t = useTranslations(locale); +const year = new Date().getFullYear(); +const links = [ + { href: localizePath("/distros/", locale), label: t("nav.distros") }, + { href: localizePath("/learn/", locale), label: t("nav.learn") }, + { href: localizePath("/finder/", locale), label: t("nav.finder") }, + { href: localizePath("/compare/", locale), label: t("nav.compare") }, + { href: localizePath("/family/", locale), label: t("nav.family") }, + { href: localizePath("/releases/", locale), label: t("nav.releases") }, + { href: localizePath("/about/", locale), label: t("footer.about") }, + { href: localizePath("/rss.xml", locale), label: t("footer.rss") }, +]; +--- + +
    + +
    + + diff --git a/src/components/site/nav.ts b/src/components/site/nav.ts new file mode 100644 index 0000000..9508d99 --- /dev/null +++ b/src/components/site/nav.ts @@ -0,0 +1,26 @@ +import { type Locale, localizePath, stripLocale, useTranslations } from "~/i18n"; + +export interface NavItem { + href: string; + label: string; + icon: string; + current: boolean; +} + +/** Primary destinations shown in the header (desktop) and bottom bar (mobile). */ +export function primaryNav(locale: Locale, pathname: string): NavItem[] { + const t = useTranslations(locale); + const path = stripLocale(pathname); + const item = (base: string, label: string, icon: string): NavItem => ({ + href: localizePath(base, locale), + label, + icon, + current: path === base || path.startsWith(base), + }); + return [ + item("/distros/", t("nav.distros"), "app-grid"), + item("/learn/", t("nav.learn"), "contents"), + item("/finder/", t("nav.finder"), "star"), + item("/family/", t("nav.family"), "community"), + ]; +} diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..962a6ce --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,10 @@ +import { defineCollection } from "astro:content"; +import { glob } from "astro/loaders"; +import { distroSchema } from "./lib/schemas"; + +const distros = defineCollection({ + loader: glob({ pattern: "*.yaml", base: "./src/content/distros" }), + schema: distroSchema, +}); + +export const collections = { distros }; diff --git a/src/content/distros/fedora.yaml b/src/content/distros/fedora.yaml new file mode 100644 index 0000000..ba6e877 --- /dev/null +++ b/src/content/distros/fedora.yaml @@ -0,0 +1,50 @@ +# Template for every catalog entry. Facts must come from the official sources +# listed under `sources`. Versions/files/checksums do NOT belong here (src/data/). +name: Fedora Linux +order: 4 +family: fedora +basedOn: null +status: active +releaseModel: fixed +desktops: [gnome, kde, xfce, cinnamon, mate, lxqt, budgie, sway, i3] +packageManagers: [dnf, rpm, flatpak] +initSystem: systemd +installer: anaconda +useCases: [desktop, developer, beginner] +difficulty: intermediate +architectures: [x86_64, aarch64, ppc64le, s390x] +secureBoot: "yes" +immutable: false +origin: + country: US + firstRelease: 2003-11-06 +requirements: + ramGB: 4 + diskGB: 20 + note: + ko: Fedora Workstation 기준 권장 사양입니다(2GHz 듀얼코어 이상). + en: Recommended for Fedora Workstation (2 GHz dual-core or better). +links: + home: https://fedoraproject.org/ + download: https://fedoraproject.org/workstation/download + docs: https://docs.fedoraproject.org/ + installGuide: https://docs.fedoraproject.org/en-US/fedora/latest/getting-started/ + forum: https://discussion.fedoraproject.org/ + releaseNotes: https://docs.fedoraproject.org/en-US/fedora/latest/release-notes/ + source: https://src.fedoraproject.org/ +tagline: + ko: 최신 기술을 빠르게 담아내는, Red Hat이 후원하는 커뮤니티 배포판 + en: A community distro, sponsored by Red Hat, that ships new technology early +aliases: [페도라, fedora workstation] +logo: + file: fedora.svg + source: https://commons.wikimedia.org/wiki/File:Fedora_icon_(2021).svg + license: Public domain (simple geometry); Fedora trademark + trademark: https://fedoraproject.org/legal/trademarks/ +download: + strategy: redirector +sources: + - https://fedoraproject.org/ + - https://docs.fedoraproject.org/en-US/fedora/latest/release-notes/welcome/Hardware_Overview/ + - https://fedoraproject.org/wiki/History_of_Fedora_release_names +lastReviewed: 2026-10-03 diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..9af8d37 --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,74 @@ +/** + * English UI strings. This file defines the dictionary shape; ko.ts must + * provide exactly the same keys (enforced by its type annotation). + */ +export const en = { + site: { + name: "Linuxhub", + tagline: "Find, download and install the right Linux", + description: + "A clean catalog of popular Linux distributions: short overviews, official downloads from the nearest mirror, and step-by-step install guides.", + }, + nav: { + home: "Home", + distros: "Distributions", + learn: "Linux Guide", + finder: "Distro Finder", + family: "Family Tree", + releases: "Releases", + compare: "Compare", + about: "About", + skipToContent: "Skip to content", + mainMenu: "Main menu", + primary: "Primary", + }, + search: { + open: "Search", + placeholder: "Search distributions and guides", + shortcut: "Ctrl K", + close: "Close search", + noResults: "No results for “{query}”", + loading: "Searching…", + hint: "Type a distro name, desktop or topic.", + results: "{count} results", + }, + theme: { + label: "Appearance", + system: "Follow system style", + light: "Light style", + dark: "Dark style", + }, + language: { + label: "Language", + }, + footer: { + about: "About Linuxhub", + data: "Release data refreshes automatically from official sources.", + trademarks: + "Distribution names and logos are trademarks of their respective owners and are used only to identify the projects.", + license: "Text is available under CC BY-SA 4.0; code under the MIT License.", + rss: "Release feed (RSS)", + source: "Source code", + }, + common: { + loading: "Loading…", + copy: "Copy", + copied: "Copied", + close: "Close", + more: "More", + learnMore: "Learn more", + officialSite: "Official website", + externalLink: "(opens an external site)", + lastChecked: "Checked {time}", + updated: "Updated {date}", + backToTop: "Back to top", + }, + notFound: { + title: "Page not found", + description: "The page you’re looking for doesn’t exist or has moved.", + home: "Go to the home page", + }, +} as const; + +type Widen = { [K in keyof T]: T[K] extends string ? string : Widen }; +export type DictionaryShape = Widen; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..82b3084 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,80 @@ +import { en } from "./en"; +import { ko } from "./ko"; + +export const LOCALES = ["ko", "en"] as const; +export type Locale = (typeof LOCALES)[number]; +export const DEFAULT_LOCALE: Locale = "ko"; + +export const LOCALE_META: Record = { + ko: { label: "한국어", htmlLang: "ko", ogLocale: "ko_KR" }, + en: { label: "English", htmlLang: "en", ogLocale: "en_US" }, +}; + +const dictionaries = { ko, en } as const; +export type Dictionary = typeof en; + +export function isLocale(value: unknown): value is Locale { + return typeof value === "string" && (LOCALES as readonly string[]).includes(value); +} + +/** The `[...locale]` route param for a locale: undefined for the default (no prefix). */ +export function localeParam(locale: Locale): string | undefined { + return locale === DEFAULT_LOCALE ? undefined : locale; +} + +/** Resolves the locale from a `[...locale]` param value. */ +export function localeFromParam(param: string | undefined): Locale { + return isLocale(param) ? param : DEFAULT_LOCALE; +} + +/** getStaticPaths() entries for a page that exists once per locale. */ +export function localePaths() { + return LOCALES.map((locale) => ({ params: { locale: localeParam(locale) }, props: { locale } })); +} + +/** Removes a leading /en from a pathname; always returns a path starting with "/". */ +export function stripLocale(pathname: string): string { + for (const locale of LOCALES) { + if (locale === DEFAULT_LOCALE) continue; + if (pathname === `/${locale}` || pathname === `/${locale}/`) return "/"; + if (pathname.startsWith(`/${locale}/`)) return pathname.slice(locale.length + 1); + } + return pathname || "/"; +} + +/** Builds a site-absolute path for `path` (written without a locale prefix) in `locale`. */ +export function localizePath(path: string, locale: Locale): string { + const clean = path.startsWith("/") ? path : `/${path}`; + if (locale === DEFAULT_LOCALE) return clean; + return clean === "/" ? `/${locale}/` : `/${locale}${clean}`; +} + +type Join = K extends string ? (P extends string ? `${K}.${P}` : never) : never; +type Paths = T extends string + ? never + : { + [K in keyof T]: K extends string ? (T[K] extends string ? K : Join>) : never; + }[keyof T]; +export type MessageKey = Paths; + +/** + * Returns a translator for `locale`. Keys are dot paths into the dictionary; + * `{name}` placeholders are replaced from `vars`. + */ +export function useTranslations(locale: Locale) { + const dict = dictionaries[locale] as unknown as Record; + return function t(key: MessageKey, vars?: Record): string { + let value: unknown = dict; + for (const part of key.split(".")) + value = (value as Record | undefined)?.[part]; + if (typeof value !== "string") throw new Error(`Missing i18n key "${key}" for ${locale}`); + return vars + ? value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? `{${name}}`)) + : value; + }; +} + +/** Picks the right side of a `{ ko, en }` object. */ +export function pick(value: Record, locale: Locale): T { + return value[locale]; +} diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts new file mode 100644 index 0000000..01dc6a7 --- /dev/null +++ b/src/i18n/ko.ts @@ -0,0 +1,70 @@ +import type { DictionaryShape } from "./en"; + +/** 한국어 UI 문자열. en.ts와 키가 정확히 같아야 합니다(타입으로 강제). */ +export const ko: DictionaryShape = { + site: { + name: "Linuxhub", + tagline: "나에게 맞는 리눅스를 찾고, 내려받고, 설치하기", + description: + "인기 리눅스 배포판을 한눈에 비교하고, 가장 가까운 공식 미러에서 내려받고, 단계별 설치 가이드를 따라 설치하세요.", + }, + nav: { + home: "홈", + distros: "배포판", + learn: "리눅스 가이드", + finder: "배포판 찾기", + family: "계보", + releases: "릴리스 소식", + compare: "비교", + about: "소개", + skipToContent: "본문으로 건너뛰기", + mainMenu: "주 메뉴", + primary: "주요 탐색", + }, + search: { + open: "검색", + placeholder: "배포판과 가이드 검색", + shortcut: "Ctrl K", + close: "검색 닫기", + noResults: "“{query}”에 대한 결과가 없습니다", + loading: "검색 중…", + hint: "배포판 이름, 데스크톱 환경, 주제를 입력해 보세요.", + results: "결과 {count}개", + }, + theme: { + label: "화면 스타일", + system: "시스템 설정 따르기", + light: "밝은 스타일", + dark: "어두운 스타일", + }, + language: { + label: "언어", + }, + footer: { + about: "Linuxhub 소개", + data: "릴리스 정보는 공식 소스에서 자동으로 갱신됩니다.", + trademarks: + "배포판 이름과 로고는 각 소유자의 상표이며, 해당 프로젝트를 알리는 용도로만 사용합니다.", + license: "글은 CC BY-SA 4.0, 코드는 MIT 라이선스를 따릅니다.", + rss: "릴리스 피드 (RSS)", + source: "소스 코드", + }, + common: { + loading: "불러오는 중…", + copy: "복사", + copied: "복사됨", + close: "닫기", + more: "더 보기", + learnMore: "자세히 보기", + officialSite: "공식 웹사이트", + externalLink: "(외부 사이트로 이동)", + lastChecked: "{time} 확인", + updated: "{date} 업데이트", + backToTop: "맨 위로", + }, + notFound: { + title: "페이지를 찾을 수 없습니다", + description: "찾으시는 페이지가 없거나 다른 곳으로 옮겨졌습니다.", + home: "홈으로 가기", + }, +}; diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro new file mode 100644 index 0000000..498197a --- /dev/null +++ b/src/layouts/Base.astro @@ -0,0 +1,131 @@ +--- +/** + * Root layout: document head (SEO, alternates, fonts, theme), the Adwaita + * header bar, page content, footer and the mobile bottom bar. + */ +import { Font } from "astro:assets"; +import "~/styles/tokens.css"; +import "~/styles/base.css"; +import BottomBar from "~/components/site/BottomBar.astro"; +import HeaderBar from "~/components/site/HeaderBar.astro"; +import SiteFooter from "~/components/site/SiteFooter.astro"; +import { + DEFAULT_LOCALE, + LOCALE_META, + LOCALES, + type Locale, + localizePath, + stripLocale, + useTranslations, +} from "~/i18n"; +import { sha256Source } from "~/lib/csp"; +import { THEME_INIT } from "~/scripts/theme-init"; + +interface Props { + locale: Locale; + title?: string; + description?: string; + /** Absolute or root-relative OG image. */ + image?: string; + noindex?: boolean; + /** JSON-LD objects to embed. */ + jsonLd?: Record[]; + /** Load the monospace font (pages with code). */ + mono?: boolean; +} + +const { + locale, + title, + description, + image, + noindex = false, + jsonLd = [], + mono = false, +} = Astro.props; +const t = useTranslations(locale); +const siteName = t("site.name"); +const fullTitle = title ? `${title} – ${siteName}` : `${siteName} – ${t("site.tagline")}`; +const metaDescription = description ?? t("site.description"); +const basePath = stripLocale(Astro.url.pathname); +const canonical = new URL(localizePath(basePath, locale), Astro.site); +const ogImage = new URL(image ?? "/og/default.png", Astro.site); +// is:inline scripts are not hashed automatically; register this one explicitly. +Astro.csp?.insertScriptHash(sha256Source(THEME_INIT)); +--- + + + + + + + {fullTitle} + + + { + LOCALES.map((l) => ( + + )) + } + + {noindex && } + + + + + + + + + + + + + + + + + {mono && } + + + + diff --git a/src/components/FamilyNode.astro b/src/components/FamilyNode.astro new file mode 100644 index 0000000..44a5d5d --- /dev/null +++ b/src/components/FamilyNode.astro @@ -0,0 +1,32 @@ +--- +import { type Locale, localizePath } from "~/i18n"; +/** One node of the family tree, rendering its descendants recursively. */ +import type { Distro } from "~/lib/catalog"; +import DistroLogo from "./DistroLogo.astro"; + +interface Props { + distro: Distro; + childrenOf: Map; + locale: Locale; +} + +const { distro, childrenOf, locale } = Astro.props; +const kids = childrenOf.get(distro.id) ?? []; +--- + +
  • + + + {distro.data.name} + {distro.data.origin.firstRelease.slice(0, 4)} + + { + kids.length > 0 && ( +
      + {kids.map((k) => ( + + ))} +
    + ) + } +
  • diff --git a/src/components/Toc.astro b/src/components/Toc.astro new file mode 100644 index 0000000..271d310 --- /dev/null +++ b/src/components/Toc.astro @@ -0,0 +1,61 @@ +--- +/** "On this page" navigation from MDX headings (h2/h3). */ +import type { MarkdownHeading } from "astro"; + +interface Props { + headings: MarkdownHeading[]; + title: string; +} + +const { headings, title } = Astro.props; +const items = headings.filter((h) => h.depth === 2 || h.depth === 3); +--- + +{ + items.length > 0 && ( + + ) +} + + diff --git a/src/components/adw/ActionRow.astro b/src/components/adw/ActionRow.astro index 23cd5ae..bd32183 100644 --- a/src/components/adw/ActionRow.astro +++ b/src/components/adw/ActionRow.astro @@ -8,13 +8,13 @@ import Icon from "./Icon.astro"; interface Props { title: string; - subtitle?: string; - icon?: string; - href?: string; - external?: boolean; + subtitle?: string | undefined; + icon?: string | undefined; + href?: string | undefined; + external?: boolean | undefined; /** Render the subtitle above the title, like AdwActionRow's `subtitle-lines` property style used for property rows. */ - property?: boolean; - monospaceSubtitle?: boolean; + property?: boolean | undefined; + monospaceSubtitle?: boolean | undefined; } const { @@ -53,91 +53,3 @@ const hasSubtitleSlot = Astro.slots.has("subtitle"); - - diff --git a/src/components/adw/Badge.astro b/src/components/adw/Badge.astro index db0e243..d36e68f 100644 --- a/src/components/adw/Badge.astro +++ b/src/components/adw/Badge.astro @@ -5,9 +5,9 @@ * With `href` it becomes a link (e.g. to a filtered catalog). */ interface Props { - tone?: "neutral" | "accent" | "success" | "warning" | "error"; - href?: string; - class?: string; + tone?: "neutral" | "accent" | "success" | "warning" | "error" | undefined; + href?: string | undefined; + class?: string | undefined; } const { tone = "neutral", href, class: className } = Astro.props; @@ -15,47 +15,3 @@ const Tag = href ? "a" : "span"; --- - - diff --git a/src/components/adw/Banner.astro b/src/components/adw/Banner.astro index 0b575bc..756685f 100644 --- a/src/components/adw/Banner.astro +++ b/src/components/adw/Banner.astro @@ -2,7 +2,7 @@ /** libadwaita AdwBanner: a full-width accent-tinted strip with an optional action. */ interface Props { title: string; - class?: string; + class?: string | undefined; } const { title, class: className } = Astro.props; @@ -12,20 +12,3 @@ const { title, class: className } = Astro.props;

    {title}

    - - diff --git a/src/components/adw/BoxedList.astro b/src/components/adw/BoxedList.astro index 5e32bc4..59a500f 100644 --- a/src/components/adw/BoxedList.astro +++ b/src/components/adw/BoxedList.astro @@ -5,12 +5,12 @@ * heading + description render like an AdwPreferencesGroup. */ interface Props { - title?: string; - description?: string; + title?: string | undefined; + description?: string | undefined; /** Heading level for the group title. */ - level?: 2 | 3 | 4; - class?: string; - id?: string; + level?: 2 | 3 | 4 | undefined; + class?: string | undefined; + id?: string | undefined; } const { title, description, level = 3, class: className, id } = Astro.props; @@ -38,43 +38,3 @@ const headingId = id ? `${id}-title` : undefined; - - diff --git a/src/components/adw/Button.astro b/src/components/adw/Button.astro index 1fd67ee..f699507 100644 --- a/src/components/adw/Button.astro +++ b/src/components/adw/Button.astro @@ -10,17 +10,17 @@ import Icon from "./Icon.astro"; type Variant = "default" | "suggested" | "destructive" | "flat" | "opaque"; type Props = (HTMLAttributes<"button"> & HTMLAttributes<"a">) & { - variant?: Variant; - pill?: boolean; - circular?: boolean; - icon?: string; + variant?: Variant | undefined; + pill?: boolean | undefined; + circular?: boolean | undefined; + icon?: string | undefined; /** Icon after the label (e.g. external links). */ - iconEnd?: string; + iconEnd?: string | undefined; /** For icon-only buttons: the accessible label (also the tooltip). */ - label?: string; - href?: string; - external?: boolean; - type?: "button" | "submit" | "reset"; + label?: string | undefined; + href?: string | undefined; + external?: boolean | undefined; + type?: "button" | "submit" | "reset" | undefined; }; const { @@ -65,133 +65,3 @@ const linkAttrs = href {hasText && } {iconEnd && } - - diff --git a/src/components/adw/Callout.astro b/src/components/adw/Callout.astro index 5295099..f57aaa5 100644 --- a/src/components/adw/Callout.astro +++ b/src/components/adw/Callout.astro @@ -9,8 +9,8 @@ import Icon from "./Icon.astro"; type Kind = "note" | "tip" | "warning" | "danger"; interface Props { - type?: Kind; - title?: string; + type?: Kind | undefined; + title?: string | undefined; } const { type = "note", title } = Astro.props; @@ -29,47 +29,3 @@ const icons: Record = { - - diff --git a/src/components/adw/Card.astro b/src/components/adw/Card.astro index bec0ae7..cf34bd6 100644 --- a/src/components/adw/Card.astro +++ b/src/components/adw/Card.astro @@ -7,9 +7,9 @@ import type { HTMLAttributes } from "astro/types"; type Props = HTMLAttributes<"div"> & { - href?: string; - as?: "div" | "article" | "section" | "li"; - padded?: boolean; + href?: string | undefined; + as?: "div" | "article" | "section" | "li" | undefined; + padded?: boolean | undefined; }; const { href, as = "div", padded = true, class: className, ...rest } = Astro.props; @@ -23,52 +23,3 @@ const Tag = href ? "a" : as; > - - diff --git a/src/components/adw/ExpanderRow.astro b/src/components/adw/ExpanderRow.astro index 2841ef0..95f58b2 100644 --- a/src/components/adw/ExpanderRow.astro +++ b/src/components/adw/ExpanderRow.astro @@ -7,10 +7,10 @@ import Icon from "./Icon.astro"; interface Props { title: string; - subtitle?: string; - icon?: string; - open?: boolean; - name?: string; + subtitle?: string | undefined; + icon?: string | undefined; + open?: boolean | undefined; + name?: string | undefined; } const { title, subtitle, icon, open = false, name } = Astro.props; @@ -34,29 +34,3 @@ const { title, subtitle, icon, open = false, name } = Astro.props;
    - - diff --git a/src/components/adw/Icon.astro b/src/components/adw/Icon.astro index 5b6f38a..10d2d47 100644 --- a/src/components/adw/Icon.astro +++ b/src/components/adw/Icon.astro @@ -14,10 +14,10 @@ export type IconName = string; interface Props { name: IconName; - size?: number; + size?: number | undefined; /** Accessible label; omit for decorative icons (the default). */ - label?: string; - class?: string; + label?: string | undefined; + class?: string | undefined; } const { name, size = 16, label, class: className } = Astro.props; @@ -35,11 +35,3 @@ const svg = raw.replace(/ - - diff --git a/src/components/adw/Kbd.astro b/src/components/adw/Kbd.astro index cff2cb2..ca7cabb 100644 --- a/src/components/adw/Kbd.astro +++ b/src/components/adw/Kbd.astro @@ -3,19 +3,3 @@ --- - - diff --git a/src/components/adw/StatusPage.astro b/src/components/adw/StatusPage.astro index 2b0a89b..42402f3 100644 --- a/src/components/adw/StatusPage.astro +++ b/src/components/adw/StatusPage.astro @@ -3,11 +3,11 @@ import Icon from "./Icon.astro"; interface Props { - icon?: string; + icon?: string | undefined; title: string; - description?: string; + description?: string | undefined; /** Heading level; 1 for full-page states (404), 2 inside a page. */ - level?: 1 | 2; + level?: 1 | 2 | undefined; } const { icon, title, description, level = 1 } = Astro.props; @@ -20,40 +20,3 @@ const Heading = `h${level}` as "h1" | "h2"; {description &&

    {description}

    }
    - - diff --git a/src/components/adw/ViewSwitcher.astro b/src/components/adw/ViewSwitcher.astro index 7da8615..d55facd 100644 --- a/src/components/adw/ViewSwitcher.astro +++ b/src/components/adw/ViewSwitcher.astro @@ -16,7 +16,7 @@ interface Item { interface Props { items: Item[]; label: string; - class?: string; + class?: string | undefined; } const { items, label, class: className } = Astro.props; @@ -32,41 +32,3 @@ const { items, label, class: className } = Astro.props; )) } - - diff --git a/src/components/mdx/Anchor.astro b/src/components/mdx/Anchor.astro new file mode 100644 index 0000000..def9720 --- /dev/null +++ b/src/components/mdx/Anchor.astro @@ -0,0 +1,19 @@ +--- +/** + * Fixed link target for cross-page anchors (MDX has no `{#id}` heading syntax). + * Standard targets in install guides: verify, usb, boot, install, after-install. + */ +interface Props { + id: string; +} +--- + + + + diff --git a/src/components/mdx/Checksum.astro b/src/components/mdx/Checksum.astro new file mode 100644 index 0000000..6c66886 --- /dev/null +++ b/src/components/mdx/Checksum.astro @@ -0,0 +1,17 @@ +--- +/** The official checksum of the default image: . */ +import { lookup } from "./data"; + +interface Props { + slug: string; +} +const { artifact } = await lookup(Astro.props.slug); +--- + +{artifact?.checksum ? {artifact.checksum.value} : —} + + diff --git a/src/components/mdx/IsoName.astro b/src/components/mdx/IsoName.astro new file mode 100644 index 0000000..3e56f72 --- /dev/null +++ b/src/components/mdx/IsoName.astro @@ -0,0 +1,11 @@ +--- +/** The default image file name from the synced data: . */ +import { lookup } from "./data"; + +interface Props { + slug: string; +} +const { artifact } = await lookup(Astro.props.slug); +--- + +{artifact?.file ?? "*.iso"} diff --git a/src/components/mdx/Latest.astro b/src/components/mdx/Latest.astro new file mode 100644 index 0000000..d168212 --- /dev/null +++ b/src/components/mdx/Latest.astro @@ -0,0 +1,27 @@ +--- +/** + * Inline value from the synced release data, so docs never hand-type a + * version: → "44"; field="releaseDate" | "eol" | "codename" | "name". + */ +import { formatDate } from "~/lib/catalog"; +import { lookup } from "./data"; + +interface Props { + slug: string; + field?: "version" | "releaseDate" | "eol" | "codename" | "name"; + locale?: "ko" | "en"; +} + +const { slug, field = "version", locale = "ko" } = Astro.props; +const { distro, release } = await lookup(slug); +const value = + field === "name" + ? distro.data.name + : field === "releaseDate" || field === "eol" + ? formatDate(release?.[field] ?? null, locale) + : field === "codename" + ? release?.codename + : release?.version; +--- + +{value ?? "—"} diff --git a/src/components/mdx/Steps.astro b/src/components/mdx/Steps.astro new file mode 100644 index 0000000..b32a397 --- /dev/null +++ b/src/components/mdx/Steps.astro @@ -0,0 +1,41 @@ +--- +/** Numbered procedure: wrap an ordered list (1. … 2. …) to get Adwaita step badges. */ +--- + +
    + + diff --git a/src/components/mdx/Tab.astro b/src/components/mdx/Tab.astro new file mode 100644 index 0000000..551933c --- /dev/null +++ b/src/components/mdx/Tab.astro @@ -0,0 +1,35 @@ +--- +interface Props { + label: string; + open?: boolean; +} +const { label, open = false } = Astro.props; +--- + +
    + {label} +
    +
    + + diff --git a/src/components/mdx/Tabs.astro b/src/components/mdx/Tabs.astro new file mode 100644 index 0000000..2994103 --- /dev/null +++ b/src/components/mdx/Tabs.astro @@ -0,0 +1,16 @@ +--- +/** + * OS-specific instructions (Windows / macOS / Linux …) without JS: a group of + * collapsible sections styled as Adwaita cards. + */ +--- + +
    + + diff --git a/src/components/mdx/VerifyCommand.astro b/src/components/mdx/VerifyCommand.astro new file mode 100644 index 0000000..6e11c52 --- /dev/null +++ b/src/components/mdx/VerifyCommand.astro @@ -0,0 +1,33 @@ +--- +/** + * Checksum verification command for the default image, per OS, filled from + * the synced data: . + */ +import { lookup } from "./data"; + +interface Props { + slug: string; + os: "linux" | "macos" | "windows"; +} + +const { slug, os } = Astro.props; +const { artifact } = await lookup(slug); +const file = artifact?.file ?? "image.iso"; +const algo = artifact?.checksum?.type ?? "sha256"; +const bits = algo === "sha512" ? "512" : algo === "sha1" ? "1" : "256"; +const cmd = + os === "windows" + ? algo === "md5" + ? `Get-FileHash .\\${file} -Algorithm MD5` + : `Get-FileHash .\\${file} -Algorithm SHA${bits}` + : os === "macos" + ? algo === "md5" + ? `md5 ${file}` + : `shasum -a ${bits} ${file}` + : algo === "md5" + ? `md5sum ${file}` + : `sha${bits}sum ${file}`; +const lang = os === "windows" ? "powershell" : "bash"; +--- + +
    {cmd}
    diff --git a/src/components/mdx/data.ts b/src/components/mdx/data.ts new file mode 100644 index 0000000..1e54265 --- /dev/null +++ b/src/components/mdx/data.ts @@ -0,0 +1,11 @@ +/** Shared lookups for MDX data components (build time). */ +import { defaultArtifact, featuredRelease, getDistro, getReleases } from "~/lib/catalog"; + +export async function lookup(slug: string) { + const distro = await getDistro(slug); + if (!distro) throw new Error(`MDX data component: unknown distro "${slug}"`); + const releases = await getReleases(slug); + const release = featuredRelease(releases); + const choice = defaultArtifact(release); + return { distro, releases, release, edition: choice?.edition, artifact: choice?.artifact }; +} diff --git a/src/components/mdx/index.ts b/src/components/mdx/index.ts new file mode 100644 index 0000000..cb011b4 --- /dev/null +++ b/src/components/mdx/index.ts @@ -0,0 +1,24 @@ +/** Components available in every MDX document without importing them. */ +import Callout from "../adw/Callout.astro"; +import Kbd from "../adw/Kbd.astro"; +import Anchor from "./Anchor.astro"; +import Checksum from "./Checksum.astro"; +import IsoName from "./IsoName.astro"; +import Latest from "./Latest.astro"; +import Steps from "./Steps.astro"; +import Tab from "./Tab.astro"; +import Tabs from "./Tabs.astro"; +import VerifyCommand from "./VerifyCommand.astro"; + +export const mdxComponents = { + Anchor, + Callout, + Kbd, + Checksum, + IsoName, + Latest, + Steps, + Tab, + Tabs, + VerifyCommand, +}; diff --git a/src/components/site/SearchDialog.astro b/src/components/site/SearchDialog.astro new file mode 100644 index 0000000..798ecb8 --- /dev/null +++ b/src/components/site/SearchDialog.astro @@ -0,0 +1,143 @@ +--- +/** + * Site search (Ctrl/⌘+K, "/" or the header search button): an Adwaita dialog + * over Pagefind's static index. Pagefind's JS (~30 KB) and the index chunks + * load only when the dialog first opens. + */ +import { type Locale, useTranslations } from "~/i18n"; +import Icon from "../adw/Icon.astro"; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const t = useTranslations(locale); +const labels = { + noResults: t("search.noResults", { query: "{query}" }), + loading: t("search.loading"), + results: t("search.results", { count: "{count}" }), +}; +--- + + + +

    {t("search.hint")}

    +
      +
      + + + + diff --git a/src/content.config.ts b/src/content.config.ts index 962a6ce..d636095 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,10 +1,54 @@ import { defineCollection } from "astro:content"; import { glob } from "astro/loaders"; +import { z } from "astro/zod"; +import { releasesFileSchema } from "./lib/data-schemas"; import { distroSchema } from "./lib/schemas"; +/** Hand-written catalog (src/content/distros/.yaml). */ const distros = defineCollection({ loader: glob({ pattern: "*.yaml", base: "./src/content/distros" }), schema: distroSchema, }); -export const collections = { distros }; +/** Machine-written release data (src/data/releases/.json, `bun run sync`). */ +const releases = defineCollection({ + loader: glob({ pattern: "*.json", base: "./src/data/releases" }), + schema: releasesFileSchema, +}); + +const docMeta = z.object({ + title: z.string(), + description: z.string(), + /** Official pages this document paraphrases (required, see docs/content-guide.md). */ + sources: z.array(z.url({ protocol: /^https$/ })).min(1), + lastReviewed: z.preprocess( + (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : v), + z.iso.date(), + ), +}); + +/** + * Per-distro documents: src/content/distro-docs///{overview,install}.mdx + * (entry id: "//"). + */ +const distroDocs = defineCollection({ + loader: glob({ pattern: "*/*/*.mdx", base: "./src/content/distro-docs" }), + schema: docMeta, +}); + +/** Linux guide chapters: src/content/learn//.mdx (id: "/"). */ +const learn = defineCollection({ + loader: glob({ pattern: "*/*.mdx", base: "./src/content/learn" }), + schema: docMeta.extend({ + order: z.number().int().positive(), + /** Section of the guide the chapter belongs to (src/lib/learn.ts). */ + group: z.enum(["basics", "distros", "system", "install", "beyond"]), + /** Short label for navigation. */ + navTitle: z.string(), + icon: z.string().default("contents"), + /** Catalog slugs worth visiting after this chapter. */ + related: z.array(z.string()).default([]), + }), +}); + +export const collections = { distros, releases, distroDocs, learn }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9af8d37..24f3c6f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -63,6 +63,268 @@ export const en = { updated: "Updated {date}", backToTop: "Back to top", }, + home: { + title: "Find the Linux that fits you", + lead: "Browse popular distributions, get the right image from the nearest official mirror, and follow a step-by-step install guide.", + browse: "Browse distributions", + finder: "Take the distro finder", + featured: "Popular picks", + featuredLead: "Friendly, well-supported places to start.", + byUse: "Find by purpose", + recent: "Recent releases", + recentLead: "New versions detected from official sources.", + allReleases: "All release news", + newToLinux: "New to Linux?", + newToLinuxLead: + "Start with the basics: what Linux is, how distributions differ, and how to install one safely.", + openGuide: "Open the Linux guide", + stats: "{count} distributions · updated automatically", + }, + catalog: { + title: "Distributions", + lead: "{count} popular Linux distributions. Filter by what you need.", + filter: "Filter", + search: "Filter by name", + use: "Purpose", + family: "Family", + desktop: "Desktop", + model: "Release model", + level: "Difficulty", + any: "Any", + sort: "Sort", + sortRecommended: "Recommended", + sortName: "Name", + sortUpdated: "Recently updated", + reset: "Reset filters", + count: "{count} shown", + emptyTitle: "No matching distributions", + emptyLead: "Try removing a filter or searching for another name.", + }, + distro: { + download: "Download", + installGuide: "Install guide", + officialSite: "Official website", + overview: "Overview", + facts: "At a glance", + links: "Official links", + related: "Similar distributions", + relatedSameFamily: "More from the {family}", + noOverview: "An overview for this distribution is coming soon.", + installCta: "Ready to install? Follow the step-by-step guide.", + dataNote: "Versions and files are checked against official sources every 6 hours.", + dataStale: "We couldn’t reach the official source recently; the data shown may be out of date.", + breadcrumb: "Distributions", + }, + install: { + title: "How to install {name}", + description: + "Step-by-step {name} installation: download and verify, create a bootable USB, and run the installer.", + onThisPage: "On this page", + downloadIso: "Download the image", + officialDocs: "Official installation docs", + comingSoonTitle: "This guide is being written", + comingSoonLead: + "Until it’s ready, the official installation documentation is the best place to start.", + backToDistro: "Back to {name}", + meta: "Installer: {installer} · Secure Boot: {secureBoot} · Difficulty: {difficulty}", + }, + facts: { + latest: "Latest version", + released: "Released {date}", + eol: "Supported until", + rolling: "Rolling release — always up to date", + family: "Family", + basedOn: "Based on", + releaseModel: "Release model", + desktop: "Default desktop", + otherDesktops: "Also available", + packages: "Package management", + installer: "Installer", + architectures: "Architectures", + secureBoot: "Secure Boot", + requirements: "Recommended hardware", + requirementsValue: "{ram} GB RAM · {disk} GB storage", + origin: "Origin", + originValue: "{country} · since {year}", + difficulty: "Difficulty", + immutable: "Immutable (image-based) system", + useCases: "Good for", + }, + links: { + home: "Official website", + download: "Official download page", + docs: "Documentation", + installGuide: "Official installation guide", + forum: "Community forum", + releaseNotes: "Release notes", + source: "Source code", + }, + download: { + title: "Download", + version: "Version", + edition: "Edition", + arch: "Architecture", + format: "File type", + mirror: "Download server", + button: "Download {file}", + buttonShort: "Download", + size: "{size}", + checksum: "SHA-256 checksum", + checksumMd5: "MD5 checksum", + checksumOther: "Checksum", + checksumFile: "Checksum file", + signature: "Signature", + torrent: "Torrent", + verifyHint: "After downloading, compare the checksum before writing the image to a USB drive.", + verifyLink: "How to verify", + nextStep: "Next: create a bootable USB drive", + auto: "Automatic — nearest official server", + autoHint: "The official server picks a mirror near you.", + recommended: "Recommended for you", + recommendedFor: "Nearest to {place}", + locating: "Finding the nearest mirror…", + mirrorCount: "{count} official mirrors", + distance: "about {km} km", + officialPage: "Download from the official website", + officialPageHint: + "This project only offers downloads from its own website (for example, links that expire). We send you there directly.", + sourceforge: "Hosted on the project’s official SourceForge page, which picks a nearby mirror.", + noFiles: "No files for this combination.", + beta: "Beta", + lts: "LTS", + latest: "Latest", + channel: { stable: "Stable", lts: "LTS", rolling: "Rolling", beta: "Beta", testing: "Testing" }, + kind: { + desktop: "Desktop", + server: "Server", + minimal: "Minimal", + netinst: "Network installer", + live: "Live", + cloud: "Cloud", + other: "Other", + }, + noscript: + "Turn on JavaScript to pick another version or mirror. The button above downloads the default image from the official server.", + }, + learn: { + title: "The Linux Guide", + lead: "Everything you need to understand and use Linux, from what it is to running it every day. Read in order or jump to what you need.", + groups: { + basics: "Basics", + distros: "Distributions & desktops", + system: "Using the system", + install: "Installing & switching", + beyond: "Going further", + }, + chapters: "Chapters", + next: "Next", + previous: "Previous", + related: "Distributions to look at", + readingTime: "{minutes} min read", + start: "Start reading", + }, + finder: { + title: "Distro Finder", + lead: "Answer a few questions and we’ll suggest distributions that fit, with the reasons why.", + q: { + experience: "How familiar are you with Linux?", + uses: "What will you mainly use it for? (pick any)", + hardware: "What kind of computer is it?", + look: "Which kind of desktop do you prefer?", + updates: "How should updates feel?", + privacy: "Is privacy or anonymity a top priority?", + }, + a: { + experience: { + new: "I’m new to Linux", + some: "I’ve used it a bit", + expert: "I’m comfortable with Linux", + }, + hardware: { + modern: "Recent and powerful", + average: "Average", + old: "Old or low on memory (≤ 4 GB)", + }, + look: { + windows: "Like Windows", + macos: "Like macOS", + gnome: "Modern GNOME", + any: "No preference", + }, + updates: { + stable: "Stable and predictable", + latest: "Always the newest software", + any: "No preference", + }, + privacy: { yes: "Yes", no: "Not especially" }, + }, + uses: { + desktop: "Web, office, media", + gaming: "Gaming", + developer: "Programming", + creative: "Creative work", + server: "Servers", + security: "Security testing", + education: "Learning", + }, + submit: "Show my matches", + results: "Your best matches", + resultsLead: "Ranked from your answers. Every match is explained by the reasons below it.", + reasons: { + experience: "Fits your experience", + use: "Made for what you’ll do", + hardware: "Suits your hardware", + look: "The desktop style you like", + updates: "Your kind of updates", + privacy: "Strong on privacy", + }, + restart: "Start over", + compare: "Compare these", + noscript: + "The finder needs JavaScript. You can also filter the catalog by purpose, family and desktop.", + }, + compare: { + title: "Compare distributions", + lead: "Put up to three distributions side by side.", + pick: "Distribution {n}", + none: "—", + rows: { + latest: "Latest version", + released: "Released", + eol: "Supported until", + family: "Family / based on", + model: "Release model", + desktop: "Default desktop", + desktops: "All desktops", + packages: "Package management", + installer: "Installer", + arch: "Architectures", + secureBoot: "Secure Boot", + requirements: "Recommended hardware", + difficulty: "Difficulty", + uses: "Good for", + origin: "Origin", + }, + }, + family: { + title: "Family tree & history", + lead: "Most distributions build on another. See how the catalog’s distributions are related, and when each one began.", + tree: "Family tree", + timeline: "Timeline", + timelineLead: "First public release of each distribution.", + roots: "{count} distributions", + }, + releases: { + title: "Release news", + lead: "New versions of the distributions in our catalog, detected automatically from official sources.", + empty: "No releases recorded yet.", + feed: "Subscribe with RSS", + detected: "Detected {date}", + }, + about: { + title: "About Linuxhub", + lead: "A fast, clean catalog that helps you choose, download and install Linux.", + }, notFound: { title: "Page not found", description: "The page you’re looking for doesn’t exist or has moved.", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 01dc6a7..006e953 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -62,6 +62,264 @@ export const ko: DictionaryShape = { updated: "{date} 업데이트", backToTop: "맨 위로", }, + home: { + title: "나에게 맞는 리눅스를 찾아보세요", + lead: "인기 배포판을 둘러보고, 가장 가까운 공식 미러에서 내려받고, 단계별 설치 가이드를 따라 설치하세요.", + browse: "배포판 둘러보기", + finder: "배포판 찾기 시작", + featured: "추천 배포판", + featuredLead: "처음 시작하기 좋고 지원이 탄탄한 배포판입니다.", + byUse: "용도별로 찾기", + recent: "최근 릴리스", + recentLead: "공식 소스에서 감지한 새 버전입니다.", + allReleases: "릴리스 소식 전체 보기", + newToLinux: "리눅스가 처음이라면", + newToLinuxLead: + "리눅스가 무엇인지, 배포판은 어떻게 다른지, 안전하게 설치하는 방법까지 기초부터 알아보세요.", + openGuide: "리눅스 가이드 열기", + stats: "배포판 {count}개 · 자동 업데이트", + }, + catalog: { + title: "배포판", + lead: "인기 리눅스 배포판 {count}개. 필요한 조건으로 골라 보세요.", + filter: "필터", + search: "이름으로 찾기", + use: "용도", + family: "계열", + desktop: "데스크톱", + model: "릴리스 방식", + level: "난이도", + any: "전체", + sort: "정렬", + sortRecommended: "추천순", + sortName: "이름순", + sortUpdated: "최근 업데이트순", + reset: "필터 초기화", + count: "{count}개 표시", + emptyTitle: "조건에 맞는 배포판이 없습니다", + emptyLead: "필터를 하나 빼거나 다른 이름으로 찾아보세요.", + }, + distro: { + download: "다운로드", + installGuide: "설치 가이드", + officialSite: "공식 웹사이트", + overview: "개요", + facts: "한눈에 보기", + links: "공식 링크", + related: "비슷한 배포판", + relatedSameFamily: "{family}의 다른 배포판", + noOverview: "이 배포판의 소개 글은 곧 추가됩니다.", + installCta: "설치할 준비가 되었나요? 단계별 가이드를 따라 해 보세요.", + dataNote: "버전과 파일 정보는 6시간마다 공식 소스에서 확인합니다.", + dataStale: "최근 공식 소스에 접속하지 못해 표시된 정보가 오래되었을 수 있습니다.", + breadcrumb: "배포판", + }, + install: { + title: "{name} 설치 가이드", + description: + "{name} 설치를 단계별로 안내합니다. 다운로드와 검증, 부팅 USB 만들기, 설치 프로그램 사용법까지 다룹니다.", + onThisPage: "이 페이지의 내용", + downloadIso: "이미지 내려받기", + officialDocs: "공식 설치 문서", + comingSoonTitle: "이 가이드는 작성 중입니다", + comingSoonLead: "준비되는 동안에는 공식 설치 문서를 참고하세요.", + backToDistro: "{name}(으)로 돌아가기", + meta: "설치 프로그램: {installer} · Secure Boot: {secureBoot} · 난이도: {difficulty}", + }, + facts: { + latest: "최신 버전", + released: "{date} 출시", + eol: "지원 종료", + rolling: "롤링 릴리스 — 항상 최신 상태", + family: "계열", + basedOn: "기반", + releaseModel: "릴리스 방식", + desktop: "기본 데스크톱", + otherDesktops: "다른 에디션", + packages: "패키지 관리", + installer: "설치 프로그램", + architectures: "아키텍처", + secureBoot: "Secure Boot", + requirements: "권장 사양", + requirementsValue: "RAM {ram}GB · 저장공간 {disk}GB", + origin: "출신", + originValue: "{country} · {year}년부터", + difficulty: "난이도", + immutable: "불변(이미지 기반) 시스템", + useCases: "이런 분께 좋아요", + }, + links: { + home: "공식 웹사이트", + download: "공식 다운로드 페이지", + docs: "공식 문서", + installGuide: "공식 설치 안내", + forum: "커뮤니티 포럼", + releaseNotes: "릴리스 노트", + source: "소스 코드", + }, + download: { + title: "다운로드", + version: "버전", + edition: "에디션", + arch: "아키텍처", + format: "파일 형식", + mirror: "다운로드 서버", + button: "{file} 다운로드", + buttonShort: "다운로드", + size: "{size}", + checksum: "SHA-256 체크섬", + checksumMd5: "MD5 체크섬", + checksumOther: "체크섬", + checksumFile: "체크섬 파일", + signature: "서명", + torrent: "토렌트", + verifyHint: "내려받은 뒤 USB에 굽기 전에 체크섬을 꼭 비교하세요.", + verifyLink: "검증 방법", + nextStep: "다음 단계: 부팅 USB 만들기", + auto: "자동 — 가장 가까운 공식 서버", + autoHint: "공식 서버가 가까운 미러로 자동 연결합니다.", + recommended: "추천 서버", + recommendedFor: "{place}에서 가장 가까움", + locating: "가장 가까운 미러를 찾는 중…", + mirrorCount: "공식 미러 {count}곳", + distance: "약 {km}km", + officialPage: "공식 웹사이트에서 내려받기", + officialPageHint: + "이 프로젝트는 자체 웹사이트에서만 내려받을 수 있습니다(예: 만료되는 링크). 공식 페이지로 바로 연결합니다.", + sourceforge: + "프로젝트의 공식 SourceForge 페이지에서 제공하며, 가까운 미러가 자동으로 선택됩니다.", + noFiles: "이 조합에 해당하는 파일이 없습니다.", + beta: "베타", + lts: "LTS", + latest: "최신", + channel: { stable: "안정", lts: "LTS", rolling: "롤링", beta: "베타", testing: "테스트" }, + kind: { + desktop: "데스크톱", + server: "서버", + minimal: "최소 설치", + netinst: "네트워크 설치", + live: "라이브", + cloud: "클라우드", + other: "기타", + }, + noscript: + "다른 버전이나 미러를 고르려면 JavaScript를 켜 주세요. 위 버튼은 공식 서버에서 기본 이미지를 내려받습니다.", + }, + learn: { + title: "리눅스 가이드", + lead: "리눅스가 무엇인지부터 매일 쓰는 방법까지, 리눅스를 이해하고 쓰는 데 필요한 모든 것. 순서대로 읽어도, 필요한 장만 골라 읽어도 좋습니다.", + groups: { + basics: "기초", + distros: "배포판과 데스크톱", + system: "시스템 다루기", + install: "설치와 전환", + beyond: "더 나아가기", + }, + chapters: "목차", + next: "다음 장", + previous: "이전 장", + related: "함께 보면 좋은 배포판", + readingTime: "{minutes}분 분량", + start: "처음부터 읽기", + }, + finder: { + title: "배포판 찾기", + lead: "몇 가지 질문에 답하면 잘 맞는 배포판을 이유와 함께 추천해 드립니다.", + q: { + experience: "리눅스를 얼마나 써 보셨나요?", + uses: "주로 무엇에 쓰실 건가요? (여러 개 선택 가능)", + hardware: "어떤 컴퓨터에 설치하시나요?", + look: "어떤 데스크톱 화면이 좋으세요?", + updates: "업데이트는 어떤 방식이 좋으세요?", + privacy: "개인정보 보호나 익명성이 가장 중요한가요?", + }, + a: { + experience: { new: "처음이에요", some: "조금 써 봤어요", expert: "익숙해요" }, + hardware: { + modern: "최신·고사양", + average: "보통", + old: "오래됐거나 메모리가 적어요(4GB 이하)", + }, + look: { + windows: "Windows처럼", + macos: "macOS처럼", + gnome: "깔끔한 GNOME", + any: "상관없어요", + }, + updates: { + stable: "안정적이고 예측 가능하게", + latest: "항상 최신 소프트웨어로", + any: "상관없어요", + }, + privacy: { yes: "네", no: "특별히 아니에요" }, + }, + uses: { + desktop: "웹·문서·영상", + gaming: "게임", + developer: "프로그래밍", + creative: "창작·편집", + server: "서버", + security: "보안 점검", + education: "공부·교육", + }, + submit: "추천 결과 보기", + results: "가장 잘 맞는 배포판", + resultsLead: "답변을 바탕으로 순위를 매겼습니다. 추천 이유를 아래에 함께 표시합니다.", + reasons: { + experience: "경험 수준에 맞음", + use: "원하는 용도에 적합", + hardware: "사양에 맞음", + look: "좋아하는 데스크톱 스타일", + updates: "원하는 업데이트 방식", + privacy: "개인정보 보호에 강함", + }, + restart: "다시 하기", + compare: "추천 결과 비교하기", + noscript: + "배포판 찾기는 JavaScript가 필요합니다. 배포판 목록에서 용도·계열·데스크톱으로 직접 골라 볼 수도 있습니다.", + }, + compare: { + title: "배포판 비교", + lead: "최대 세 개의 배포판을 나란히 비교해 보세요.", + pick: "배포판 {n}", + none: "—", + rows: { + latest: "최신 버전", + released: "출시일", + eol: "지원 종료", + family: "계열 / 기반", + model: "릴리스 방식", + desktop: "기본 데스크톱", + desktops: "제공 데스크톱", + packages: "패키지 관리", + installer: "설치 프로그램", + arch: "아키텍처", + secureBoot: "Secure Boot", + requirements: "권장 사양", + difficulty: "난이도", + uses: "추천 용도", + origin: "출신", + }, + }, + family: { + title: "계보와 역사", + lead: "대부분의 배포판은 다른 배포판을 바탕으로 만들어집니다. 수록된 배포판이 어떻게 이어져 있는지, 언제 시작됐는지 살펴보세요.", + tree: "계보도", + timeline: "타임라인", + timelineLead: "각 배포판이 처음 공개된 시점입니다.", + roots: "배포판 {count}개", + }, + releases: { + title: "릴리스 소식", + lead: "수록된 배포판의 새 버전을 공식 소스에서 자동으로 감지해 보여 줍니다.", + empty: "아직 기록된 릴리스가 없습니다.", + feed: "RSS로 구독하기", + detected: "{date} 감지", + }, + about: { + title: "Linuxhub 소개", + lead: "리눅스를 고르고, 내려받고, 설치하는 일을 돕는 빠르고 깔끔한 카탈로그입니다.", + }, notFound: { title: "페이지를 찾을 수 없습니다", description: "찾으시는 페이지가 없거나 다른 곳으로 옮겨졌습니다.", diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 498197a..6b13319 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -6,8 +6,10 @@ import { Font } from "astro:assets"; import "~/styles/tokens.css"; import "~/styles/base.css"; +import "~/styles/adwaita.css"; import BottomBar from "~/components/site/BottomBar.astro"; import HeaderBar from "~/components/site/HeaderBar.astro"; +import SearchDialog from "~/components/site/SearchDialog.astro"; import SiteFooter from "~/components/site/SiteFooter.astro"; import { DEFAULT_LOCALE, @@ -99,6 +101,7 @@ Astro.csp?.insertScriptHash(sha256Source(THEME_INIT)); + diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts new file mode 100644 index 0000000..acde607 --- /dev/null +++ b/src/lib/catalog.ts @@ -0,0 +1,103 @@ +/** + * Site-side access to the catalog and synced data. All functions run at build + * time (static output). + */ +import { type CollectionEntry, getCollection, getEntry } from "astro:content"; +import type { Artifact, Edition, Release, ReleasesFile } from "./data-schemas"; +import { historyFileSchema, mirrorsFileSchema, statusFileSchema } from "./data-schemas"; + +export type Distro = CollectionEntry<"distros">; + +let cache: Distro[] | undefined; + +/** All distros in editorial order. */ +export async function getDistros(): Promise { + cache ??= (await getCollection("distros")).sort((a, b) => a.data.order - b.data.order); + return cache; +} + +export async function getDistro(slug: string): Promise { + return (await getDistros()).find((d) => d.id === slug); +} + +export async function getReleases(slug: string): Promise { + return (await getEntry("releases", slug))?.data; +} + +/** The release to feature: newest non-beta (stable/lts/rolling), else the first one. */ +export function featuredRelease(file: ReleasesFile | undefined): Release | undefined { + if (!file) return undefined; + return ( + file.releases.find((r) => r.channel !== "beta" && r.channel !== "testing") ?? file.releases[0] + ); +} + +/** The default edition + artifact to offer: first edition, prefer x86_64 ISO. */ +export function defaultArtifact( + release: Release | undefined, +): { edition: Edition; artifact: Artifact } | undefined { + const edition = release?.editions[0]; + if (!edition) return undefined; + const artifact = + edition.artifacts.find((a) => a.arch === "x86_64" && a.format === "iso") ?? + edition.artifacts.find((a) => a.arch === "x86_64") ?? + edition.artifacts[0]; + return artifact ? { edition, artifact } : undefined; +} + +const mirrorFiles = import.meta.glob("/src/data/mirrors/*.json", { + eager: true, + import: "default", +}); + +export function getMirrors(slug: string) { + const raw = mirrorFiles[`/src/data/mirrors/${slug}.json`]; + return raw ? mirrorsFileSchema.parse(raw) : undefined; +} + +export function mirrorSlugs(): string[] { + return Object.keys(mirrorFiles).map((p) => p.replace(/^.*\/|\.json$/g, "")); +} + +const historyRaw = import.meta.glob("/src/data/history.json", { eager: true, import: "default" }); +const statusRaw = import.meta.glob("/src/data/status.json", { eager: true, import: "default" }); + +export function getHistory() { + return historyFileSchema.parse(Object.values(historyRaw)[0] ?? []); +} + +export function getStatus() { + return statusFileSchema.parse(Object.values(statusRaw)[0] ?? {}); +} + +export function formatBytes(bytes: number | null, locale: string): string | null { + if (!bytes) return null; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unit = 0; + while (value >= 1000 && unit < units.length - 1) { + value /= 1000; + unit++; + } + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: value < 10 ? 1 : 0 }).format(value)} ${units[unit]}`; +} + +export function formatDate( + iso: string | null, + locale: string, + style: "long" | "medium" = "long", +): string | null { + if (!iso) return null; + return new Intl.DateTimeFormat(locale, { dateStyle: style, timeZone: "UTC" }).format( + new Date(`${iso}T00:00:00Z`), + ); +} + +type DocEntry = CollectionEntry<"distroDocs">; +let docs: Map | undefined; + +/** A distro document (overview/install) in a locale, or undefined when not written yet. */ +export async function getDistroDoc(slug: string, locale: string, doc: "overview" | "install") { + docs ??= new Map((await getCollection("distroDocs")).map((e) => [e.id, e])); + return docs.get(`${slug}/${locale}/${doc}`); +} diff --git a/src/lib/finder.test.ts b/src/lib/finder.test.ts new file mode 100644 index 0000000..a39fd8c --- /dev/null +++ b/src/lib/finder.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { type FinderDistro, recommend, scoreDistro } from "./finder"; + +const base = { immutable: false, status: "active" as const }; +const distros: FinderDistro[] = [ + { + ...base, + id: "mint", + name: "Linux Mint", + order: 1, + difficulty: "beginner", + useCases: ["beginner", "desktop"], + desktops: ["cinnamon", "xfce", "mate"], + releaseModel: "lts", + ramGB: 4, + }, + { + ...base, + id: "arch", + name: "Arch", + order: 11, + difficulty: "advanced", + useCases: ["tinkerer", "developer"], + desktops: ["none"], + releaseModel: "rolling", + ramGB: 2, + }, + { + ...base, + id: "bazzite", + name: "Bazzite", + order: 14, + difficulty: "beginner", + useCases: ["gaming", "desktop"], + desktops: ["kde", "gnome"], + releaseModel: "fixed", + ramGB: 8, + }, + { + ...base, + id: "antix", + name: "antiX", + order: 27, + difficulty: "intermediate", + useCases: ["lightweight"], + desktops: ["icewm"], + releaseModel: "fixed", + ramGB: 1, + }, + { + ...base, + id: "tails", + name: "Tails", + order: 32, + difficulty: "intermediate", + useCases: ["privacy", "security"], + desktops: ["gnome"], + releaseModel: "fixed", + ramGB: 2, + }, + { + ...base, + id: "old", + name: "Gone", + order: 99, + difficulty: "beginner", + useCases: ["beginner"], + desktops: ["xfce"], + releaseModel: "lts", + ramGB: 1, + status: "discontinued", + }, +]; + +describe("finder", () => { + it("recommends a beginner-friendly Windows-like LTS distro to newcomers", () => { + const [top] = recommend(distros, { experience: "new", look: "windows", updates: "stable" }); + expect(top?.distro.id).toBe("mint"); + expect(top?.reasons).toEqual(expect.arrayContaining(["experience", "look", "updates"])); + }); + + it("puts gaming distros first for gamers", () => { + expect(recommend(distros, { experience: "some", uses: ["gaming"] })[0]?.distro.id).toBe( + "bazzite", + ); + }); + + it("prefers lightweight distros on old hardware", () => { + expect(recommend(distros, { hardware: "old" })[0]?.distro.id).toBe("antix"); + }); + + it("keeps advanced distros away from newcomers and rewards experts who want the latest", () => { + expect(scoreDistro(distros[1] as FinderDistro, { experience: "new" }).score).toBeLessThan(0); + expect( + recommend(distros, { experience: "expert", updates: "latest", uses: ["developer"] })[0] + ?.distro.id, + ).toBe("arch"); + }); + + it("matches privacy needs", () => { + expect(recommend(distros, { privacy: "yes", experience: "some" })[0]?.distro.id).toBe("tails"); + }); + + it("never recommends discontinued distros", () => { + expect(recommend(distros, { experience: "new" }, 10).some((r) => r.distro.id === "old")).toBe( + false, + ); + }); +}); diff --git a/src/lib/finder.ts b/src/lib/finder.ts new file mode 100644 index 0000000..9c46388 --- /dev/null +++ b/src/lib/finder.ts @@ -0,0 +1,109 @@ +/** + * Distro finder scoring (runs in the browser; pure and unit-tested). + * Every point comes from a catalog attribute, so each recommendation can be + * explained ("matches: beginner-friendly, gaming, Windows-like desktop"). + */ + +export interface FinderDistro { + id: string; + name: string; + order: number; + difficulty: "beginner" | "intermediate" | "advanced"; + useCases: string[]; + desktops: string[]; + releaseModel: "fixed" | "lts" | "rolling" | "semi-rolling"; + ramGB: number; + immutable: boolean; + status: "active" | "discontinued"; +} + +export interface FinderAnswers { + experience?: "new" | "some" | "expert" | undefined; + uses?: string[] | undefined; + hardware?: "modern" | "average" | "old" | undefined; + look?: "windows" | "macos" | "gnome" | "any" | undefined; + updates?: "stable" | "latest" | "any" | undefined; + privacy?: "yes" | "no" | undefined; +} + +export type Reason = "experience" | "use" | "hardware" | "look" | "updates" | "privacy"; + +export interface FinderResult { + distro: FinderDistro; + score: number; + reasons: Reason[]; +} + +const LOOK_DESKTOPS: Record, string[]> = { + windows: ["cinnamon", "kde", "xfce", "mate", "lxqt", "ukui", "trinity"], + macos: ["pantheon", "budgie", "dde", "cosmic"], + gnome: ["gnome"], +}; + +export function scoreDistro(d: FinderDistro, a: FinderAnswers): FinderResult { + let score = 0; + const reasons: Reason[] = []; + const add = (points: number, reason: Reason) => { + score += points; + if (points > 0 && !reasons.includes(reason)) reasons.push(reason); + }; + + if (a.experience) { + const fit = { + new: { beginner: 6, intermediate: 1, advanced: -8 }, + some: { beginner: 3, intermediate: 4, advanced: -2 }, + expert: { beginner: 0, intermediate: 3, advanced: 4 }, + }[a.experience][d.difficulty]; + add(fit, "experience"); + } + + for (const use of a.uses ?? []) { + if (d.useCases.includes(use)) add(use === "desktop" ? 2 : 5, "use"); + else if (use !== "desktop") score -= 1; + } + + if (a.hardware === "old") { + if (d.useCases.includes("lightweight")) add(6, "hardware"); + if (d.ramGB <= 2) add(3, "hardware"); + else if (d.ramGB >= 4) score -= 3; + } else if (a.hardware === "average" && d.ramGB <= 4) add(1, "hardware"); + + if (a.look && a.look !== "any") { + const wanted = LOOK_DESKTOPS[a.look]; + if (wanted.includes(d.desktops[0] ?? "")) add(4, "look"); + else if (d.desktops.some((x) => wanted.includes(x))) add(2, "look"); + } + + if (a.updates === "stable") { + if (d.releaseModel === "lts") add(4, "updates"); + else if (d.releaseModel === "fixed") add(2, "updates"); + else if (d.releaseModel === "rolling") score -= 3; + } else if (a.updates === "latest") { + if (d.releaseModel === "rolling") add(4, "updates"); + else if (d.releaseModel === "semi-rolling") add(3, "updates"); + else if (d.releaseModel === "fixed") add(1, "updates"); + else score -= 1; + } + + if (a.privacy === "yes" && d.useCases.includes("privacy")) add(5, "privacy"); + + // Server/security-only distros are rarely what a newcomer wants on a desktop. + if (a.experience === "new" && (d.useCases.includes("security") || d.desktops[0] === "none")) + score -= 4; + + // Tiny nudge toward editorial order so ties are stable and sensible. + score -= d.order / 1000; + return { distro: d, score, reasons }; +} + +export function recommend( + distros: FinderDistro[], + answers: FinderAnswers, + limit = 3, +): FinderResult[] { + return distros + .filter((d) => d.status === "active") + .map((d) => scoreDistro(d, answers)) + .sort((x, y) => y.score - x.score) + .slice(0, limit); +} diff --git a/src/lib/learn.ts b/src/lib/learn.ts new file mode 100644 index 0000000..ce2405c --- /dev/null +++ b/src/lib/learn.ts @@ -0,0 +1,20 @@ +import { type CollectionEntry, getCollection } from "astro:content"; +import type { Locale } from "~/i18n"; + +export type Chapter = CollectionEntry<"learn">; +export const GROUPS = ["basics", "distros", "system", "install", "beyond"] as const; + +/** Chapters of one locale in reading order; the slug is the file name without locale. */ +export async function getChapters(locale: Locale): Promise<(Chapter & { slug: string })[]> { + return (await getCollection("learn", (e) => e.id.startsWith(`${locale}/`))) + .map((e) => Object.assign(e, { slug: e.id.slice(locale.length + 1) })) + .sort((a, b) => a.data.order - b.data.order); +} + +/** Rough reading time: ~500 Korean characters or ~220 English words per minute. */ +export function readingMinutes(body: string | undefined, locale: Locale): number { + const text = (body ?? "").replace(/```[\s\S]*?```/g, "").replace(/<[^>]+>/g, ""); + const units = + locale === "ko" ? text.replace(/\s/g, "").length / 500 : text.split(/\s+/).length / 220; + return Math.max(1, Math.round(units)); +} diff --git a/src/lib/mirrors.test.ts b/src/lib/mirrors.test.ts new file mode 100644 index 0000000..341a2c2 --- /dev/null +++ b/src/lib/mirrors.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { haversineKm, type MirrorPayload, rankMirrors } from "./mirrors"; + +const payload: MirrorPayload = { + countries: { KR: [36.38, 128.13], JP: [36.0, 138.0], US: [39.54, -97.48], DE: [51.1, 10.4] }, + mirrors: [ + { u: "https://us.example/", c: "US", n: "us", la: null, lo: null, s: 1 }, + { u: "https://jp.example/", c: "JP", n: "jp", la: null, lo: null, s: 1 }, + { u: "https://kr-b.example/", c: "KR", n: "kr-b", la: null, lo: null, s: 3 }, + { u: "https://kr-a.example/", c: "KR", n: "kr-a", la: null, lo: null, s: 1 }, + { u: "https://de.example/", c: "DE", n: "de", la: 50.11, lo: 8.68, s: null }, + ], +}; + +describe("haversineKm", () => { + it("measures Seoul → Tokyo at roughly 1,160 km", () => { + expect(haversineKm(37.57, 126.98, 35.68, 139.69)).toBeGreaterThan(1100); + expect(haversineKm(37.57, 126.98, 35.68, 139.69)).toBeLessThan(1200); + }); +}); + +describe("rankMirrors", () => { + it("puts same-country mirrors first, best score first", () => { + const ranked = rankMirrors(payload, { cc: "KR", lat: 37.57, lon: 126.98 }); + // Seoul: Korea first (score 1 before 3), then Japan, Frankfurt, central US. + expect(ranked.map((m) => m.n)).toEqual(["kr-a", "kr-b", "jp", "de", "us"]); + expect(ranked[0]?.sameCountry).toBe(true); + }); + + it("orders foreign mirrors by distance", () => { + const ranked = rankMirrors(payload, { cc: "FR", lat: 48.86, lon: 2.35 }); + expect(ranked[0]?.n).toBe("de"); + expect(ranked.at(-1)?.n).not.toBe("de"); + }); + + it("falls back to the country label point when coordinates are missing", () => { + const ranked = rankMirrors(payload, { cc: "JP", lat: null, lon: null }); + expect(ranked[0]?.n).toBe("jp"); + expect(ranked[1]?.c).toBe("KR"); + }); + + it("keeps a stable order without any location", () => { + const ranked = rankMirrors(payload, { cc: null, lat: null, lon: null }); + expect(ranked.every((m) => m.km === null)).toBe(true); + expect(ranked[0]?.s).toBe(1); + }); +}); diff --git a/src/lib/mirrors.ts b/src/lib/mirrors.ts new file mode 100644 index 0000000..0d0385e --- /dev/null +++ b/src/lib/mirrors.ts @@ -0,0 +1,73 @@ +/** + * Mirror ranking (runs in the browser; pure functions, unit-tested). + * Order: same country first, then great-circle distance from the visitor + * (mirror coordinates when published, else the country's label point), then + * the project's own quality score (lower is better), then URL for stability. + */ + +export interface GeoPoint { + cc: string | null; + lat: number | null; + lon: number | null; +} + +/** Compact mirror record served at /data/mirrors/.json. */ +export interface MirrorRecord { + u: string; + c: string | null; + n: string | null; + la: number | null; + lo: number | null; + s: number | null; +} + +export interface MirrorPayload { + mirrors: MirrorRecord[]; + /** Label points for the countries in the list: cc → [lat, lon]. */ + countries: Record; +} + +export interface RankedMirror extends MirrorRecord { + km: number | null; + sameCountry: boolean; +} + +const R = 6371; +const rad = (d: number) => (d * Math.PI) / 180; + +export function haversineKm(aLat: number, aLon: number, bLat: number, bLon: number): number { + const dLat = rad(bLat - aLat); + const dLon = rad(bLon - aLon); + const h = + Math.sin(dLat / 2) ** 2 + Math.cos(rad(aLat)) * Math.cos(rad(bLat)) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); +} + +export function rankMirrors(payload: MirrorPayload, geo: GeoPoint): RankedMirror[] { + const origin = + geo.lat !== null && geo.lon !== null + ? ([geo.lat, geo.lon] as const) + : geo.cc && payload.countries[geo.cc] + ? payload.countries[geo.cc] + : null; + + const ranked = payload.mirrors.map((m): RankedMirror => { + const point = + m.la !== null && m.lo !== null + ? ([m.la, m.lo] as const) + : m.c + ? payload.countries[m.c] + : undefined; + const km = + origin && point ? Math.round(haversineKm(origin[0], origin[1], point[0], point[1])) : null; + return { ...m, km, sameCountry: Boolean(geo.cc && m.c === geo.cc) }; + }); + + return ranked.sort( + (a, b) => + Number(b.sameCountry) - Number(a.sameCountry) || + (a.km ?? Number.POSITIVE_INFINITY) - (b.km ?? Number.POSITIVE_INFINITY) || + (a.s ?? Number.POSITIVE_INFINITY) - (b.s ?? Number.POSITIVE_INFINITY) || + a.u.localeCompare(b.u), + ); +} diff --git a/src/lib/related.ts b/src/lib/related.ts new file mode 100644 index 0000000..9f8a08f --- /dev/null +++ b/src/lib/related.ts @@ -0,0 +1,26 @@ +import type { Distro } from "./catalog"; + +/** + * Similar distributions: shared lineage first (same base, children, siblings), + * then same family, then overlapping use cases; ties broken by editorial order. + */ +export function relatedDistros(distro: Distro, all: Distro[], limit = 6): Distro[] { + const d = distro.data; + const score = (o: Distro) => { + const x = o.data; + let s = 0; + if (x.basedOn === distro.id || d.basedOn === o.id) s += 6; + if (d.basedOn && x.basedOn === d.basedOn) s += 4; + if (x.family === d.family) s += 3; + s += x.useCases.filter((u) => d.useCases.includes(u)).length; + if (x.desktops[0] === d.desktops[0]) s += 1; + if (x.difficulty === d.difficulty) s += 0.5; + return s; + }; + return all + .filter((o) => o.id !== distro.id && o.data.status === "active") + .map((o) => ({ o, s: score(o) })) + .sort((a, b) => b.s - a.s || a.o.data.order - b.o.data.order) + .slice(0, limit) + .map(({ o }) => o); +} diff --git a/src/lib/use-icons.ts b/src/lib/use-icons.ts new file mode 100644 index 0000000..8ef7045 --- /dev/null +++ b/src/lib/use-icons.ts @@ -0,0 +1,17 @@ +import type { UseCase } from "./taxonomy"; + +/** Adwaita symbolic icon per use case (src/assets/icons). */ +export const USE_ICONS: Record = { + beginner: "star", + desktop: "display", + gaming: "gaming", + developer: "terminal", + server: "server", + security: "security", + privacy: "privacy", + lightweight: "computer", + creative: "graphics", + education: "science", + enterprise: "engineering", + tinkerer: "settings", +}; diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..d2ed04a --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,30 @@ +--- +/** + * Vercel serves /404.html for unknown paths in every locale. The page is in + * Korean with an English line; the language switch still works. + */ +import Button from "~/components/adw/Button.astro"; +import StatusPage from "~/components/adw/StatusPage.astro"; +import { useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; + +const t = useTranslations("ko"); +const en = useTranslations("en"); +--- + + +
      + + + + +

      {en("notFound.title")} — {en("notFound.description")}

      +
      + + + diff --git a/src/pages/[...locale]/about/index.astro b/src/pages/[...locale]/about/index.astro new file mode 100644 index 0000000..cc09488 --- /dev/null +++ b/src/pages/[...locale]/about/index.astro @@ -0,0 +1,181 @@ +--- +/** + * About: how the site works, data sources, privacy, the sync bot, and + * credits for every logo, font and icon (generated from the catalog). + */ +import { localePaths, localizePath, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { getDistros } from "~/lib/catalog"; +import "~/styles/prose.css"; + +export const getStaticPaths = localePaths; +const { locale } = Astro.props; +const t = useTranslations(locale); +const distros = [...(await getDistros())].sort((a, b) => a.data.name.localeCompare(b.data.name)); +const ko = locale === "ko"; +const repo = "https://github.com/neramc/linuxhub"; +--- + + +
      +
      +

      {t("about.title")}

      +

      {t("about.lead")}

      + + { + ko ? ( + <> +

      무엇을 하나요

      +

      + Linuxhub는 인기 리눅스 배포판의 핵심 정보를 짧게 정리하고, 공식 미러에서 설치 이미지를 내려받게 하고, 설치를 + 처음부터 끝까지 안내합니다. 회원 가입이나 광고, 추적 스크립트는 없습니다. +

      +

      데이터는 어디서 오나요

      +
        +
      • + 버전, 파일, 체크섬, 미러는 각 프로젝트의 공식 API, 체크섬 파일, 공식 미러 목록에서 + 6시간마다(미러 목록은 하루 한 번) 자동으로 가져옵니다. 가져온 데이터는 검증을 거쳐 저장소에 기록되고, + 바뀌었을 때만 사이트가 다시 배포됩니다. +
      • +
      • + 배포판 소개와 설치 가이드는 공식 문서를 참고해 직접 다시 쓴 글이며, 문서마다 참고한 공식 + 자료와 마지막 검토일을 밝힙니다. 공식 문서를 그대로 옮기지 않습니다. +
      • +
      • + 수록 배포판은 편집자가 고릅니다. 다른 사이트의 인기 순위를 가져오지 않습니다. +
      • +
      +

      위치 정보와 개인정보

      +

      + 가장 가까운 미러를 추천하기 위해, 다운로드 화면을 열면 서버가 접속 IP로부터 추정한 대략적인 국가와 좌표(도시 + 수준)를 한 번 알려 줍니다. 이 값은 저장하거나 기록하지 않으며 캐시하지도 않습니다. 브라우저의 위치 권한은 + 요청하지 않습니다. 선택한 다운로드 옵션과 화면 테마는 이 브라우저의 로컬 저장소에만 남습니다. +

      +

      LinuxhubBot

      +

      + 자동 갱신은 LinuxhubBot이라는 이름으로 접속합니다. robots.txt와 Crawl-delay를 지키고, 같은 + 서버에는 최소 1초 간격으로 요청하며, ETag/If-Modified-Since로 바뀐 내용만 받습니다. 문제가 있으면 + 이슈로 알려 주세요. +

      +

      상표와 로고

      +

      + 배포판 이름과 로고는 각 소유자의 상표이며, 해당 프로젝트를 알리기 위해서만 원본 그대로 사용합니다. 로고 + 사용에 문제가 있다면 이슈로 알려 주시면 바로 조치하겠습니다. +

      + + ) : ( + <> +

      What it does

      +

      + Linuxhub summarizes what matters about popular Linux distributions, lets you download installation images + from official mirrors, and walks you through installing them. No accounts, no ads, no tracking scripts. +

      +

      Where the data comes from

      +
        +
      • + Versions, files, checksums and mirrors are pulled automatically every 6 hours (mirror + lists daily) from each project’s official APIs, checksum files and mirror lists. The data is validated, + committed to the repository, and the site redeploys only when something changed. +
      • +
      • + Overviews and install guides are written in our own words from the official + documentation; every document lists its official sources and when it was last reviewed. We never copy + official docs verbatim. +
      • +
      • The list of distributions is editorial; we don’t import popularity rankings from other sites.
      • +
      +

      Location and privacy

      +

      + To suggest the nearest mirror, opening the download panel asks our server once for the approximate country + and city-level coordinates derived from your IP address. Nothing is stored, logged or cached, and we never + ask for your browser’s location permission. Your download choices and theme stay in this browser’s local + storage. +

      +

      LinuxhubBot

      +

      + The automatic refresh identifies itself as LinuxhubBot. It honors robots.txt and Crawl-delay, + waits at least one second between requests to the same server, and revalidates with ETag / + If-Modified-Since. Report problems via issues. +

      +

      Trademarks and logos

      +

      + Distribution names and logos are trademarks of their respective owners, used unmodified and only to + identify the projects. If something is wrong, please open an issue and we’ll + fix it promptly. +

      + + ) + } + +

      {ko ? "로고 출처" : "Logo credits"}

      + + + + + + + + + + { + distros.map((d) => ( + + + + + + )) + } + +
      {ko ? "배포판" : "Distribution"}{ko ? "라이선스 / 상표" : "License / trademark"}{ko ? "출처" : "Source"}
      + {d.data.name} + + {d.data.logo.license} + {d.data.logo.trademark && ( + <> + {" · "} + + {ko ? "상표 정책" : "Trademark policy"} + + + )} + + + {new URL(d.data.logo.source).hostname} + +
      + +

      {ko ? "글꼴과 아이콘" : "Fonts and icons"}

      +
        +
      • + Adwaita Sans · Adwaita Mono — GNOME Project, SIL Open Font License 1.1 ( + adwaita-fonts) +
      • +
      • + {ko ? "아이콘" : "Icons"}: Adwaita icon theme & libadwaita — GNOME Project, CC BY-SA 3.0 / LGPL ( + adwaita-icon-theme) +
      • +
      • + {ko ? "국가 좌표" : "Country locations"}: Natural Earth (public domain) · IANA Time Zone Database (public + domain) +
      • +
      + +

      {ko ? "라이선스와 소스 코드" : "License and source code"}

      +

      + {t("footer.license")} {repo.replace("https://", "")} +

      +
      +
      + + + diff --git a/src/pages/[...locale]/compare/index.astro b/src/pages/[...locale]/compare/index.astro new file mode 100644 index 0000000..569f044 --- /dev/null +++ b/src/pages/[...locale]/compare/index.astro @@ -0,0 +1,229 @@ +--- +/** + * Side-by-side comparison of up to three distros. Every cell is pre-rendered + * per locale into a compact JSON; the page shows a default trio without JS and + * src/scripts/compare.ts swaps columns from the selects / ?d=a,b,c. + */ +import { localePaths, localizePath, type MessageKey, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { featuredRelease, formatDate, getDistros, getReleases } from "~/lib/catalog"; +import { logoUrl } from "~/lib/logos"; +import { + ARCHITECTURES, + DESKTOPS, + DIFFICULTIES, + FAMILIES, + INSTALLERS, + RELEASE_MODELS, + SECURE_BOOT, + USE_CASES, +} from "~/lib/taxonomy"; + +export const getStaticPaths = localePaths; +const { locale } = Astro.props; +const t = useTranslations(locale); +const distros = await getDistros(); +const byId = new Map(distros.map((d) => [d.id, d])); +const regions = new Intl.DisplayNames([locale], { type: "region" }); +const ROWS = [ + "latest", + "released", + "eol", + "family", + "model", + "desktop", + "desktops", + "packages", + "installer", + "arch", + "secureBoot", + "requirements", + "difficulty", + "uses", + "origin", +] as const; + +const cells: Record< + string, + Record<(typeof ROWS)[number], string> & { name: string; logo: string; href: string } +> = {}; +for (const d of distros) { + const x = d.data; + const release = featuredRelease(await getReleases(d.id)); + const base = x.basedOn ? byId.get(x.basedOn)?.data.name : x.upstream; + cells[d.id] = { + name: x.name, + logo: logoUrl(x.logo.file), + href: localizePath(`/distros/${d.id}/`, locale), + latest: release ? release.version : "—", + released: + release?.channel === "rolling" + ? RELEASE_MODELS.rolling[locale] + : (formatDate(release?.releaseDate ?? null, locale) ?? "—"), + eol: formatDate(release?.eol ?? null, locale) ?? "—", + family: base ? `${FAMILIES[x.family][locale]} · ${base}` : FAMILIES[x.family][locale], + model: RELEASE_MODELS[x.releaseModel][locale], + desktop: DESKTOPS[x.desktops[0] ?? "none"][locale], + desktops: x.desktops.map((k) => DESKTOPS[k][locale]).join(", "), + packages: x.packageManagers.join(", "), + installer: INSTALLERS[x.installer][locale], + arch: x.architectures.map((a) => ARCHITECTURES[a][locale].split(" (")[0]).join(", "), + secureBoot: SECURE_BOOT[x.secureBoot][locale], + requirements: t("facts.requirementsValue", { + ram: x.requirements.ramGB, + disk: x.requirements.diskGB, + }), + difficulty: DIFFICULTIES[x.difficulty][locale], + uses: x.useCases.map((u) => USE_CASES[u][locale]).join(", "), + origin: `${x.origin.country === "INT" ? "INT" : (regions.of(x.origin.country) ?? x.origin.country)} · ${x.origin.firstRelease.slice(0, 4)}`, + }; +} +const preferred = ["ubuntu", "fedora", "linux-mint"].filter((id) => byId.has(id)); +const defaults = (preferred.length >= 2 ? preferred : distros.slice(0, 3).map((d) => d.id)).slice( + 0, + 3, +); +const rowLabel = (r: (typeof ROWS)[number]) => t(`compare.rows.${r}` as MessageKey); +const sortedOptions = [...distros].sort((a, b) => a.data.name.localeCompare(b.data.name)); +--- + + +
      + + +
      + { + [0, 1, 2].map((i) => ( + + )) + } +
      + +
      + + + + + { + [0, 1, 2].map((i) => { + const c = defaults[i] ? cells[defaults[i] as string] : undefined; + return ( + + ); + }) + } + + + + { + ROWS.map((row) => ( + + + {[0, 1, 2].map((i) => ( + + ))} + + )) + } + +
      + {c && ( + + + {c.name} + + )} +
      {rowLabel(row)}{defaults[i] ? cells[defaults[i] as string]?.[row] : ""}
      +
      + +
      + + + + + diff --git a/src/pages/[...locale]/distros/[slug]/index.astro b/src/pages/[...locale]/distros/[slug]/index.astro new file mode 100644 index 0000000..32ac13b --- /dev/null +++ b/src/pages/[...locale]/distros/[slug]/index.astro @@ -0,0 +1,267 @@ +--- +import { render } from "astro:content"; +import Badge from "~/components/adw/Badge.astro"; +import Button from "~/components/adw/Button.astro"; +import Callout from "~/components/adw/Callout.astro"; +import Icon from "~/components/adw/Icon.astro"; +import DistroCard from "~/components/DistroCard.astro"; +import DistroFacts from "~/components/DistroFacts.astro"; +import DistroLinks from "~/components/DistroLinks.astro"; +import DistroLogo from "~/components/DistroLogo.astro"; +import DownloadWizard from "~/components/DownloadWizard.astro"; +import { LOCALES, type Locale, localeParam, localizePath, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { + featuredRelease, + getDistroDoc, + getDistros, + getMirrors, + getReleases, + getStatus, +} from "~/lib/catalog"; +import { relatedDistros } from "~/lib/related"; +import { FAMILIES, RELEASE_MODELS, USE_CASES } from "~/lib/taxonomy"; + +export async function getStaticPaths() { + const distros = await getDistros(); + return LOCALES.flatMap((locale) => + distros.map((distro) => ({ + params: { locale: localeParam(locale), slug: distro.id }, + props: { locale, slug: distro.id }, + })), + ); +} + +const { locale, slug } = Astro.props as { locale: Locale; slug: string }; +const t = useTranslations(locale); +const all = await getDistros(); +const distro = all.find((d) => d.id === slug); +if (!distro) throw new Error(`unknown distro ${slug}`); +const d = distro.data; +const base = d.basedOn ? all.find((x) => x.id === d.basedOn) : undefined; +const releases = await getReleases(slug); +const release = featuredRelease(releases); +const overview = await getDistroDoc(slug, locale, "overview"); +const Overview = overview ? (await render(overview)).Content : null; +const related = relatedDistros(distro, all); +const status = getStatus()[slug]; +const stale = Boolean(status?.releases?.failingSince); +const installHref = localizePath(`/distros/${slug}/install/`, locale); +const description = `${d.name}: ${d.tagline[locale]}`; + +const jsonLd = [ + { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: d.name, + description: d.tagline[locale], + applicationCategory: "OperatingSystem", + operatingSystem: "Linux", + ...(release ? { softwareVersion: release.version } : {}), + url: d.links.home, + downloadUrl: d.links.download, + offers: { "@type": "Offer", price: 0, priceCurrency: "USD" }, + }, + { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: t("nav.distros"), + item: new URL(localizePath("/distros/", locale), Astro.site).href, + }, + { "@type": "ListItem", position: 2, name: d.name }, + ], + }, +]; +--- + + +
      + + + +
      + +
      +

      {d.name}

      +

      {d.tagline[locale]}

      +
      + {FAMILIES[d.family][locale]} + {RELEASE_MODELS[d.releaseModel][locale]} + {d.useCases.slice(0, 3).map((u) => {USE_CASES[u][locale]})} + {release && {release.version}} +
      +
      +
      + + + +
      +
      + + {stale && {t("distro.dataStale")}} + +
      +
      +
      +

      {t("distro.overview")}

      + {Overview ?
      :

      {t("distro.noOverview")}

      } +
      + +
      + +
      + + + + {t("distro.installGuide")} + {t("distro.installCta")} + + + +
      + + +
      + + +
      + + + diff --git a/src/pages/[...locale]/distros/[slug]/install/index.astro b/src/pages/[...locale]/distros/[slug]/install/index.astro new file mode 100644 index 0000000..a08416f --- /dev/null +++ b/src/pages/[...locale]/distros/[slug]/install/index.astro @@ -0,0 +1,174 @@ +--- +import { render } from "astro:content"; +import Button from "~/components/adw/Button.astro"; +import StatusPage from "~/components/adw/StatusPage.astro"; +import ViewSwitcher from "~/components/adw/ViewSwitcher.astro"; +import DistroLogo from "~/components/DistroLogo.astro"; +import DocSources from "~/components/DocSources.astro"; +import { mdxComponents } from "~/components/mdx"; +import Toc from "~/components/Toc.astro"; +import { LOCALES, type Locale, localeParam, localizePath, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { getDistroDoc, getDistros } from "~/lib/catalog"; +import { DIFFICULTIES, INSTALLERS, SECURE_BOOT } from "~/lib/taxonomy"; +import "~/styles/prose.css"; + +export async function getStaticPaths() { + const distros = await getDistros(); + return LOCALES.flatMap((locale) => + distros.map((d) => ({ + params: { locale: localeParam(locale), slug: d.id }, + props: { locale, slug: d.id }, + })), + ); +} + +const { locale, slug } = Astro.props as { locale: Locale; slug: string }; +const t = useTranslations(locale); +const distro = (await getDistros()).find((d) => d.id === slug); +if (!distro) throw new Error(`unknown distro ${slug}`); +const d = distro.data; +const doc = await getDistroDoc(slug, locale, "install"); +const rendered = doc ? await render(doc) : null; +const distroHref = localizePath(`/distros/${slug}/`, locale); +const title = t("install.title", { name: d.name }); +const jsonLd = doc + ? [ + { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: title, + description: doc.data.description, + inLanguage: locale, + dateModified: doc.data.lastReviewed, + about: d.name, + }, + ] + : []; +--- + + +
      + + + + +
      + +
      +

      {title}

      +

      + {t("install.meta", { installer: INSTALLERS[d.installer][locale], secureBoot: SECURE_BOOT[d.secureBoot][locale], difficulty: DIFFICULTIES[d.difficulty][locale] })} +

      +
      +
      + +
      + + {d.links.installGuide && } +
      + + { + rendered ? ( +
      + +
      + + +
      +
      + ) : ( + + + + ) + } + + ← {t("install.backToDistro", { name: d.name })} +
      + + + + + diff --git a/src/pages/[...locale]/distros/index.astro b/src/pages/[...locale]/distros/index.astro new file mode 100644 index 0000000..cf37edf --- /dev/null +++ b/src/pages/[...locale]/distros/index.astro @@ -0,0 +1,205 @@ +--- +import Button from "~/components/adw/Button.astro"; +import Icon from "~/components/adw/Icon.astro"; +/** + * Catalog: every distro as a Flathub-style card. All cards are in the HTML + * (works without JS); src/scripts/catalog-filter.ts filters/sorts them in + * place and mirrors the state in the URL (?use=&family=&desktop=&model=&level=&sort=&q=). + */ +import StatusPage from "~/components/adw/StatusPage.astro"; +import DistroCard from "~/components/DistroCard.astro"; +import { localePaths, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { featuredRelease, getDistros, getReleases } from "~/lib/catalog"; +import { DESKTOPS, DIFFICULTIES, FAMILIES, RELEASE_MODELS, USE_CASES } from "~/lib/taxonomy"; + +export const getStaticPaths = localePaths; +const { locale } = Astro.props; +const t = useTranslations(locale); +const distros = await getDistros(); +const updated = new Map( + await Promise.all( + distros.map( + async (d) => [d.id, featuredRelease(await getReleases(d.id))?.releaseDate ?? ""] as const, + ), + ), +); +const usedDesktops = [...new Set(distros.flatMap((d) => d.data.desktops))].filter( + (k) => k !== "none", +); +const usedFamilies = [...new Set(distros.map((d) => d.data.family))]; +const filters = [ + { + name: "use", + label: t("catalog.use"), + options: Object.keys(USE_CASES).map((k) => [k, USE_CASES[k as keyof typeof USE_CASES][locale]]), + }, + { + name: "family", + label: t("catalog.family"), + options: Object.keys(FAMILIES) + .filter((k) => usedFamilies.includes(k as never)) + .map((k) => [k, FAMILIES[k as keyof typeof FAMILIES][locale]]), + }, + { + name: "desktop", + label: t("catalog.desktop"), + options: Object.keys(DESKTOPS) + .filter((k) => usedDesktops.includes(k as never)) + .map((k) => [k, DESKTOPS[k as keyof typeof DESKTOPS][locale]]), + }, + { + name: "model", + label: t("catalog.model"), + options: Object.keys(RELEASE_MODELS).map((k) => [ + k, + RELEASE_MODELS[k as keyof typeof RELEASE_MODELS][locale], + ]), + }, + { + name: "level", + label: t("catalog.level"), + options: Object.keys(DIFFICULTIES).map((k) => [ + k, + DIFFICULTIES[k as keyof typeof DIFFICULTIES][locale], + ]), + }, +] as const; +--- + + +
      + + + + +
        + { + distros.map((d, i) => ( +
      • + +
      • + )) + } +
      + + +
      + + + + + diff --git a/src/pages/[...locale]/family/index.astro b/src/pages/[...locale]/family/index.astro new file mode 100644 index 0000000..b67d15c --- /dev/null +++ b/src/pages/[...locale]/family/index.astro @@ -0,0 +1,225 @@ +--- +/** + * Family tree (from each distro's `basedOn`) and a timeline of first releases. + * Plain nested lists — accessible, responsive, zero JS. + */ +import DistroLogo from "~/components/DistroLogo.astro"; +import FamilyNode from "~/components/FamilyNode.astro"; +import { localePaths, localizePath, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { type Distro, getDistros } from "~/lib/catalog"; +import { FAMILIES, type Family } from "~/lib/taxonomy"; + +export const getStaticPaths = localePaths; +const { locale } = Astro.props; +const t = useTranslations(locale); +const distros = await getDistros(); +const ids = new Set(distros.map((d) => d.id)); +const byYear = (a: Distro, b: Distro) => + a.data.origin.firstRelease.localeCompare(b.data.origin.firstRelease); + +const childrenOf = new Map(); +for (const d of distros) { + if (d.data.basedOn && ids.has(d.data.basedOn)) { + childrenOf.set(d.data.basedOn, [...(childrenOf.get(d.data.basedOn) ?? []), d]); + } +} +for (const list of childrenOf.values()) list.sort(byYear); + +const roots = distros.filter((d) => !d.data.basedOn || !ids.has(d.data.basedOn)).sort(byYear); +const familyOrder = Object.keys(FAMILIES) as Family[]; +const sections = familyOrder + .map((family) => ({ family, roots: roots.filter((r) => r.data.family === family) })) + .filter((s) => s.roots.length > 0); +const countTree = (d: Distro): number => + 1 + (childrenOf.get(d.id) ?? []).reduce((n, k) => n + countTree(k), 0); + +const years = new Map(); +for (const d of [...distros].sort(byYear)) { + const y = d.data.origin.firstRelease.slice(0, 4); + years.set(y, [...(years.get(y) ?? []), d]); +} +--- + + +
      + + +
      +

      {t("family.tree")}

      +
      + { + sections.map((s) => ( +
      +

      {FAMILIES[s.family][locale]}

      +

      + {t("family.roots", { count: s.roots.reduce((n, r) => n + countTree(r), 0) })} + {s.roots[0]?.data.upstream && !s.roots[0].data.basedOn ? ` · ${s.roots[0].data.upstream}` : ""} +

      +
        + {s.roots.map((r) => ( + + ))} +
      +
      + )) + } +
      +
      + +
      +

      {t("family.timeline")}

      +

      {t("family.timelineLead")}

      +
        + { + [...years].map(([year, list]) => ( +
      1. + {year} + +
      2. + )) + } +
      +
      +
      + + + diff --git a/src/pages/[...locale]/finder/index.astro b/src/pages/[...locale]/finder/index.astro new file mode 100644 index 0000000..b0bcf3e --- /dev/null +++ b/src/pages/[...locale]/finder/index.astro @@ -0,0 +1,250 @@ +--- +/** + * Distro finder quiz. Questions are plain HTML form controls; scoring runs in + * the browser (src/lib/finder.ts) over a compact JSON of catalog attributes. + */ +import Button from "~/components/adw/Button.astro"; +import { localePaths, localizePath, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { getDistros } from "~/lib/catalog"; +import type { FinderDistro } from "~/lib/finder"; +import { logoUrl } from "~/lib/logos"; + +export const getStaticPaths = localePaths; +const { locale } = Astro.props; +const t = useTranslations(locale); +const distros = await getDistros(); +const data: (FinderDistro & { tagline: string; logo: string; href: string })[] = distros.map( + (d) => ({ + id: d.id, + name: d.data.name, + order: d.data.order, + difficulty: d.data.difficulty, + useCases: d.data.useCases, + desktops: d.data.desktops, + releaseModel: d.data.releaseModel, + ramGB: d.data.requirements.ramGB, + immutable: d.data.immutable, + status: d.data.status, + tagline: d.data.tagline[locale], + logo: logoUrl(d.data.logo.file), + href: localizePath(`/distros/${d.id}/`, locale), + }), +); +const single = [ + { name: "experience", options: ["new", "some", "expert"] }, + { name: "hardware", options: ["modern", "average", "old"] }, + { name: "look", options: ["windows", "macos", "gnome", "any"] }, + { name: "updates", options: ["stable", "latest", "any"] }, + { name: "privacy", options: ["yes", "no"] }, +] as const; +const uses = [ + "desktop", + "gaming", + "developer", + "creative", + "server", + "security", + "education", +] as const; +const reasons = { + experience: t("finder.reasons.experience"), + use: t("finder.reasons.use"), + hardware: t("finder.reasons.hardware"), + look: t("finder.reasons.look"), + updates: t("finder.reasons.updates"), + privacy: t("finder.reasons.privacy"), +}; +--- + + +
      + + +
      +
      + 1. {t("finder.q.experience")} +
      + {single[0].options.map((o, i) => ( + + ))} +
      +
      + +
      + 2. {t("finder.q.uses")} +
      + {uses.map((u) => ( + + ))} +
      +
      + + { + single.slice(1).map((q, qi) => ( +
      + {qi + 3}. {t(`finder.q.${q.name}`)} +
      + {q.options.map((o, i) => ( + + ))} +
      +
      + )) + } + +
      + +
      + +
      + + + + +
      + + + + + diff --git a/src/pages/[...locale]/index.astro b/src/pages/[...locale]/index.astro index 11be72d..d003c05 100644 --- a/src/pages/[...locale]/index.astro +++ b/src/pages/[...locale]/index.astro @@ -1,15 +1,218 @@ --- -import { localePaths, useTranslations } from "~/i18n"; -// Temporary home until Phase 4 (docs/plan.md §10). +import Button from "~/components/adw/Button.astro"; +import Icon from "~/components/adw/Icon.astro"; +import DistroCard from "~/components/DistroCard.astro"; +import DistroLogo from "~/components/DistroLogo.astro"; +import { localePaths, localizePath, useTranslations } from "~/i18n"; import Base from "~/layouts/Base.astro"; +import { formatDate, getDistros, getHistory } from "~/lib/catalog"; +import { USE_CASES, type UseCase } from "~/lib/taxonomy"; +import { USE_ICONS } from "~/lib/use-icons"; export const getStaticPaths = localePaths; const { locale } = Astro.props; const t = useTranslations(locale); +const distros = await getDistros(); +const featured = distros.filter((d) => d.data.status === "active").slice(0, 6); +const bySlug = new Map(distros.map((d) => [d.id, d])); +const recent = getHistory() + .filter((h) => bySlug.has(h.slug)) + .slice(0, 6); +const uses: UseCase[] = [ + "beginner", + "gaming", + "developer", + "server", + "lightweight", + "privacy", + "security", + "creative", +]; +const jsonLd = [ + { + "@context": "https://schema.org", + "@type": "WebSite", + name: "Linuxhub", + url: new URL(localizePath("/", locale), Astro.site).href, + inLanguage: locale, + }, +]; --- - -
      -

      {t("site.tagline")}

      + +
      +
      +

      {t("home.title")}

      +

      {t("home.lead")}

      +
      + + +
      +

      {t("home.stats", { count: distros.length })}

      +
      + +
      +
      +
      +
      + +

      {t("home.featuredLead")}

      +
      + +
      +
        + {featured.map((d, i) =>
      • )} +
      +
      + +
      +

      {t("home.byUse")}

      + +
      + + { + recent.length > 0 && ( +
      +
      +
      +

      {t("home.recent")}

      +

      {t("home.recentLead")}

      +
      + +
      + +
      + ) + } + +
      +
      +

      {t("home.newToLinux")}

      +

      {t("home.newToLinuxLead")}

      +
      + +
      +
      + + diff --git a/src/pages/[...locale]/learn/[chapter].astro b/src/pages/[...locale]/learn/[chapter].astro new file mode 100644 index 0000000..f75f3b0 --- /dev/null +++ b/src/pages/[...locale]/learn/[chapter].astro @@ -0,0 +1,223 @@ +--- +import { render } from "astro:content"; +import Icon from "~/components/adw/Icon.astro"; +import ChapterNav from "~/components/ChapterNav.astro"; +import DistroCard from "~/components/DistroCard.astro"; +import DocSources from "~/components/DocSources.astro"; +import { mdxComponents } from "~/components/mdx"; +import Toc from "~/components/Toc.astro"; +import { LOCALES, type Locale, localeParam, localizePath, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { getDistros } from "~/lib/catalog"; +import { getChapters, readingMinutes } from "~/lib/learn"; +import "~/styles/prose.css"; + +export async function getStaticPaths() { + const paths = []; + for (const locale of LOCALES) { + for (const c of await getChapters(locale)) { + paths.push({ + params: { locale: localeParam(locale), chapter: c.slug }, + props: { locale, slug: c.slug }, + }); + } + } + return paths; +} + +const { locale, slug } = Astro.props as { locale: Locale; slug: string }; +const t = useTranslations(locale); +const chapters = await getChapters(locale); +const index = chapters.findIndex((c) => c.slug === slug); +const chapter = chapters[index]; +if (!chapter) throw new Error(`unknown chapter ${slug}`); +const { Content, headings } = await render(chapter); +const prev = chapters[index - 1]; +const next = chapters[index + 1]; +const distros = await getDistros(); +const related = chapter.data.related + .map((s) => distros.find((d) => d.id === s)) + .filter((d): d is NonNullable => Boolean(d)); +const href = (s: string) => localizePath(`/learn/${s}/`, locale); +const jsonLd = [ + { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: chapter.data.title, + description: chapter.data.description, + inLanguage: locale, + dateModified: chapter.data.lastReviewed, + isPartOf: { "@type": "CreativeWorkSeries", name: t("learn.title") }, + }, +]; +--- + + +
      + + +
      +
      +

      + {t(`learn.groups.${chapter.data.group}`)} · {t("learn.readingTime", { minutes: readingMinutes(chapter.body, locale) })} +

      +

      {chapter.data.title}

      +

      {chapter.data.description}

      + + +
      + + { + related.length > 0 && ( + + ) + } + + +
      + + +
      + + + + + diff --git a/src/pages/[...locale]/learn/index.astro b/src/pages/[...locale]/learn/index.astro new file mode 100644 index 0000000..86f931d --- /dev/null +++ b/src/pages/[...locale]/learn/index.astro @@ -0,0 +1,117 @@ +--- +import Button from "~/components/adw/Button.astro"; +import Icon from "~/components/adw/Icon.astro"; +import { localePaths, localizePath, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { GROUPS, getChapters, readingMinutes } from "~/lib/learn"; + +export const getStaticPaths = localePaths; +const { locale } = Astro.props; +const t = useTranslations(locale); +const chapters = await getChapters(locale); +const first = chapters[0]; +--- + + +
      + + + { + GROUPS.map((group) => { + const items = chapters.filter((c) => c.data.group === group); + return items.length ? ( +
      +

      {t(`learn.groups.${group}`)}

      + +
      + ) : null; + }) + } +
      + + + diff --git a/src/pages/[...locale]/releases/index.astro b/src/pages/[...locale]/releases/index.astro new file mode 100644 index 0000000..9de87d1 --- /dev/null +++ b/src/pages/[...locale]/releases/index.astro @@ -0,0 +1,80 @@ +--- +import Button from "~/components/adw/Button.astro"; +import Icon from "~/components/adw/Icon.astro"; +import StatusPage from "~/components/adw/StatusPage.astro"; +import DistroLogo from "~/components/DistroLogo.astro"; +import { localePaths, localizePath, useTranslations } from "~/i18n"; +import Base from "~/layouts/Base.astro"; +import { formatDate, getDistros, getHistory } from "~/lib/catalog"; + +export const getStaticPaths = localePaths; +const { locale } = Astro.props; +const t = useTranslations(locale); +const bySlug = new Map((await getDistros()).map((d) => [d.id, d])); +const history = getHistory().filter((h) => bySlug.has(h.slug)); +const byMonth = new Map(); +for (const h of history) { + const key = h.date.slice(0, 7); + byMonth.set(key, [...(byMonth.get(key) ?? []), h]); +} +const monthLabel = (ym: string) => + new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", timeZone: "UTC" }).format( + new Date(`${ym}-01T00:00:00Z`), + ); +--- + + +
      + + + { + history.length === 0 ? ( + + ) : ( + [...byMonth].map(([month, items]) => ( +
      +

      {monthLabel(month)}

      + +
      + )) + ) + } +
      + + + diff --git a/src/pages/[...locale]/rss.xml.ts b/src/pages/[...locale]/rss.xml.ts new file mode 100644 index 0000000..9c59d0c --- /dev/null +++ b/src/pages/[...locale]/rss.xml.ts @@ -0,0 +1,33 @@ +/** Release feed (RSS 2.0) per locale: /rss.xml (ko) and /en/rss.xml. */ +import rss from "@astrojs/rss"; +import type { APIRoute } from "astro"; +import { type Locale, localePaths, localizePath, useTranslations } from "~/i18n"; +import { getDistros, getHistory } from "~/lib/catalog"; + +export const getStaticPaths = localePaths; + +export const GET: APIRoute = async ({ props, site }) => { + const locale = props.locale as Locale; + const t = useTranslations(locale); + const bySlug = new Map((await getDistros()).map((d) => [d.id, d])); + const items = getHistory() + .filter((h) => bySlug.has(h.slug)) + .slice(0, 50) + .map((h) => { + const d = bySlug.get(h.slug); + return { + title: `${d?.data.name} ${h.version}`, + // The fragment keeps each release's link (= RSS guid) unique. + link: `${new URL(localizePath(`/distros/${h.slug}/`, locale), site).href}#v${encodeURIComponent(h.version)}`, + pubDate: new Date(`${h.date}T00:00:00Z`), + description: d?.data.tagline[locale] ?? "", + }; + }); + return rss({ + title: `${t("site.name")} – ${t("releases.title")}`, + description: t("releases.lead"), + site: new URL(localizePath("/", locale), site).href, + items, + customData: `${locale}`, + }); +}; diff --git a/src/pages/api/geo.ts b/src/pages/api/geo.ts new file mode 100644 index 0000000..6d2b898 --- /dev/null +++ b/src/pages/api/geo.ts @@ -0,0 +1,29 @@ +/** + * The site's only server function. Returns the visitor's approximate location + * from Vercel's edge headers (country + city-level coordinates) so the + * download wizard can suggest the nearest official mirror. + * + * Privacy: nothing is logged or stored, and the response is never cached + * (docs/decisions.md ADR-0004). + */ +import { geolocation } from "@vercel/functions"; +import type { APIRoute } from "astro"; + +export const prerender = false; + +const num = (v: string | undefined) => { + const n = v === undefined ? Number.NaN : Number.parseFloat(v); + return Number.isFinite(n) ? Math.round(n * 100) / 100 : null; +}; + +export const GET: APIRoute = ({ request }) => { + const geo = geolocation(request); + const cc = geo.country && /^[A-Z]{2}$/.test(geo.country) ? geo.country : null; + return new Response(JSON.stringify({ cc, lat: num(geo.latitude), lon: num(geo.longitude) }), { + headers: { + "content-type": "application/json", + "cache-control": "private, no-store", + "x-robots-tag": "noindex", + }, + }); +}; diff --git a/src/pages/data/mirrors/[slug].json.ts b/src/pages/data/mirrors/[slug].json.ts new file mode 100644 index 0000000..ae66d2c --- /dev/null +++ b/src/pages/data/mirrors/[slug].json.ts @@ -0,0 +1,33 @@ +/** + * Static, compact official mirror list per distro for the download wizard + * (fetched on demand in the browser, ranked client-side by src/lib/mirrors.ts). + */ +import type { APIRoute, GetStaticPaths } from "astro"; +import countries from "~/data/geo/countries.json"; +import { getMirrors, mirrorSlugs } from "~/lib/catalog"; +import type { MirrorPayload } from "~/lib/mirrors"; + +export const getStaticPaths: GetStaticPaths = () => + mirrorSlugs().map((slug) => ({ params: { slug } })); + +export const GET: APIRoute = ({ params }) => { + const file = getMirrors(params.slug ?? ""); + if (!file) return new Response("Not found", { status: 404 }); + const centroids = countries as Record; + const payload: MirrorPayload = { + mirrors: file.mirrors.map((m) => ({ + u: m.url, + c: m.country, + n: m.name, + la: m.lat, + lo: m.lon, + s: m.score, + })), + countries: Object.fromEntries( + [...new Set(file.mirrors.map((m) => m.country).filter((c): c is string => Boolean(c)))] + .filter((cc) => centroids[cc]) + .map((cc) => [cc, [centroids[cc]?.lat ?? 0, centroids[cc]?.lon ?? 0]]), + ), + }; + return new Response(JSON.stringify(payload), { headers: { "content-type": "application/json" } }); +}; diff --git a/src/pages/data/releases/[slug].json.ts b/src/pages/data/releases/[slug].json.ts new file mode 100644 index 0000000..392826c --- /dev/null +++ b/src/pages/data/releases/[slug].json.ts @@ -0,0 +1,14 @@ +/** Static release data per distro, fetched by the download wizard when the visitor changes a selection. */ +import { getCollection } from "astro:content"; +import type { APIRoute, GetStaticPaths } from "astro"; + +export const getStaticPaths: GetStaticPaths = async () => + (await getCollection("releases")).map((entry) => ({ + params: { slug: entry.id }, + props: { data: entry.data }, + })); + +export const GET: APIRoute = ({ props }) => + new Response(JSON.stringify(props.data.releases), { + headers: { "content-type": "application/json" }, + }); diff --git a/src/scripts/catalog-filter.ts b/src/scripts/catalog-filter.ts new file mode 100644 index 0000000..8eafbfa --- /dev/null +++ b/src/scripts/catalog-filter.ts @@ -0,0 +1,77 @@ +/** Client-side filtering/sorting for the catalog grid; state lives in the URL query. */ +const form = document.querySelector("[data-filters]"); +const grid = document.querySelector("[data-grid]"); +const empty = document.querySelector("[data-empty]"); +const counter = document.querySelector("[data-count]"); + +const KEYS = ["q", "use", "family", "desktop", "model", "level", "sort"] as const; +type Key = (typeof KEYS)[number]; + +/** Very small Korean-aware normalization: lowercase, strip common particles. */ +function normalize(q: string): string { + return q + .toLowerCase() + .trim() + .replace(/(은|는|이|가|을|를|의|에|에서|으로|로|와|과)$/u, ""); +} + +if (form && grid) { + const items = [...grid.querySelectorAll("[data-distro]")]; + const field = (name: Key) => + form.elements.namedItem(name) as HTMLInputElement | HTMLSelectElement | null; + const template = grid.dataset.countLabel ?? "{count}"; + + const read = (): Record => + Object.fromEntries(KEYS.map((k) => [k, field(k)?.value ?? ""])) as Record; + + function apply(updateUrl: boolean) { + const state = read(); + const q = normalize(state.q); + let shown = 0; + for (const item of items) { + const d = item.dataset; + const ok = + (!q || (d.search ?? "").includes(q)) && + (!state.use || (d.use ?? "").split(" ").includes(state.use)) && + (!state.family || d.family === state.family) && + (!state.desktop || (d.desktop ?? "").split(" ").includes(state.desktop)) && + (!state.model || d.model === state.model) && + (!state.level || d.level === state.level); + item.hidden = !ok; + if (ok) shown++; + } + const sorted = [...items].sort((a, b) => { + if (state.sort === "name") return (a.dataset.name ?? "").localeCompare(b.dataset.name ?? ""); + if (state.sort === "updated") + return (b.dataset.updated ?? "").localeCompare(a.dataset.updated ?? ""); + return Number(a.dataset.order) - Number(b.dataset.order); + }); + grid?.append(...sorted); + if (empty) empty.hidden = shown > 0; + if (counter) counter.textContent = template.replace("{count}", String(shown)); + if (updateUrl) { + const params = new URLSearchParams(); + for (const k of KEYS) if (state[k]) params.set(k, state[k]); + const qs = params.toString(); + history.replaceState(null, "", qs ? `?${qs}` : location.pathname); + } + } + + // Initial state from the URL (links like /distros/?use=gaming). + const params = new URLSearchParams(location.search); + for (const k of KEYS) { + const el = field(k); + const v = params.get(k); + if (el && v) el.value = v; + } + apply(false); + + form.addEventListener("input", () => apply(true)); + form.addEventListener("submit", (e) => e.preventDefault()); + document.querySelector("[data-reset]")?.addEventListener("click", () => { + form.reset(); + apply(true); + }); +} + +export {}; diff --git a/src/scripts/compare.ts b/src/scripts/compare.ts new file mode 100644 index 0000000..8c15121 --- /dev/null +++ b/src/scripts/compare.ts @@ -0,0 +1,56 @@ +/** Swaps comparison columns from the selects and keeps ?d=a,b,c in the URL. */ +type Cells = Record & { name: string; logo: string; href: string }>; + +const root = document.querySelector("[data-compare]"); +const raw = document.getElementById("compare-data")?.textContent; + +if (root && raw) { + const cells = JSON.parse(raw) as Cells; + const selects = [...root.querySelectorAll("select[data-column]")]; + + function render(column: number, id: string) { + const c = cells[id]; + const head = document.querySelector(`[data-head="${column}"]`); + if (head) { + head.replaceChildren(); + if (c) { + const a = document.createElement("a"); + a.className = "head-link"; + a.href = c.href; + const img = document.createElement("img"); + img.src = c.logo; + img.alt = ""; + img.width = 48; + img.height = 48; + const name = document.createElement("span"); + name.textContent = c.name; + a.append(img, name); + head.append(a); + } + } + for (const cell of document.querySelectorAll(`[data-cell^="${column}:"]`)) { + const key = cell.dataset.cell?.split(":")[1] ?? ""; + cell.textContent = c?.[key] ?? ""; + } + } + + function sync() { + const ids = selects.map((s) => s.value); + for (const [i, id] of ids.entries()) render(i, id); + const chosen = ids.filter(Boolean); + history.replaceState(null, "", chosen.length ? `?d=${chosen.join(",")}` : location.pathname); + } + + const fromUrl = + new URLSearchParams(location.search) + .get("d") + ?.split(",") + .filter((id) => cells[id]) ?? []; + if (fromUrl.length) { + for (const [i, s] of selects.entries()) s.value = fromUrl[i] ?? ""; + sync(); + } + for (const s of selects) s.addEventListener("change", sync); +} + +export {}; diff --git a/src/scripts/download.ts b/src/scripts/download.ts new file mode 100644 index 0000000..ea3e2fa --- /dev/null +++ b/src/scripts/download.ts @@ -0,0 +1,322 @@ +/** + * Progressive enhancement for the download wizard (src/components/DownloadWizard.astro). + * + * - Version/edition/arch/format selects cascade from the full release data, + * fetched once from /data/releases/.json on first interaction. + * - For distros with an official mirror list, the nearest mirrors are ranked + * from /api/geo (Vercel edge geolocation) with a timezone fallback, and the + * download link points at the chosen mirror + the file's relative path. + * - The last choice per distro is remembered in localStorage. + */ +import type { Artifact, Release } from "~/lib/data-schemas"; +import { type GeoPoint, type MirrorPayload, type RankedMirror, rankMirrors } from "~/lib/mirrors"; + +interface Labels { + latest: string; + lts: string; + channel: Record; + checksum: string; + checksumMd5: string; + checksumOther: string; + auto: string; + recommended: string; + recommendedFor: string; + locating: string; + distance: string; + noFiles: string; + button: string; +} + +interface Choice { + version: string; + edition: string; + arch: string; + format: string; + mirror?: string; +} + +const MAX_MIRRORS = 12; + +function storageGet(key: string): Choice | null { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as Choice) : null; + } catch { + return null; + } +} + +function storageSet(key: string, value: Choice) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* private mode */ + } +} + +async function locate(): Promise { + try { + const res = await fetch("/api/geo", { cache: "no-store" }); + if (res.ok) { + const geo = (await res.json()) as GeoPoint; + if (geo.cc || geo.lat !== null) return geo; + } + } catch { + /* offline, blocked, or a non-Vercel preview */ + } + // Fallback: the browser's time zone has an official location (IANA zone1970.tab). + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const zones = (await import("~/data/geo/timezones.json")).default as Record< + string, + { cc: string; lat: number; lon: number } + >; + const zone = zones[tz]; + if (zone) return { cc: zone.cc, lat: zone.lat, lon: zone.lon }; + } catch { + /* ignore */ + } + return { cc: null, lat: null, lon: null }; +} + +function option(value: string, label: string, selected: boolean): HTMLOptionElement { + const o = document.createElement("option"); + o.value = value; + o.textContent = label; + o.selected = selected; + return o; +} + +function fill( + select: HTMLSelectElement, + items: [string, string][], + wanted: string | undefined, +): string { + const value = items.some(([v]) => v === wanted) ? (wanted as string) : (items[0]?.[0] ?? ""); + select.replaceChildren(...items.map(([v, label]) => option(v, label, v === value))); + select.value = value; + return value; +} + +function init(root: HTMLElement) { + const slug = root.dataset.slug as string; + const locale = root.dataset.locale ?? "ko"; + const labels = JSON.parse(root.dataset.labels ?? "{}") as Labels; + const archLabels = JSON.parse(root.dataset.arches ?? "{}") as Record; + const defaults = JSON.parse(root.dataset.default ?? "{}") as Choice; + const storageKey = `lh-dl-${slug}`; + + const select = (name: string) => root.querySelector(`select[name="${name}"]`); + const versionSel = select("version"); + const editionSel = select("edition"); + const archSel = select("arch"); + const formatSel = select("format"); + const mirrorSel = select("mirror"); + const formatRow = root.querySelector("[data-format-row]"); + const download = root.querySelector("[data-download]"); + const sizeEl = root.querySelector("[data-size]"); + const fileEl = root.querySelector("[data-file]"); + const verify = root.querySelector("[data-verify]"); + const checksumEl = root.querySelector("[data-checksum]"); + const checksumLabel = root.querySelector("[data-checksum-label]"); + const copyBtn = root.querySelector("[data-copy-checksum]"); + const mirrorLabel = root.querySelector("[data-mirror-label]"); + const links = { + checksum: root.querySelector("[data-checksum-url]"), + signature: root.querySelector("[data-signature-url]"), + torrent: root.querySelector("[data-torrent-url]"), + }; + if (!versionSel || !editionSel || !archSel || !formatSel || !download) return; + + const bytes = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); + const formatSize = (n: number | null) => { + if (!n) return ""; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = n; + let u = 0; + while (v >= 1000 && u < units.length - 1) { + v /= 1000; + u++; + } + return ` · ${bytes.format(v)} ${units[u]}`; + }; + const regionNames = (() => { + try { + return new Intl.DisplayNames([locale], { type: "region" }); + } catch { + return null; + } + })(); + const country = (cc: string | null) => (cc ? (regionNames?.of(cc) ?? cc) : ""); + + let releases: Release[] | null = null; + let loading: Promise | null = null; + let mirrors: RankedMirror[] = []; + let current: Artifact | null = null; + + const loadReleases = () => { + loading ??= fetch(root.dataset.releasesUrl as string) + .then((r) => r.json() as Promise) + .then((data) => { + releases = data; + return data; + }); + return loading; + }; + + const versionLabel = (r: Release, i: number) => { + const tags: string[] = []; + if (i === 0 && r.channel !== "beta" && r.channel !== "testing") tags.push(labels.latest); + if (r.channel === "beta" || r.channel === "testing") tags.push(labels.channel[r.channel]); + if (r.channel === "lts") tags.push(labels.lts); + const base = r.codename ? `${r.version} “${r.codename}”` : r.version; + return tags.length ? `${base} (${tags.join(", ")})` : base; + }; + + function href(artifact: Artifact): string { + const mirror = mirrorSel?.value; + return mirror && artifact.path ? `${mirror}${artifact.path}` : artifact.url; + } + + function render(artifact: Artifact | null) { + current = artifact; + if (!artifact || !download) { + download?.setAttribute("aria-disabled", "true"); + if (fileEl) fileEl.textContent = labels.noFiles; + return; + } + download.removeAttribute("aria-disabled"); + download.href = href(artifact); + if (sizeEl) sizeEl.textContent = formatSize(artifact.size); + if (fileEl) fileEl.textContent = artifact.file; + const sum = artifact.checksum; + if (verify) verify.hidden = !sum; + if (sum && checksumEl) checksumEl.textContent = sum.value; + if (sum && copyBtn) copyBtn.dataset.copy = sum.value; + if (sum && checksumLabel) { + checksumLabel.textContent = + sum.type === "sha256" + ? labels.checksum + : sum.type === "md5" + ? labels.checksumMd5 + : `${sum.type.toUpperCase()} ${labels.checksumOther}`; + } + for (const [key, url] of [ + ["checksum", sum?.url], + ["signature", artifact.signatureUrl], + ["torrent", artifact.torrentUrl], + ] as const) { + const a = links[key]; + if (!a) continue; + a.hidden = !url; + if (url) a.href = url; + } + } + + /** Rebuild dependent selects from the full data, keeping compatible choices. */ + function cascade(wanted: Partial) { + if (!releases || !versionSel || !editionSel || !archSel || !formatSel) return; + fill( + versionSel, + releases.map((r, i) => [r.version, versionLabel(r, i)]), + wanted.version, + ); + const release = releases.find((r) => r.version === versionSel.value); + const editionId = fill( + editionSel, + (release?.editions ?? []).map((e) => [e.id, e.name]), + wanted.edition, + ); + const edition = release?.editions.find((e) => e.id === editionId); + const arches = [...new Set(edition?.artifacts.map((a) => a.arch) ?? [])]; + const arch = fill( + archSel, + arches.map((a) => [a, archLabels[a] ?? a]), + wanted.arch, + ); + const formats = [ + ...new Set(edition?.artifacts.filter((a) => a.arch === arch).map((a) => a.format) ?? []), + ]; + const format = fill( + formatSel, + formats.map((f) => [f, `.${f}`]), + wanted.format, + ); + if (formatRow) formatRow.hidden = formats.length < 2; + render(edition?.artifacts.find((a) => a.arch === arch && a.format === format) ?? null); + } + + const snapshot = (): Choice => ({ + version: versionSel.value, + edition: editionSel.value, + arch: archSel.value, + format: formatSel.value, + ...(mirrorSel?.value ? { mirror: mirrorSel.value } : {}), + }); + + async function onChange() { + await loadReleases(); + cascade(snapshot()); + storageSet(storageKey, snapshot()); + } + + for (const s of [versionSel, editionSel, archSel, formatSel]) + s.addEventListener("change", () => void onChange()); + + // Restore a previous choice (only when it differs from the server default). + const saved = storageGet(storageKey); + if ( + saved && + (saved.version !== defaults.version || + saved.edition !== defaults.edition || + saved.arch !== defaults.arch || + saved.format !== defaults.format) + ) { + void loadReleases().then(() => cascade(saved)); + } + + // Nearest-mirror suggestion. + const mirrorsUrl = root.dataset.mirrors; + if (mirrorsUrl && mirrorSel && mirrorLabel) { + const run = async () => { + const [payload, geo] = await Promise.all([ + fetch(mirrorsUrl).then((r) => r.json() as Promise), + locate(), + ]); + mirrors = rankMirrors(payload, geo).slice(0, MAX_MIRRORS); + const describe = (m: RankedMirror) => + [ + m.n ?? new URL(m.u).hostname, + country(m.c), + m.km !== null ? labels.distance.replace("{km}", m.km.toLocaleString(locale)) : "", + ] + .filter(Boolean) + .join(" · "); + mirrorSel.replaceChildren( + option("", labels.auto, false), + ...mirrors.map((m, i) => option(m.u, `${i === 0 ? "★ " : ""}${describe(m)}`, false)), + ); + const remembered = + saved?.mirror && mirrors.some((m) => m.u === saved.mirror) ? saved.mirror : undefined; + const best = mirrors[0]; + mirrorSel.value = remembered ?? best?.u ?? ""; + mirrorSel.hidden = false; + const place = country(geo.cc); + mirrorLabel.textContent = + best && place ? labels.recommendedFor.replace("{place}", place) : labels.recommended; + if (current) render(current); + }; + mirrorSel.addEventListener("change", () => { + if (current) render(current); + storageSet(storageKey, snapshot()); + }); + const start = () => + void run().catch(() => { + mirrorLabel.textContent = labels.auto; + }); + if ("requestIdleCallback" in window) window.requestIdleCallback(start, { timeout: 2000 }); + else setTimeout(start, 300); + } +} + +for (const root of document.querySelectorAll("[data-wizard]")) init(root); diff --git a/src/scripts/finder.ts b/src/scripts/finder.ts new file mode 100644 index 0000000..1ac19b5 --- /dev/null +++ b/src/scripts/finder.ts @@ -0,0 +1,73 @@ +import { type FinderAnswers, type FinderDistro, type Reason, recommend } from "~/lib/finder"; + +type Item = FinderDistro & { tagline: string; logo: string; href: string }; + +const form = document.querySelector("[data-finder]"); +const results = document.querySelector("[data-results]"); +const list = document.querySelector("[data-result-list]"); +const raw = document.getElementById("finder-data")?.textContent; + +if (form && results && list && raw) { + const { distros, reasons } = JSON.parse(raw) as { + distros: Item[]; + reasons: Record; + }; + + const el = (tag: K, cls?: string, text?: string) => { + const node = document.createElement(tag); + if (cls) node.className = cls; + if (text) node.textContent = text; + return node; + }; + + form.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(form); + const answers: FinderAnswers = { + experience: (data.get("experience") as FinderAnswers["experience"]) ?? undefined, + uses: data.getAll("uses") as string[], + hardware: (data.get("hardware") as FinderAnswers["hardware"]) ?? undefined, + look: (data.get("look") as FinderAnswers["look"]) ?? undefined, + updates: (data.get("updates") as FinderAnswers["updates"]) ?? undefined, + privacy: (data.get("privacy") as FinderAnswers["privacy"]) ?? undefined, + }; + const top = recommend(distros, answers, 3); + list.replaceChildren( + ...top.map((r, i) => { + const item = r.distro as Item; + const li = el("li"); + const a = el("a", "adw-card activatable"); + a.setAttribute("href", item.href); + const img = el("img"); + img.src = item.logo; + img.alt = ""; + img.width = 64; + img.height = 64; + const text = el("div"); + const title = el("p"); + title.append(el("span", "result-rank", `${i + 1}. `), el("span", "result-name", item.name)); + const badges = el("div", "result-reasons"); + for (const reason of r.reasons) + badges.append(el("span", "adw-badge accent", reasons[reason])); + text.append(title, el("p", "dim-label", item.tagline), badges); + a.append(img, text); + li.append(a); + return li; + }), + ); + const compare = results.querySelector("[data-compare-link]"); + if (compare) + compare.href = `${form.dataset.compare}?d=${top.map((r) => r.distro.id).join(",")}`; + results.hidden = false; + results.querySelector("h2")?.focus(); + results.scrollIntoView({ + behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", + }); + }); + + results.querySelector("[data-restart]")?.addEventListener("click", () => { + form.reset(); + results.hidden = true; + window.scrollTo({ top: 0 }); + }); +} diff --git a/src/scripts/search.ts b/src/scripts/search.ts new file mode 100644 index 0000000..e7cd8bf --- /dev/null +++ b/src/scripts/search.ts @@ -0,0 +1,109 @@ +/** Opens the search dialog and queries Pagefind (loaded lazily on first open). */ + +interface PagefindResult { + data: () => Promise<{ url: string; excerpt: string; meta: { title?: string } }>; +} +interface Pagefind { + init: () => Promise; + debouncedSearch: ( + q: string, + opts?: object, + ms?: number, + ) => Promise<{ results: PagefindResult[] } | null>; +} + +const dialog = document.querySelector("#search-dialog"); +const input = dialog?.querySelector("[data-search-input]"); +const list = dialog?.querySelector("[data-search-results]"); +const status = dialog?.querySelector("[data-search-status]"); +const labels = JSON.parse(dialog?.dataset.labels ?? "{}") as Record; +let pagefind: Promise | null = null; + +/** Drop a trailing Korean particle so "우분투를" matches "우분투". */ +function normalize(q: string): string { + return q + .trim() + .split(/\s+/) + .map((w) => w.replace(/(은|는|이|가|을|를|의|에|에서|으로|로|와|과|도)$/u, "")) + .join(" "); +} + +function load(): Promise { + pagefind ??= import(/* @vite-ignore */ `${location.origin}/pagefind/pagefind.js`).then( + async (pf: Pagefind) => { + await pf.init(); + return pf; + }, + ); + return pagefind; +} + +function open() { + if (!dialog || !input) return; + if (!dialog.open) dialog.showModal(); + input.focus(); + input.select(); + void load(); +} + +async function search() { + if (!input || !list || !status) return; + const q = normalize(input.value); + if (!q) { + list.replaceChildren(); + return; + } + status.textContent = labels.loading ?? ""; + const pf = await load(); + const res = await pf.debouncedSearch(q, {}, 150); + if (!res) return; // superseded by a newer query + const top = await Promise.all(res.results.slice(0, 8).map((r) => r.data())); + list.replaceChildren( + ...top.map((r) => { + const li = document.createElement("li"); + const a = document.createElement("a"); + a.href = r.url; + const title = document.createElement("span"); + title.className = "result-title"; + title.textContent = r.meta.title ?? r.url; + const excerpt = document.createElement("span"); + excerpt.className = "result-excerpt"; + excerpt.innerHTML = r.excerpt; // Pagefind escapes page text; only is added. + a.append(title, excerpt); + li.append(a); + return li; + }), + ); + status.textContent = top.length + ? (labels.results ?? "").replace("{count}", String(res.results.length)) + : (labels.noResults ?? "").replace("{query}", input.value.trim()); +} + +for (const trigger of document.querySelectorAll("[data-search-open]")) + trigger.addEventListener("click", open); +input?.addEventListener("input", () => void search()); +document.addEventListener("keydown", (e) => { + const typing = + e.target instanceof HTMLElement && + e.target.closest("input, textarea, select, [contenteditable]"); + if ((e.key === "k" && (e.ctrlKey || e.metaKey)) || (e.key === "/" && !typing)) { + e.preventDefault(); + open(); + } +}); +// Arrow keys move between results; Enter follows the link (native). +dialog?.addEventListener("keydown", (e) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + const links = [...(list?.querySelectorAll("a") ?? [])]; + if (!links.length) return; + e.preventDefault(); + const i = links.indexOf(document.activeElement as HTMLAnchorElement); + const next = e.key === "ArrowDown" ? Math.min(i + 1, links.length - 1) : i <= 0 ? -1 : i - 1; + if (next === -1) input?.focus(); + else links[next]?.focus(); +}); +dialog?.addEventListener("click", (e) => { + if (e.target === dialog) dialog.close(); // backdrop click +}); + +export {}; diff --git a/src/scripts/theme.ts b/src/scripts/theme.ts index 18a1cbe..3e232af 100644 --- a/src/scripts/theme.ts +++ b/src/scripts/theme.ts @@ -34,3 +34,5 @@ for (const input of document.querySelectorAll('input[name="lh- if (input.checked) apply(input.value as Choice); }); } + +export {}; diff --git a/src/styles/adwaita.css b/src/styles/adwaita.css new file mode 100644 index 0000000..65fcb0e --- /dev/null +++ b/src/styles/adwaita.css @@ -0,0 +1,529 @@ +/* + * libadwaita widget styles for the components in src/components/adw/. + * Global (not scoped) so markup can use the classes directly; imported once in Base.astro. + */ + +/* ---- ActionRow.astro ---- */ +.adw-action-row { + display: flex; + align-items: center; + gap: var(--space-2); + min-block-size: 54px; + padding: 8px 14px; + color: inherit; + text-decoration: none; +} + +.adw-action-row.activatable { + transition: background-color var(--duration) var(--ease-out-quad); +} + +.adw-action-row.activatable:hover { + background-color: var(--view-hover-color); + text-decoration: none; +} + +.adw-action-row.activatable:active { + background-color: var(--view-active-color); +} + +.adw-action-row.activatable:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; + border-radius: 0; +} + +.adw-boxed-list > li:first-child > .adw-action-row.activatable:focus-visible { + border-start-start-radius: var(--radius-card); + border-start-end-radius: var(--radius-card); +} + +.adw-boxed-list > li:last-child > .adw-action-row.activatable:focus-visible { + border-end-start-radius: var(--radius-card); + border-end-end-radius: var(--radius-card); +} + +.adw-row-text { + display: flex; + flex-direction: column; + flex: 1; + min-inline-size: 0; + gap: 1px; +} + +.adw-row-title { + overflow-wrap: anywhere; +} + +.adw-row-subtitle { + font-size: var(--text-caption); + opacity: var(--dim-opacity); + overflow-wrap: anywhere; +} + +.adw-row-subtitle.mono { + font-family: var(--font-mono); +} + +/* Property rows: dim caption label above the value. */ +.adw-action-row.property .adw-row-text { + flex-direction: column-reverse; +} + +.adw-action-row.property .adw-row-title { + font-size: var(--text-body); +} + +.adw-row-suffix { + display: inline-flex; + align-items: center; + gap: var(--space-1); + flex: none; +} + +.adw-row-suffix:empty { + display: none; +} + +.adw-row-chevron, +.adw-row-prefix { + opacity: var(--dim-opacity); +} + +/* ---- Badge.astro ---- */ +.adw-badge { + --badge-color: currentColor; + display: inline-flex; + align-items: center; + gap: 4px; + min-block-size: 24px; + padding: 2px 10px; + border-radius: var(--radius-pill); + background-color: color-mix(in srgb, var(--badge-color) 10%, transparent); + color: inherit; + font-size: var(--text-caption); + font-weight: 700; + line-height: 1.3; + text-decoration: none; + white-space: nowrap; +} + +a.adw-badge:hover { + background-color: color-mix(in srgb, var(--badge-color) 16%, transparent); + text-decoration: none; +} + +.adw-badge.accent { + --badge-color: var(--accent-color); + color: var(--accent-color); +} + +.adw-badge.success { + --badge-color: var(--success-color); + color: var(--success-color); +} + +.adw-badge.warning { + --badge-color: var(--warning-color); + color: var(--warning-color); +} + +.adw-badge.error { + --badge-color: var(--error-color); + color: var(--error-color); +} + +/* ---- Banner.astro ---- */ +.adw-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--space-1) var(--space-2); + padding: 8px var(--gutter); + background-color: color-mix(in srgb, var(--accent-bg-color) 30%, transparent); + text-align: center; +} + +.adw-banner-title { + font-weight: 700; +} + +/* ---- BoxedList.astro ---- */ +.adw-group { + display: grid; + gap: var(--space-2); +} + +.adw-group-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: var(--space-2); + padding-inline: 2px; +} + +.adw-group-title { + font-size: var(--text-body); + font-weight: 700; +} + +.adw-group-description { + font-size: var(--text-caption); + margin-block-start: 2px; +} + +.adw-boxed-list { + list-style: none; + margin: 0; + padding: 0; + background-color: var(--card-bg-color); + color: var(--card-fg-color); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.adw-boxed-list > li + li { + border-block-start: 1px solid var(--card-shade-color); +} + +/* ---- Button.astro ---- */ +.adw-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-block-size: 36px; + padding: 6px 12px; + border: 0; + border-radius: var(--radius-button); + background-color: var(--button-color); + color: inherit; + font-weight: 700; + font-size: 0.9375rem; + line-height: 1.2; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + user-select: none; + transition: + background-color var(--duration) var(--ease-out-quad), + outline-color var(--duration) var(--ease-out-quad); + -webkit-tap-highlight-color: transparent; +} + +.adw-button:hover { + background-color: var(--button-hover-color); + text-decoration: none; +} + +.adw-button:active { + background-color: var(--button-active-color); +} + +.adw-button:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + border-radius: var(--radius-button); +} + +.adw-button.text-button { + padding-inline: 17px; +} + +.adw-button.image-button { + min-inline-size: 36px; + padding-inline: 10px; +} + +.adw-button.image-text-button { + padding-inline: 14px; +} + +.adw-button.flat { + background-color: transparent; +} + +.adw-button.flat:hover { + background-color: var(--hover-color); +} + +.adw-button.flat:active { + background-color: var(--active-color); +} + +.adw-button.suggested, +.adw-button.destructive, +.adw-button.opaque { + background-image: none; +} + +.adw-button.suggested { + background-color: var(--accent-bg-color); + color: var(--accent-fg-color); +} + +.adw-button.destructive { + background-color: var(--destructive-bg-color); + color: var(--destructive-fg-color); +} + +.adw-button.opaque { + background-color: color-mix(in srgb, var(--window-bg-color) 85%, var(--window-fg-color)); + color: var(--window-fg-color); +} + +.adw-button.suggested:hover, +.adw-button.destructive:hover, +.adw-button.opaque:hover { + background-image: linear-gradient( + color-mix(in srgb, currentColor 10%, transparent), + color-mix(in srgb, currentColor 10%, transparent) + ); +} + +.adw-button.suggested:active, +.adw-button.destructive:active, +.adw-button.opaque:active { + background-image: linear-gradient(rgb(0 0 6 / 20%), rgb(0 0 6 / 20%)); +} + +.adw-button.pill { + min-block-size: 44px; + padding: 10px 32px; + border-radius: var(--radius-pill); +} + +.adw-button.pill.image-text-button { + padding-inline: 24px 28px; +} + +.adw-button.circular { + border-radius: var(--radius-pill); + min-inline-size: 36px; + padding-inline: 0; +} + +.adw-button[disabled], +.adw-button[aria-disabled="true"] { + opacity: var(--disabled-opacity); + pointer-events: none; +} + +@media (prefers-contrast: more) { + .adw-button:not(.flat, .suggested, .destructive) { + box-shadow: inset 0 0 0 1px var(--border-color); + } +} + +/* ---- Callout.astro ---- */ +.adw-callout { + --callout-color: var(--accent-color); + --callout-bg: var(--accent-bg-color); + display: flex; + gap: var(--space-2); + margin-block: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-card); + background-color: color-mix(in srgb, var(--callout-bg) 12%, var(--view-bg-color)); +} + +.adw-callout.tip { + --callout-color: var(--success-color); + --callout-bg: var(--success-bg-color); +} + +.adw-callout.warning { + --callout-color: var(--warning-color); + --callout-bg: var(--warning-bg-color); +} + +.adw-callout.danger { + --callout-color: var(--destructive-color); + --callout-bg: var(--destructive-bg-color); +} + +.adw-callout-icon { + color: var(--callout-color); + margin-block-start: 0.2em; +} + +.adw-callout-body { + min-inline-size: 0; + display: grid; + gap: var(--space-1); +} + +.adw-callout-title { + font-weight: 700; + color: var(--callout-color); +} + +/* ---- Card.astro ---- */ +.adw-card { + display: block; + background-color: var(--card-bg-color); + color: var(--card-fg-color); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + text-decoration: none; +} + +.adw-card.padded { + padding: var(--space-3); +} + +.adw-card.activatable { + transition: background-image var(--duration) var(--ease-out-quad); +} + +.adw-card.activatable:hover { + background-image: linear-gradient( + color-mix(in srgb, currentColor 3%, transparent), + color-mix(in srgb, currentColor 3%, transparent) + ); + text-decoration: none; +} + +.adw-card.activatable:active { + background-image: linear-gradient( + color-mix(in srgb, currentColor 8%, transparent), + color-mix(in srgb, currentColor 8%, transparent) + ); +} + +.adw-card.activatable:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: -1px; + border-radius: var(--radius-card); +} + +@media (prefers-contrast: more) { + .adw-card { + box-shadow: + 0 0 0 1px var(--border-color), + 0 1px 3px 1px rgb(0 0 6 / 7%), + 0 2px 6px 2px rgb(0 0 6 / 3%); + } +} + +/* ---- ExpanderRow.astro ---- */ +.adw-expander-row > summary { + list-style: none; + cursor: pointer; +} + +.adw-expander-row > summary::-webkit-details-marker { + display: none; +} + +.adw-expander-arrow { + opacity: var(--dim-opacity); + transition: transform var(--duration) var(--ease-out-quad); +} + +.adw-expander-row[open] .adw-expander-arrow { + transform: rotate(180deg); +} + +.adw-expander-content { + background-color: color-mix(in srgb, var(--card-shade-color) 50%, transparent); + border-block-start: 1px solid var(--card-shade-color); + padding: var(--space-2) 14px; +} + +/* ---- Icon.astro ---- */ +.adw-icon { + flex: none; + display: inline-block; + vertical-align: middle; +} + +/* ---- Kbd.astro ---- */ +.adw-kbd { + display: inline-block; + min-inline-size: 1.6em; + padding: 1px 6px; + border-radius: 6px; + background-color: var(--button-color); + box-shadow: inset 0 -1px var(--border-color); + font-family: var(--font-sans); + font-size: 0.8125em; + font-weight: 700; + text-align: center; + line-height: 1.6; +} + +/* ---- StatusPage.astro ---- */ +.adw-status-page { + display: grid; + justify-items: center; + gap: var(--space-2); + padding: var(--space-6) var(--space-2); + text-align: center; +} + +.adw-status-icon { + opacity: var(--dim-opacity); + margin-block-end: var(--space-3); +} + +.adw-status-title { + font-size: var(--text-title-1); + font-weight: 800; +} + +.adw-status-description { + max-inline-size: 36rem; + text-wrap: balance; +} + +.adw-status-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: center; + margin-block-start: var(--space-3); +} + +.adw-status-actions:empty { + display: none; +} + +/* ---- ViewSwitcher.astro ---- */ +.adw-view-switcher { + display: inline-flex; + gap: 3px; + padding: 3px; + border-radius: calc(var(--radius-button) + 3px); + background-color: var(--button-color); + max-inline-size: 100%; + overflow-x: auto; + scrollbar-width: none; +} + +.adw-view-switcher-item { + display: inline-flex; + align-items: center; + gap: 8px; + min-block-size: 32px; + padding: 4px 14px; + border-radius: var(--radius-button); + color: inherit; + font-weight: 700; + font-size: 0.9375rem; + text-decoration: none; + white-space: nowrap; + transition: background-color var(--duration) var(--ease-out-quad); +} + +.adw-view-switcher-item:hover { + background-color: var(--hover-color); + text-decoration: none; +} + +.adw-view-switcher-item[aria-current="page"] { + background-color: light-dark(#ffffff, rgb(255 255 255 / 20%)); + box-shadow: 0 1px 3px rgb(0 0 6 / 12%); +} diff --git a/src/styles/base.css b/src/styles/base.css index c3ad190..4202d5f 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -286,3 +286,37 @@ inset-block-end: 24px; } } + +/* libadwaita dropdown (AdwComboRow / GtkDropDown look) on a native diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 24f3c6f..f3fc117 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -179,6 +179,7 @@ export const en = { verifyLink: "How to verify", nextStep: "Next: create a bootable USB drive", auto: "Automatic — nearest official server", + origin: "Official server", autoHint: "The official server picks a mirror near you.", recommended: "Recommended for you", recommendedFor: "Nearest to {place}", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 006e953..4ce3a0f 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -177,6 +177,7 @@ export const ko: DictionaryShape = { verifyLink: "검증 방법", nextStep: "다음 단계: 부팅 USB 만들기", auto: "자동 — 가장 가까운 공식 서버", + origin: "공식 서버", autoHint: "공식 서버가 가까운 미러로 자동 연결합니다.", recommended: "추천 서버", recommendedFor: "{place}에서 가장 가까움", From de80a941949ff9323f989fd51bee13b67eab1ccf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:20:49 +0000 Subject: [PATCH 11/16] test(e2e): run pages under the production CSP scripts/serve.ts now replays the per-page headers Astro writes into .vercel/output/config.json, so e2e sees the same CSP as Vercel. New test: no CSP violations across key pages, including Pagefind's WebAssembly search. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8A7WuJeLxG7aBvK6xfgC9 --- e2e/features.spec.ts | 16 ++++++++++++++++ scripts/serve.ts | 25 +++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/e2e/features.spec.ts b/e2e/features.spec.ts index af4567c..c8d84d4 100644 --- a/e2e/features.spec.ts +++ b/e2e/features.spec.ts @@ -33,3 +33,19 @@ test("install guide renders with live data and anchors", async ({ page }) => { await expect(page.locator("#usb")).toBeAttached(); await expect(page.locator("article.prose")).toContainText(/Fedora-Workstation-Live-\d+/); }); + +test("pages run under the production CSP without violations", async ({ page }) => { + const violations: string[] = []; + page.on("console", (m) => { + if (/Content Security Policy|Refused to/i.test(m.text())) violations.push(m.text()); + }); + for (const path of ["/", "/distros/fedora/", "/finder/", "/compare/"]) { + const res = await page.goto(path); + expect(res?.headers()["content-security-policy"]).toContain("script-src"); + } + // Theme menu, search (Pagefind WebAssembly) and the download wizard all execute script. + await page.keyboard.press("Control+k"); + await page.locator("[data-search-input]").fill("Debian"); + await expect(page.locator("[data-search-results] a").first()).toBeVisible({ timeout: 10_000 }); + expect(violations).toEqual([]); +}); diff --git a/scripts/serve.ts b/scripts/serve.ts index 7532ab4..641ff0d 100644 --- a/scripts/serve.ts +++ b/scripts/serve.ts @@ -6,7 +6,7 @@ * * Usage: bun scripts/serve.ts [--port=4322] */ -import { existsSync, statSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { join, normalize } from "node:path"; const port = Number(process.argv.find((a) => a.startsWith("--port="))?.split("=")[1] ?? 4322); @@ -14,6 +14,26 @@ const port = Number(process.argv.find((a) => a.startsWith("--port="))?.split("=" const dist = join(import.meta.dir, "..", "dist"); const root = existsSync(join(dist, "client")) ? join(dist, "client") : dist; +// Emulate Vercel's per-route static headers (the CSP Astro computes per page). +const routeHeaders = new Map>(); +const configPath = join(import.meta.dir, "..", ".vercel", "output", "config.json"); +if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, "utf8")) as { + routes?: { src?: string; headers?: Record; status?: number }[]; + }; + for (const route of config.routes ?? []) { + if ( + route.src && + route.headers && + !route.status && + route.src.startsWith("/") && + !/[\^$()*?[\]]/.test(route.src) + ) { + routeHeaders.set(route.src, route.headers); + } + } +} + function resolve(pathname: string): string | null { const safe = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, ""); const candidate = join(root, safe); @@ -37,7 +57,8 @@ Bun.serve({ return Response.redirect(`${url.pathname}/${url.search}`, 308); } const file = resolve(url.pathname); - if (file) return new Response(Bun.file(file)); + if (file) + return new Response(Bun.file(file), { headers: routeHeaders.get(url.pathname) ?? {} }); const notFound = join(root, "404.html"); return new Response(existsSync(notFound) ? Bun.file(notFound) : "Not found", { status: 404, From 309dbcc08be409c591acb13fe6f5661d9d541745 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:21:23 +0000 Subject: [PATCH 12/16] refactor(sync): discover source modules automatically Each scripts/sync/sources/.ts default-exports a DistroSource (or an array); index.ts loads them all and rejects duplicate slugs, so adding a distro is a single new file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8A7WuJeLxG7aBvK6xfgC9 --- scripts/sync/sources/index.ts | 40 ++++++++++++++------------- scripts/sync/sources/ubuntu-family.ts | 2 ++ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/scripts/sync/sources/index.ts b/scripts/sync/sources/index.ts index e870acf..4e5c1e6 100644 --- a/scripts/sync/sources/index.ts +++ b/scripts/sync/sources/index.ts @@ -1,21 +1,23 @@ -/** Registry of per-distro sync sources (one module per catalog slug). */ +/** + * Registry of per-distro sync sources. Every module in this directory + * (except index/tests) default-exports a DistroSource or an array of them; + * they are discovered automatically so adding a distro is one new file. + */ +import { readdirSync } from "node:fs"; import type { DistroSource } from "../source"; -import arch from "./arch"; -import debian from "./debian"; -import fedora from "./fedora"; -import linuxMint from "./linux-mint"; -import popOs from "./pop-os"; -import { kubuntu, lubuntu, ubuntu, ubuntuMate, xubuntu } from "./ubuntu-family"; -export const SOURCES: DistroSource[] = [ - arch, - debian, - fedora, - kubuntu, - linuxMint, - lubuntu, - popOs, - ubuntu, - ubuntuMate, - xubuntu, -]; +const files = readdirSync(import.meta.dir).filter( + (f) => f.endsWith(".ts") && f !== "index.ts" && !f.endsWith(".test.ts"), +); +const loaded = await Promise.all( + files.map( + async (f) => (await import(`./${f}`)).default as DistroSource | DistroSource[] | undefined, + ), +); + +export const SOURCES: DistroSource[] = loaded + .flatMap((m) => (Array.isArray(m) ? m : m ? [m] : [])) + .sort((a, b) => a.slug.localeCompare(b.slug)); + +const dupes = SOURCES.map((s) => s.slug).filter((s, i, all) => all.indexOf(s) !== i); +if (dupes.length) throw new Error(`duplicate sync sources: ${dupes.join(", ")}`); diff --git a/scripts/sync/sources/ubuntu-family.ts b/scripts/sync/sources/ubuntu-family.ts index df3b46e..43a17c6 100644 --- a/scripts/sync/sources/ubuntu-family.ts +++ b/scripts/sync/sources/ubuntu-family.ts @@ -226,3 +226,5 @@ export const ubuntuMate = ubuntuFamily({ flavor: "ubuntu-mate", editions: desktopOnly("mate"), }); + +export default [ubuntu, kubuntu, xubuntu, lubuntu, ubuntuMate]; From 0be5295aa7b2d56fdcedd88522c460d4e4debae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:24:06 +0000 Subject: [PATCH 13/16] docs: deployment runbook and generated data-source table - docs/deploy.md: Vercel project setup, SITE_URL, Actions permissions, optional deploy hook, pre-deploy checks, smoke tests, operations - scripts/sync/docs.ts (bun run sync:docs) regenerates the per-distro table in docs/data-sources.md from the catalog, sync modules and synced data Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8A7WuJeLxG7aBvK6xfgC9 --- docs/deploy.md | 66 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- scripts/sync/docs.ts | 53 +++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 docs/deploy.md create mode 100644 scripts/sync/docs.ts diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..eb424cd --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,66 @@ +# 배포 런북 (Vercel) + +## 구성 요약 + +| 항목 | 값 | +|---|---| +| 호스팅 | Vercel. 프레임워크 프리셋은 Astro이고, `vercel.json`에 설정되어 있습니다 | +| 설치 / 빌드 | `bun install --frozen-lockfile` / `bun run build` (Astro + Pagefind + sitemap) | +| 출력 | 정적 페이지 전체(`.vercel/output/static`)와 서버 함수 1개(`/api/geo`, `_render.func`) | +| 함수 리전 | `icn1`(서울)로, `vercel.json`의 `regions`에 지정합니다 | +| 프로덕션 브랜치 | `main`. 그 밖의 브랜치와 PR은 프리뷰로 배포됩니다 | +| 데이터 갱신 | `.github/workflows/sync.yml`이 `main`에 커밋하면 Vercel이 자동으로 다시 배포합니다 | + +## 처음 한 번 설정 + +1. **Vercel 프로젝트 만들기** + - Vercel 대시보드에서 **Add New → Project**를 누르고 GitHub의 `neramc/linuxhub`를 가져옵니다. + - Framework Preset이 **Astro**로 잡히는지 확인합니다. 빌드 설정은 `vercel.json`이 덮어쓰므로 바꾸지 않아도 됩니다. + - Root Directory는 저장소 루트로 둡니다. +2. **환경 변수**(Production, Preview) + - `SITE_URL`: 사이트의 정식 주소(예: `https://linuxhub.example`). canonical, hreflang, sitemap, OG 이미지 URL에 쓰입니다. 비워 두면 Vercel이 주는 프로덕션 도메인(`VERCEL_PROJECT_PRODUCTION_URL`)을 씁니다. +3. **도메인**: Project → Settings → Domains에서 연결합니다. 연결한 뒤 `SITE_URL`도 그 주소로 바꿉니다. +4. **GitHub 저장소 설정** + - **Settings → Actions → General → Workflow permissions**를 **Read and write**로 바꿉니다. 동기화 워크플로가 데이터를 커밋하고 이슈를 열려면 필요합니다. + - 선택 사항으로 **Variables**에 `SITE_URL`을 넣으면 동기화 봇의 User-Agent에 사이트 주소가 들어갑니다. + - 선택 사항으로 **Secrets**에 `VERCEL_DEPLOY_HOOK`을 넣습니다. + - Vercel의 Settings → Git → Deploy Hooks에서 만든 URL입니다. + - 봇 커밋으로 배포가 시작되지 않는 경우에 대비한 장치입니다. Hobby 플랜에서 비공개 저장소의 봇 커밋은 배포가 막힐 수 있습니다. + - **Settings → Branches**에서 `main`에 보호 규칙을 걸 때는 `github-actions[bot]`의 푸시를 허용해야 합니다. +5. **이슈 라벨**: `sync-failure` 라벨을 만들어 둡니다. 없으면 GitHub이 이슈를 만들 때 자동으로 생성합니다. + +## 배포 전 확인 + +```bash +bun install +bun run lint && bun run check && bun run test +bun run sync:validate && bun scripts/validate-catalog.ts +bun run build +bun run test:e2e # dist/를 serve.ts로 띄워 Playwright와 axe를 실행합니다 +``` + +## 배포 후 스모크 테스트 + +```bash +SITE=https://<도메인> +curl -sI $SITE/ | grep -i -E "content-security-policy|strict-transport|x-frame" +curl -s $SITE/api/geo # {"cc":"KR","lat":…,"lon":…}, Cache-Control: private, no-store +curl -sI $SITE/distros/fedora/ | head -1 # 200 +curl -s $SITE/sitemap-index.xml | head -3 +curl -s $SITE/rss.xml | head -5 +curl -sI $SITE/og/fedora.png | grep -i content-type # image/png +``` + +브라우저에서도 다음을 확인합니다. +- 다운로드 패널에서 미러가 추천되는지(Arch, Ubuntu, Debian) +- 검색(Ctrl+K)이 동작하는지 +- 라이트/다크 전환이 되는지 + +## 운영 + +- **데이터 동기화**: Actions의 **Data sync**가 6시간마다(미러는 하루 한 번) 실행됩니다. + - **Run workflow**로 수동 실행하면서 `kind`와 `only`를 지정할 수 있습니다. + - 같은 소스가 하루 넘게 계속 실패하면 `Data sync: failing sources` 이슈가 열리고, 모두 복구되면 자동으로 닫힙니다. +- **롤백**: Vercel 대시보드에서 이전 배포를 **Promote to Production**합니다. 데이터를 되돌릴 때는 해당 `chore(data): sync …` 커밋을 revert합니다. +- **크론 비활성화 주의**: 공개 저장소에서 60일 동안 활동이 없으면 GitHub이 예약 워크플로를 끕니다. 데이터 커밋이 보통 그보다 자주 생기지만, 꺼지면 Actions 화면에서 다시 켭니다. +- **Hobby 플랜**: 비상업적 용도만 허용합니다. 상업적으로 운영하려면 Pro 플랜이 필요합니다. diff --git a/package.json b/package.json index 438f445..ed0be4e 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test": "vitest run", "test:e2e": "playwright test", "sync": "bun scripts/sync/run.ts", - "sync:validate": "bun scripts/sync/validate.ts" + "sync:validate": "bun scripts/sync/validate.ts", + "sync:docs": "bun scripts/sync/docs.ts" }, "dependencies": { "@astrojs/markdown-remark": "^7.3.1", diff --git a/scripts/sync/docs.ts b/scripts/sync/docs.ts new file mode 100644 index 0000000..3ddebeb --- /dev/null +++ b/scripts/sync/docs.ts @@ -0,0 +1,53 @@ +/** + * Regenerates the per-distro table in docs/data-sources.md from the catalog + * and the synced data (`bun run sync:docs`), so the documentation always says + * where each distro's data really comes from. + */ +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { SOURCES } from "./sources"; + +const root = join(import.meta.dir, "..", ".."); +const docPath = join(root, "docs", "data-sources.md"); +const START = ""; +const END = ""; + +const catalog = readdirSync(join(root, "src/content/distros")) + .filter((f) => f.endsWith(".yaml")) + .map((f) => ({ + slug: f.replace(/\.yaml$/, ""), + ...parse(readFileSync(join(root, "src/content/distros", f), "utf8")), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + +const json = (p: string) => (existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : undefined); +const hostList = (urls: string[] = []) => + [...new Set(urls.map((u) => new URL(u).hostname))].join(", "); + +const rows = catalog.map((d) => { + const source = SOURCES.find((s) => s.slug === d.slug); + const releases = json(join(root, "src/data/releases", `${d.slug}.json`)); + const mirrors = json(join(root, "src/data/mirrors", `${d.slug}.json`)); + const latest = releases?.releases?.[0]?.version ?? "—"; + return `| ${d.name} | ${d.download?.strategy ?? "—"} | ${source ? "✓" : "—"} | ${hostList(releases?.sources) || "—"} | ${latest} | ${mirrors ? `${mirrors.mirrors.length} (${hostList(mirrors.sources)})` : "—"} |`; +}); + +const table = [ + START, + "", + "| 배포판 | 다운로드 전략 | 동기화 모듈 | 릴리스 데이터 출처(호스트) | 현재 최신 | 미러 목록 |", + "|---|---|---|---|---|---|", + ...rows, + "", + `_${new Date().toISOString().slice(0, 10)} 기준, \`bun run sync:docs\`로 생성._`, + "", + END, +].join("\n"); + +const doc = readFileSync(docPath, "utf8"); +const next = doc.includes(START) + ? doc.replace(new RegExp(`${START}[\\s\\S]*?${END}`), table) + : `${doc.trimEnd()}\n\n## 배포판별 데이터 출처\n\n${table}\n`; +writeFileSync(docPath, next); +console.log(`docs/data-sources.md: ${rows.length} distros`); From ffd9e04642909f5fdd947b8b4f78050627950e16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:24:46 +0000 Subject: [PATCH 14/16] fix(search): index install guides with their page title Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8A7WuJeLxG7aBvK6xfgC9 --- src/components/adw/ViewSwitcher.astro | 5 +++-- .../[...locale]/distros/[slug]/install/index.astro | 13 +++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/adw/ViewSwitcher.astro b/src/components/adw/ViewSwitcher.astro index d55facd..cd474ef 100644 --- a/src/components/adw/ViewSwitcher.astro +++ b/src/components/adw/ViewSwitcher.astro @@ -14,15 +14,16 @@ interface Item { } interface Props { + [key: `data-${string}`]: unknown; items: Item[]; label: string; class?: string | undefined; } -const { items, label, class: className } = Astro.props; +const { items, label, class: className, ...rest } = Astro.props; --- -