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 index 751a99b..1417240 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -1,7 +1,7 @@ #!/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. +# 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 @@ -10,19 +10,16 @@ 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) +# 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 -# 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. +# 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, SvelteKit types synced." +echo "linuxhub: dependencies installed, Astro types synced." diff --git a/.claude/settings.json b/.claude/settings.json index 23532b3..e06b033 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,14 +1,14 @@ { - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" - } - ] - } - ] - } + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } } diff --git a/.editorconfig b/.editorconfig index b8e0e02..8c52ff9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,34 +1,12 @@ -# 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 +insert_final_newline = true +trim_trailing_whitespace = true -# 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 index 04df901..4e9e66f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,102 +1,67 @@ name: CI on: + pull_request: 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' }} + cancel-in-progress: true + +permissions: + contents: read jobs: - ci: - name: check · lint · test · build + checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v5 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 + node-version: 22 + - run: bun install --frozen-lockfile + - run: bun run lint + - run: bun run check + - run: bun run sync:validate + - run: bun scripts/validate-catalog.ts + - run: bun run test + - run: bun run build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist + retention-days: 3 e2e: - name: e2e · accessibility + needs: checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v5 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 + node-version: 22 + - run: bun install --frozen-lockfile + - uses: actions/download-artifact@v5 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' + name: dist + path: dist + - run: bunx playwright install --with-deps chromium + - run: bun run test:e2e - # 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 + lighthouse: + needs: checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - uses: actions/download-artifact@v5 with: - name: playwright-report - path: apps/web/playwright-report - retention-days: 7 + name: dist + path: dist + - name: Lighthouse budgets (performance ≥ 95, a11y/best practices/SEO = 100) + run: npx --yes @lhci/cli@0.15.x autorun diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml new file mode 100644 index 0000000..289a5c2 --- /dev/null +++ b/.github/workflows/sync.yml @@ -0,0 +1,136 @@ +name: Data sync + +# Refreshes release data (every 6 h) and official mirror lists (daily) from +# official sources, validates them, and commits src/data/ when something +# changed. The push to main triggers a Vercel production deploy. +on: + schedule: + - cron: "17 */6 * * *" # releases + - cron: "43 3 * * *" # mirror lists + workflow_dispatch: + inputs: + kind: + description: "What to sync" + type: choice + options: [all, releases, mirrors] + default: all + only: + description: "Comma-separated slugs (empty = all)" + required: false + default: "" + +concurrency: + group: data-sync + cancel-in-progress: false + +permissions: + contents: write + issues: write + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + ref: main + + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v5 + with: + node-version: 22 + + - name: Restore HTTP revalidation cache (ETag / Last-Modified) + uses: actions/cache@v4 + with: + path: scripts/sync/.cache + key: sync-http-${{ github.run_id }} + restore-keys: sync-http- + + - run: bun install --frozen-lockfile + + - name: Choose what to sync + id: plan + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "kind=${{ inputs.kind }}" >> "$GITHUB_OUTPUT" + echo "only=${{ inputs.only }}" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event.schedule }}" = "43 3 * * *" ]; then + echo "kind=mirrors" >> "$GITHUB_OUTPUT" + else + echo "kind=releases" >> "$GITHUB_OUTPUT" + fi + + - name: Sync from official sources + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SITE_URL: ${{ vars.SITE_URL }} + run: | + ARGS="--kind=${{ steps.plan.outputs.kind }}" + if [ -n "${{ steps.plan.outputs.only }}" ]; then ARGS="$ARGS --only=${{ steps.plan.outputs.only }}"; fi + bun run sync -- $ARGS + + - name: Validate data + run: bun run sync:validate + + - name: Type-check the site against the new data + run: bunx astro sync && bun run check + + - name: Commit and push when data changed + id: commit + run: | + if git diff --quiet -- src/data; then + echo "No data changes." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/data + git commit -m "chore(data): sync $(date -u +%Y-%m-%d) (${{ steps.plan.outputs.kind }})" + git push origin HEAD:main + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Trigger Vercel deploy hook (optional) + if: steps.commit.outputs.changed == 'true' && env.DEPLOY_HOOK != '' + env: + DEPLOY_HOOK: ${{ secrets.VERCEL_DEPLOY_HOOK }} + run: curl -fsS -X POST "$DEPLOY_HOOK" > /dev/null + + - name: Track failing sources in an issue + if: always() + uses: actions/github-script@v8 + with: + script: | + const fs = require("node:fs"); + const path = "scripts/sync/.cache/report.json"; + if (!fs.existsSync(path)) return; + const report = JSON.parse(fs.readFileSync(path, "utf8")); + const dayMs = 24 * 3600 * 1000; + // A source counts as broken after ~1 day (4 scheduled runs) of failures. + const broken = report.failing.filter((f) => Date.parse(report.today) - Date.parse(f.since) >= dayMs); + const title = "Data sync: failing sources"; + const { data: issues } = await github.rest.issues.listForRepo({ + ...context.repo, state: "open", labels: "sync-failure", per_page: 10, + }); + const existing = issues.find((i) => i.title === title); + if (!broken.length) { + if (existing) { + await github.rest.issues.createComment({ ...context.repo, issue_number: existing.number, body: "All sources recovered. Closing." }); + await github.rest.issues.update({ ...context.repo, issue_number: existing.number, state: "closed", state_reason: "completed" }); + } + return; + } + const rows = broken.map((f) => `| \`${f.slug}\` | ${f.kind} | ${f.since} | ${String(f.error ?? "").replaceAll("|", "\\|")} |`); + const body = [ + "The data sync keeps the last good data for these sources, but they have been failing for over a day.", + "", + "| Source | Kind | Failing since | Last error |", + "|---|---|---|---|", + ...rows, + "", + `Updated by [run ${context.runId}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + ].join("\n"); + if (existing) await github.rest.issues.update({ ...context.repo, issue_number: existing.number, body }); + else await github.rest.issues.create({ ...context.repo, title, body, labels: ["sync-failure"] }); diff --git a/.gitignore b/.gitignore index 2d0adb2..89c9347 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,25 @@ -# Dependencies -node_modules/ - -# Build outputs +# build output dist/ -.svelte-kit/ .vercel/ -.wrangler/ -coverage/ +.astro/ + +# dependencies +node_modules/ -# Environment & secrets (never commit — see .ai/security.md) +# logs & env +*.log .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 output test-results/ playwright-report/ +coverage/ +.lighthouseci/ -# Misc -*.log -*.tsbuildinfo +# sync http cache (ETag/Last-Modified), regenerated per run +scripts/sync/.cache/ + +# os .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 87eafc0..410cfce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,173 +1,70 @@ -# CLAUDE.md — Linuxhub Operating Rules +# CLAUDE.md — Linuxhub -> 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**. +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. -## What Linuxhub is +The approved plan lives in `docs/plan.md` (Korean). Decisions are logged in +`docs/decisions.md`. Read both before changing architecture. -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`) +## Stack (changes need a new ADR in `docs/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) +| 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 check # types across all packages -bun run lint # Biome (bun run format autofixes) +bun run lint # Biome (bun run format to autofix) +bun run check # astro check + scripts typecheck 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 +bun run build # astro build (+ pagefind, sitemap, vercel output) +bun run test:e2e # Playwright + axe against the production build (UI changes) ``` -## Definition of Done (per stage) +## Git -- 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. +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/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 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/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 index b7e7d42..dbb6a2f 100644 --- a/README.md +++ b/README.md @@ -1,141 +1,31 @@ # 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. +리눅스 배포판을 둘러보고, 공식 미러에서 내려받고, 설치까지 따라 할 수 있는 +빠르고 깔끔한 카탈로그입니다. A fast, clean catalog of Linux distributions with +official downloads and install guides (Korean / English). -[![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) +- **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 -> **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 +## Development ```bash bun install -bun run dev:web # http://localhost:5173 +bun run dev # http://localhost:4321 +bun run build # production build (dist/ + .vercel/output) +bun run sync # refresh release + mirror data from official sources ``` -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 +Quality gates: `bun run lint`, `bun run check`, `bun run test`, `bun run build`, +`bun run test:e2e`. -Not a single licence, and the difference matters here. +See `docs/plan.md` for the project plan and `docs/decisions.md` for decisions. -- **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. +## License -Linuxhub is not affiliated with, endorsed by, or sponsored by any of the -distributions it catalogs. +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/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/content-guide.md b/docs/content-guide.md new file mode 100644 index 0000000..42acbc5 --- /dev/null +++ b/docs/content-guide.md @@ -0,0 +1,33 @@ +# 콘텐츠 작성 가이드 + +## 원칙 +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` 등). 프롬프트 기호(`$`)는 넣지 않는다. +- 위험한 작업(디스크 지우기 등)은 ``으로 경고한다. + +## 링크 +- 본문에 URL을 그대로 쓰지 않고 항상 `[보이는 글자](https://…)` 형식으로 씁니다. 한국어 문장에서 맨 URL을 쓰면 뒤에 붙은 조사까지 링크로 잡힙니다. +- 사이트 안의 다른 페이지는 상대 경로로 연결합니다. 예를 들어 다운로드 패널은 `../#download`입니다. diff --git a/docs/data-sources.md b/docs/data-sources.md new file mode 100644 index 0000000..4a36101 --- /dev/null +++ b/docs/data-sources.md @@ -0,0 +1,67 @@ +# 데이터 소스 + +Linuxhub의 버전, 파일, 체크섬, 미러 정보는 모두 각 프로젝트의 **공식** 소스에서 가져옵니다. 코드는 `scripts/sync/`에 있고, 배포판마다 소스 모듈이 하나씩 있습니다(`scripts/sync/sources/.ts`). + +## 공통 규칙 +- **식별**: User-Agent로 `LinuxhubBot/1.0 (+/about/#bot)`를 보냅니다. +- **robots.txt**: 호스트마다 확인하고 캐시합니다. `Crawl-delay`를 지키며, 막힌 URL은 요청하지 않습니다. +- **요청 간격**: 같은 호스트에는 1초 이상 간격을 두고, 429·5xx만 백오프 후 재시도합니다. +- **재검증**: ETag와 If-Modified-Since로 바뀌지 않은 응답은 다시 받지 않습니다(GitHub Actions 캐시). +- **허용하는 출처**: + - endoflife.date는 출시일, 지원 종료일, LTS 여부에만 씁니다. + - 파일과 체크섬은 언제나 배포판 자체 서버에서 가져옵니다. + - SourceForge는 공식 사이트가 링크하는 프로젝트만 씁니다(MD5만 제공). +- **DistroWatch는 사용하지 않습니다**(ADR-0008). +- **검증**: `bun run sync:validate`가 다음을 검사합니다. + - 스키마 + - 공식 호스트 허용 목록(소스 모듈의 `hosts`) + - 체크섬 길이와 중복 + +## 다운로드 전략 +| 전략 | 의미 | +|---|---| +| `redirector` | 공식 CDN이나 리다이렉터가 가까운 서버를 고릅니다. 다운로드 버튼은 공식 URL을 그대로 씁니다 | +| `mirrors` | 공식 미러 목록이 있으면 방문자 위치로 순위를 매겨 추천합니다(`src/lib/mirrors.ts`). 목록이 없으면 공식 원본 서버를 씁니다 | +| `sourceforge` | 공식 SourceForge 프로젝트에서 받으며, SourceForge가 미러를 고릅니다 | +| `official-page` | 파일 링크를 자동화할 수 없어서(링크 만료, 봇 차단 등) 공식 다운로드 페이지로 안내합니다 | + +## 로고 +배포판 YAML의 `logo` 항목(출처, 라이선스, 상표 정책)과 About 페이지의 크레딧 표를 보세요. + +## 배포판별 데이터 출처 + + + +| 배포판 | 다운로드 전략 | 동기화 모듈 | 릴리스 데이터 출처(호스트) | 현재 최신 | 미러 목록 | +|---|---|---|---|---|---| +| antiX | sourceforge | — | — | — | — | +| Arch Linux | mirrors | ✓ | archlinux.org | 2026.10.01 | 363 (archlinux.org) | +| CachyOS | redirector | — | — | — | — | +| Debian | mirrors | ✓ | deb.debian.org, cdimage.debian.org, endoflife.date | 13.7 | 48 (mirror-master.debian.org) | +| deepin | mirrors | — | — | — | — | +| Devuan GNU+Linux | mirrors | — | — | — | — | +| elementary OS | official-page | — | — | — | — | +| EndeavourOS | mirrors | — | — | — | — | +| Fedora Linux | redirector | ✓ | fedoraproject.org, endoflife.date | 45-beta | — | +| Kali Linux | redirector | — | — | — | — | +| KDE neon | redirector | — | — | — | — | +| Kubuntu | mirrors | ✓ | endoflife.date, cdimage.ubuntu.com | 26.04.1 | — | +| Linux Lite | mirrors | — | — | — | — | +| Linux Mint | mirrors | ✓ | endoflife.date, pub.linuxmint.io | 22.3 | — | +| Lubuntu | mirrors | ✓ | endoflife.date, cdimage.ubuntu.com | 26.04.1 | — | +| Manjaro | redirector | — | — | — | — | +| MX Linux | sourceforge | — | — | — | — | +| ParrotOS | redirector | — | — | — | — | +| Peppermint OS | sourceforge | — | — | — | — | +| Pop!_OS | redirector | ✓ | api.pop-os.org, endoflife.date | 24.04 | — | +| Q4OS | sourceforge | — | — | — | — | +| Raspberry Pi OS | official-page | — | — | — | — | +| TUXEDO OS | official-page | — | — | — | — | +| Ubuntu | mirrors | ✓ | endoflife.date, releases.ubuntu.com | 26.04.1 | 298 (api.launchpad.net) | +| Ubuntu MATE | mirrors | ✓ | endoflife.date, cdimage.ubuntu.com | 24.04.5 | — | +| Xubuntu | mirrors | ✓ | endoflife.date, cdimage.ubuntu.com | 26.04.1 | — | +| Zorin OS | redirector | — | — | — | — | + +_2026-10-03 기준, `bun run sync:docs`로 생성._ + + diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..15e3f89 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,75 @@ +# 결정 기록 (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개)은 편집자가 정한다. +- **정렬:** 인기 순위를 다른 사이트에서 가져와 표시하지 않는다. 정렬은 추천순(편집), 이름순, 최근 업데이트순만 둔다. + +## 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/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/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/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0dd9a52 --- /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: `bun scripts/serve.ts --port=${PORT}`, + port: PORT, + reuseExistingServer: !process.env.CI, + }, +}); 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/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/build-geo.py b/scripts/assets/build-geo.py new file mode 100644 index 0000000..69c3de1 --- /dev/null +++ b/scripts/assets/build-geo.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Builds src/data/geo/{countries,timezones}.json used for mirror ranking. + +Inputs (both public domain), downloaded to a temp dir by the caller: + - Natural Earth 1:50m admin-0 countries (label points): + https://naciscdn.org/naturalearth/50m/cultural/ne_50m_admin_0_countries.zip + - IANA tz database (zone1970.tab coordinates, `backward` aliases): + https://data.iana.org/time-zones/tzdata-latest.tar.gz + +Usage: python3 scripts/assets/build-geo.py +Requires: pip install pyshp +""" +import json +import re +import sys +from pathlib import Path + +import shapefile # pyshp + +ne_dir, tz_dir = Path(sys.argv[1]), Path(sys.argv[2]) +out = Path(__file__).resolve().parents[2] / "src" / "data" / "geo" +out.mkdir(parents=True, exist_ok=True) + +CONTINENTS = { + "Africa": "AF", "Antarctica": "AN", "Asia": "AS", "Europe": "EU", + "North America": "NA", "Oceania": "OC", "South America": "SA", + "Seven seas (open ocean)": "OC", +} + +countries = {} +reader = shapefile.Reader(str(ne_dir / "ne_50m_admin_0_countries")) +fields = [f[0] for f in reader.fields[1:]] +for rec in reader.records(): + r = dict(zip(fields, rec)) + cc = r["ISO_A2_EH"] if r["ISO_A2_EH"] not in ("-99", "", None) else r["ISO_A2"] + if not cc or cc == "-99": + continue + countries[cc] = { + "lat": round(float(r["LABEL_Y"]), 2), + "lon": round(float(r["LABEL_X"]), 2), + "continent": CONTINENTS.get(r["CONTINENT"], "OC"), + } + +def dms(s: str) -> float: + sign = -1 if s[0] == "-" else 1 + s = s[1:] + deg_len = 2 if len(s) in (4, 6) else 3 + d = int(s[:deg_len]); m = int(s[deg_len:deg_len + 2]) + sec = int(s[deg_len + 2:] or 0) + return round(sign * (d + m / 60 + sec / 3600), 2) + +zones = {} +for line in (tz_dir / "zone1970.tab").read_text().splitlines(): + if line.startswith("#") or not line.strip(): + continue + codes, coord, name = line.split("\t")[:3] + m = re.match(r"([+-]\d+)([+-]\d+)", coord) + zones[name] = {"cc": codes.split(",")[0], "lat": dms(m.group(1)), "lon": dms(m.group(2))} + +# Per-country zones from zone.tab give a better country for shared zones (e.g. Europe/Zurich). +for line in (tz_dir / "zone.tab").read_text().splitlines(): + if line.startswith("#") or not line.strip(): + continue + cc, coord, name = line.split("\t")[:3] + m = re.match(r"([+-]\d+)([+-]\d+)", coord) + zones.setdefault(name, {"cc": cc, "lat": dms(m.group(1)), "lon": dms(m.group(2))}) + +for line in (tz_dir / "backward").read_text().splitlines(): + parts = line.split() + if len(parts) >= 3 and parts[0] == "Link" and parts[1] in zones: + zones.setdefault(parts[2], zones[parts[1]]) + +(out / "countries.json").write_text(json.dumps(dict(sorted(countries.items())), separators=(",", ":")) + "\n") +(out / "timezones.json").write_text(json.dumps(dict(sorted(zones.items())), separators=(",", ":")) + "\n") +print(f"{len(countries)} countries, {len(zones)} zones") 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/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/scripts/serve.ts b/scripts/serve.ts new file mode 100644 index 0000000..641ff0d --- /dev/null +++ b/scripts/serve.ts @@ -0,0 +1,70 @@ +/** + * Minimal static server for the production build in dist/ (local preview and + * Playwright). `astro preview` is unavailable once the site has an on-demand + * route with @astrojs/vercel. /api/geo is not served here: the download + * wizard falls back to the browser's time zone, and e2e tests mock it. + * + * Usage: bun scripts/serve.ts [--port=4322] + */ +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); +// With an on-demand route the adapter puts static files in dist/client/. +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); + if (!candidate.startsWith(root)) return null; + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + const index = join(candidate, "index.html"); + if (existsSync(index)) return index; + return null; +} + +Bun.serve({ + port, + fetch(req) { + const url = new URL(req.url); + // Mirror Vercel's trailingSlash: "always" for page paths. + if ( + !url.pathname.endsWith("/") && + !/\.[a-z0-9]+$/i.test(url.pathname) && + resolve(`${url.pathname}/`) + ) { + return Response.redirect(`${url.pathname}/${url.search}`, 308); + } + const file = resolve(url.pathname); + 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, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }, +}); + +console.log(`Serving dist/ on http://localhost:${port}`); diff --git a/scripts/sync/checks.test.ts b/scripts/sync/checks.test.ts new file mode 100644 index 0000000..c2426b3 --- /dev/null +++ b/scripts/sync/checks.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { ReleasesFile } from "../../src/lib/data-schemas"; +import { checkReleases } from "./checks"; +import { defineSource, hostAllowed } from "./source"; + +const source = defineSource({ slug: "demo", hosts: ["example.org"] }); + +function file(url: string): ReleasesFile { + return { + slug: "demo", + sources: ["https://example.org/"], + updated: "2026-10-03", + releases: [ + { + version: "1.0", + channel: "stable", + codename: null, + releaseDate: null, + eol: null, + notesUrl: null, + editions: [ + { + id: "desktop", + name: "Desktop", + desktop: "gnome", + kind: "desktop", + artifacts: [ + { + arch: "x86_64", + format: "iso", + file: "demo.iso", + path: "demo/demo.iso", + url, + size: 1, + checksum: { type: "sha256", value: "a".repeat(64), url: null }, + signatureUrl: null, + torrentUrl: null, + }, + ], + }, + ], + }, + ], + }; +} + +describe("host allowlist", () => { + it("matches exact hosts and subdomains only", () => { + expect(hostAllowed("https://example.org/x", ["example.org"])).toBe(true); + expect(hostAllowed("https://dl.example.org/x", ["example.org"])).toBe(true); + expect(hostAllowed("https://evil-example.org/x", ["example.org"])).toBe(false); + expect(hostAllowed("https://example.org.evil.com/x", ["example.org"])).toBe(false); + }); + + it("rejects artifacts on non-official hosts", () => { + expect(checkReleases(file("https://cdn.example.org/demo.iso"), source)).toEqual([]); + expect(checkReleases(file("https://mirror.evil.test/demo.iso"), source)[0]).toMatch( + /not allowlisted/, + ); + }); +}); diff --git a/scripts/sync/checks.ts b/scripts/sync/checks.ts new file mode 100644 index 0000000..2c028b6 --- /dev/null +++ b/scripts/sync/checks.ts @@ -0,0 +1,54 @@ +/** + * Semantic checks on top of the Zod schemas, shared by run.ts (before + * writing) and validate.ts (in CI). Returns human-readable problems. + */ +import type { MirrorsFile, ReleasesFile } from "../../src/lib/data-schemas"; +import { type DistroSource, hostAllowed } from "./source"; + +export function checkReleases(file: ReleasesFile, source: DistroSource): string[] { + const problems: string[] = []; + const seenVersions = new Set(); + for (const release of file.releases) { + const key = `${release.channel}:${release.version}`; + if (seenVersions.has(key)) problems.push(`duplicate release ${key}`); + seenVersions.add(key); + const editionIds = new Set(); + for (const edition of release.editions) { + if (editionIds.has(edition.id)) + problems.push(`${release.version}: duplicate edition ${edition.id}`); + editionIds.add(edition.id); + const files = new Set(); + for (const a of edition.artifacts) { + const where = `${release.version}/${edition.id}/${a.arch}/${a.file}`; + if (files.has(`${a.arch}/${a.file}`)) problems.push(`${where}: duplicate artifact`); + files.add(`${a.arch}/${a.file}`); + for (const [label, url] of [ + ["url", a.url], + ["checksum", a.checksum?.url], + ["signature", a.signatureUrl], + ["torrent", a.torrentUrl], + ] as const) { + if (url && !hostAllowed(url, source.hosts)) + problems.push(`${where}: ${label} host not allowlisted (${url})`); + } + if (a.checksum) { + const expected = { md5: 32, sha1: 40, sha256: 64, sha512: 128, b2: 128 }[a.checksum.type]; + if (a.checksum.value.length !== expected) + problems.push(`${where}: ${a.checksum.type} has wrong length`); + } + if (a.path?.startsWith("/")) problems.push(`${where}: path must be relative`); + } + } + } + return problems; +} + +export function checkMirrors(file: MirrorsFile): string[] { + const problems: string[] = []; + const urls = new Set(); + for (const m of file.mirrors) { + if (urls.has(m.url)) problems.push(`duplicate mirror ${m.url}`); + urls.add(m.url); + } + return problems; +} 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`); diff --git a/scripts/sync/http.ts b/scripts/sync/http.ts new file mode 100644 index 0000000..38ef6db --- /dev/null +++ b/scripts/sync/http.ts @@ -0,0 +1,257 @@ +/** + * Polite HTTP client for the data sync (docs/data-sources.md, CLAUDE.md rule 6): + * - identifies itself with a descriptive User-Agent and contact URL + * - checks robots.txt before every request (cached per host) and refuses + * disallowed URLs + * - serializes requests per host with at least 1 s between them, or the + * host's Crawl-delay when larger + * - retries 429/5xx/network errors with exponential backoff (never 4xx) + * - revalidates with ETag / Last-Modified, keeping bodies in .cache/ + */ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import robotsParser from "robots-parser"; + +const SITE = process.env.SITE_URL ?? "https://github.com/neramc/linuxhub"; +export const USER_AGENT = `LinuxhubBot/1.0 (+${SITE.replace(/\/$/, "")}/about/#bot)`; +const UA_TOKEN = "LinuxhubBot"; +const MIN_INTERVAL_MS = 1000; +const TIMEOUT_MS = 30_000; +const RETRIES = 2; + +const CACHE_DIR = join(import.meta.dir, ".cache"); +const CACHE_INDEX = join(CACHE_DIR, "index.json"); + +export class HttpError extends Error { + constructor( + message: string, + readonly url: string, + readonly status?: number, + ) { + super(message); + } +} + +export class RobotsDisallowed extends HttpError {} + +type CacheEntry = { etag?: string; lastModified?: string; body: string }; +let cacheIndex: Record> | null = null; + +function loadCache() { + if (cacheIndex) return cacheIndex; + try { + cacheIndex = JSON.parse(readFileSync(CACHE_INDEX, "utf8")); + } catch { + cacheIndex = {}; + } + return cacheIndex as Record>; +} + +function bodyPath(url: string) { + return join(CACHE_DIR, `${createHash("sha256").update(url).digest("hex").slice(0, 24)}.body`); +} + +function readCached(url: string): CacheEntry | null { + const meta = loadCache()[url]; + if (!meta || !existsSync(bodyPath(url))) return null; + return { ...meta, body: readFileSync(bodyPath(url), "utf8") }; +} + +function writeCached(url: string, entry: CacheEntry) { + if (!entry.etag && !entry.lastModified) return; + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(bodyPath(url), entry.body); + const index = loadCache(); + index[url] = { + ...(entry.etag ? { etag: entry.etag } : {}), + ...(entry.lastModified ? { lastModified: entry.lastModified } : {}), + }; + writeFileSync(CACHE_INDEX, JSON.stringify(index)); +} + +/* ---------- per-host pacing ---------- */ + +const hostQueues = new Map>(); +const hostLast = new Map(); +const hostDelay = new Map(); + +function schedule(host: string, task: () => Promise): Promise { + const prev = hostQueues.get(host) ?? Promise.resolve(); + const run = prev + .catch(() => undefined) + .then(async () => { + const wait = + (hostLast.get(host) ?? 0) + (hostDelay.get(host) ?? MIN_INTERVAL_MS) - Date.now(); + if (wait > 0) await Bun.sleep(wait); + try { + return await task(); + } finally { + hostLast.set(host, Date.now()); + } + }); + hostQueues.set(host, run); + return run; +} + +/* ---------- robots.txt ---------- */ + +type Robots = ReturnType; +const robotsCache = new Map>(); + +async function robotsFor(url: URL): Promise { + const origin = url.origin; + let pending = robotsCache.get(origin); + if (!pending) { + pending = (async () => { + const robotsUrl = `${origin}/robots.txt`; + let text = ""; + try { + const res = await schedule(url.host, () => + fetch(robotsUrl, { + headers: { "user-agent": USER_AGENT }, + signal: AbortSignal.timeout(TIMEOUT_MS), + redirect: "follow", + }), + ); + // 4xx (incl. 404): no restrictions. 5xx: be conservative and treat as disallow-all. + if (res.ok) text = await res.text(); + else if (res.status >= 500) text = "User-agent: *\nDisallow: /"; + } catch { + text = "User-agent: *\nDisallow: /"; + } + const robots = robotsParser(robotsUrl, text); + const delay = robots.getCrawlDelay(UA_TOKEN); + if (delay && delay * 1000 > MIN_INTERVAL_MS) + hostDelay.set(url.host, Math.min(delay, 30) * 1000); + return robots; + })(); + robotsCache.set(origin, pending); + } + return pending; +} + +export async function isAllowed(url: string): Promise { + const u = new URL(url); + const robots = await robotsFor(u); + return robots.isAllowed(url, UA_TOKEN) !== false; +} + +/* ---------- fetch ---------- */ + +export interface FetchOptions { + method?: "GET" | "HEAD"; + /** Accept header; defaults to any. */ + accept?: string; + /** Extra headers (e.g. GitHub token). */ + headers?: Record; + /** Skip the ETag cache (for HEAD or tiny responses). */ + noCache?: boolean; +} + +export interface FetchResult { + url: string; + finalUrl: string; + status: number; + headers: Headers; + body: string; + fromCache: boolean; +} + +const stats = { requests: 0, notModified: 0, blocked: 0 }; +export const httpStats = () => ({ ...stats }); + +export async function request(url: string, options: FetchOptions = {}): Promise { + const u = new URL(url); + if (u.protocol !== "https:") throw new HttpError("only https is allowed", url); + if (!(await isAllowed(url))) { + stats.blocked++; + throw new RobotsDisallowed(`robots.txt disallows ${url}`, url); + } + const method = options.method ?? "GET"; + const cached = method === "GET" && !options.noCache ? readCached(url) : null; + const headers: Record = { + "user-agent": USER_AGENT, + accept: options.accept ?? "*/*", + ...options.headers, + }; + if (cached?.etag) headers["if-none-match"] = cached.etag; + if (cached?.lastModified) headers["if-modified-since"] = cached.lastModified; + + let lastError: unknown; + for (let attempt = 0; attempt <= RETRIES; attempt++) { + if (attempt > 0) await Bun.sleep(2 ** attempt * 1000); + try { + const res = await schedule(u.host, () => + fetch(url, { + method, + headers, + redirect: "follow", + signal: AbortSignal.timeout(TIMEOUT_MS), + }), + ); + stats.requests++; + if (res.status === 304 && cached) { + stats.notModified++; + return { + url, + finalUrl: res.url || url, + status: 200, + headers: res.headers, + body: cached.body, + fromCache: true, + }; + } + if (res.status === 429 || res.status >= 500) { + lastError = new HttpError(`HTTP ${res.status}`, url, res.status); + continue; + } + if (!res.ok) throw new HttpError(`HTTP ${res.status} for ${url}`, url, res.status); + const body = method === "HEAD" ? "" : await res.text(); + if (method === "GET" && !options.noCache) { + writeCached(url, { + etag: res.headers.get("etag") ?? undefined, + lastModified: res.headers.get("last-modified") ?? undefined, + body, + } as CacheEntry); + } + return { + url, + finalUrl: res.url || url, + status: res.status, + headers: res.headers, + body, + fromCache: false, + }; + } catch (error) { + if (error instanceof HttpError && error.status && error.status < 500 && error.status !== 429) + throw error; + lastError = error; + } + } + throw lastError instanceof Error ? lastError : new HttpError(String(lastError), url); +} + +export async function getText(url: string, options?: FetchOptions): Promise { + return (await request(url, options)).body; +} + +export async function getJson(url: string, options?: FetchOptions): Promise { + const body = await getText(url, { accept: "application/json", ...options }); + try { + return JSON.parse(body) as T; + } catch { + throw new HttpError(`invalid JSON from ${url}`, url); + } +} + +/** Content-Length via HEAD, or null when unavailable/disallowed. */ +export async function headSize(url: string): Promise { + try { + const res = await request(url, { method: "HEAD", noCache: true }); + const len = Number(res.headers.get("content-length")); + return Number.isFinite(len) && len > 0 ? len : null; + } catch { + return null; + } +} diff --git a/scripts/sync/lib/checksums.ts b/scripts/sync/lib/checksums.ts new file mode 100644 index 0000000..0342d16 --- /dev/null +++ b/scripts/sync/lib/checksums.ts @@ -0,0 +1,42 @@ +/** + * Parses checksum files as published by distributions: + * GNU coreutils: " " or " *" + * BSD/tagged: "SHA256 () = " (Fedora/CentOS/Rocky/Alma CHECKSUM) + * PGP-signed wrappers (-----BEGIN PGP SIGNED MESSAGE-----) and comments are ignored. + * Returns file name → lowercase hex digest (basename only). + */ +export function parseChecksums(text: string): Map { + const out = new Map(); + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith("-----") || /^Hash:/i.test(line)) continue; + const bsd = line.match( + /^(?:SHA(?:1|224|256|384|512)|MD5|BLAKE2b?|B2)\s*\((.+)\)\s*=\s*([0-9a-fA-F]+)$/, + ); + if (bsd?.[1] && bsd[2]) { + out.set(basename(bsd[1]), bsd[2].toLowerCase()); + continue; + } + const gnu = line.match(/^([0-9a-fA-F]{32,128})\s+\*?(.+)$/); + if (gnu?.[1] && gnu[2]) out.set(basename(gnu[2].trim()), gnu[1].toLowerCase()); + } + return out; +} + +function basename(path: string): string { + return path.replace(/^\.\//, "").split("/").pop() ?? path; +} + +/** Infers the algorithm from a hex digest length. */ +export function checksumType(hex: string): "md5" | "sha1" | "sha256" | "sha512" { + switch (hex.length) { + case 32: + return "md5"; + case 40: + return "sha1"; + case 128: + return "sha512"; + default: + return "sha256"; + } +} diff --git a/scripts/sync/lib/dirlist.ts b/scripts/sync/lib/dirlist.ts new file mode 100644 index 0000000..05ca2b3 --- /dev/null +++ b/scripts/sync/lib/dirlist.ts @@ -0,0 +1,18 @@ +/** + * Reads an Apache/nginx/lighttpd autoindex page and returns entry names + * (files and directories, directories with a trailing "/"). Only used on + * official download hosts whose robots.txt allows the listing. + */ +import { getText } from "../http"; + +export async function listDirectory(url: string): Promise { + const base = url.endsWith("/") ? url : `${url}/`; + const html = await getText(base, { accept: "text/html" }); + const names = new Set(); + for (const match of html.matchAll(/href\s*=\s*"([^"?#]+)"/gi)) { + const href = match[1]; + if (!href || href.startsWith("/") || href.startsWith("..") || /^[a-z]+:/i.test(href)) continue; + names.add(decodeURIComponent(href.replace(/^\.\//, ""))); + } + return [...names]; +} diff --git a/scripts/sync/lib/endoflife.ts b/scripts/sync/lib/endoflife.ts new file mode 100644 index 0000000..401a207 --- /dev/null +++ b/scripts/sync/lib/endoflife.ts @@ -0,0 +1,50 @@ +/** + * endoflife.date v1 API (community-maintained, sourced from official + * announcements). Used only for release/EOL dates and LTS flags; download + * files and checksums always come from the distro's own servers. + */ +import { getJson } from "../http"; +import { isoDay } from "./versions"; + +export interface Cycle { + name: string; + codename: string | null; + label: string; + releaseDate: string | null; + eol: string | null; + lts: boolean; + latest: string | null; + latestDate: string | null; + isMaintained: boolean; + link: string | null; +} + +interface ApiRelease { + name: string; + codename?: string | null; + label?: string; + releaseDate?: string | null; + isLts?: boolean; + isEol?: boolean; + eolFrom?: string | null; + isMaintained?: boolean; + latest?: { name?: string; date?: string | null; link?: string | null } | null; +} + +export async function endOfLife(product: string): Promise<{ url: string; cycles: Cycle[] }> { + const url = `https://endoflife.date/api/v1/products/${product}`; + const data = await getJson<{ result: { releases: ApiRelease[] } }>(url); + const cycles = data.result.releases.map((r) => ({ + name: r.name, + codename: r.codename ?? null, + label: r.label ?? r.name, + releaseDate: isoDay(r.releaseDate), + eol: isoDay(r.eolFrom), + lts: Boolean(r.isLts), + latest: r.latest?.name ?? null, + latestDate: isoDay(r.latest?.date), + isMaintained: r.isMaintained ?? !r.isEol, + link: r.latest?.link ?? null, + })); + return { url: `https://endoflife.date/${product}`, cycles }; +} diff --git a/scripts/sync/lib/github.ts b/scripts/sync/lib/github.ts new file mode 100644 index 0000000..9ae52b6 --- /dev/null +++ b/scripts/sync/lib/github.ts @@ -0,0 +1,42 @@ +/** GitHub Releases (official project repositories only). Uses GITHUB_TOKEN when present. */ +import { getJson } from "../http"; + +export interface GhAsset { + name: string; + size: number; + browser_download_url: string; + digest?: string | null; +} + +export interface GhRelease { + tag_name: string; + name: string | null; + draft: boolean; + prerelease: boolean; + published_at: string | null; + html_url: string; + assets: GhAsset[]; +} + +function headers(): Record { + const token = process.env.GITHUB_TOKEN; + return { + "x-github-api-version": "2022-11-28", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; +} + +export async function githubReleases(repo: string, limit = 10): Promise { + const url = `https://api.github.com/repos/${repo}/releases?per_page=${limit}`; + const releases = await getJson(url, { + headers: headers(), + accept: "application/vnd.github+json", + }); + return releases.filter((r) => !r.draft); +} + +/** GitHub now reports "sha256:" digests for release assets. */ +export function assetSha256(asset: GhAsset): string | null { + const m = asset.digest?.match(/^sha256:([0-9a-f]{64})$/); + return m?.[1] ?? null; +} diff --git a/scripts/sync/lib/lib.test.ts b/scripts/sync/lib/lib.test.ts new file mode 100644 index 0000000..d0f1b8e --- /dev/null +++ b/scripts/sync/lib/lib.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { checksumType, parseChecksums } from "./checksums"; +import { compareVersions, isoDay, latest } from "./versions"; + +describe("parseChecksums", () => { + it("reads GNU coreutils lines (text and binary mode)", () => { + const text = [ + "684ded26c63240ff4a41e8c25ee84ea6da233f557364821f13d12c2b0a9059a5 archlinux-2026.10.01-x86_64.iso", + "AA2D1A3F0C6A6E4F2B1BDC1F7C5F7C33B6C0B7A0D3E9F7C0C2E1B2A3D4E5F6A7 *ubuntu-24.04.3-desktop-amd64.iso", + ].join("\n"); + const sums = parseChecksums(text); + expect(sums.get("archlinux-2026.10.01-x86_64.iso")).toBe( + "684ded26c63240ff4a41e8c25ee84ea6da233f557364821f13d12c2b0a9059a5", + ); + expect(sums.get("ubuntu-24.04.3-desktop-amd64.iso")).toMatch(/^aa2d1a3f/); + }); + + it("reads BSD-tagged lines inside a PGP-signed CHECKSUM file", () => { + const text = `-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +# Fedora-Workstation-Live-43-1.6.x86_64.iso: 2742190080 bytes +SHA256 (Fedora-Workstation-Live-43-1.6.x86_64.iso) = 0d2b6f1c09a3e8d3a5b6c7d8e9f00112233445566778899aabbccddeeff0011 +-----BEGIN PGP SIGNATURE----- +abc +-----END PGP SIGNATURE-----`; + const sums = parseChecksums(text); + expect([...sums.keys()]).toEqual(["Fedora-Workstation-Live-43-1.6.x86_64.iso"]); + }); + + it("strips directories from file names", () => { + const sums = parseChecksums("0123456789abcdef0123456789abcdef ./iso/file.iso"); + expect(sums.get("file.iso")).toBe("0123456789abcdef0123456789abcdef"); + }); + + it("infers algorithms from digest length", () => { + expect(checksumType("a".repeat(32))).toBe("md5"); + expect(checksumType("a".repeat(64))).toBe("sha256"); + expect(checksumType("a".repeat(128))).toBe("sha512"); + }); +}); + +describe("versions", () => { + it("compares naturally", () => { + expect(compareVersions("10.2", "9.10")).toBeGreaterThan(0); + expect(compareVersions("24.04.3", "24.04")).toBeGreaterThan(0); + expect(compareVersions("2026.10.01", "2026.09.01")).toBeGreaterThan(0); + expect(compareVersions("13.1", "13.1")).toBe(0); + expect(latest(["22.04", "24.04", "24.10", "25.04"])).toBe("25.04"); + }); + + it("normalizes dates", () => { + expect(isoDay("2025-10-28T12:00:00Z")).toBe("2025-10-28"); + expect(isoDay("nope")).toBeNull(); + expect(isoDay(undefined)).toBeNull(); + }); +}); diff --git a/scripts/sync/lib/mirrors.ts b/scripts/sync/lib/mirrors.ts new file mode 100644 index 0000000..ab4d5da --- /dev/null +++ b/scripts/sync/lib/mirrors.ts @@ -0,0 +1,96 @@ +/** Helpers for building official mirror lists. */ +import countries from "../../../src/data/geo/countries.json"; +import { request } from "../http"; + +const CODES = Object.keys(countries as Record); +const english = new Intl.DisplayNames(["en"], { type: "region" }); +const BY_NAME = new Map(); +for (const code of CODES) { + const name = english.of(code); + if (name) BY_NAME.set(normalize(name), code); +} +const ALIASES: Record = { + usa: "US", + "united states of america": "US", + uk: "GB", + "great britain": "GB", + england: "GB", + russia: "RU", + "russian federation": "RU", + korea: "KR", + "south korea": "KR", + "korea republic of": "KR", + "republic of korea": "KR", + taiwan: "TW", + vietnam: "VN", + "viet nam": "VN", + iran: "IR", + czechia: "CZ", + "czech republic": "CZ", + moldova: "MD", + "hong kong": "HK", + turkey: "TR", + türkiye: "TR", + "the netherlands": "NL", + holland: "NL", + macedonia: "MK", + "north macedonia": "MK", + bosnia: "BA", + "bosnia and herzegovina": "BA", + "ivory coast": "CI", + laos: "LA", + syria: "SY", + tanzania: "TZ", + bolivia: "BO", + venezuela: "VE", +}; + +function normalize(s: string): string { + return s + .toLowerCase() + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z ]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** "South Korea" / "KR" / "Korea, Republic of" → "KR"; unknown → null. */ +export function countryCode(value: string | null | undefined): string | null { + if (!value) return null; + const v = value.trim(); + if (/^[A-Z]{2}$/.test(v) && CODES.includes(v)) return v; + const n = normalize(v.replace(/,\s*(.+)$/, " $1")); + return ALIASES[n] ?? BY_NAME.get(n) ?? ALIASES[normalize(v)] ?? null; +} + +export const withSlash = (url: string) => (url.endsWith("/") ? url : `${url}/`); + +/** True when `url` answers over HTTPS (HEAD, robots-aware). Used to keep only HTTPS mirrors. */ +export async function httpsReachable(url: string): Promise { + try { + const res = await request(url, { method: "HEAD", noCache: true }); + return res.status < 400; + } catch { + return false; + } +} + +/** Runs `fn` over items with at most `limit` in flight (different hosts are paced independently). */ +export async function mapLimit( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const out: R[] = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i] as T); + } + }), + ); + return out; +} diff --git a/scripts/sync/lib/sourceforge.ts b/scripts/sync/lib/sourceforge.ts new file mode 100644 index 0000000..3b488aa --- /dev/null +++ b/scripts/sync/lib/sourceforge.ts @@ -0,0 +1,40 @@ +/** + * SourceForge project file RSS (official projects only). Each item carries + * the file path, size and an MD5 (SourceForge publishes no SHA-256 here; the + * guides point users to the project's own SHA-256/signature files). + */ +import { getText } from "../http"; + +export interface SfFile { + path: string; + url: string; + size: number | null; + md5: string | null; + date: string | null; +} + +export async function sourceforgeFiles( + project: string, + path = "/", + limit = 100, +): Promise { + const url = `https://sourceforge.net/projects/${project}/rss?path=${encodeURIComponent(path)}&limit=${limit}`; + const xml = await getText(url, { accept: "application/rss+xml" }); + const files: SfFile[] = []; + for (const item of xml.split("").slice(1)) { + const title = item.match(/<!\[CDATA\[(.+?)\]\]><\/title>/)?.[1]; + const link = item.match(/<link>(.+?)<\/link>/)?.[1]; + if (!title || !link) continue; + const size = item.match(/filesize="(\d+)"/)?.[1]; + const md5 = item.match(/<media:hash algo="md5">([0-9a-f]{32})<\/media:hash>/)?.[1]; + const date = item.match(/<pubDate>(.+?)<\/pubDate>/)?.[1]; + files.push({ + path: title, + url: link.replace(/^http:/, "https:"), + size: size ? Number(size) : null, + md5: md5 ?? null, + date: date ? new Date(date).toISOString().slice(0, 10) : null, + }); + } + return files; +} diff --git a/scripts/sync/lib/versions.ts b/scripts/sync/lib/versions.ts new file mode 100644 index 0000000..523929f --- /dev/null +++ b/scripts/sync/lib/versions.ts @@ -0,0 +1,33 @@ +/** Natural version comparison: "10.2" > "9.10", "24.04.3" > "24.04", "2026.09.01" > "2026.08.01". */ +export function compareVersions(a: string, b: string): number { + const pa = a.split(/[^0-9a-z]+/i); + const pb = b.split(/[^0-9a-z]+/i); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const x = pa[i] ?? ""; + const y = pb[i] ?? ""; + const nx = Number(x); + const ny = Number(y); + if (x !== "" && y !== "" && Number.isFinite(nx) && Number.isFinite(ny)) { + if (nx !== ny) return nx - ny; + } else if (x !== y) { + if (x === "") return -1; + if (y === "") return 1; + return x < y ? -1 : 1; + } + } + return 0; +} + +export const newestFirst = (a: string, b: string) => compareVersions(b, a); + +/** Picks the highest version from a list of candidates. */ +export function latest(versions: string[]): string | undefined { + return [...versions].sort(newestFirst)[0]; +} + +/** "2025-10-28T12:00:00Z" | "2025-10-28" → "2025-10-28"; invalid → null. */ +export function isoDay(value: unknown): string | null { + if (typeof value !== "string" && !(value instanceof Date)) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); +} diff --git a/scripts/sync/run.ts b/scripts/sync/run.ts new file mode 100644 index 0000000..1b5d1f6 --- /dev/null +++ b/scripts/sync/run.ts @@ -0,0 +1,254 @@ +/** + * Data sync entry point: `bun run sync [--only=a,b] [--kind=releases|mirrors|all] [--dry-run]` + * + * Each distro source runs independently. A failing source never touches its + * existing data; it is marked in src/data/status.json (failingSince) so the + * site can flag stale data and the workflow can open an issue. + */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { + HistoryEntry, + MirrorsFile, + ReleasesFile, + StatusFile, +} from "../../src/lib/data-schemas"; +import { mirrorsFileSchema, releasesFileSchema } from "../../src/lib/data-schemas"; +import { checkMirrors, checkReleases } from "./checks"; +import { httpStats } from "./http"; +import { compareVersions } from "./lib/versions"; +import type { DistroSource, SyncContext } from "./source"; +import { SOURCES } from "./sources"; +import { + readHistory, + readMirrors, + readReleases, + readStatus, + sameContent, + writeHistory, + writeMirrors, + writeReleases, + writeStatus, +} from "./store"; + +const args = new Map( + process.argv.slice(2).map((a) => { + const [k, v] = a.replace(/^--/, "").split("="); + return [k ?? "", v ?? "true"] as const; + }), +); +const only = args.get("only")?.split(",").filter(Boolean); +const kind = args.get("kind") ?? "all"; +const dryRun = args.get("dry-run") === "true"; +const today = new Date().toISOString().slice(0, 10); +const HISTORY_LIMIT = 300; + +type Outcome = { + slug: string; + kind: "releases" | "mirrors"; + result: "changed" | "unchanged" | "failed"; + detail?: string; +}; + +const status: StatusFile = readStatus(); +let history: HistoryEntry[] = readHistory(); +const outcomes: Outcome[] = []; + +function statusEntry(slug: string) { + status[slug] ??= {}; + return status[slug]; +} + +function markFailure(slug: string, k: "releases" | "mirrors", error: unknown) { + const message = (error instanceof Error ? error.message : String(error)).slice(0, 300); + const entry = statusEntry(slug); + entry[k] = { failingSince: entry[k]?.failingSince ?? today, lastError: message }; + outcomes.push({ slug, kind: k, result: "failed", detail: message }); +} + +function markSuccess(slug: string, k: "releases" | "mirrors") { + const entry = statusEntry(slug); + entry[k] = { failingSince: null, lastError: null }; +} + +/** Rejects data whose newest stable version is older than what we have (upstream glitch). */ +function regressed(prev: ReleasesFile | undefined, next: ReleasesFile): string | null { + if (!prev) return null; + const newest = (f: ReleasesFile) => + f.releases + .filter((r) => r.channel !== "beta" && r.channel !== "testing") + .map((r) => r.version) + .sort((a, b) => compareVersions(b, a))[0]; + const a = newest(prev); + const b = newest(next); + return a && b && compareVersions(b, a) < 0 ? `newest version went backwards (${a} → ${b})` : null; +} + +function recordHistory(slug: string, prev: ReleasesFile | undefined, next: ReleasesFile) { + const known = new Set(prev?.releases.map((r) => `${r.channel}:${r.version}`) ?? []); + for (const r of next.releases) { + if (known.has(`${r.channel}:${r.version}`)) continue; + if (r.channel === "rolling") { + // Rolling snapshots (e.g. monthly ISOs) are only news after the first import. + if (!prev) continue; + } else if (!prev && !r.releaseDate) continue; + if (history.some((h) => h.slug === slug && h.version === r.version && h.channel === r.channel)) + continue; + history.push({ + slug, + version: r.version, + channel: r.channel, + date: r.releaseDate ?? today, + detected: today, + }); + } +} + +const ARCH_ORDER = ["x86_64", "aarch64", "i686", "armhf", "riscv64", "ppc64le", "s390x"]; + +/** Stable artifact order: x86_64 first (the default most people need), then other arches. */ +function normalizeOrder(releases: ReleasesFile["releases"]): ReleasesFile["releases"] { + return releases.map((r) => ({ + ...r, + editions: r.editions.map((e) => ({ + ...e, + artifacts: [...e.artifacts].sort( + (a, b) => + ARCH_ORDER.indexOf(a.arch) - ARCH_ORDER.indexOf(b.arch) || a.file.localeCompare(b.file), + ), + })), + })); +} + +async function syncReleases(source: DistroSource, ctx: SyncContext) { + if (!source.releases) return; + try { + const result = await source.releases(ctx); + result.releases = normalizeOrder(result.releases); + const prev = readReleases(source.slug); + const draft: ReleasesFile = { + slug: source.slug, + sources: result.sources, + updated: today, + releases: result.releases, + }; + const parsed = releasesFileSchema.safeParse(draft); + if (!parsed.success) + throw new Error( + `schema: ${parsed.error.issues + .slice(0, 3) + .map((i) => `${i.path.join(".")} ${i.message}`) + .join("; ")}`, + ); + const next = parsed.data; + const problems = checkReleases(next, source); + const regression = regressed(prev, next); + if (regression) problems.push(regression); + if (problems.length) throw new Error(problems.slice(0, 5).join("; ")); + markSuccess(source.slug, "releases"); + if (sameContent(prev, next)) { + outcomes.push({ slug: source.slug, kind: "releases", result: "unchanged" }); + return; + } + recordHistory(source.slug, prev, next); + if (!dryRun) writeReleases(next); + outcomes.push({ slug: source.slug, kind: "releases", result: "changed" }); + } catch (error) { + markFailure(source.slug, "releases", error); + } +} + +async function syncMirrors(source: DistroSource, ctx: SyncContext) { + if (!source.mirrors) return; + try { + const result = await source.mirrors(ctx); + const mirrors = [...result.mirrors].sort( + (a, b) => (a.country ?? "ZZ").localeCompare(b.country ?? "ZZ") || a.url.localeCompare(b.url), + ); + const prev = readMirrors(source.slug); + const parsed = mirrorsFileSchema.safeParse({ + slug: source.slug, + sources: result.sources, + updated: today, + mirrors, + }); + if (!parsed.success) + throw new Error( + `schema: ${parsed.error.issues + .slice(0, 3) + .map((i) => `${i.path.join(".")} ${i.message}`) + .join("; ")}`, + ); + const next: MirrorsFile = parsed.data; + if (next.mirrors.length === 0) throw new Error("official mirror list came back empty"); + if (prev && next.mirrors.length < prev.mirrors.length * 0.5) + throw new Error(`mirror count dropped from ${prev.mirrors.length} to ${next.mirrors.length}`); + const problems = checkMirrors(next); + if (problems.length) throw new Error(problems.slice(0, 5).join("; ")); + markSuccess(source.slug, "mirrors"); + if (sameContent(prev, next)) { + outcomes.push({ slug: source.slug, kind: "mirrors", result: "unchanged" }); + return; + } + if (!dryRun) writeMirrors(next); + outcomes.push({ slug: source.slug, kind: "mirrors", result: "changed" }); + } catch (error) { + markFailure(source.slug, "mirrors", error); + } +} + +const selected = SOURCES.filter((s) => !only || only.includes(s.slug)); +if (only && selected.length !== only.length) { + const missing = only.filter((slug) => !SOURCES.some((s) => s.slug === slug)); + console.error(`Unknown source(s): ${missing.join(", ")}`); + process.exit(2); +} + +const started = Date.now(); +await Promise.all( + selected.map(async (source) => { + const ctx: SyncContext = { today, log: (m) => console.log(`[${source.slug}] ${m}`) }; + if (kind === "all" || kind === "releases") await syncReleases(source, ctx); + if (kind === "all" || kind === "mirrors") await syncMirrors(source, ctx); + }), +); + +history = history + .sort( + (a, b) => + b.date.localeCompare(a.date) || + a.slug.localeCompare(b.slug) || + compareVersions(b.version, a.version), + ) + .slice(0, HISTORY_LIMIT); + +if (!dryRun) { + writeHistory(history); + writeStatus(status); +} + +const failed = outcomes.filter((o) => o.result === "failed"); +const changed = outcomes.filter((o) => o.result === "changed"); +for (const o of outcomes) + console.log( + `${o.result.padEnd(9)} ${o.kind.padEnd(8)} ${o.slug}${o.detail ? ` — ${o.detail}` : ""}`, + ); +const stats = httpStats(); +console.log( + `\n${changed.length} changed, ${outcomes.length - changed.length - failed.length} unchanged, ${failed.length} failed · ${stats.requests} requests (${stats.notModified} not modified, ${stats.blocked} blocked by robots.txt) · ${((Date.now() - started) / 1000).toFixed(1)}s`, +); + +// Report for the workflow: sources failing for ≥ 18 h (about 3 scheduled runs). +const stale = Object.entries(status).flatMap(([slug, s]) => + (["releases", "mirrors"] as const) + .filter( + (k) => s[k]?.failingSince && Date.parse(today) - Date.parse(s[k]?.failingSince ?? today) >= 0, + ) + .map((k) => ({ slug, kind: k, since: s[k]?.failingSince, error: s[k]?.lastError })), +); +const reportDir = join(import.meta.dir, ".cache"); +mkdirSync(reportDir, { recursive: true }); +writeFileSync( + join(reportDir, "report.json"), + JSON.stringify({ today, changed, failed, failing: stale }, null, 2), +); diff --git a/scripts/sync/source.ts b/scripts/sync/source.ts new file mode 100644 index 0000000..8c793a0 --- /dev/null +++ b/scripts/sync/source.ts @@ -0,0 +1,40 @@ +import type { Mirror, Release } from "../../src/lib/data-schemas"; + +export interface SyncContext { + /** Today's date (UTC, YYYY-MM-DD). */ + today: string; + log: (message: string) => void; +} + +export interface ReleasesResult { + /** Official endpoints/pages the data came from. */ + sources: string[]; + releases: Release[]; +} + +export interface MirrorsResult { + sources: string[]; + mirrors: Mirror[]; +} + +/** + * One distro's sync definition (scripts/sync/sources/<slug>.ts). `hosts` is + * the allowlist of official hosts (exact or dot-suffix match) that artifact, + * checksum, signature and torrent URLs may point to; validation rejects + * anything else. + */ +export interface DistroSource { + slug: string; + hosts: string[]; + releases?: (ctx: SyncContext) => Promise<ReleasesResult>; + mirrors?: (ctx: SyncContext) => Promise<MirrorsResult>; +} + +export function defineSource(source: DistroSource): DistroSource { + return source; +} + +export function hostAllowed(url: string, hosts: string[]): boolean { + const host = new URL(url).hostname; + return hosts.some((h) => host === h || host.endsWith(`.${h}`)); +} diff --git a/scripts/sync/sources/arch.ts b/scripts/sync/sources/arch.ts new file mode 100644 index 0000000..cc87366 --- /dev/null +++ b/scripts/sync/sources/arch.ts @@ -0,0 +1,110 @@ +/** + * Arch Linux: the official release-engineering JSON (monthly ISO snapshots, + * SHA-256, PGP signature, torrent, magnet) and the official mirror status + * JSON (country, ISO availability, completion, score). + */ +import type { Mirror, Release } from "../../../src/lib/data-schemas"; +import { getJson } from "../http"; +import { defineSource } from "../source"; + +const RELENG = "https://archlinux.org/releng/releases/json/"; +const MIRROR_STATUS = "https://archlinux.org/mirrors/status/json/"; +/** Arch's official GeoDNS mirror: picks a nearby server automatically. */ +const GEO = "https://geo.mirror.pkgbuild.com/"; + +interface RelengRelease { + version: string; + release_date: string; + available: boolean; + sha256_sum: string | null; + iso_url: string; + torrent_url: string | null; + torrent?: { file_length?: number } | null; +} + +interface MirrorStatus { + urls: { + url: string; + protocol: string; + active: boolean; + isos: boolean; + completion_pct: number | null; + delay: number | null; + score: number | null; + country_code: string; + details: string; + }[]; +} + +export default defineSource({ + slug: "arch", + hosts: ["archlinux.org", "geo.mirror.pkgbuild.com"], + async releases() { + const data = await getJson<{ releases: RelengRelease[] }>(RELENG); + const available = data.releases.filter((r) => r.available && r.sha256_sum).slice(0, 2); + const releases: Release[] = available.map((r) => { + const path = r.iso_url.replace(/^\//, ""); + const file = path.split("/").pop() ?? path; + const dir = path.slice(0, path.length - file.length); + return { + version: r.version, + channel: "rolling", + codename: null, + releaseDate: r.release_date, + eol: null, + notesUrl: `https://archlinux.org/releng/releases/${r.version}/`, + editions: [ + { + id: "live", + name: "Live ISO", + desktop: "none", + kind: "live", + artifacts: [ + { + arch: "x86_64", + format: "iso", + file, + path, + url: `${GEO}${path}`, + size: r.torrent?.file_length ?? null, + checksum: { + type: "sha256", + value: (r.sha256_sum ?? "").toLowerCase(), + url: `https://archlinux.org/${dir}sha256sums.txt`, + }, + signatureUrl: `https://archlinux.org/${path}.sig`, + torrentUrl: r.torrent_url ? `https://archlinux.org${r.torrent_url}` : null, + }, + ], + }, + ], + }; + }); + return { sources: [RELENG], releases }; + }, + async mirrors() { + const data = await getJson<MirrorStatus>(MIRROR_STATUS); + const mirrors: Mirror[] = data.urls + .filter( + (m) => + m.protocol === "https" && + m.active && + m.isos && + m.completion_pct === 1 && + m.score !== null && + // 3 days: generous enough that the list doesn't flap between runs. + (m.delay ?? Number.POSITIVE_INFINITY) < 3 * 86_400, + ) + .map((m) => ({ + url: m.url.endsWith("/") ? m.url : `${m.url}/`, + country: /^[A-Z]{2}$/.test(m.country_code) ? m.country_code : null, + name: new URL(m.url).hostname, + lat: null, + lon: null, + // Arch's score (lower = better) moves every check; keep coarse tiers so + // the committed list only changes when a mirror meaningfully changes. + score: Math.max(1, Math.ceil(m.score ?? 1)), + })); + return { sources: [MIRROR_STATUS], mirrors }; + }, +}); diff --git a/scripts/sync/sources/debian.ts b/scripts/sync/sources/debian.ts new file mode 100644 index 0000000..9cdc275 --- /dev/null +++ b/scripts/sync/sources/debian.ts @@ -0,0 +1,146 @@ +/** + * Debian stable: version/codename from the archive's Release file, images and + * SHA-256 from cdimage.debian.org (netinst + live images per desktop), EOL + * dates from endoflife.date. Mirrors: the official Mirrors.masterlist entries + * that carry the CD image tree, kept only when they answer over HTTPS. + */ + +import type { Artifact, Edition, Mirror, Release } from "../../../src/lib/data-schemas"; +import type { Desktop } from "../../../src/lib/taxonomy"; +import { getText } from "../http"; +import { parseChecksums } from "../lib/checksums"; +import { endOfLife } from "../lib/endoflife"; +import { countryCode, httpsReachable, mapLimit } from "../lib/mirrors"; +import { defineSource } from "../source"; + +const CD = "https://cdimage.debian.org/debian-cd/"; +const RELEASE = "https://deb.debian.org/debian/dists/stable/Release"; +const MASTERLIST = "https://mirror-master.debian.org/status/Mirrors.masterlist"; + +const LIVE: { id: string; name: string; desktop: Desktop }[] = [ + { id: "gnome", name: "Live GNOME", desktop: "gnome" }, + { id: "kde", name: "Live KDE Plasma", desktop: "kde" }, + { id: "xfce", name: "Live Xfce", desktop: "xfce" }, + { id: "cinnamon", name: "Live Cinnamon", desktop: "cinnamon" }, + { id: "mate", name: "Live MATE", desktop: "mate" }, + { id: "lxqt", name: "Live LXQt", desktop: "lxqt" }, + { id: "lxde", name: "Live LXDE", desktop: "lxde" }, +]; + +function artifact( + dir: string, + mirrorDir: string, + file: string, + sha: string, + arch: Artifact["arch"], +): Artifact { + return { + arch, + format: "iso", + file, + path: `${mirrorDir}${file}`, + url: `${dir}${file}`, + size: null, + checksum: { type: "sha256", value: sha, url: `${dir}SHA256SUMS` }, + signatureUrl: `${dir}SHA256SUMS.sign`, + torrentUrl: null, + }; +} + +export default defineSource({ + slug: "debian", + hosts: ["cdimage.debian.org"], + async releases() { + const release = await getText(RELEASE); + const version = release.match(/^Version:\s*(\S+)/m)?.[1]; + const codename = release.match(/^Codename:\s*(\S+)/m)?.[1] ?? null; + if (!version) throw new Error("no Version in stable Release file"); + const eol = await endOfLife("debian"); + const cycle = eol.cycles.find((c) => c.name === version.split(".")[0]); + + const editions: Edition[] = []; + // Network installer (amd64 + arm64). + const netinst: Artifact[] = []; + for (const [debArch, arch] of [ + ["amd64", "x86_64"], + ["arm64", "aarch64"], + ] as const) { + const dir = `${CD}current/${debArch}/iso-cd/`; + const files = parseChecksums(await getText(`${dir}SHA256SUMS`)); + for (const [file, sha] of files) { + const m = file.match(new RegExp(`^debian-(\\d+\\.\\d+\\.\\d+)-${debArch}-netinst\\.iso$`)); + if (m) netinst.push(artifact(dir, `${m[1]}/${debArch}/iso-cd/`, file, sha, arch)); + } + } + // Live images (amd64), one edition per desktop. + const liveDir = `${CD}current-live/amd64/iso-hybrid/`; + const live = parseChecksums(await getText(`${liveDir}SHA256SUMS`)); + for (const def of LIVE) { + const hit = [...live].find(([file]) => + new RegExp(`^debian-live-[\\d.]+-amd64-${def.id}\\.iso$`).test(file), + ); + if (!hit) continue; + const [file, sha] = hit; + const ver = file.match(/^debian-live-([\d.]+)-/)?.[1] ?? ""; + editions.push({ + id: `live-${def.id}`, + name: def.name, + desktop: def.desktop, + kind: "live", + artifacts: [artifact(liveDir, `${ver}-live/amd64/iso-hybrid/`, file, sha, "x86_64")], + }); + } + if (netinst.length) { + editions.unshift({ + id: "netinst", + name: "netinst", + desktop: null, + kind: "netinst", + artifacts: netinst, + }); + } + + const releases: Release[] = [ + { + version, + channel: "stable", + codename: codename ? codename[0]?.toUpperCase() + codename.slice(1) : null, + releaseDate: cycle?.releaseDate ?? null, + eol: cycle?.eol ?? null, + notesUrl: `https://www.debian.org/releases/${codename ?? "stable"}/releasenotes`, + editions, + }, + ]; + return { sources: [RELEASE, `${CD}current/`, `${CD}current-live/`, eol.url], releases }; + }, + async mirrors() { + const text = await getText(MASTERLIST); + const candidates: { site: string; path: string; country: string | null }[] = []; + for (const block of text.split(/\n\s*\n/)) { + const site = block.match(/^Site:\s*(\S+)/m)?.[1]; + const path = block.match(/^CDImage-http:\s*(\S+)/m)?.[1]; + const type = block.match(/^Type:\s*(\S+)/m)?.[1]; + if (!site || !path || type === "Origin") continue; + candidates.push({ + site, + path, + country: countryCode(block.match(/^Country:\s*([A-Z]{2})/m)?.[1]), + }); + } + const checked = await mapLimit(candidates, 8, async (c) => { + const url = `https://${c.site}${c.path.endsWith("/") ? c.path : `${c.path}/`}`; + return (await httpsReachable(url)) ? c : null; + }); + const mirrors: Mirror[] = checked + .filter((c): c is NonNullable<typeof c> => c !== null) + .map((c) => ({ + url: `https://${c.site}${c.path.endsWith("/") ? c.path : `${c.path}/`}`, + country: c.country, + name: c.site, + lat: null, + lon: null, + score: null, + })); + return { sources: [MASTERLIST], mirrors }; + }, +}); diff --git a/scripts/sync/sources/fedora.ts b/scripts/sync/sources/fedora.ts new file mode 100644 index 0000000..ee964e2 --- /dev/null +++ b/scripts/sync/sources/fedora.ts @@ -0,0 +1,210 @@ +/** + * Fedora: official release metadata JSON (version, variant, arch, link, + * sha256, size) + endoflife.date for release/EOL dates. Downloads go through + * download.fedoraproject.org, Fedora's MirrorManager redirector, which sends + * each visitor to a nearby mirror. + */ + +import type { Artifact, Edition, Release } from "../../../src/lib/data-schemas"; +import type { Desktop } from "../../../src/lib/taxonomy"; +import { getJson } from "../http"; +import { endOfLife } from "../lib/endoflife"; +import { compareVersions } from "../lib/versions"; +import { defineSource } from "../source"; + +interface Entry { + version: string; + arch: string; + link: string; + variant: string; + subvariant: string; + sha256?: string; + size?: string; +} + +const RELEASES_JSON = "https://fedoraproject.org/releases.json"; + +/** variant/subvariant → edition. Order here is the display order. */ +const EDITIONS: { + match: (e: Entry) => boolean; + id: string; + name: string; + desktop: Desktop | null; + kind: Edition["kind"]; +}[] = [ + { + match: (e) => e.variant === "Workstation", + id: "workstation", + name: "Workstation", + desktop: "gnome", + kind: "desktop", + }, + { + match: (e) => e.variant === "KDE", + id: "kde", + name: "KDE Plasma Desktop", + desktop: "kde", + kind: "desktop", + }, + { + match: (e) => e.variant === "Server" && /-dvd-/.test(e.link), + id: "server", + name: "Server", + desktop: null, + kind: "server", + }, + { + match: (e) => e.variant === "Server" && /-netinst-/.test(e.link), + id: "server-netinst", + name: "Server (netinstall)", + desktop: null, + kind: "netinst", + }, + { + match: (e) => e.variant === "Everything", + id: "everything", + name: "Everything (netinstall)", + desktop: null, + kind: "netinst", + }, + { + match: (e) => e.variant === "Silverblue", + id: "silverblue", + name: "Silverblue", + desktop: "gnome", + kind: "desktop", + }, + { + match: (e) => e.variant === "Kinoite", + id: "kinoite", + name: "Kinoite", + desktop: "kde", + kind: "desktop", + }, + { + match: (e) => e.subvariant === "Xfce", + id: "xfce", + name: "Xfce", + desktop: "xfce", + kind: "desktop", + }, + { + match: (e) => e.subvariant === "Cinnamon", + id: "cinnamon", + name: "Cinnamon", + desktop: "cinnamon", + kind: "desktop", + }, + { + match: (e) => e.subvariant === "MATE_Compiz", + id: "mate", + name: "MATE-Compiz", + desktop: "mate", + kind: "desktop", + }, + { + match: (e) => e.subvariant === "LXQt", + id: "lxqt", + name: "LXQt", + desktop: "lxqt", + kind: "desktop", + }, + { + match: (e) => e.subvariant === "Budgie", + id: "budgie", + name: "Budgie", + desktop: "budgie", + kind: "desktop", + }, + { + match: (e) => e.subvariant === "COSMIC" && e.variant === "Spins", + id: "cosmic", + name: "COSMIC", + desktop: "cosmic", + kind: "desktop", + }, + { + match: (e) => e.subvariant === "Sway", + id: "sway", + name: "Sway", + desktop: "sway", + kind: "desktop", + }, + { match: (e) => e.subvariant === "i3", id: "i3", name: "i3", desktop: "i3", kind: "desktop" }, +]; + +const ARCHES: Record<string, Artifact["arch"]> = { + x86_64: "x86_64", + aarch64: "aarch64", + ppc64le: "ppc64le", + s390x: "s390x", +}; + +export default defineSource({ + slug: "fedora", + hosts: ["download.fedoraproject.org", "fedoraproject.org"], + async releases() { + const [entries, eol] = await Promise.all([ + getJson<Entry[]>(RELEASES_JSON), + endOfLife("fedora"), + ]); + const stable = [...new Set(entries.map((e) => e.version).filter((v) => /^\d+$/.test(v)))].sort( + (a, b) => compareVersions(b, a), + ); + const beta = [ + ...new Set(entries.map((e) => e.version).filter((v) => /^\d+ Beta$/.test(v))), + ].sort((a, b) => compareVersions(b, a))[0]; + const cycles = new Map(eol.cycles.map((c) => [c.name, c])); + // Supported releases (Fedora supports the two newest stable releases). + const versions = stable.filter((v) => cycles.get(v)?.isMaintained !== false).slice(0, 2); + if (beta && compareVersions(beta.replace(" Beta", ""), versions[0] ?? "0") > 0) + versions.unshift(beta); + + const releases: Release[] = versions.map((version) => { + const isBeta = version.endsWith(" Beta"); + const cycle = cycles.get(version.replace(" Beta", "")); + const editions: Edition[] = []; + for (const def of EDITIONS) { + const artifacts: Artifact[] = entries + .filter( + (e) => + e.version === version && e.link.endsWith(".iso") && ARCHES[e.arch] && def.match(e), + ) + .map((e) => ({ + arch: ARCHES[e.arch] as Artifact["arch"], + format: "iso" as const, + file: e.link.split("/").pop() ?? e.link, + path: e.link.replace("https://download.fedoraproject.org/pub/", ""), + url: e.link, + size: e.size ? Number(e.size) : null, + checksum: e.sha256 + ? { type: "sha256" as const, value: e.sha256.toLowerCase(), url: null } + : null, + signatureUrl: null, + torrentUrl: null, + })) + .sort((a, b) => a.arch.localeCompare(b.arch) || a.file.localeCompare(b.file)); + if (artifacts.length) + editions.push({ + id: def.id, + name: def.name, + desktop: def.desktop, + kind: def.kind, + artifacts, + }); + } + return { + version: isBeta ? version.replace(" Beta", "-beta") : version, + channel: isBeta ? ("beta" as const) : ("stable" as const), + codename: null, + releaseDate: isBeta ? null : (cycle?.releaseDate ?? null), + eol: isBeta ? null : (cycle?.eol ?? null), + notesUrl: isBeta + ? null + : `https://docs.fedoraproject.org/en-US/fedora/f${version}/release-notes/`, + editions, + }; + }); + return { sources: [RELEASES_JSON, eol.url], releases }; + }, +}); diff --git a/scripts/sync/sources/index.ts b/scripts/sync/sources/index.ts new file mode 100644 index 0000000..4e5c1e6 --- /dev/null +++ b/scripts/sync/sources/index.ts @@ -0,0 +1,23 @@ +/** + * 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"; + +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/linux-mint.ts b/scripts/sync/sources/linux-mint.ts new file mode 100644 index 0000000..1218099 --- /dev/null +++ b/scripts/sync/sources/linux-mint.ts @@ -0,0 +1,81 @@ +/** + * Linux Mint: maintained versions from endoflife.date, images + SHA-256 from + * the official ISO origin (pub.linuxmint.io, sha256sum.txt signed by the Mint + * team). The official mirror list (linuxmint.com/mirrors.php) sits behind a + * bot challenge, so downloads use the official origin; ranking mirrors for + * Mint would need a scriptable official list. + */ + +import type { Artifact, Edition, Release } from "../../../src/lib/data-schemas"; +import type { Desktop } from "../../../src/lib/taxonomy"; +import { getText } from "../http"; +import { parseChecksums } from "../lib/checksums"; +import { endOfLife } from "../lib/endoflife"; +import { compareVersions } from "../lib/versions"; +import { defineSource } from "../source"; + +const ORIGIN = "https://pub.linuxmint.io/"; +const EDITIONS: { id: string; name: string; desktop: Desktop }[] = [ + { id: "cinnamon", name: "Cinnamon Edition", desktop: "cinnamon" }, + { id: "xfce", name: "Xfce Edition", desktop: "xfce" }, + { id: "mate", name: "MATE Edition", desktop: "mate" }, +]; + +export default defineSource({ + slug: "linux-mint", + hosts: ["pub.linuxmint.io"], + async releases(ctx) { + const eol = await endOfLife("linuxmint"); + const numeric = eol.cycles + .filter( + (c) => c.isMaintained && (!c.eol || c.eol >= ctx.today) && /^\d+(\.\d+)?$/.test(c.name), + ) + .sort((a, b) => compareVersions(b.name, a.name)); + // Latest release, plus the newest point release of the previous series. + const latest = numeric[0]; + const previous = numeric.find( + (c) => latest && c.name.split(".")[0] !== latest.name.split(".")[0], + ); + const releases: Release[] = []; + for (const cycle of [latest, previous].filter((c): c is NonNullable<typeof c> => Boolean(c))) { + const dir = `${ORIGIN}stable/${cycle.name}/`; + const sums = parseChecksums(await getText(`${dir}sha256sum.txt`)); + const editions: Edition[] = []; + for (const def of EDITIONS) { + const file = `linuxmint-${cycle.name}-${def.id}-64bit.iso`; + const sha = sums.get(file); + if (!sha) continue; + const artifact: Artifact = { + arch: "x86_64", + format: "iso", + file, + path: `stable/${cycle.name}/${file}`, + url: `${dir}${file}`, + size: null, + checksum: { type: "sha256", value: sha, url: `${dir}sha256sum.txt` }, + signatureUrl: `${dir}sha256sum.txt.gpg`, + torrentUrl: null, + }; + editions.push({ + id: def.id, + name: def.name, + desktop: def.desktop, + kind: "desktop", + artifacts: [artifact], + }); + } + if (editions.length) { + releases.push({ + version: cycle.name, + channel: "lts", + codename: cycle.codename, + releaseDate: cycle.releaseDate, + eol: cycle.eol, + notesUrl: `https://linuxmint.com/rel_${(cycle.codename ?? "").toLowerCase()}.php`, + editions, + }); + } + } + return { sources: [eol.url, `${ORIGIN}stable/`], releases }; + }, +}); diff --git a/scripts/sync/sources/pop-os.ts b/scripts/sync/sources/pop-os.ts new file mode 100644 index 0000000..e74dd42 --- /dev/null +++ b/scripts/sync/sources/pop-os.ts @@ -0,0 +1,94 @@ +/** + * Pop!_OS: System76's build API returns the current ISO (URL, size, SHA-256) + * per version and channel (generic / NVIDIA). Served from iso.pop-os.org (CDN). + */ +import type { Artifact, Edition, Release } from "../../../src/lib/data-schemas"; +import { getJson, HttpError } from "../http"; +import { endOfLife } from "../lib/endoflife"; +import { defineSource } from "../source"; + +interface Build { + version: string; + url: string; + size: number; + sha_sum: string; + channel: string; + build: string; +} + +const API = "https://api.pop-os.org/builds"; +const CHANNELS: { id: string; name: string }[] = [ + { id: "generic", name: "Intel/AMD" }, + { id: "nvidia", name: "NVIDIA" }, +]; + +async function build(version: string, channel: string, arch: string): Promise<Build | null> { + try { + return await getJson<Build>(`${API}/${version}/${channel}?arch=${arch}`); + } catch (error) { + if (error instanceof HttpError && error.status === 404) return null; + throw error; + } +} + +export default defineSource({ + slug: "pop-os", + hosts: ["iso.pop-os.org"], + async releases(ctx) { + const eol = await endOfLife("pop-os"); + const versions = eol.cycles + .filter((c) => c.isMaintained && (!c.eol || c.eol >= ctx.today)) + .map((c) => c.name) + .slice(0, 2); + const releases: Release[] = []; + for (const version of versions) { + const editions: Edition[] = []; + for (const ch of CHANNELS) { + const artifacts: Artifact[] = []; + for (const [apiArch, arch] of [ + ["amd64", "x86_64"], + ["arm64", "aarch64"], + ] as const) { + const b = + (await build(version, ch.id, apiArch)) ?? + (ch.id === "generic" ? await build(version, "intel", apiArch) : null); + if (!b?.url.startsWith("https://")) continue; + artifacts.push({ + arch, + format: "iso", + file: b.url.split("/").pop() ?? b.url, + path: null, + url: b.url, + size: b.size || null, + checksum: { type: "sha256", value: b.sha_sum.toLowerCase(), url: null }, + signatureUrl: null, + torrentUrl: null, + }); + } + if (artifacts.length) + editions.push({ + id: ch.id, + name: ch.name, + desktop: "cosmic", + kind: "desktop", + artifacts, + }); + } + const cycle = eol.cycles.find((c) => c.name === version); + if (editions.length) { + releases.push({ + version, + channel: cycle?.lts ? "lts" : "stable", + codename: null, + releaseDate: cycle?.releaseDate ?? null, + eol: cycle?.eol ?? null, + notesUrl: null, + editions: version.startsWith("22.") + ? editions.map((e) => ({ ...e, desktop: "gnome" as const })) + : editions, + }); + } + } + return { sources: [`${API}/`, eol.url], releases }; + }, +}); diff --git a/scripts/sync/sources/ubuntu-family.ts b/scripts/sync/sources/ubuntu-family.ts new file mode 100644 index 0000000..43a17c6 --- /dev/null +++ b/scripts/sync/sources/ubuntu-family.ts @@ -0,0 +1,230 @@ +/** + * Ubuntu and its official flavors. Releases and EOL dates come from + * endoflife.date (sourced from Canonical's release cycle); files and SHA-256 + * from the official SHA256SUMS next to the images: + * Ubuntu desktop/server amd64 → releases.ubuntu.com/<cycle>/ (mirrored by + * Ubuntu's official CD-image mirrors listed in Launchpad) + * arm64 and flavors → cdimage.ubuntu.com + */ + +import type { Artifact, Edition, Mirror, Release } from "../../../src/lib/data-schemas"; +import type { Desktop } from "../../../src/lib/taxonomy"; +import { getJson, getText, HttpError } from "../http"; +import { parseChecksums } from "../lib/checksums"; +import { listDirectory } from "../lib/dirlist"; +import { endOfLife } from "../lib/endoflife"; +import { countryCode, withSlash } from "../lib/mirrors"; +import { compareVersions } from "../lib/versions"; +import { defineSource } from "../source"; + +interface EditionDef { + id: string; + name: string; + desktop: Desktop | null; + kind: Edition["kind"]; + /** Matches the ISO file name for an arch label (amd64/arm64). */ + pattern: (arch: string) => RegExp; +} + +const ARCH_MAP: Record<string, Artifact["arch"]> = { amd64: "x86_64", arm64: "aarch64" }; + +async function sums(dir: string): Promise<Map<string, string> | null> { + try { + return parseChecksums(await getText(`${dir}SHA256SUMS`)); + } catch (error) { + if (error instanceof HttpError && error.status === 404) return null; + throw error; + } +} + +/** Keeps only the newest point release per architecture (checksum files can list older ones). */ +function newestPerArch(artifacts: Artifact[]): Artifact[] { + const best = new Map<string, Artifact>(); + const ver = (a: Artifact) => a.file.match(/-(\d+\.\d+(?:\.\d+)?)-/)?.[1] ?? "0"; + for (const a of artifacts) { + const cur = best.get(a.arch); + if (!cur || compareVersions(ver(a), ver(cur)) > 0) best.set(a.arch, a); + } + return [...best.values()].sort((a, b) => a.arch.localeCompare(b.arch)); +} + +async function artifactsFrom( + dir: string, + mirrorPrefix: string | null, + def: EditionDef, + arch: string, +): Promise<Artifact[]> { + const checksums = await sums(dir); + if (!checksums) return []; + let listing: string[] = []; + try { + listing = await listDirectory(dir); + } catch { + /* listing is optional (torrent detection only) */ + } + return [...checksums] + .filter(([file]) => def.pattern(arch).test(file)) + .map(([file, sha256]) => ({ + arch: ARCH_MAP[arch] as Artifact["arch"], + format: "iso" as const, + file, + path: mirrorPrefix ? `${mirrorPrefix}${file}` : null, + url: `${dir}${file}`, + size: null, + checksum: { type: "sha256" as const, value: sha256, url: `${dir}SHA256SUMS` }, + signatureUrl: `${dir}SHA256SUMS.gpg`, + torrentUrl: listing.includes(`${file}.torrent`) ? `${dir}${file}.torrent` : null, + })); +} + +export function ubuntuFamily(options: { + slug: string; + /** cdimage.ubuntu.com sub-tree for flavors (e.g. "kubuntu"); undefined for Ubuntu itself. */ + flavor?: string; + editions: EditionDef[]; + withMirrors?: boolean; +}) { + const { slug, flavor, editions } = options; + return defineSource({ + slug, + hosts: ["releases.ubuntu.com", "cdimage.ubuntu.com"], + async releases(ctx) { + const eol = await endOfLife("ubuntu"); + // Standard support only (ESM/Pro-only releases are not offered for new installs). + const cycles = eol.cycles + .filter((c) => c.isMaintained && (!c.eol || c.eol >= ctx.today)) + .slice(0, 3); + const releases: Release[] = []; + for (const cycle of cycles) { + const out: Edition[] = []; + for (const def of editions) { + const artifacts: Artifact[] = []; + for (const arch of ["amd64", "arm64"]) { + if (!flavor && arch === "amd64") { + artifacts.push( + ...(await artifactsFrom( + `https://releases.ubuntu.com/${cycle.name}/`, + `${cycle.name}/`, + def, + arch, + )), + ); + } else { + const base = flavor + ? `https://cdimage.ubuntu.com/${flavor}/releases/${cycle.name}/release/` + : `https://cdimage.ubuntu.com/releases/${cycle.name}/release/`; + if (flavor && arch === "arm64") continue; // flavors publish amd64 images + artifacts.push(...(await artifactsFrom(base, null, def, arch))); + } + } + const newest = newestPerArch(artifacts); + if (newest.length) + out.push({ + id: def.id, + name: def.name, + desktop: def.desktop, + kind: def.kind, + artifacts: newest, + }); + } + if (!out.length) continue; // the flavor dropped this cycle + const first = out[0]?.artifacts.find((a) => a.arch === "x86_64") ?? out[0]?.artifacts[0]; + const point = + first?.file.match(/-(\d+\.\d+(?:\.\d+)?)-/)?.[1] ?? cycle.latest ?? cycle.name; + releases.push({ + version: point, + channel: cycle.lts ? "lts" : "stable", + codename: cycle.codename, + releaseDate: cycle.releaseDate, + eol: cycle.eol, + notesUrl: null, + editions: out, + }); + } + return { + sources: [ + eol.url, + flavor + ? `https://cdimage.ubuntu.com/${flavor}/releases/` + : "https://releases.ubuntu.com/", + ], + releases, + }; + }, + ...(options.withMirrors + ? { + async mirrors() { + const sources = ["https://api.launchpad.net/devel/ubuntu/cdimage_mirrors"]; + const mirrors: Mirror[] = []; + let next: string | null = sources[0] ?? null; + while (next) { + const page: { + entries: { + https_base_url: string | null; + enabled: boolean; + status: string; + country_link: string; + displayname: string | null; + }[]; + next_collection_link?: string; + } = await getJson(next); + for (const m of page.entries) { + if (!m.enabled || m.status !== "Official" || !m.https_base_url) continue; + mirrors.push({ + url: withSlash(m.https_base_url), + country: countryCode(m.country_link.split("/").pop()), + name: m.displayname ?? new URL(m.https_base_url).hostname, + lat: null, + lon: null, + score: null, + }); + } + next = page.next_collection_link ?? null; + } + return { sources, mirrors }; + }, + } + : {}), + }); +} + +const iso = (kind: string) => (arch: string) => new RegExp(`-${kind}-${arch}\\.iso$`); + +export const ubuntu = ubuntuFamily({ + slug: "ubuntu", + withMirrors: true, + editions: [ + { id: "desktop", name: "Desktop", desktop: "gnome", kind: "desktop", pattern: iso("desktop") }, + { id: "server", name: "Server", desktop: null, kind: "server", pattern: iso("live-server") }, + ], +}); + +const desktopOnly = (desktop: Desktop) => [ + { id: "desktop", name: "Desktop", desktop, kind: "desktop" as const, pattern: iso("desktop") }, +]; + +export const kubuntu = ubuntuFamily({ + slug: "kubuntu", + flavor: "kubuntu", + editions: desktopOnly("kde"), +}); +export const xubuntu = ubuntuFamily({ + slug: "xubuntu", + flavor: "xubuntu", + editions: [ + ...desktopOnly("xfce"), + { id: "minimal", name: "Minimal", desktop: "xfce", kind: "minimal", pattern: iso("minimal") }, + ], +}); +export const lubuntu = ubuntuFamily({ + slug: "lubuntu", + flavor: "lubuntu", + editions: desktopOnly("lxqt"), +}); +export const ubuntuMate = ubuntuFamily({ + slug: "ubuntu-mate", + flavor: "ubuntu-mate", + editions: desktopOnly("mate"), +}); + +export default [ubuntu, kubuntu, xubuntu, lubuntu, ubuntuMate]; diff --git a/scripts/sync/store.ts b/scripts/sync/store.ts new file mode 100644 index 0000000..db185d6 --- /dev/null +++ b/scripts/sync/store.ts @@ -0,0 +1,72 @@ +/** + * Reads/writes src/data/**. Output is deterministic (fixed key order from the + * schemas, sorted arrays) so a run only produces a git diff when facts change. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + type HistoryEntry, + historyFileSchema, + type MirrorsFile, + mirrorsFileSchema, + type ReleasesFile, + releasesFileSchema, + type StatusFile, + statusFileSchema, +} from "../../src/lib/data-schemas"; + +export const DATA_DIR = join(import.meta.dir, "..", "..", "src", "data"); +export const releasesPath = (slug: string) => join(DATA_DIR, "releases", `${slug}.json`); +export const mirrorsPath = (slug: string) => join(DATA_DIR, "mirrors", `${slug}.json`); +export const HISTORY_PATH = join(DATA_DIR, "history.json"); +export const STATUS_PATH = join(DATA_DIR, "status.json"); + +function readJson(path: string): unknown { + return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : undefined; +} + +function writeJson(path: string, value: unknown) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +export function readReleases(slug: string): ReleasesFile | undefined { + const raw = readJson(releasesPath(slug)); + return raw === undefined ? undefined : releasesFileSchema.parse(raw); +} + +export function readMirrors(slug: string): MirrorsFile | undefined { + const raw = readJson(mirrorsPath(slug)); + return raw === undefined ? undefined : mirrorsFileSchema.parse(raw); +} + +export function readHistory(): HistoryEntry[] { + return historyFileSchema.parse(readJson(HISTORY_PATH) ?? []); +} + +export function readStatus(): StatusFile { + return statusFileSchema.parse(readJson(STATUS_PATH) ?? {}); +} + +/** Same content ignoring the `updated` stamp? */ +export function sameContent<T extends { updated: string }>(a: T | undefined, b: T): boolean { + if (!a) return false; + return JSON.stringify({ ...a, updated: "" }) === JSON.stringify({ ...b, updated: "" }); +} + +export function writeReleases(file: ReleasesFile) { + writeJson(releasesPath(file.slug), releasesFileSchema.parse(file)); +} + +export function writeMirrors(file: MirrorsFile) { + writeJson(mirrorsPath(file.slug), mirrorsFileSchema.parse(file)); +} + +export function writeHistory(entries: HistoryEntry[]) { + writeJson(HISTORY_PATH, historyFileSchema.parse(entries)); +} + +export function writeStatus(status: StatusFile) { + const sorted = Object.fromEntries(Object.entries(status).sort(([a], [b]) => a.localeCompare(b))); + writeJson(STATUS_PATH, statusFileSchema.parse(sorted)); +} diff --git a/scripts/sync/validate.ts b/scripts/sync/validate.ts new file mode 100644 index 0000000..956b5c6 --- /dev/null +++ b/scripts/sync/validate.ts @@ -0,0 +1,72 @@ +/** + * Validates everything in src/data/ against the shared schemas and the + * per-source host allowlists. Run in CI and by the sync workflow before + * committing: `bun run sync:validate`. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + historyFileSchema, + mirrorsFileSchema, + releasesFileSchema, + statusFileSchema, +} from "../../src/lib/data-schemas"; +import { checkMirrors, checkReleases } from "./checks"; +import { SOURCES } from "./sources"; +import { DATA_DIR, HISTORY_PATH, STATUS_PATH } from "./store"; + +const errors: string[] = []; +const bySlug = new Map(SOURCES.map((s) => [s.slug, s])); +const catalog = new Set( + readdirSync(join(DATA_DIR, "..", "content", "distros")) + .filter((f) => f.endsWith(".yaml")) + .map((f) => f.replace(/\.yaml$/, "")), +); + +function each(dir: string, fn: (slug: string, data: unknown) => void) { + const path = join(DATA_DIR, dir); + if (!existsSync(path)) return; + for (const file of readdirSync(path).filter((f) => f.endsWith(".json"))) { + const slug = file.replace(/\.json$/, ""); + try { + fn(slug, JSON.parse(readFileSync(join(path, file), "utf8"))); + } catch (error) { + errors.push(`${dir}/${file}: ${error instanceof Error ? error.message : error}`); + } + } +} + +each("releases", (slug, data) => { + const parsed = releasesFileSchema.safeParse(data); + if (!parsed.success) + return void errors.push(`releases/${slug}: ${parsed.error.issues[0]?.message}`); + if (parsed.data.slug !== slug) errors.push(`releases/${slug}: slug mismatch`); + if (!catalog.has(slug)) errors.push(`releases/${slug}: not in the catalog`); + const source = bySlug.get(slug); + if (!source) return void errors.push(`releases/${slug}: no sync source registered`); + for (const p of checkReleases(parsed.data, source)) errors.push(`releases/${slug}: ${p}`); +}); + +each("mirrors", (slug, data) => { + const parsed = mirrorsFileSchema.safeParse(data); + if (!parsed.success) + return void errors.push(`mirrors/${slug}: ${parsed.error.issues[0]?.message}`); + if (!catalog.has(slug)) errors.push(`mirrors/${slug}: not in the catalog`); + for (const p of checkMirrors(parsed.data)) errors.push(`mirrors/${slug}: ${p}`); +}); + +for (const [path, schema] of [ + [HISTORY_PATH, historyFileSchema], + [STATUS_PATH, statusFileSchema], +] as const) { + if (!existsSync(path)) continue; + const parsed = schema.safeParse(JSON.parse(readFileSync(path, "utf8"))); + if (!parsed.success) errors.push(`${path}: ${parsed.error.issues[0]?.message}`); +} + +if (errors.length) { + console.error(errors.join("\n")); + console.error(`\n✗ ${errors.length} problem(s) in src/data`); + process.exit(1); +} +console.log("✓ src/data is valid"); 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<number, string>(); +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 (!/<svg[\s>]/.test(svg)) errors.push("logo is not an SVG"); + if (!/viewBox=/.test(svg)) errors.push("logo has no viewBox"); + if (/<script|on\w+=|href="https?:/i.test(svg)) + errors.push("logo has scripts or external refs"); + if (svg.length > 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 0000000..2df045d Binary files /dev/null and b/src/assets/fonts/AdwaitaMono-Bold-latin.woff2 differ diff --git a/src/assets/fonts/AdwaitaMono-latin.woff2 b/src/assets/fonts/AdwaitaMono-latin.woff2 new file mode 100644 index 0000000..aa8713c Binary files /dev/null and b/src/assets/fonts/AdwaitaMono-latin.woff2 differ diff --git a/src/assets/fonts/AdwaitaSans-Italic-latin.woff2 b/src/assets/fonts/AdwaitaSans-Italic-latin.woff2 new file mode 100644 index 0000000..04852a3 Binary files /dev/null and b/src/assets/fonts/AdwaitaSans-Italic-latin.woff2 differ diff --git a/src/assets/fonts/AdwaitaSans-latin.woff2 b/src/assets/fonts/AdwaitaSans-latin.woff2 new file mode 100644 index 0000000..09f26ed Binary files /dev/null and b/src/assets/fonts/AdwaitaSans-latin.woff2 differ diff --git a/src/assets/fonts/AdwaitaSans-og-500.ttf b/src/assets/fonts/AdwaitaSans-og-500.ttf new file mode 100644 index 0000000..7dff90b Binary files /dev/null and b/src/assets/fonts/AdwaitaSans-og-500.ttf differ diff --git a/src/assets/fonts/AdwaitaSans-og-800.ttf b/src/assets/fonts/AdwaitaSans-og-800.ttf new file mode 100644 index 0000000..5bee0cf Binary files /dev/null and b/src/assets/fonts/AdwaitaSans-og-800.ttf differ diff --git a/src/assets/fonts/OFL.txt b/src/assets/fonts/OFL.txt new file mode 100644 index 0000000..6dc188d --- /dev/null +++ b/src/assets/fonts/OFL.txt @@ -0,0 +1,96 @@ +Copyright (c) 2016, The Inter Project Authors (https://github.com/rsms/inter) +Copyright (c) 2015-2025, Renzhi Li (aka. Belleve Invis, belleve@typeof.net) +Copyright (c) 2025, Jamie Gravendeel (https://jamie.garden) +Copyright (c) 2025, Florian Müllner + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/icons/README.md b/src/assets/icons/README.md new file mode 100644 index 0000000..92bdf44 --- /dev/null +++ b/src/assets/icons/README.md @@ -0,0 +1,46 @@ +# Icons + +Symbolic icons copied from upstream GNOME sources, recolored to +`currentColor`, with editor metadata and inline styles stripped, then +optimized with SVGO (`scripts/assets/svgo.config.mjs`). + +- **Adwaita icon theme 51.0**: https://download.gnome.org/sources/adwaita-icon-theme/51/ + (sha256 `ba561cf3c96305a47179fa5605856ad695c9238ea07f7093aebd9ba80c4e323b`). + License: CC BY-SA 3.0 or LGPL-3.0, © GNOME Project. +- **libadwaita 1.10.0**: `external.svg` comes from `adw-external-link.svg`. License: LGPL-2.1-or-later. + +| File | Upstream `*-symbolic` | File | Upstream `*-symbolic` | +|---|---|---|---| +| about | help-about | language | preferences-desktop-locale | +| add | list-add | link | insert-link | +| app-grid | view-app-grid | list | view-list | +| bookmark | user-bookmarks | location | mark-location | +| calendar | x-office-calendar | lock | system-lock-screen | +| check | object-select | menu | open-menu | +| close | window-close | moon | weather-clear-night | +| community | system-users | more | view-more | +| compare | view-dual | package | package-x-generic | +| computer | computer | paged | view-paged | +| contents | help-contents | pan-down / pan-end / pan-start | pan-* | +| copy | edit-copy | pin | view-pin | +| dictionary | accessories-dictionary | privacy | preferences-system-privacy | +| disc | media-optical | recent | document-open-recent | +| display | video-display | refresh | view-refresh | +| docs | help-browser | remove | list-remove | +| document | x-office-document | reveal | view-reveal | +| download | folder-download | science | applications-science | +| engineering | applications-engineering | search | system-search | +| external | adw-external-link (libadwaita) | security | security-high | +| faq | help-faq | server | network-server | +| gaming | input-gaming | settings | emblem-system | +| go-next / go-previous / go-up | go-* | sort / sort-desc | view-sort-ascending / -descending | +| graphics | applications-graphics | star | starred | +| grid | view-grid | sun | weather-clear | +| harddisk | drive-harddisk | system | applications-system | +| important | emblem-important | terminal | utilities-terminal | +| info | dialog-information | time | preferences-system-time | +| keyboard | input-keyboard | update | software-update-available | +| | | usb | drive-removable-media | +| | | utilities | applications-utilities | +| | | warning | dialog-warning | +| | | website | web-browser | diff --git a/src/assets/icons/about.svg b/src/assets/icons/about.svg new file mode 100644 index 0000000..9e8e9bf --- /dev/null +++ b/src/assets/icons/about.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 0C3.59 0 0 3.59 0 8s3.59 8 8 8 8-3.59 8-8-3.59-8-8-8m0 2c3.332 0 6 2.668 6 6s-2.668 6-6 6-6-2.668-6-6 2.668-6 6-6m0 1.875a1.125 1.125 0 1 0 0 2.25 1.125 1.125 0 0 0 0-2.25M6.477 7A.5.5 0 0 0 6.5 8H7v3h-.5a.499.499 0 1 0 0 1h3a.499.499 0 1 0 0-1H9V7zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M7 1v6H1v2h6v6h2V9h6V7H9V1zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M3.992 3c-.554 0-1 .445-1 1s.446 1 1 1c.555 0 1-.445 1-1s-.445-1-1-1m4 0c-.554 0-1 .445-1 1s.446 1 1 1c.555 0 1-.445 1-1s-.445-1-1-1m4 0c-.555 0-1 .445-1 1s.445 1 1 1 1-.445 1-1-.445-1-1-1m-8 4c-.554 0-1 .445-1 1s.446 1 1 1c.555 0 1-.445 1-1s-.445-1-1-1m4 0c-.554 0-1 .445-1 1s.446 1 1 1c.555 0 1-.445 1-1s-.445-1-1-1m4 0c-.555 0-1 .445-1 1s.445 1 1 1 1-.445 1-1-.445-1-1-1m-8 4c-.554 0-1 .445-1 1s.446 1 1 1c.555 0 1-.445 1-1s-.445-1-1-1m4 0c-.554 0-1 .445-1 1s.446 1 1 1c.555 0 1-.445 1-1s-.445-1-1-1m4 0c-.555 0-1 .445-1 1s.445 1 1 1 1-.445 1-1-.445-1-1-1m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M4.969.004c-1.332 0-2.274.816-2.645 1.555-.37.738-.355 1.445-.355 1.445L2 15.477l5.996-2.403L14 15.477 13.969 3c0-.004.011-.707-.356-1.441C13.243.82 12.301.004 10.97.004zm0 2h6c.695.012 1.004.367 1 1l.023 9.52-3.996-1.602-4.004 1.601L3.97 3c-.008-.648.398-.996 1-.996m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M4 1v1C2.355 2 1 3.355 1 5v7c0 1.645 1.355 3 3 3h8c1.645 0 3-1.355 3-3V5c0-1.645-1.355-3-3-3V1h-2v1H6V1zm0 5h8c.555 0 1 .445 1 1v5c0 .57-.441.887-1 1H4c-.57 0-1-.43-1-1V7c0-.555.445-1 1-1m3 1v1h2V7zm3 0v1h2V7zM4 9v1h2V9zm3 0v1h2V9zm3 0v1h2V9zm-6 2v1h2v-1zm3 0v1h2v-1zm3 0v1h2v-1zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M13.754 4.66c.176-.2.262-.46.246-.726A1.006 1.006 0 0 0 12.934 3c-.266.02-.512.14-.688.34L5.95 10.535 3.707 8.293a1 1 0 1 0-1.414 1.414l3 3a1 1 0 0 0 .738.293c.278-.008.54-.133.723-.34zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M4 4h1.031c.254.012.512.129.688.313L8 6.592l2.313-2.28c.265-.231.445-.305.687-.313h1v1c0 .285-.035.55-.25.75L9.469 8.031l2.25 2.25c.187.188.281.453.281.719v1h-1c-.266 0-.531-.094-.719-.281L8 9.438l-2.281 2.28A1.02 1.02 0 0 1 5 12H4v-1c0-.266.094-.531.281-.719l2.282-2.25L4.28 5.75A.9.9 0 0 1 4 5zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M13.5 6.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0"/><path d="M7.5 3.5C7.5 4.879 6.379 6 5 6S2.5 4.879 2.5 3.5 3.621 1 5 1s2.5 1.121 2.5 2.5m0 0"/><path d="M3 7c-1.66 0-3 1.34-3 3v2c0 .555.445 1 1 1h4.074a3.93 3.93 0 0 1 3.582-3.906 3.5 3.5 0 0 1-1.11-2.043A3 3 0 0 0 7 7zm6 3.926A2.05 2.05 0 0 0 6.922 13H9c.555 0 1-.445 1-1v-1.074zm0 0"/><path d="M9 10a2.99 2.99 0 0 0-3 3v2c0 .555.445 1 1 1h8c.555 0 1-.445 1-1v-2c0-1.664-1.34-3-3-3z"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path fill-opacity=".349" d="M3.5 5c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h2c.277 0 .5-.223.5-.5v-1c0-.277-.223-.5-.5-.5zm7 0c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h2c.277 0 .5-.223.5-.5v-1c0-.277-.223-.5-.5-.5zm-7 3c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h2c.277 0 .5-.223.5-.5v-1c0-.277-.223-.5-.5-.5zm7 0c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h2c.277 0 .5-.223.5-.5v-1c0-.277-.223-.5-.5-.5zm0 0"/><path d="M3 1C1.355 1 0 2.355 0 4v8c0 1.645 1.355 3 3 3h10c1.645 0 3-1.355 3-3V4c0-1.645-1.355-3-3-3zm0 2h4v10H3c-.57 0-1-.43-1-1V4c0-.57.43-1 1-1m6 0h4c.57 0 1 .43 1 1v8c0 .57-.43 1-1 1H9zm0 0"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M3 0C1.34 0 0 1.34 0 3v7c0 1.66 1.34 3 3 3h10c1.66 0 3-1.34 3-3V3c0-1.66-1.34-3-3-3zm0 2h10c.555 0 1 .445 1 1v7c0 .555-.445 1-1 1H3c-.555 0-1-.445-1-1V3c0-.555.445-1 1-1m2 12a2 2 0 0 0-2 2h10a2 2 0 0 0-2-2zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m3.025 0 .034.002a2 2 0 0 0-.159 0h-.02l-.024.002A1.98 1.98 0 0 0 1 2.045v11.914A1.98 1.98 0 0 0 3.041 16h9.918A1.98 1.98 0 0 0 15 13.96V5a2 2 0 0 0-.617-1.422L11.426.62A2 2 0 0 0 10.004 0h-.008zm-.017 2c-.002-.002.005 0 .002 0h6.965c-.01 0 .014.011.007.004l.008.008 2.998 3.002.008.006c-.007-.007.004.019.004.01v8.959c-.001-.08-.093.01-.012.012H3.012c.081-.001-.01-.092-.012-.012V2.011c.001.055.027.005.008-.01z" color="#bebebe" font-family="sans-serif" font-weight="400" overflow="visible"/><path d="M8 4.877c-2.24 0-4.06 1.82-4.06 4.06A4.063 4.063 0 0 0 8 12.998c2.24 0 4.06-1.82 4.06-4.06A4.063 4.063 0 0 0 8 4.877m0 1a3.043 3.043 0 0 1 3.06 3.06A3.043 3.043 0 0 1 8 11.999a3.043 3.043 0 0 1-3.06-3.06A3.043 3.043 0 0 1 8 5.877" color="#bebebe" font-family="sans-serif" font-weight="400" overflow="visible"/><path d="M8 6.938c-1.1 0-2 .9-2 2 0 1.099.9 2 2 2s2-.901 2-2c0-1.1-.9-2-2-2m0 1c.563 0 1 .437 1 1s-.437 1-1 1-1-.438-1-1 .437-1 1-1" color="#bebebe" font-family="sans-serif" font-weight="400" overflow="visible"/><path d="M6.331 5.6a3 3 0 0 0-1.669 1.684l1.908.954a1.3 1.3 0 0 1 .699-.731zm3.338 0L8.73 7.507c.322.136.571.405.7.731l1.907-.954A3 3 0 0 0 9.668 5.6zm-3.1 4.069-1.907.953c.309.757.91 1.348 1.67 1.653l.953-1.907a1.32 1.32 0 0 1-.715-.7zm2.861 0a1.32 1.32 0 0 1-.715.699l.954 1.907a3 3 0 0 0 1.669-1.653z"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M0 3c0-1.645 1.355-3 3-3h5c1.645 0 3 1.355 3 3 0 .55-.45 1-1 1s-1-.45-1-1c0-.57-.43-1-1-1H3c-.57 0-1 .43-1 1v5c0 .57.43 1 1 1 .55 0 1 .45 1 1s-.45 1-1 1c-1.645 0-3-1.355-3-3zm5 5c0-1.645 1.355-3 3-3h5c1.645 0 3 1.355 3 3v5c0 1.645-1.355 3-3 3H8c-1.645 0-3-1.355-3-3zm2 0v5c0 .57.43 1 1 1h5c.57 0 1-.43 1-1V8c0-.57-.43-1-1-1H8c-.57 0-1 .43-1 1m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M9.57 1.184c-.567.078-1.251.35-2.015.753-1.541-.844-2.741-.896-3.748-.638-1.07.274-1.938.722-3.291.681L0 1.965v10.81h.5c1.322 0 2.48-.601 3.555-.867 1.075-.265 1.992-.296 3.144.738l.338.305.336-.308c1.119-1.033 2.01-1.004 3.074-.74 1.065.263 2.224.864 3.55.872l.503.004V1.982L14.482 2c-1.516.055-2.27-.394-3.25-.684a3.8 3.8 0 0 0-1.662-.132m.11.96c.478-.07.854.01 1.267.131.726.215 1.679.602 3.053.664v8.707c-.875-.127-1.778-.459-2.812-.714-1.12-.277-2.408-.163-3.655.8-1.278-.967-2.586-1.074-3.717-.795-1.043.258-1.946.59-2.816.711v-8.73c1.274-.073 2.247-.444 3.055-.65.924-.237 1.727-.287 3.242.623l.252.152.254-.147C8.62 2.43 9.2 2.214 9.68 2.145z" font-family="sans-serif" font-weight="400" overflow="visible"/><path d="M7 2.397h1V12H7z"/><path d="M0 2.5V16h15V2.518h-2V14H2V2.5z" font-family="sans-serif" font-weight="400" overflow="visible"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 0C3.594 0 0 3.594 0 8s3.594 8 8 8 8-3.594 8-8-3.594-8-8-8m0 2c3.313 0 6 2.688 6 6s-2.687 6-6 6A6 6 0 0 1 8 2m0 3a3 3 0 1 0 .002 6.002A3 3 0 0 0 8 5m0 1a2 2 0 1 1-2 2 2 2 0 0 1 2-2m0 1c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M3 0C1.34 0 0 1.34 0 3v7c0 1.66 1.34 3 3 3h10c1.66 0 3-1.34 3-3V3c0-1.66-1.34-3-3-3zm0 2h10c.555 0 1 .445 1 1v7c0 .555-.445 1-1 1H3c-.555 0-1-.445-1-1V3c0-.555.445-1 1-1m2 12a2 2 0 0 0-2 2h10a2 2 0 0 0-2-2zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M8 0C3.585 0 0 3.585 0 8s3.585 8 8 8 8-3.585 8-8-3.585-8-8-8m0 2c3.341 0 6 2.659 6 6s-2.659 6-6 6-6-2.659-6-6 2.659-6 6-6"/><path d="M7.338 5A2.344 2.344 0 0 0 5 7.338v1.324A2.344 2.344 0 0 0 7.338 11h1.324A2.344 2.344 0 0 0 11 8.662V7.338A2.344 2.344 0 0 0 8.662 5zm0 1h1.324C9.412 6 10 6.588 10 7.338v1.324C10 9.412 9.412 10 8.662 10H7.338C6.588 10 6 9.412 6 8.662V7.338C6 6.588 6.588 6 7.338 6"/><path d="M4.719 1.438A5.88 5.88 0 0 0 1.437 4.75l3.75 1.875c.252-.64.743-1.17 1.375-1.438zm6.562 0-1.844 3.75a2.57 2.57 0 0 1 1.376 1.437l3.75-1.875a5.88 5.88 0 0 0-3.282-3.313zm-6.094 8-3.75 1.874a5.9 5.9 0 0 0 3.282 3.25l1.875-3.75a2.6 2.6 0 0 1-1.407-1.374m5.625 0a2.6 2.6 0 0 1-1.406 1.374l1.875 3.75a5.9 5.9 0 0 0 3.281-3.25z"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M4.969 1.004c-1.645 0-3 1.355-3 3v9c0 1.644 1.355 3 3 3h6c1.644 0 3-1.356 3-3v-7.5a1 1 0 0 0-.293-.707l-3.5-3.5a1 1 0 0 0-.707-.293zm0 2h4v1.5q0 1.5 1.5 1.5h1.5v7c0 .57-.43 1-1 1h-6c-.57 0-1-.43-1-1v-9c0-.57.43-1 1-1m1.5 2c-.824 0-1.5.676-1.5 1.5s.676 1.5 1.5 1.5 1.5-.676 1.5-1.5-.676-1.5-1.5-1.5m0 1c.281 0 .5.219.5.5s-.219.5-.5.5-.5-.219-.5-.5.218-.5.5-.5m2.484 1.988v1l2.016.012v-1zM4.97 10.004v1h6v-1zm0 2v1h6v-1zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M7.984 0c-.55 0-1 .45-1 1v8.586L4.691 7.293a1 1 0 1 0-1.414 1.414l4 4a1 1 0 0 0 1.414 0l4-4a1 1 0 1 0-1.414-1.414L8.984 9.586V1c0-.55-.449-1-1-1m-6 14v2h12v-2zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path fill-rule="evenodd" d="M7.996 8C4 8 2 11 2 14H.25c-.25 0-.25.156-.25.25v1.5c0 .25.137.25.25.25h15.492c.195.008.25-.129.25-.25v-1.5c.024-.145 0-.25-.25-.25h-1.746c0-3-2-6-6-6m0 3c2.352 0 3 1.762 3 3H5c0-1.2.7-3 2.996-3m0 0"/><path d="M14.988.602c-.281-.243-.625-.422-.965-.563L14 2.27c-.004.461-.453.731-1.004.731h-1c-.555-.008-1.004-.309-1-.77L11.02 0a3.6 3.6 0 0 0-.977.535C8.664 1.652 8.645 3.485 10 4.633c.285.242.625.422.969.562.027.012.027 2.055.027 2.055 1.488.75 2.352 1.75 3 2.75V5.25a3.6 3.6 0 0 0 .953-.55c1.38-1.118 1.395-2.95.04-4.098m0 0"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 1h5v5M8 8l7-7M7 3H3a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V9"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M2 5C.892 5 0 5.892 0 7v7c0 1.108.892 2 2 2h9c1.108 0 2-.892 2-2v-3.8L16 7h-3c0-1.108-.892-2-2-2zm4.438 2C7.75 6.926 8.926 7.936 9 9.25c0 1.121-.308 1.544-1.281 2.281a1.7 1.7 0 0 0-.281.25c-.04.05-.032.033-.032.031.006.423-.39.782-.812.782a.79.79 0 0 1-.781-.782c0-.401.179-.754.375-1a3 3 0 0 1 .562-.562 4 4 0 0 0 .563-.531c.074-.09.097-.24.093-.344v-.031a.785.785 0 0 0-.843-.75c-.451.025-.813.362-.851.758a.697.697 0 0 1-.718.648.82.82 0 0 1-.781-.594 1.7 1.7 0 0 1 .01-.533c.005-.014.006-.03.012-.043.202-1.024 1.09-1.768 2.203-1.83m.156 6.406a.82.82 0 0 1 .812.813c0 .442-.37.781-.812.781a.77.77 0 0 1-.781-.781c0-.442.339-.813.78-.813zM2 1C.892 1 0 1.892 0 3v.568A3.9 3.9 0 0 1 2 3h9c1.376 0 2.55.763 3.268 1.848L16 3h-3c0-1.108-.892-2-2-2z"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M3.469 2c-1.66 0-2.801 1-3 3 0 0-.5 3-.5 5 0 0 0 3 3.031 3 2.969 0 2.969-3 2.969-3 0-1.5 1.05-2.004 2-2 .941.004 2 .5 2.031 2 0 0-.031 3 2.969 3s3-3 3-3c.031-2-.5-5-.5-5-.164-1.652-1.34-3-3-3zm.75 2h.5c.136 0 .25.113.25.25V5h.75c.136 0 .25.113.25.25v.5c0 .137-.114.25-.25.25h-.75v.75c0 .137-.114.25-.25.25h-.5a.25.25 0 0 1-.25-.25V6h-.75a.25.25 0 0 1-.25-.25v-.5c0-.137.113-.25.25-.25h.75v-.75c0-.137.113-.25.25-.25m5.75 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .449-1 1-1m3 2c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .449-1 1-1m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M4 2a1 1 0 0 1 1.707-.707l6 6a1 1 0 0 1 0 1.414l-6 6a1 1 0 1 1-1.414-1.414L9.586 8 4.293 2.707A1 1 0 0 1 4 2m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M12 2a1 1 0 0 0-1.707-.707l-6 6a1 1 0 0 0 0 1.414l6 6a1 1 0 1 0 1.414-1.414L6.414 8l5.293-5.293A1 1 0 0 0 12 2m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M1 11a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 0l6 6a1 1 0 1 1-1.414 1.414L8 6.414l-5.293 5.293A1 1 0 0 1 1 11m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15.973 15.996"><path fill="currentColor" d="M7.89 0C5.782.031 2.946.8 2.993 3.277c.051 2.59 2.922 1.664 3.156 2.72.036-.044.086-.083.122-.126.199-.21.394-.387.593-.594.313-.351.637-.675.965-1.03 1.11-1.177 1.156-1.25 3.8-3.25C10.595.378 9.369.034 8.329 0a8 8 0 0 0-.437 0m6.144 0c-1.797 1.297-4.203 3.3-6.113 5.332-.172.184-.34.363-.504.547-.102.11-.211.195-.313.309q-.098.119-.19.238c-1.302 1.437-2.583 3.129-3.65 4.683-.308-.156-.699-.171-1.117.102-.753.496-.597 2.285-2.148 2.285.148.602 1.734.594 2.809.324 1.011-.254 1.324-1.14 1.132-1.886a1.4 1.4 0 0 0-.144-.32C5.78 9.972 7.184 8.608 8.14 7.59c.242-.246.53-.567.851-.926C10.2 5.332 11.918 3.23 14.035 0M5.992 1.527c.824 0 1.492.672 1.492 1.5 0 .832-.668 1.5-1.492 1.5a1.5 1.5 0 0 1 0-3m7.266.664C11.84 4.426 10.89 5.746 9.89 6.777c-.641.66-1.164 1.313-1.625 1.782-.836.89-1.938 1.941-3.149 3.093.063 1.47.809 2.746 3.242 3.22 3.899.757 8.028-1.825 7.614-7.063-.18-2.247-1.407-4.282-2.715-5.618m.344 3.805a1.001 1.001 0 0 1 0 2 1.001 1.001 0 0 1 0-2m-.625 3a1.001 1.001 0 0 1 0 2 1.001 1.001 0 0 1 0-2m-4.993 2c.551 0 1 .45 1 1s-.449 1-1 1a1.001 1.001 0 0 1 0-2m2.996 0c.547 0 1 .45 1 1s-.453 1-1 1a1.001 1.001 0 0 1 0-2m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M2 1c-.555 0-1 .445-1 1v4c0 .555.445 1 1 1h4c.555 0 1-.445 1-1V2c0-.555-.445-1-1-1zm8 0c-.555 0-1 .445-1 1v4c0 .555.445 1 1 1h4c.555 0 1-.445 1-1V2c0-.555-.445-1-1-1zM2 9c-.555 0-1 .445-1 1v4c0 .555.445 1 1 1h4c.555 0 1-.445 1-1v-4c0-.555-.445-1-1-1zm8 0c-.555 0-1 .445-1 1v4c0 .555.445 1 1 1h4c.555 0 1-.445 1-1v-4c0-.555-.445-1-1-1zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M4 0C2.355 0 1 1.355 1 3v10c0 1.645 1.355 3 3 3h8c1.645 0 3-1.355 3-3V3c0-1.645-1.355-3-3-3zm0 2h8c.57 0 1 .43 1 1v9c0 .57-.43 1-1 1H4c-.555 0-1-.445-1-1V3c0-.555.445-1 1-1m4 1C5.79 3 4 4.79 4 7v4h4c2.5 0 4-1.79 4-4s-1.79-4-4-4m0 2a2 2 0 1 1-2 2 2 2 0 0 1 2-2m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 0C3.594 0 0 3.594 0 8s3.594 8 8 8 8-3.594 8-8-3.594-8-8-8m1 3v6c0 .55-.45 1-1 1s-1-.45-1-1V3zm-1 8c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M7.996 0C5.16 0 2.703 2 2.125 4.777 1.598 7.313 2.813 9.813 5 11.105V12a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.004-.898c2.18-1.293 3.398-3.797 2.867-6.332A6.01 6.01 0 0 0 7.996 0m0 2a3.994 3.994 0 0 1 3.918 3.18 3.99 3.99 0 0 1-2.312 4.488 1 1 0 0 0-.602.914V11H7v-.418a1.01 1.01 0 0 0-.602-.914c-1.742-.762-2.703-2.625-2.316-4.484S6.098 2 7.996 2M6 14v1c0 .555.445 1 1 1h2c.555 0 1-.445 1-1v-1zm0 0"/><path fill-opacity=".349" d="M6.645 6.145a.5.5 0 0 0 0 .707l1 1a.5.5 0 0 0 .707 0l1-1a.5.5 0 0 0 0-.707.507.507 0 0 0-.707 0l-.649.648-.644-.648a.507.507 0 0 0-.707 0m0 0"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M2.5 2A2.52 2.52 0 0 0 0 4.5v7C0 12.867 1.133 14 2.5 14h11c1.367 0 2.5-1.133 2.5-2.5v-7C16 3.133 14.867 2 13.5 2zm0 2h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.496.496 0 0 1 .5-.5m3 0h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m3 0h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m3 0h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m-8 3h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m3 0h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m3 0h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m3 0h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m-10 3h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m3 0h4c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-4a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m6 0h1c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor" fill-rule="evenodd"><path d="M2 1v14h2V1z"/><path d="M3 1a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h3.383l.722 1.447A1 1 0 0 0 8 11h5a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H9.617l-.722-1.447A1 1 0 0 0 8 1zm1 2h3.383l.722 1.447A1 1 0 0 0 9 5h3v4H8.617l-.722-1.447A1 1 0 0 0 7 7H4z"/><path d="M3 8h4l1 2h5V4H9L8 2H3z"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 15.98"><g fill="currentColor"><path d="M5 11.977v1h.008c-.004.261.101.515.285.707l2 1.996a1 1 0 0 0 1.414 0l2-1.996a1 1 0 0 0 .29-.707H11v-1zm0 0"/><path d="M7 1.973c-1.297 0-2.406.84-2.824 2h7.652a3.02 3.02 0 0 0-2.828-2zm-5.996 3a1 1 0 0 0-1.004 1c0 .554.445 1 1.004 1H6c.555 0 1-.446 1-1 0-.555-.445-1-1-1zm9 0a1 1 0 0 0-1.004 1c0 .554.445 1 1.004 1H15c.555 0 1-.446 1-1 0-.555-.445-1-1-1zm-5.828 3A3.01 3.01 0 0 0 7 9.969h2a3.01 3.01 0 0 0 2.828-1.996H9.035c-.012 0-.023.004-.035.004H7l-.035-.004zm0 0"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M2 1c-.555 0-1 .445-1 1v2c0 .555.445 1 1 1h2c.555 0 1-.445 1-1V2c0-.555-.445-1-1-1zm5 1v2h8V2zM2 6.016c-.555 0-1 .445-1 1v1.98a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-1.98c0-.555-.445-1-1-1zM7 7v2h8V7zm-5 4c-.555 0-1 .445-1 1v1.98a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V12c0-.555-.445-1-1-1zm5 .984v2h8v-2zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 0C4.688 0 2 2.688 2 6c.008.71.137 1.414.387 2.078l-.016-.004c.637 1.988 3.781 5.082 5.625 6.93H8V15c1.508-1.508 3.879-3.926 5.047-5.754.262-.414.469-.809.586-1.172l-.02.004c.25-.664.383-1.367.387-2.078a6 6 0 0 0-6-6m0 3.691c1.273 0 2.309 1.036 2.309 2.309S9.273 8.309 8 8.309A2.31 2.31 0 0 1 8 3.69m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 1C5.8 1 4 2.8 4 5v2c-1.11 0-2 .89-2 2v5c0 .555.445 1 1 1h10c.555 0 1-.445 1-1V9c0-1.11-.89-2-2-2V5c0-2.2-1.8-4-4-4m0 2c1.125 0 2 .875 2 2v2H6V5c0-1.125.875-2 2-2m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M1 2h14v2H1z"/><path d="M1 7h14v2H1z"/><path d="M1 12h14v2H1z"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M.918 8.004a7.077 7.077 0 0 0 7.078 7.078 7.08 7.08 0 0 0 7.024-6.285 1.01 1.01 0 0 0-.457-.957 1 1 0 0 0-1.063-.004 3.9 3.9 0 0 1-2.031.578 3.87 3.87 0 0 1-3.883-3.883c0-.715.203-1.422.578-2.031a1 1 0 0 0-.004-1.062c-.207-.32-.578-.5-.957-.458A7.08 7.08 0 0 0 .918 8.004M5.586 4.53a5.88 5.88 0 0 0 5.887 5.883 5.84 5.84 0 0 0 3.078-.879l-1.52-.96a5.066 5.066 0 0 1-5.035 4.507 5.063 5.063 0 0 1-5.078-5.078 5.066 5.066 0 0 1 4.508-5.035l-.961-1.52a5.9 5.9 0 0 0-.88 3.082m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M7.996 0a2 2 0 1 0-.002 4.002A2 2 0 0 0 7.996 0m0 6a2 2 0 1 0-.002 4.002A2 2 0 0 0 7.996 6m0 6a2 2 0 1 0-.002 4.002A2 2 0 0 0 7.996 12m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M4.023.996c-1.449 0-2.68 1.047-2.964 2.422q-.03.149-.047.3a4 4 0 0 0-.016.305v7.91a3.043 3.043 0 0 0 3.027 3.028h7.95A3.043 3.043 0 0 0 15 11.934v-7.91q.001-.16-.016-.305a4 4 0 0 0-.046-.301C14.652 2.043 13.426.996 11.973.996zm0 2.004H7v3H4.023a1.004 1.004 0 0 1-1.027-1.027v-.95c.004-.035.004-.07.008-.105l.012-.106c.007-.035.02-.066.027-.097A1 1 0 0 1 4.023 3M9 3h2.973c.586 0 1.023.438 1.027 1.023v.95C13 5.559 12.559 6 11.973 6H9zm4 3.719v5.215c0 .586-.441 1.027-1.027 1.027h-7.95a1.005 1.005 0 0 1-1.027-1.027V6.758c.324.148.672.246 1.027.246H7v3.035l2 .992V7.004h2.973c.343 0 .695-.113 1.027-.285M8 40c-.852 0-1.7.328-2.352.977L.977 45.648a3.32 3.32 0 0 0 0 4.704l4.671 4.671a3.32 3.32 0 0 0 4.7 0l4.675-4.671a3.323 3.323 0 0 0 0-4.704l-4.675-4.671A3.3 3.3 0 0 0 8 40m0 1.984c.336 0 .676.133.941.403l3.032 3.027q-.1.059-.211.094c-.02.004-.04.012-.063.015-.441.149-1.004.02-1.277-.359l-.14-.195a1.02 1.02 0 0 0-.653-.399q-.1-.017-.2-.011a1 1 0 0 0-.542.191.997.997 0 0 0-.227 1.395l.145.199c.273.379.226.953-.051 1.332q-.018.022-.035.05c-.274.38-.809.598-1.254.454l-.227-.075a1 1 0 0 0-1.258.645 1 1 0 0 0 .637 1.262l.227.07c.445.145.738.64.746 1.106a.4.4 0 0 1 .004.066c.015.465-.305.96-.75 1.105l-.227.075a1 1 0 0 0-.445.296l-3.785-3.785a1.314 1.314 0 0 1 0-1.89l4.668-4.668c.27-.27.61-.403.945-.403m5.586 5.235q.088.001.176.008c.383.523.336 1.234-.145 1.718l-3.754 3.75a2 2 0 0 1-.07-.203l-.012-.035c-.004-.008-.008-.012-.011-.02-.286-.843-.153-1.644 0-2.437l.011-.016.012-.039A3.96 3.96 0 0 1 11.203 48c.012-.004.024-.012.031-.016.02-.015.043-.03.059-.047a4 4 0 0 1 2.293-.718m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path fill-opacity=".349" d="M9 5.5v-1c0-.277-.223-.5-.5-.5h-3c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h3c.277 0 .5-.223.5-.5m0 0"/><path d="M4 0C2.355 0 1 1.355 1 3v7c0 1.645 1.355 3 3 3h6c1.645 0 3-1.355 3-3V3c0-1.645-1.355-3-3-3zm0 2h6c.57 0 1 .43 1 1v7c0 .57-.43 1-1 1H4c-.57 0-1-.43-1-1V3c0-.57.43-1 1-1m11 2c-.55 0-1 .45-1 1v8c0 .55-.45 1-1 1H6c-.55 0-1 .45-1 1s.45 1 1 1h7a3 3 0 0 0 3-3V5c0-.55-.45-1-1-1m0 0"/><path fill-opacity=".349" d="M9 8.5v-1c0-.277-.223-.5-.5-.5h-3c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h3c.277 0 .5-.223.5-.5m0 0"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="m3.293 7.707 4 4a1 1 0 0 0 1.414 0l4-4a1 1 0 1 0-1.414-1.414L8 9.586 4.707 6.293a1 1 0 1 0-1.414 1.414m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M7 13c.266 0 .52-.102.707-.293l4-4a1 1 0 0 0 0-1.414l-4.004-4a1.007 1.007 0 0 0-1.414 0 1.003 1.003 0 0 0 .004 1.414L9.586 8l-3.293 3.293A1 1 0 0 0 7 13m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="m8.285 12.7-3.918-4a.997.997 0 0 1 0-1.395l3.918-4a.996.996 0 0 1 1.414-.02c.395.383.403 1.016.02 1.414L6.477 8l3.242 3.3a1.01 1.01 0 0 1-.02 1.419 1.003 1.003 0 0 1-1.414-.02m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M5 0c-.55 0-1 .45-1 1s.45 1 1 1h.14l.516 3.594A5 5 0 0 0 3 10h10a5 5 0 0 0-2.656-4.406L10.855 2H11a1.002 1.002 0 0 0 .707-1.707A1 1 0 0 0 11 0zm2 11v4l1 1 1-1v-4zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 0c.555 0 1 .445 1 1v6.5s0 .5.5.5.5-.5.5-.5V3c0-.555.445-1 1-1s1 .445 1 1v8.5c0 .5.5.5.5.5l1.793-1.707c.187-.195.445-.3.719-.305 1.082.086 1.144 1.27.695 1.719l-3 3C12 15.5 10.949 15.997 10 16H4c-3 0-3-3-3-3V5c0-.555.445-1 1-1s1 .445 1 1v3.5s0 .5.5.5.5-.5.5-.5V2c0-.555.445-1 1-1s1 .445 1 1v5.5s0 .5.5.5.5-.5.5-.5V1c0-.555.445-1 1-1m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8.488.02C4.36.02 1 3.375 1 7.508c0 4.129 3.36 7.484 7.488 7.484 4.13 0 7.489-3.355 7.489-7.484 0-4.133-3.36-7.488-7.489-7.488m0 1.972A5.506 5.506 0 0 1 14 7.508c0 3.047-2.46 5.512-5.512 5.512s-5.511-2.465-5.511-5.512a5.506 5.506 0 0 1 5.511-5.516M11.496 4c-.031 0-.066 0-.101.008a.47.47 0 0 0-.25.156L8.488 6.82 6.832 5.164a.5.5 0 0 0-.516-.234.504.504 0 0 0-.171.922l2 2c.19.18.496.18.687 0l3-3A.502.502 0 0 0 11.496 4m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M7.406 1a7.5 7.5 0 0 0-1.847.254C2.285 2.133 0 5.109 0 8.5s2.285 6.367 5.559 7.242a7.51 7.51 0 0 0 8.437-3.492c.277-.48.11-1.09-.367-1.367a1.006 1.006 0 0 0-1.367.367 5.49 5.49 0 0 1-6.188 2.562A5.494 5.494 0 0 1 2 8.5a5.49 5.49 0 0 1 4.074-5.312 5.49 5.49 0 0 1 6.188 2.562q.087.13.203.227l-.02.015-.008.008H11c-.55 0-1 .45-1 1 0 0 0 1 1 1h5V3s.004-1-1-1c-.55 0-1 .45-1 1v1.688l-.016.011-.011.012A7.51 7.51 0 0 0 7.406 1m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M1 7h14v2H1z"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 2A8 8 0 0 0 .262 8.008 8 8 0 0 0 8 14a8 8 0 0 0 7.738-6.012A8 8 0 0 0 8 2m0 2c2.21 0 4 1.79 4 4s-1.79 4-4 4-4-1.79-4-4 1.79-4 4-4m0 2a2 2 0 1 0 2 2 2 2 0 0 0-2-2m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 15.996"><path fill="currentColor" d="M4.996 0C3.965.004 3.61 1.371 4.512 1.871L5 2.145v2.683c-.129.207-.25.39-.441.715-.27.473-.602 1.062-.965 1.707-.723 1.297-1.555 2.91-2.203 4.094-.575 1.047-.551 2.453.3 3.511.672.903 1.739 1.141 2.618 1.141h7.351c.88 0 1.95-.238 2.621-1.14.852-1.06.867-2.465.297-3.512-1.164-2.371-2.473-4.317-3.562-6.496v-2.7l.488-.277c.902-.5.547-1.871-.484-1.871zM7 2h2.016v3.105c0 .153.035.305.101.442 1.203 2.457 2.574 4.441 3.688 6.715l.015.039c.243.441.258.883-.097 1.316l-.036.043c-.144.2-.488.336-1.027.336H4.31c-.54 0-.883-.137-1.028-.336l-.035-.043c-.351-.433-.34-.875-.098-1.316.645-1.18 1.473-2.79 2.192-4.07.36-.645.687-1.231.953-1.684.262-.453.488-.813.516-.848.125-.172.191-.379.191-.59zm0 0"/><path fill="currentColor" fill-opacity=".349" d="M6.219 9 4 13h8L9.781 9zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M6.5 0C2.922 0 0 2.922 0 6.5s2.922 6.496 6.5 6.496c1.43 0 2.754-.465 3.828-1.254l2.945 2.945c.957.938 2.364-.5 1.407-1.437l-2.93-2.93A6.45 6.45 0 0 0 13 6.5C13 2.922 10.078 0 6.5 0m0 2C8.996 2 11 4.004 11 6.5s-2.004 4.496-4.5 4.496S2 8.996 2 6.5 4.004 2 6.5 2m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M2 0c-.55 0-1 .45-1 1v8c0 2.5 1.816 4.246 3.445 5.332a15 15 0 0 0 3.239 1.617c.207.07.425.07.632 0 0 0 1.61-.531 3.239-1.617S15 11.5 15 9V1c0-.55-.45-1-1-1zm1 2h10v7c0 1.5-1.184 2.754-2.555 3.668-1.215.809-2.18 1.129-2.445 1.227-.266-.098-1.23-.418-2.445-1.227C4.184 11.754 3 10.5 3 9zm1 1v6c0 1 .867 2.008 2.11 2.836.933.621 1.472.785 1.89.95.418-.165.957-.329 1.89-.95C11.134 11.008 12 10 12 9V3zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M4.008 0A3.995 3.995 0 0 0 0 4.004V6.98a3.995 3.995 0 0 0 4.008 4.004h2.98v1.286a2 2 0 0 0-.707.703H2.996a1 1 0 1 0 0 2h3.246a2.01 2.01 0 0 0 1.754 1.039 2.01 2.01 0 0 0 1.75-1.04h3.262c.55 0 1-.449 1-1s-.45-1-1-1H9.715A2.05 2.05 0 0 0 9 12.27v-1.286h2.996A3.994 3.994 0 0 0 16 6.98V4.004A3.994 3.994 0 0 0 11.996 0zm0 2h7.988C13.121 2 14 2.879 14 4.004V6.98a1.98 1.98 0 0 1-2.004 2.004H4.008A1.98 1.98 0 0 1 2 6.98V4.004C2 2.879 2.879 2 4.008 2m3.988 11C8.551 13 9 13.45 9 14.004A1.005 1.005 0 1 1 7.996 13m0 0"/><path d="M5 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1m0 0"/><path d="M8 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1m0 0"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 0c-.55 0-1 .45-1 1v.238c0 .465-.379.903-.82 1.047-.024.008-.043.012-.063.02-.445.148-1.008.015-1.281-.36l-.14-.195a1 1 0 0 0-.653-.398.997.997 0 0 0-.965 1.574l.14.199c.278.375.227.953-.05 1.328l-.035.05c-.274.38-.805.602-1.25.458l-.23-.074a.997.997 0 0 0-1.258.64 1 1 0 0 0 .64 1.262l.223.074c.445.145.738.637.75 1.102v.07c.015.465-.305.961-.746 1.106l-.227.07a1 1 0 0 0 .617 1.902l.23-.074c.446-.144.977.078 1.25.457l.04.05c.277.376.324.954.05 1.329l-.144.203a.996.996 0 0 0 .223 1.395 1 1 0 0 0 1.394-.22l.145-.198c.273-.38.836-.508 1.277-.36l.063.02c.445.14.82.578.82 1.047V15c0 .55.45 1 1 1s1-.45 1-1v-.238c0-.469.379-.907.82-1.047l.067-.024c.441-.144 1.004-.015 1.277.364l.145.199a.999.999 0 0 0 1.617-1.176l-.149-.203c-.273-.375-.226-.953.051-1.328l.04-.05c.273-.38.804-.602 1.25-.458l.234.078c.523.168 1.086-.12 1.257-.644s-.117-1.09-.64-1.258l-.23-.074c-.446-.145-.735-.641-.747-1.106v-.07c-.015-.465.301-.957.746-1.102l.23-.074a1.001 1.001 0 0 0-.616-1.902l-.23.074c-.446.144-.981-.078-1.255-.457l-.035-.05c-.277-.376-.324-.954-.05-1.329l.144-.2a1 1 0 0 0-1.617-1.175l-.145.195c-.273.38-.836.508-1.281.364l-.063-.024C9.38 2.145 9 1.707 9 1.238V1c0-.55-.45-1-1-1m0 4c.871 0 1.676.273 2.332.742q.007.012.02.024.016.005.03.015a3.97 3.97 0 0 1 1.423 1.977v.008s.004.004.004.007c.293.852.156 1.657 0 2.457 0 0-.004.004-.004.008v.004a4 4 0 0 1-1.414 1.977q-.023.006-.04.02l-.019.019A4 4 0 0 1 8 12a4 4 0 0 1-2.29-.715c-.019-.015-.042-.035-.062-.047q-.017-.012-.035-.02a3.97 3.97 0 0 1-1.406-1.945c-.004-.015-.008-.023-.012-.035q-.005-.012-.011-.02c-.286-.847-.149-1.644 0-2.437l.011-.02.012-.038a4 4 0 0 1 1.414-1.946c.008-.004.02-.007.027-.015a.3.3 0 0 0 .059-.047A4 4 0 0 1 8 4m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M3 0c-.55 0-1 .45-1 1v11H0v1h.008a1 1 0 0 0 .285.707l2 2a1 1 0 0 0 1.414 0l2-2a1 1 0 0 0 .29-.707H6v-1H4V1c0-.55-.45-1-1-1m0 0"/><path d="M6.5 10a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5h5c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5zm0-3a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5h7c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5zm0-3a.5.5 0 0 1-.5-.5v-1c0-.277.223-.5.5-.5h9c.277 0 .5.223.5.5v1c0 .277-.223.5-.5.5zm0 0"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M6.5 6c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h5c.277 0 .5-.223.5-.5v-1c0-.277-.223-.5-.5-.5zm0 3c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h7c.277 0 .5-.223.5-.5v-1c0-.277-.223-.5-.5-.5zm0 3c-.277 0-.5.223-.5.5v1c0 .277.223.5.5.5h9c.277 0 .5-.223.5-.5v-1c0-.277-.223-.5-.5-.5zm0 0"/><path d="M3 16c-.55 0-1-.45-1-1V4H0V3h.008a1 1 0 0 1 .285-.707l2-2a1 1 0 0 1 1.414 0l2 2c.184.187.29.441.29.707H6v1H4v11c0 .55-.45 1-1 1m0 0"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 0a1 1 0 0 0-.95.684l-1.448 4.34-4.59-.016C.032 5.004-.371 6.266.43 6.828l3.625 2.555-1.5 4.285c-.317.902.687 1.691 1.492 1.172l4.004-2.594 3.894 2.586c.801.531 1.817-.258 1.5-1.16l-1.504-4.29 3.645-2.577c.789-.563.394-1.809-.574-1.813l-4.66-.015L8.949.69A1 1 0 0 0 8 0m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M15 7h-1c-.55 0-1 .45-1 1s.45 1 1 1h1c.55 0 1-.45 1-1s-.45-1-1-1m-2.754-4.66-.707.707a1 1 0 1 0 1.414 1.414l.707-.707a1 1 0 1 0-1.414-1.414M7.004.992v1a1 1 0 1 0 2 0v-1a1 1 0 1 0-2 0m-4.66 2.75.707.707a1 1 0 1 0 1.414-1.414l-.707-.707a.99.99 0 0 0-.965-.258.994.994 0 0 0-.45 1.672M.997 8.984h1a1 1 0 1 0 0-2h-1a1 1 0 1 0 0 2m2.754 4.664.707-.707a1 1 0 1 0-1.414-1.414l-.711.707a1.007 1.007 0 0 0 0 1.414c.39.391 1.027.391 1.418 0m5.242 1.348v-1a1 1 0 1 0-2 0v1a1 1 0 1 0 2 0m4.66-2.754-.707-.707a1.01 1.01 0 0 0-.968-.262 1.01 1.01 0 0 0-.707.711 1 1 0 0 0 .261.965l.707.707a.995.995 0 0 0 1.672-.445 1 1 0 0 0-.258-.969m0 0"/><path d="M8 10c-1.117 0-2-.883-2-2s.883-2 2-2 2 .883 2 2-.883 2-2 2m0-6C5.793 4 4 5.793 4 8s1.793 4 4 4 4-1.793 4-4-1.793-4-4-4"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 0c-.55 0-1 .45-1 1v.238c0 .469-.375.903-.82 1.047-.024.008-.043.012-.063.02-.445.148-1.004.02-1.281-.36l-.14-.195a1 1 0 0 0-.653-.398.997.997 0 0 0-.965 1.574l.14.199c.278.379.227.953-.05 1.328l-.035.05c-.274.38-.805.602-1.25.458l-.23-.074a.997.997 0 0 0-1.258.64 1 1 0 0 0 .64 1.262l.223.074c.445.145.738.637.75 1.102v.07c.015.465-.305.961-.746 1.106l-.227.074a.994.994 0 0 0-.64 1.258 1 1 0 0 0 1.257.644l.23-.078c.446-.144.977.078 1.25.457q.02.029.04.05c.277.376.324.954.05 1.329l-.144.203a.996.996 0 0 0 .223 1.395 1 1 0 0 0 1.394-.22l.145-.198c.273-.375.836-.508 1.277-.36l.063.02c.445.14.82.578.82 1.047V15a1 1 0 1 0 2 0v-.238c0-.469.379-.907.824-1.047a.3.3 0 0 1 .063-.02c.441-.148 1.004-.02 1.277.36l.145.199a.999.999 0 0 0 1.617-1.176l-.145-.203c-.277-.375-.226-.953.051-1.328l.035-.05c.274-.38.805-.602 1.25-.458l.235.078a1 1 0 0 0 1.257-.644.997.997 0 0 0-.64-1.258l-.23-.074c-.446-.145-.735-.641-.743-1.106q-.005-.035-.004-.07c-.015-.465.301-.957.746-1.102l.23-.074a1.001 1.001 0 0 0-.616-1.902l-.23.074c-.446.144-.977-.078-1.25-.457q-.024-.026-.04-.05c-.277-.376-.324-.95-.05-1.329l.144-.2a1 1 0 0 0-1.617-1.175l-.145.195c-.273.38-.836.508-1.277.364l-.067-.024C9.38 2.145 9 1.707 9 1.238V1c0-.55-.45-1-1-1m0 4c.871 0 1.676.273 2.332.742q.007.012.02.024.016.005.03.015a4 4 0 0 1 1.423 1.977v.008s.004.004.004.007c.293.852.156 1.657 0 2.457 0 0-.004.004-.004.008v.004a4 4 0 0 1-1.414 1.977q-.023.006-.04.02-.012.009-.019.019A4 4 0 0 1 8 12a4 4 0 0 1-2.29-.715l-.062-.047q-.017-.012-.035-.02a4 4 0 0 1-1.406-1.945l-.012-.035-.011-.02c-.282-.847-.149-1.644 0-2.437l.011-.015.012-.04a3.97 3.97 0 0 1 1.414-1.945c.008-.008.02-.011.027-.015l.063-.051A4 4 0 0 1 8 4m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M2.2 0C.993 0 0 1.007 0 2.209v10.582C0 13.993.994 15 2.2 15h11.6c1.206 0 2.2-1.007 2.2-2.209V2.209C16 1.007 15.006 0 13.8 0zm0 2h11.6c.124 0 .2.071.2.209v10.582c0 .138-.076.209-.2.209H2.2c-.124 0-.2-.071-.2-.209V2.209C2 2.071 2.076 2 2.2 2"/><path d="M4.494 5.895a.6.6 0 0 0-.418 1.029L5.652 8.5l-1.576 1.576a.6.6 0 1 0 .848.848l2-2a.6.6 0 0 0 0-.848l-2-2a.6.6 0 0 0-.43-.181"/><path d="M8 10h3v1H8z"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M8 0C3.594 0 0 3.594 0 8s3.594 8 8 8 8-3.594 8-8-3.594-8-8-8m0 2c3.324 0 6 2.672 6 6 0 3.324-2.676 6-6 6s-6-2.676-6-6c0-3.328 2.676-6 6-6"/><path d="M4.93 4.953a.53.53 0 0 0-.352.152.503.503 0 0 0 .008.707l3.113 3.043a.5.5 0 0 0 .387.13H11a.501.501 0 0 0 0-1H8.238L5.285 5.097a.5.5 0 0 0-.355-.145m0 0"/></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 0a1 1 0 0 0-.707.293L5.586 2H3c-.55 0-1 .45-1 1v2.586L.293 7.293a1 1 0 0 0 0 1.414L2 10.414V13c0 .55.45 1 1 1h2.586l1.707 1.707a1 1 0 0 0 1.414 0L10.414 14H13c.55 0 1-.45 1-1v-2.586l1.707-1.707a1 1 0 0 0 0-1.414L14 5.586V3c0-.55-.45-1-1-1h-2.586L8.707.293A1 1 0 0 0 8 0m0 5c.258 0 .512.098.707.293l2 2c.184.187.29.441.285.707H11v1H9v2H7V9H5V8h.008a1 1 0 0 1 .285-.707l2-2A1 1 0 0 1 8 5m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M3 1C1.355 1 0 2.355 0 4v8c0 1.645 1.355 3 3 3h10c1.645 0 3-1.355 3-3V4c0-1.645-1.355-3-3-3zm0 2h10c.57 0 1 .43 1 1v7c0 .555-.445 1-1 1H3c-.555 0-1-.445-1-1V4c0-.57.5-1 1-1m.5 7a.499.499 0 1 0 0 1h9a.499.499 0 1 0 0-1zm0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M2.934 0C1.309 0 0 1.332 0 2.988v10.028C0 14.668 1.309 16 2.934 16h.132C4.691 16 6 14.668 6 13.016V2.988C6 1.332 4.691 0 3.066 0zm2.593 0c.88.715 1.47 1.793 1.47 3v1H9.12c-.074-1.094 1.25-2 2-2 .68 0 1 .012 1 1v1h1.219c1.23-.602 1.781-1.969 1.781-2.687C15.121.578 12.734 0 11.434 0zM7 5v4.313c2.766 2.66 6.125 2.406 6.125 2.406s.352-1.52-1.906-4.031L8.844 5H7.063c-.02.004-.043-.004-.063 0m2.977 2.531L11.66 9.27c-1.8.164-2.152-.86-1.683-1.739m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M7.906.094C7.38.066 6.867.375 6.47 1.063L.219 12.656C-.316 13.621.266 15 1.313 15h13.156c.98 0 1.902-1.16 1.219-2.344L9.375 1.125C8.977.48 8.434.121 7.906.094M9 4v5c.008.527-.473 1-1 1s-1.008-.473-1-1V4zm-1 7c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1m0 0"/></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16.004"><g fill="currentColor"><path d="M8 4.846v10.38l-2.344-2.288-1.34 2.734c-.327.74-2.033.145-1.548-.844l1.326-2.839H1.136z" display="block"/><path d="M7.156 1.063C3.33 1.522.578 5.02 1.063 8.843c.131 1.035.293 1.383.293 1.383l1.675-1.632A4.99 4.99 0 0 1 7.406 3.03a4.99 4.99 0 0 1 5.563 4.375c.314 2.608-1.391 5.025-3.969 5.532l.031 2s.521-.106.624-.131c3.416-.834 5.706-4.128 5.283-7.65-.46-3.827-3.955-6.555-7.782-6.095z"/><path d="M4.113 7.815v-.206l-.198.019.05-.393h-.117l-.115.15-.116.056-.165-.093-.017-.206.033-.225.248-.186h.199l.033-.113.247.056.182.225.033-.374.314-.262.116-.28.231-.094.133-.187.297-.056.149-.225h-.446l.28-.13h.199l.28-.094.034-.112-.1-.094-.115-.037.033-.112-.083-.169-.198.075.033-.15-.231-.13-.182.317.017.113-.182.075-.116.243-.05-.225-.313-.13-.05-.169.413-.243.182-.168.017-.206-.1-.056L4.84 4l-.082.206s-.139.027-.174.036C4.13 4.66 3.214 5.562 3 7.266c.008.04.155.268.155.268l.347.206.347.093m3.966-4.3-.43-.168-.496.056-.611.168-.116.112.38.262v.15l-.149.15.199.392.132-.075.165-.262a5.5 5.5 0 0 0 .727-.28zm2.529.342-.375.094-.219.156v.125l-.375.25.094.344.219-.157.125.157.156.093.094-.28L10 4.5l.063-.094.218-.187h.094l-.094.218v.188c.09-.024.16-.051.25-.063l-.25.188v.125l-.312.219-.281-.063v-.156l-.125.062.062.157h-.219l-.125.218-.156.157-.094.03v.188l.032.157H9.03v.53l.063-.03.094-.219L9.375 6l.031-.094.282-.062.156.187.187.094-.093.187.156-.03.062-.22-.187-.218h.062l.22.156.03.219.157.219.062-.313.094-.031c.096.1.169.231.25.344h.281l.188.125-.094.093-.156.157h-.25l-.344-.094h-.188l-.125.156-.343-.375-.25-.062-.375.062-.157.094V9l.032.031.25-.156.093.094h.281l.125.156-.093.312.187.188V10l.125.25-.093.25c-.01.161 0 .307 0 .469.08.219.143.435.218.656l.063.344v.187h.125l.219-.125h.25l.375-.437-.032-.157.25-.218-.187-.188.219-.187.218-.125.094-.125-.062-.25V9.75l.187-.375.188-.25.25-.563v-.156q-.174.02-.344.031c-.072.005-.145 0-.219 0a7 7 0 0 1-.312-.78l-.157-.188-.093-.313.062-.062.219.25.25.562.156.156-.062.22.156.155.25-.25.312-.218.157-.188v-.219c-.04-.073-.055-.145-.094-.218l-.156.187-.125-.156-.188-.125v-.281l.219.218.219-.03c.101.091.192.207.28.312L13 7.28c0-.174-.2-1.02-.625-1.75S11.22 4.125 11.22 4.125l-.063.094-.218.218-.25-.25h.25l.125-.125-.47-.093z" opacity=".3"/></g></svg> \ No newline at end of file diff --git a/src/assets/logos/arch.svg b/src/assets/logos/arch.svg new file mode 100644 index 0000000..ac4e77f --- /dev/null +++ b/src/assets/logos/arch.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><linearGradient id="b" x1="541.335" x2="606.912" y1="104.507" y2="303.14" gradientTransform="matrix(-.39377 0 0 .39375 978.35 416.981)" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="a" x1="112.499" x2="112.499" y1="6.137" y2="129.347" gradientTransform="translate(287 -83)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#fff;stop-opacity:0"/><stop offset="1" style="stop-color:#fff;stop-opacity:.27450982"/></linearGradient></defs><path d="M128 0c-11.395 27.937-18.313 46.237-31 73.344 7.778 8.245 17.338 17.812 32.844 28.656-16.67-6.86-28.034-13.729-36.531-20.875C77.076 115.005 51.622 163.246 0 256c40.563-23.418 72.008-37.862 101.313-43.375-1.254-5.404-1.924-11.278-1.876-17.375l.032-1.281c.644-25.994 14.17-45.982 30.187-44.625s28.488 23.537 27.844 49.531c-.121 4.896-.69 9.6-1.656 13.969C184.834 218.517 215.982 232.897 256 256a14843 14843 0 0 1-21.687-40.094c-10.597-8.212-21.644-18.897-44.188-30.469 15.496 4.027 26.602 8.683 35.25 13.875C156.98 71.973 151.455 55.04 128 0" style="fill:#1793d1;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"/><path d="M818.226 548.553c-41.181-55.895-50.727-100.945-53.145-111.7 21.968 50.668 21.818 51.29 53.145 111.7" style="fill:#fff;fill-opacity:.16568047;fill-rule:evenodd;stroke:none;stroke-width:36.09999847;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:10;stroke-opacity:1" transform="translate(-902.4 -586.945)scale(1.34737)"/><path d="M765.098 436.435c-1.056 2.597-2.086 5.117-3.061 7.515-1.082 2.655-2.11 5.19-3.132 7.677a1118 1118 0 0 1-3.038 7.306 752 752 0 0 1-3.038 7.143c-1.028 2.382-2.062 4.789-3.155 7.236a619 619 0 0 1-3.432 7.538 686 686 0 0 1-3.804 8.095c-.186.39-.413.834-.603 1.229 5.755 6.096 12.841 13.15 24.284 21.152-12.34-5.078-20.766-10.157-27.067-15.446-.327.667-.614 1.264-.951 1.948-.444.9-.976 1.923-1.438 2.853-.81 1.66-1.656 3.365-2.528 5.125-.335.667-.63 1.243-.974 1.925-5.508 11.053-12.34 24.283-21.13 40.728 24.096-13.576 50.086-33.163 97.297-16.305a1624 1624 0 0 1-6.587-12.64 1193 1193 0 0 1-5.706-11.156 865 865 0 0 1-4.917-9.927 671 671 0 0 1-4.267-8.906 563 563 0 0 1-3.758-8.165 521 521 0 0 1-3.363-7.584 548 548 0 0 1-3.084-7.306 692 692 0 0 1-2.946-7.236c-.348-.866-.695-1.769-1.044-2.644-2.663-6.253-5.398-12.732-8.558-20.155" style="fill:url(#b);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:.71416688;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" transform="translate(-902.4 -586.945)scale(1.34737)"/></svg> \ No newline at end of file diff --git a/src/assets/logos/cachyos.svg b/src/assets/logos/cachyos.svg new file mode 100644 index 0000000..c3f1492 --- /dev/null +++ b/src/assets/logos/cachyos.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17.921 17.921"><defs><linearGradient id="d" x1="237.19" x2="237.07" y1="296.2" y2="304.08" gradientTransform="matrix(.04476 0 0 .04468 -8.592 -4.63)" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="a"><stop offset="0" stop-color="#001313"/><stop offset="1" stop-color="#001313" stop-opacity="0"/></linearGradient><linearGradient id="e" x1="994.81" x2="982.34" y1="1533.3" y2="1556.8" gradientTransform="matrix(.08414 0 0 .08399 -76.331 -126.67)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="b"><stop offset="0" stop-color="#020202"/><stop offset="1" stop-color="#020202" stop-opacity="0"/></linearGradient><linearGradient id="f" x1="1022.5" x2="1018.6" y1="1582.4" y2="1575.6" gradientTransform="matrix(.08638 0 0 .0818 -79.192 -124.97)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="g" x1="940.43" x2="930.59" y1="1612.5" y2="1594.5" gradientTransform="matrix(.08414 0 0 .08399 -76.331 -126.67)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="h" x1="965.6" x2="951.66" y1="1571.4" y2="1571.3" gradientTransform="matrix(.08414 0 0 .08399 -76.331 -126.67)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="i" x1="946.23" x2="961.37" y1="1655.9" y2="1655.8" gradientTransform="matrix(.08414 0 0 .08399 -76.331 -126.67)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="j" x1="366.15" x2="350.92" y1="427.32" y2="419.64" gradientTransform="matrix(.04476 0 0 .04468 -10.921 -4.435)" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="k" x1="936.34" x2="933.38" y1="1628.8" y2="1623" gradientTransform="matrix(.08414 0 0 .08399 -76.331 -126.67)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="l" x1="950.33" x2="941.97" y1="1618.6" y2="1645.8" gradientTransform="matrix(.08414 0 0 .08399 -76.331 -126.67)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="m" x1="1008.2" x2="1015.7" y1="1681.3" y2="1668.4" gradientTransform="matrix(.08414 0 0 .08399 -76.331 -126.67)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="n" x1="1148.3" x2="1145.4" y1="1585.5" y2="1630" gradientTransform="translate(-282.87 -491.67)scale(.34992)" gradientUnits="userSpaceOnUse" href="#c"/><linearGradient id="c"><stop offset="0" stop-color="#008066" stop-opacity="0"/><stop offset="1" stop-color="#0fc"/></linearGradient><linearGradient id="o" x1="1148.3" x2="1145.4" y1="1585.5" y2="1630" gradientTransform="translate(-211.15 -375.49)scale(.26565)" gradientUnits="userSpaceOnUse" href="#c"/><linearGradient id="p" x1="1148.3" x2="1145.4" y1="1585.5" y2="1630" gradientTransform="translate(-53.624 -195.03)scale(.13679)" gradientUnits="userSpaceOnUse" href="#c"/><linearGradient id="q" x1="348.05" x2="361.21" y1="194.78" y2="187.24" gradientTransform="matrix(.04476 0 0 .04468 -10.921 -4.435)" gradientUnits="userSpaceOnUse" href="#a"/></defs><path fill="#fff" d="M4.329 1.924h8.345l-2.112 3.653H6.036L4.15 8.837l1.912 3.307h8.829l-2.167 3.745H4.15L.013 8.737l3.96-6.848z"/><path fill="#0a8" d="m3.973 1.89 6.589 3.687 2.112-3.653zm2.089 10.254L4.149 15.89h8.575l2.166-3.746z"/><path fill="#0cf" d="m3.973 1.89 6.589 3.687H6.036L4.15 8.837l1.912 3.307-1.913 3.745L.013 8.737z"/><path fill="url(#d)" d="m.013 8.737 4.137.1.115.199-4.123-.083z"/><path fill="url(#e)" d="M10.562 5.577 3.973 1.89l-.5.902 4.994 2.765z"/><path fill="url(#f)" d="m6.003 5.543 6.711-3.784-2.169 3.558z"/><path fill="#0a8" d="m6.036 5.577 6.638-3.653-2.112 3.653zM.013 8.737l6.023-3.16-1.886 3.26z"/><path fill="url(#g)" d="m.013 8.737 6.023-3.16-.407-.635L.438 7.945z"/><path fill="#0a8" d="M6.036 5.577 3.973 1.89l.177 6.949z"/><path fill="#0cf" d="m6.062 12.144 6.662 3.746 2.166-3.746z"/><path fill="url(#h)" d="m3.973 1.89-.501.901.048 6.017.63.03z"/><path fill="#0a8" d="m4.15 8.838-3.16 1.58 3.16 5.472z"/><path fill="url(#i)" d="M4.15 8.838v7.051l.774-1.507.06-4.134z"/><path fill="url(#j)" d="m4.15 15.889-.299-.535 1.92-3.7.29.49z"/><path fill="url(#k)" d="M.991 10.419 4.15 8.838l-.63-.03-2.74 1.307z"/><path fill="#0a8" d="m.991 10.419 5.07 1.725L4.15 8.838z"/><path fill="url(#l)" d="m6.062 12.144-5.07-1.725.55.95 4.167 1.51z"/><path fill="url(#m)" d="m6.062 12.144 6.662 3.746.799-1.42-4.288-2.363z"/><g transform="matrix(.14699 0 0 .14672 -.848 -.426)"><circle cx="117.95" cy="75.441" r="9.689" fill="#0cf"/><circle cx="118.08" cy="75.341" r="9.689" fill="url(#n)"/></g><g transform="matrix(.14699 0 0 .14672 -.2 -.75)"><circle cx="93.138" cy="55.045" r="7.356" fill="#0cf"/><circle cx="93.239" cy="54.969" r="7.356" fill="url(#o)"/></g><g transform="matrix(.14699 0 0 .14672 -.17 -.326)"><circle cx="103.06" cy="26.657" r="3.788" fill="#0cf"/><circle cx="103.11" cy="26.618" r="3.788" fill="url(#p)"/></g><path fill="url(#q)" d="M6.036 5.577 3.973 1.89l.525-.007 1.94 3.446z"/></svg> \ No newline at end of file diff --git a/src/assets/logos/debian.svg b/src/assets/logos/debian.svg new file mode 100644 index 0000000..aa16d67 --- /dev/null +++ b/src/assets/logos/debian.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" overflow="visible" viewBox="0 0 87.041 108.445"><g fill="#A80030"><path d="M51.986 57.297c-1.797.025.34.926 2.686 1.287a24 24 0 0 0 1.76-1.516c-1.461.358-2.948.366-4.446.229"/><path d="M61.631 54.893c1.07-1.477 1.85-3.094 2.125-4.766-.24 1.192-.887 2.221-1.496 3.307-3.359 2.115-.316-1.256-.002-2.537-3.612 4.546-.496 2.726-.627 3.996"/><path d="M65.191 45.629c.217-3.236-.637-2.213-.924-.978.335.174.6 2.281.924.978"/><path d="M45.172 1.399c.959.172 2.072.304 1.916.533 1.049-.23 1.287-.442-1.916-.533"/><path d="m47.088 1.932-.678.14.631-.056z"/><path d="M76.992 46.856c.107 2.906-.85 4.316-1.713 6.812l-1.553.776c-1.271 2.468.123 1.567-.787 3.53-1.984 1.764-6.021 5.52-7.313 5.863-.943-.021.639-1.113.846-1.541-2.656 1.824-2.131 2.738-6.193 3.846l-.119-.264c-10.018 4.713-23.934-4.627-23.751-17.371-.107.809-.304.607-.526.934-.517-6.557 3.028-13.143 9.007-15.832 5.848-2.895 12.704-1.707 16.893 2.197-2.301-3.014-6.881-6.209-12.309-5.91-5.317.084-10.291 3.463-11.951 7.131-2.724 1.715-3.04 6.611-4.227 7.507-1.597 11.737 3.004 16.808 10.787 22.773 1.225.826.345.951.511 1.58-2.586-1.211-4.954-3.039-6.901-5.277 1.033 1.512 2.148 2.982 3.589 4.137-2.438-.826-5.695-5.908-6.646-6.115 4.203 7.525 17.052 13.197 23.78 10.383-3.113.115-7.068.064-10.566-1.229-1.469-.756-3.467-2.322-3.11-2.615 9.182 3.43 18.667 2.598 26.612-3.771 2.021-1.574 4.229-4.252 4.867-4.289-.961 1.445.164.695-.574 1.971 2.014-3.248-.875-1.322 2.082-5.609l1.092 1.504c-.406-2.696 3.348-5.97 2.967-10.234.861-1.304.961 1.403.047 4.403 1.268-3.328.334-3.863.66-6.609.352.923.814 1.904 1.051 2.878-.826-3.216.848-5.416 1.262-7.285-.408-.181-1.275 1.422-1.473-2.377.029-1.65.459-.865.625-1.271-.324-.186-1.174-1.451-1.691-3.877.375-.57 1.002 1.478 1.512 1.562-.328-1.929-.893-3.4-.916-4.88-1.49-3.114-.527.415-1.736-1.337-1.586-4.947 1.316-1.148 1.512-3.396 2.404 3.483 3.775 8.881 4.404 11.117-.48-2.726-1.256-5.367-2.203-7.922.73.307-1.176-5.609.949-1.691-2.27-8.352-9.715-16.156-16.564-19.818.838.767 1.896 1.73 1.516 1.881-3.406-2.028-2.807-2.186-3.295-3.043-2.775-1.129-2.957.091-4.795.002-5.23-2.774-6.238-2.479-11.051-4.217l.219 1.023c-3.465-1.154-4.037.438-7.782.004-.228-.178 1.2-.644 2.375-.815-3.35.442-3.193-.66-6.471.122.808-.567 1.662-.942 2.524-1.424-2.732.166-6.522 1.59-5.352.295-4.456 1.988-12.37 4.779-16.811 8.943l-.14-.933c-2.035 2.443-8.874 7.296-9.419 10.46l-.544.127c-1.059 1.793-1.744 3.825-2.584 5.67-1.385 2.36-2.03.908-1.833 1.278-2.724 5.523-4.077 10.164-5.246 13.97.833 1.245.02 7.495.335 12.497-1.368 24.704 17.338 48.69 37.785 54.228 2.997 1.072 7.454 1.031 11.245 1.141-4.473-1.279-5.051-.678-9.408-2.197-3.143-1.48-3.832-3.17-6.058-5.102l.881 1.557c-4.366-1.545-2.539-1.912-6.091-3.037l.941-1.229c-1.415-.107-3.748-2.385-4.386-3.646l-1.548.061c-1.86-2.295-2.851-3.949-2.779-5.23l-.5.891c-.567-.973-6.843-8.607-3.587-6.83-.605-.553-1.409-.9-2.281-2.484l.663-.758c-1.567-2.016-2.884-4.6-2.784-5.461.836 1.129 1.416 1.34 1.99 1.533-3.957-9.818-4.179-.541-7.176-9.994l.634-.051c-.486-.732-.781-1.527-1.172-2.307l.276-2.75c-2.849-3.294-.797-14.006-.386-19.881.285-2.389 2.378-4.932 3.97-8.92l-.97-.167c1.854-3.234 10.586-12.988 14.63-12.486 1.959-2.461-.389-.009-.772-.629 4.303-4.453 5.656-3.146 8.56-3.947 3.132-1.859-2.688.725-1.203-.709 5.414-1.383 3.837-3.144 10.9-3.846.745.424-1.729.655-2.35 1.205 4.511-2.207 14.275-1.705 20.617 1.225 7.359 3.439 15.627 13.605 15.953 23.17l.371.1c-.188 3.802.582 8.199-.752 12.238z"/><path d="m32.372 59.764-.252 1.26c1.181 1.604 2.118 3.342 3.626 4.596-1.085-2.118-1.891-2.993-3.374-5.856"/><path d="M35.164 59.654c-.625-.691-.995-1.523-1.409-2.352.396 1.457 1.207 2.709 1.962 3.982z"/><path d="m84.568 48.916-.264.662a32 32 0 0 1-3.131 9.994 31.4 31.4 0 0 0 3.395-10.656"/><path d="M45.527.537C46.742.092 48.514.293 49.803 0c-1.68.141-3.352.225-5.003.438z"/><path d="M2.872 23.219c.28 2.592-1.95 3.598.494 1.889 1.31-2.951-.512-.815-.494-1.889"/><path d="M0 35.215c.563-1.728.665-2.766.88-3.766-1.556 1.989-.716 2.413-.88 3.766"/></g></svg> \ No newline at end of file diff --git a/src/assets/logos/deepin.svg b/src/assets/logos/deepin.svg new file mode 100644 index 0000000..5f64208 --- /dev/null +++ b/src/assets/logos/deepin.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 22"><title>logo \ No newline at end of file diff --git a/src/assets/logos/devuan.svg b/src/assets/logos/devuan.svg new file mode 100644 index 0000000..4fd0702 --- /dev/null +++ b/src/assets/logos/devuan.svg @@ -0,0 +1 @@ +Devuan Logo \ No newline at end of file diff --git a/src/assets/logos/elementary-os.svg b/src/assets/logos/elementary-os.svg new file mode 100644 index 0000000..885c338 --- /dev/null +++ b/src/assets/logos/elementary-os.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/endeavouros.svg b/src/assets/logos/endeavouros.svg new file mode 100644 index 0000000..5557b62 --- /dev/null +++ b/src/assets/logos/endeavouros.svg @@ -0,0 +1 @@ +EndeavorOS Logo \ 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/assets/logos/kali-linux.svg b/src/assets/logos/kali-linux.svg new file mode 100644 index 0000000..c14137f --- /dev/null +++ b/src/assets/logos/kali-linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/kde-neon.svg b/src/assets/logos/kde-neon.svg new file mode 100644 index 0000000..6cf5ca1 --- /dev/null +++ b/src/assets/logos/kde-neon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/kubuntu.svg b/src/assets/logos/kubuntu.svg new file mode 100644 index 0000000..53dda03 --- /dev/null +++ b/src/assets/logos/kubuntu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/linux-lite.svg b/src/assets/logos/linux-lite.svg new file mode 100644 index 0000000..5194544 --- /dev/null +++ b/src/assets/logos/linux-lite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/linux-mint.svg b/src/assets/logos/linux-mint.svg new file mode 100644 index 0000000..f86a66d --- /dev/null +++ b/src/assets/logos/linux-mint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/lubuntu.svg b/src/assets/logos/lubuntu.svg new file mode 100644 index 0000000..66c0b57 --- /dev/null +++ b/src/assets/logos/lubuntu.svg @@ -0,0 +1 @@ +LXQt \ No newline at end of file diff --git a/src/assets/logos/manjaro.svg b/src/assets/logos/manjaro.svg new file mode 100644 index 0000000..7cb6007 --- /dev/null +++ b/src/assets/logos/manjaro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/mx-linux.svg b/src/assets/logos/mx-linux.svg new file mode 100755 index 0000000..c016ee3 --- /dev/null +++ b/src/assets/logos/mx-linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/parrot-os.svg b/src/assets/logos/parrot-os.svg new file mode 100644 index 0000000..8d4fd01 --- /dev/null +++ b/src/assets/logos/parrot-os.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/peppermint-os.svg b/src/assets/logos/peppermint-os.svg new file mode 100644 index 0000000..4f403e3 --- /dev/null +++ b/src/assets/logos/peppermint-os.svg @@ -0,0 +1 @@ +ermineOSless is more \ No newline at end of file diff --git a/src/assets/logos/pop-os.svg b/src/assets/logos/pop-os.svg new file mode 100644 index 0000000..35646dd --- /dev/null +++ b/src/assets/logos/pop-os.svg @@ -0,0 +1 @@ +Pop_icon \ No newline at end of file diff --git a/src/assets/logos/q4os.svg b/src/assets/logos/q4os.svg new file mode 100644 index 0000000..e984aff --- /dev/null +++ b/src/assets/logos/q4os.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/raspberry-pi-os.svg b/src/assets/logos/raspberry-pi-os.svg new file mode 100644 index 0000000..b87bdaf --- /dev/null +++ b/src/assets/logos/raspberry-pi-os.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/tuxedo-os.svg b/src/assets/logos/tuxedo-os.svg new file mode 100644 index 0000000..e28dfd0 --- /dev/null +++ b/src/assets/logos/tuxedo-os.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/ubuntu-mate.svg b/src/assets/logos/ubuntu-mate.svg new file mode 100644 index 0000000..f18c1e5 --- /dev/null +++ b/src/assets/logos/ubuntu-mate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/ubuntu.svg b/src/assets/logos/ubuntu.svg new file mode 100644 index 0000000..3efac3c --- /dev/null +++ b/src/assets/logos/ubuntu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/xubuntu.svg b/src/assets/logos/xubuntu.svg new file mode 100644 index 0000000..4436833 --- /dev/null +++ b/src/assets/logos/xubuntu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logos/zorin-os.svg b/src/assets/logos/zorin-os.svg new file mode 100644 index 0000000..e546523 --- /dev/null +++ b/src/assets/logos/zorin-os.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ChapterNav.astro b/src/components/ChapterNav.astro new file mode 100644 index 0000000..83a2215 --- /dev/null +++ b/src/components/ChapterNav.astro @@ -0,0 +1,70 @@ +--- +import { type Locale, localizePath, useTranslations } from "~/i18n"; +import { type Chapter, GROUPS } from "~/lib/learn"; + +interface Props { + chapters: (Chapter & { slug: string })[]; + current: string; + locale: Locale; +} + +const { chapters, current, locale } = Astro.props; +const t = useTranslations(locale); +--- + + + + diff --git a/src/components/DistroCard.astro b/src/components/DistroCard.astro new file mode 100644 index 0000000..d1587c5 --- /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 | undefined; + headingLevel?: 2 | 3 | undefined; +} + +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/DistroFacts.astro b/src/components/DistroFacts.astro new file mode 100644 index 0000000..5c838fd --- /dev/null +++ b/src/components/DistroFacts.astro @@ -0,0 +1,85 @@ +--- +import { type Locale, localizePath, useTranslations } from "~/i18n"; +/** "At a glance" facts for a distro, as an Adwaita boxed list of property rows. */ +import type { Distro } from "~/lib/catalog"; +import { featuredRelease, formatDate } from "~/lib/catalog"; +import type { ReleasesFile } from "~/lib/data-schemas"; +import { + ARCHITECTURES, + DESKTOPS, + DIFFICULTIES, + FAMILIES, + INSTALLERS, + RELEASE_MODELS, + SECURE_BOOT, +} from "~/lib/taxonomy"; +import ActionRow from "./adw/ActionRow.astro"; +import BoxedList from "./adw/BoxedList.astro"; + +interface Props { + distro: Distro; + base?: Distro | undefined; + releases: ReleasesFile | undefined; + locale: Locale; +} + +const { distro, base, releases, locale } = Astro.props; +const d = distro.data; +const t = useTranslations(locale); +const release = featuredRelease(releases); +const regions = new Intl.DisplayNames([locale], { type: "region" }); +const country = + d.origin.country === "INT" + ? locale === "ko" + ? "국제 커뮤니티" + : "International" + : regions.of(d.origin.country); +const year = d.origin.firstRelease.slice(0, 4); +const latestSubtitle = release + ? release.channel === "rolling" + ? t("facts.rolling") + : release.releaseDate + ? t("facts.released", { date: formatDate(release.releaseDate, locale) ?? "" }) + : undefined + : undefined; +const list = (items: string[]) => items.join(" · "); +--- + + + { + release && ( + + {latestSubtitle && {latestSubtitle}} + + ) + } + {release?.eol && } + + + + {d.desktops.length > 1 && {t("facts.otherDesktops")}: {list(d.desktops.slice(1).map((x) => DESKTOPS[x][locale]))}} + + + + ARCHITECTURES[a][locale].split(" (")[0] ?? a))} subtitle={t("facts.architectures")} property /> + + + + + + + diff --git a/src/components/DistroLinks.astro b/src/components/DistroLinks.astro new file mode 100644 index 0000000..f17946f --- /dev/null +++ b/src/components/DistroLinks.astro @@ -0,0 +1,34 @@ +--- +import { type Locale, useTranslations } from "~/i18n"; +import type { Distro } from "~/lib/catalog"; +import ActionRow from "./adw/ActionRow.astro"; +import BoxedList from "./adw/BoxedList.astro"; + +interface Props { + distro: Distro; + locale: Locale; +} + +const { distro, locale } = Astro.props; +const t = useTranslations(locale); +const l = distro.data.links; +const rows = [ + { key: "home", url: l.home, icon: "website" }, + { key: "download", url: l.download, icon: "download" }, + { key: "docs", url: l.docs, icon: "docs" }, + { key: "installGuide", url: l.installGuide, icon: "contents" }, + { key: "releaseNotes", url: l.releaseNotes, icon: "document" }, + { key: "forum", url: l.forum, icon: "community" }, + { key: "source", url: l.source, icon: "terminal" }, +] as const; +--- + + + { + rows + .filter((r) => r.url) + .map((r) => ( + + )) + } + diff --git a/src/components/DistroLogo.astro b/src/components/DistroLogo.astro new file mode 100644 index 0000000..bbbeb5f --- /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 | undefined; + eager?: boolean | undefined; + /** Decorative when the name is already next to it (default). */ + decorative?: boolean | undefined; +} + +const { file, name, size = 64, eager = false, decorative = true } = Astro.props; +--- + + + + diff --git a/src/components/DocSources.astro b/src/components/DocSources.astro new file mode 100644 index 0000000..26d6638 --- /dev/null +++ b/src/components/DocSources.astro @@ -0,0 +1,49 @@ +--- +import { type Locale } from "~/i18n"; +/** Citation footer for a document: official sources + last reviewed date. */ +import { formatDate } from "~/lib/catalog"; + +interface Props { + sources: string[]; + lastReviewed: string; + locale: Locale; +} + +const { sources, lastReviewed, locale } = Astro.props; +const heading = locale === "ko" ? "참고한 공식 자료" : "Official sources"; +const reviewed = + locale === "ko" + ? `마지막 검토: ${formatDate(lastReviewed, locale)}` + : `Last reviewed ${formatDate(lastReviewed, locale)}`; +const note = + locale === "ko" + ? "이 문서는 아래 공식 자료를 바탕으로 직접 정리한 것입니다. 최종 기준은 언제나 공식 문서입니다." + : "This page summarizes the official sources below in our own words. The official documentation always takes precedence."; +--- + + + + diff --git a/src/components/DownloadWizard.astro b/src/components/DownloadWizard.astro new file mode 100644 index 0000000..0ae4340 --- /dev/null +++ b/src/components/DownloadWizard.astro @@ -0,0 +1,374 @@ +--- +import { type Locale, localizePath, useTranslations } from "~/i18n"; +import { defaultArtifact, featuredRelease, formatBytes, formatDate } from "~/lib/catalog"; +/** + * Guided download (version → edition → architecture → file type → mirror). + * The default choice is fully server-rendered, so the download button works + * without JS. src/scripts/download.ts enhances it: other versions are fetched + * on demand from /data/releases/.json and, for distros with an official + * mirror list, the nearest mirror is suggested from /api/geo. + */ +import type { Artifact, Release, ReleasesFile } from "~/lib/data-schemas"; +import { ARCHITECTURES, type DownloadStrategy } from "~/lib/taxonomy"; +import Button from "./adw/Button.astro"; +import Icon from "./adw/Icon.astro"; + +interface Props { + slug: string; + name: string; + locale: Locale; + strategy: DownloadStrategy; + officialDownload: string; + releases: ReleasesFile | undefined; + hasMirrors: boolean; +} + +const { slug, name, locale, strategy, officialDownload, releases, hasMirrors } = Astro.props; +const t = useTranslations(locale); +const release = featuredRelease(releases); +const choice = defaultArtifact(release); +const usable = strategy !== "official-page" && release && choice; +const installHref = localizePath(`/distros/${slug}/install/`, locale); + +function versionLabel(r: Release): string { + const tags: string[] = []; + if (r === release) tags.push(t("download.latest")); + if (r.channel === "beta" || r.channel === "testing") + tags.push(t(`download.channel.${r.channel}`)); + if (r.channel === "lts") tags.push(t("download.lts")); + const label = r.codename ? `${r.version} “${r.codename}”` : r.version; + return tags.length ? `${label} (${tags.join(", ")})` : label; +} + +function checksumLabel(a: Artifact): string { + if (!a.checksum) return t("download.checksumOther"); + if (a.checksum.type === "sha256") return t("download.checksum"); + if (a.checksum.type === "md5") return t("download.checksumMd5"); + return `${a.checksum.type.toUpperCase()} ${t("download.checksumOther")}`; +} + +const arches = choice ? [...new Set(choice.edition.artifacts.map((a) => a.arch))] : []; +const formats = choice + ? [ + ...new Set( + choice.edition.artifacts + .filter((a) => a.arch === choice.artifact.arch) + .map((a) => a.format), + ), + ] + : []; +const size = choice ? formatBytes(choice.artifact.size, locale) : null; +const mirrorMode = strategy === "mirrors" && hasMirrors && Boolean(choice?.artifact.path); + +const labels = { + latest: t("download.latest"), + lts: t("download.lts"), + channel: { + stable: t("download.channel.stable"), + lts: t("download.channel.lts"), + rolling: t("download.channel.rolling"), + beta: t("download.channel.beta"), + testing: t("download.channel.testing"), + }, + checksum: t("download.checksum"), + checksumMd5: t("download.checksumMd5"), + checksumOther: t("download.checksumOther"), + auto: t("download.auto"), + recommended: t("download.recommended"), + recommendedFor: t("download.recommendedFor", { place: "{place}" }), + locating: t("download.locating"), + distance: t("download.distance", { km: "{km}" }), + noFiles: t("download.noFiles"), + button: t("download.buttonShort"), +}; +const arches_ = ARCHITECTURES as Record; +const archLabels = Object.fromEntries(Object.entries(arches_).map(([k, v]) => [k, v[locale]])); +--- + +
+

{t("download.title")}

+ + { + usable ? ( +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • + +
  • +
    + {t("download.mirror")} + + + {mirrorMode + ? t("download.locating") + : strategy === "redirector" || strategy === "sourceforge" + ? t("download.auto") + : t("download.origin")} + + {mirrorMode && ( + + )} + +
    +
  • +
+ +
+ + + + {t("download.buttonShort")} + + {size ? ` · ${size}` : ""} + + + +

+ {choice.artifact.file} +

+ {strategy === "sourceforge" &&

{t("download.sourceforge")}

} + {strategy === "redirector" &&

{t("download.autoHint")}

} +
+ + + + +
+ ) : ( +
+

{t("download.officialPageHint")}

+ {release && ( +

+ {t("facts.latest")}: {release.version} + {release.releaseDate && ` · ${formatDate(release.releaseDate, locale)}`} +

+ )} + +
+ ) + } + + + + {t("download.nextStep")} + + + {name} +
+ + + + 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 new file mode 100644 index 0000000..bd32183 --- /dev/null +++ b/src/components/adw/ActionRow.astro @@ -0,0 +1,55 @@ +--- +/** + * 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 | 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 | undefined; + monospaceSubtitle?: boolean | undefined; +} + +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..d36e68f --- /dev/null +++ b/src/components/adw/Badge.astro @@ -0,0 +1,17 @@ +--- +/** + * 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" | undefined; + href?: string | undefined; + class?: string | undefined; +} + +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..756685f --- /dev/null +++ b/src/components/adw/Banner.astro @@ -0,0 +1,14 @@ +--- +/** libadwaita AdwBanner: a full-width accent-tinted strip with an optional action. */ +interface Props { + title: string; + class?: string | undefined; +} + +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..59a500f --- /dev/null +++ b/src/components/adw/BoxedList.astro @@ -0,0 +1,40 @@ +--- +/** + * 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 | undefined; + description?: string | undefined; + /** Heading level for the group title. */ + level?: 2 | 3 | 4 | undefined; + class?: string | undefined; + id?: string | undefined; +} + +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..f699507 --- /dev/null +++ b/src/components/adw/Button.astro @@ -0,0 +1,67 @@ +--- +/** + * 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/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/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..d636095 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +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, +}); + +/** 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/content/distro-docs/fedora/en/install.mdx b/src/content/distro-docs/fedora/en/install.mdx new file mode 100644 index 0000000..befc151 --- /dev/null +++ b/src/content/distro-docs/fedora/en/install.mdx @@ -0,0 +1,141 @@ +--- +title: How to install Fedora Linux +description: Download and verify Fedora Workstation, create a bootable USB drive, install it with Anaconda, and finish first-time setup. +sources: + - https://docs.fedoraproject.org/en-US/fedora/latest/getting-started/ + - https://docs.fedoraproject.org/en-US/quick-docs/creating-and-using-a-live-installation-image/ + - https://fedoraproject.org/security/ + - https://rpmfusion.org/Configuration + - https://rpmfusion.org/Howto/Multimedia + - https://rpmfusion.org/Howto/NVIDIA + - https://flathub.org/setup/Fedora +lastReviewed: 2026-10-03 +--- +import BootUsb from "~/content/partials/en/boot-usb.mdx"; +import DualBoot from "~/content/partials/en/dual-boot.mdx"; +import Verify from "~/content/partials/en/verify.mdx"; +import WriteUsb from "~/content/partials/en/write-usb.mdx"; + +This guide follows the most popular edition, **Fedora Workstation ** (GNOME). The KDE Plasma Desktop edition and the Spins install almost the same way. + +## Before you begin + +- **Recommended hardware**: a 64-bit dual-core CPU at 2 GHz or faster, 4 GB of RAM and at least 20 GB of storage. A PC with UEFI firmware is recommended. +- **Back up**: installing can erase the disk. Copy important files to an external drive or cloud storage first. +- **Internet**: not required to install, but you’ll want it for updates and drivers right afterwards. + +### Keeping Windows (dual boot) + + + + +## 1. Download the installation image + +In the [download panel](../#download), pick **Workstation** and your architecture (**x86_64** for most PCs); the file comes from the nearest official server. The current default image is . + + + **Fedora Media Writer**, made by the Fedora Project, downloads the image and writes the USB drive in one go. Windows and macOS installers are on Fedora’s official download page; on Linux, get it from Flathub. With it you can skip steps 2 and 3. + + + +## 2. Verify the download + + + +Each release’s `CHECKSUM` file is signed with the Fedora Project’s GPG keys. How to check the signature, and the current keys, are on [Fedora’s security page](https://fedoraproject.org/security/). + + +## 3. Create a bootable USB drive + + + + +## 4. Boot from the USB drive + + + +Choose **Start Fedora-Workstation-Live** in the boot menu to open the live desktop. + + +## 5. Install Fedora + +In the live desktop’s welcome window, click **Install Fedora…** (or **Install to Hard Drive** in the Dash) to start the **Anaconda** installer. Screen names may differ slightly between releases. + + + +1. **Language**: choose the language for the installer and the installed system. +2. **Installation method**: choose the target disk, then how to use it. + - **Use entire disk** erases everything on the selected disk and installs only Fedora. + - **Share disk with other operating systems** keeps Windows (or another system) and installs into the free space you prepared (dual boot). + - To partition by hand, open the **storage editor** under the advanced options. Fedora uses the Btrfs file system by default, with `/` and `/home` as subvolumes. +3. **Disk encryption** (optional): turn on **Encrypt my data** and set a passphrase for LUKS full-disk encryption — recommended on laptops. If you forget this passphrase, the data can’t be recovered. +4. **Review and install**: check your choices and click **Erase data and install** (or **Install**). Installation usually takes 5–15 minutes. +5. **Restart**: click **Reboot** on the final screen and remove the USB drive when the screen goes dark. + + + +### First-time setup + +After the restart, GNOME Initial Setup walks you through: + + + +1. **Privacy**: choose whether to enable location services and automatic problem reporting. +2. **Third-party repositories**: turning these on lets GNOME Software install outside software such as NVIDIA drivers, Steam and Google Chrome, plus the full Flathub catalog. Most people should enable them. +3. **Online accounts** (optional): connect Google, Microsoft or Nextcloud accounts for calendars, contacts and files. +4. **About you and password**: set your name, user name and login password. This account can use `sudo` for administrative tasks. + + + + +## 6. After installing + +### Update the system + +Updates keep coming after the image was built. Use the **Updates** tab in GNOME Software, or run: + +```bash +sudo dnf upgrade --refresh +``` + +### Media codecs (RPM Fusion) + +Fedora leaves out patent-encumbered codecs, so some videos may not play. Adding the community repository **RPM Fusion** fixes that: + +```bash +sudo dnf install https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \ + https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm +sudo dnf swap ffmpeg-free ffmpeg --allowerasing +sudo dnf install @multimedia --setopt="install_weak_deps=False" --exclude=PackageKit-gstreamer-plugin +``` + +### NVIDIA graphics + +To get full performance from an NVIDIA GPU, install the proprietary driver from RPM Fusion. After adding RPM Fusion, run the command below, wait a few minutes for the kernel module to build, then restart. + +```bash +sudo dnf install akmod-nvidia +``` + + + Locally built driver modules only load after their signing key is enrolled in the firmware. Follow the Secure Boot section of [RPM Fusion’s NVIDIA guide](https://rpmfusion.org/Howto/NVIDIA). + + +### Flathub apps + +If you enabled third-party repositories during setup, Flathub apps are already available in GNOME Software. To add Flathub yourself, run: + +```bash +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +``` + +## Troubleshooting + +- **The USB drive doesn’t boot**: choose the boot menu entry marked **UEFI**. If you used Rufus, recreate the drive in **DD Image mode**. +- **Black screen before or after installing** (usually NVIDIA): in the GRUB menu press e, add `nomodeset` to the end of the line starting with `linux`, and boot with Ctrl+X. Once the NVIDIA driver is installed you no longer need it. +- **Windows is missing from the boot menu**: make sure Windows Fast Startup is off, then rebuild the menu with `sudo grub2-mkconfig -o /boot/grub2/grub.cfg`. +- **No Wi-Fi**: some Broadcom wireless chips need the `broadcom-wl` package from RPM Fusion. Connect with a cable or USB tethering from your phone first. + +## Upgrading to the next release + +GNOME Software tells you when a new version is available. Each release is supported for about 13 months, so upgrade before yours reaches end of life. The command-line method is in [Fedora’s upgrade guide](https://docs.fedoraproject.org/en-US/quick-docs/upgrading-fedora-offline/). diff --git a/src/content/distro-docs/fedora/en/overview.mdx b/src/content/distro-docs/fedora/en/overview.mdx new file mode 100644 index 0000000..9f2f2ff --- /dev/null +++ b/src/content/distro-docs/fedora/en/overview.mdx @@ -0,0 +1,15 @@ +--- +title: About Fedora Linux +description: What sets Fedora Linux, the Red Hat–sponsored community distribution, apart and who it suits. +sources: + - https://fedoraproject.org/ + - https://docs.fedoraproject.org/en-US/releases/lifecycle/ + - https://rpmfusion.org/Configuration +lastReviewed: 2026-10-03 +--- + +Fedora Linux is built by a worldwide community and sponsored by Red Hat. A new version arrives roughly every six months, and each one receives updates until about a month after the release two versions later (around 13 months). Fedora adopts new open-source technology early — new kernels, GNOME, system tooling — and much of what matures here later flows into CentOS Stream and Red Hat Enterprise Linux. + +The flagship **Workstation** edition ships a largely unmodified GNOME desktop, and **KDE Plasma Desktop** is an official edition too. Other desktops such as Xfce and Cinnamon come as “Spins”, and image-based (atomic) variants like **Silverblue** and **Kinoite** update the whole system as a unit. System packages are managed with `dnf`, desktop apps with Flatpak. + +It suits developers and everyday users who want current software without giving up stability. Because of patent concerns, some media codecs and proprietary drivers such as NVIDIA’s aren’t included by default; add a third-party repository like RPM Fusion if you need them. diff --git a/src/content/distro-docs/fedora/ko/install.mdx b/src/content/distro-docs/fedora/ko/install.mdx new file mode 100644 index 0000000..f59d6db --- /dev/null +++ b/src/content/distro-docs/fedora/ko/install.mdx @@ -0,0 +1,141 @@ +--- +title: Fedora Linux 설치 가이드 +description: Fedora Workstation을 내려받아 검증하고, 부팅 USB를 만들어 Anaconda 설치 프로그램으로 설치한 뒤 첫 설정까지 마치는 방법. +sources: + - https://docs.fedoraproject.org/en-US/fedora/latest/getting-started/ + - https://docs.fedoraproject.org/en-US/quick-docs/creating-and-using-a-live-installation-image/ + - https://fedoraproject.org/security/ + - https://rpmfusion.org/Configuration + - https://rpmfusion.org/Howto/Multimedia + - https://rpmfusion.org/Howto/NVIDIA + - https://flathub.org/setup/Fedora +lastReviewed: 2026-10-03 +--- +import BootUsb from "~/content/partials/ko/boot-usb.mdx"; +import DualBoot from "~/content/partials/ko/dual-boot.mdx"; +import Verify from "~/content/partials/ko/verify.mdx"; +import WriteUsb from "~/content/partials/ko/write-usb.mdx"; + +이 가이드는 가장 많이 쓰는 **Fedora Workstation **(GNOME)을 기준으로 합니다. KDE Plasma Desktop 에디션이나 다른 Spin도 과정은 거의 같습니다. + +## 시작하기 전에 + +- **권장 사양**: 2GHz 이상의 듀얼코어 64비트 CPU, 메모리 4GB, 저장공간 20GB 이상. UEFI 펌웨어를 쓰는 PC를 권장합니다. +- **백업**: 설치 과정에서 디스크를 지울 수 있습니다. 중요한 파일은 외장 디스크나 클라우드에 먼저 백업하세요. +- **인터넷 연결**: 설치 자체는 오프라인으로도 되지만, 설치 직후 업데이트와 드라이버 설치에 필요합니다. + +### Windows와 함께 쓰려면(듀얼 부팅) + + + + +## 1. 설치 이미지 내려받기 + +[다운로드 패널](../#download)에서 **Workstation**, 아키텍처(대부분의 PC는 **x86_64**)를 고르면 가장 가까운 공식 서버에서 내려받습니다. 현재 기본 이미지는 입니다. + + + Fedora가 만든 **Fedora Media Writer**는 이미지를 내려받고 USB에 쓰는 일을 한 번에 해 줍니다. Windows·macOS용 설치 파일은 Fedora 공식 다운로드 페이지에, Linux용은 Flathub에 있습니다. 이 도구를 쓰면 2·3단계를 건너뛰어도 됩니다. + + + +## 2. 내려받은 이미지 검증하기 + + + +Fedora는 각 릴리스의 `CHECKSUM` 파일에 Fedora 프로젝트의 GPG 키로 서명합니다. 서명 검증 방법과 현재 키 목록은 [Fedora 보안 페이지](https://fedoraproject.org/security/)에 있습니다. + + +## 3. 부팅 USB 만들기 + + + + +## 4. USB로 부팅하기 + + + +부팅 메뉴에서 **Start Fedora-Workstation-Live**를 고르면 라이브 데스크톱이 열립니다. + + +## 5. Fedora 설치하기 + +라이브 데스크톱의 환영 창에서 **Install Fedora…**(또는 Dash의 **Install to Hard Drive**)를 누르면 설치 프로그램 **Anaconda**가 열립니다. 화면 이름은 버전에 따라 조금씩 다를 수 있습니다. + + + +1. **언어 선택**: 설치 과정과 시스템에서 쓸 언어를 고릅니다. 한국어를 고르면 설치 후 한글 입력기(IBus 한글)도 함께 설정됩니다. +2. **설치 방법 선택**: 설치할 디스크를 고른 뒤 방법을 정합니다. + - **전체 디스크 사용**: 선택한 디스크의 모든 데이터를 지우고 Fedora만 설치합니다. + - **다른 운영체제와 디스크 공유**: Windows 등 기존 시스템을 그대로 두고, 미리 비워 둔 빈 공간에 설치합니다(듀얼 부팅). + - 파티션을 직접 나누려면 고급 옵션의 **저장소 편집기**를 씁니다. Fedora는 기본으로 Btrfs 파일 시스템을 쓰고 `/`와 `/home`을 하위 볼륨으로 나눕니다. +3. **디스크 암호화**(선택): **데이터 암호화**를 켜고 암호를 정하면 LUKS 전체 디스크 암호화가 적용됩니다. 노트북이라면 켜 두기를 권장합니다. 이 암호를 잊으면 데이터를 복구할 수 없습니다. +4. **검토 및 설치**: 선택한 내용을 확인하고 **데이터 지우기 및 설치**(또는 **설치**)를 누릅니다. 설치는 보통 5~15분 걸립니다. +5. **다시 시작**: 완료 화면에서 **재부팅**을 누르고, 화면이 꺼지면 USB를 뽑습니다. + + + +### 처음 켤 때 설정 + +다시 시작하면 GNOME 초기 설정이 이어집니다. + + + +1. **개인정보**: 위치 서비스와 자동 문제 보고를 켤지 정합니다. +2. **서드파티 저장소**: 켜면 NVIDIA 드라이버, Steam, Google Chrome 같은 외부 소프트웨어와 전체 Flathub를 GNOME 소프트웨어에서 바로 설치할 수 있습니다. 일반 사용자라면 켜는 편이 편리합니다. +3. **온라인 계정**(선택): Google, Microsoft, Nextcloud 계정을 연결하면 캘린더·연락처·파일이 연동됩니다. +4. **사용자 정보와 암호**: 이름, 사용자 이름, 로그인 암호를 정합니다. 이 계정은 `sudo`로 관리자 권한을 쓸 수 있습니다. + + + + +## 6. 설치 후 할 일 + +### 시스템 업데이트 + +설치 이미지가 나온 뒤에도 업데이트가 계속 나옵니다. GNOME 소프트웨어의 **업데이트** 탭을 쓰거나 터미널에서 실행하세요. + +```bash +sudo dnf upgrade --refresh +``` + +### 미디어 코덱(RPM Fusion) + +Fedora에는 특허 문제가 있는 코덱이 빠져 있어 일부 동영상이 재생되지 않을 수 있습니다. 커뮤니티 저장소 **RPM Fusion**을 추가하면 해결됩니다. + +```bash +sudo dnf install https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \ + https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm +sudo dnf swap ffmpeg-free ffmpeg --allowerasing +sudo dnf install @multimedia --setopt="install_weak_deps=False" --exclude=PackageKit-gstreamer-plugin +``` + +### NVIDIA 그래픽 카드 + +NVIDIA GPU의 성능을 제대로 쓰려면 RPM Fusion의 독점 드라이버가 필요합니다. RPM Fusion을 추가한 뒤 다음을 실행하고, 설치가 끝나면 커널 모듈이 빌드될 때까지 몇 분 기다렸다가 다시 시작하세요. + +```bash +sudo dnf install akmod-nvidia +``` + + + 직접 빌드된 드라이버 모듈은 서명 키를 펌웨어에 등록해야 로드됩니다. 키를 등록하는 방법은 [RPM Fusion의 NVIDIA 안내](https://rpmfusion.org/Howto/NVIDIA)에 있는 Secure Boot 항목을 따르세요. + + +### Flathub 앱 + +초기 설정에서 서드파티 저장소를 켰다면 GNOME 소프트웨어에서 Flathub 앱을 바로 설치할 수 있습니다. 직접 추가하려면 다음을 실행합니다. + +```bash +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +``` + +## 문제 해결 + +- **USB로 부팅되지 않음**: 부팅 메뉴에서 이름에 **UEFI**가 붙은 항목을 고르세요. Rufus로 만들었다면 **DD 이미지 모드**로 다시 만들어 보세요. +- **설치 전후로 화면이 검게 멈춤**(주로 NVIDIA): GRUB 메뉴에서 e를 눌러 `linux`로 시작하는 줄 끝에 `nomodeset`을 붙이고 Ctrl+X로 부팅하세요. 설치를 마친 뒤 NVIDIA 드라이버를 설치하면 이 옵션은 더 필요 없습니다. +- **부팅 메뉴에 Windows가 없음**: Windows의 빠른 시작이 꺼져 있는지 확인한 뒤 `sudo grub2-mkconfig -o /boot/grub2/grub.cfg`로 부팅 메뉴를 다시 만드세요. +- **Wi-Fi가 잡히지 않음**: 일부 Broadcom 무선 칩은 RPM Fusion의 `broadcom-wl` 패키지가 필요합니다. 유선 연결이나 휴대폰 USB 테더링으로 먼저 인터넷에 연결하세요. + +## 다음 버전으로 업그레이드하기 + +새 버전이 나오면 GNOME 소프트웨어가 업그레이드를 알려 줍니다. 각 버전은 약 13개월 동안 지원되므로, 지원이 끝나기 전에 업그레이드하세요. 터미널을 쓰는 방법은 [Fedora의 업그레이드 안내](https://docs.fedoraproject.org/en-US/quick-docs/upgrading-fedora-offline/)에 있습니다. diff --git a/src/content/distro-docs/fedora/ko/overview.mdx b/src/content/distro-docs/fedora/ko/overview.mdx new file mode 100644 index 0000000..9e996ea --- /dev/null +++ b/src/content/distro-docs/fedora/ko/overview.mdx @@ -0,0 +1,15 @@ +--- +title: Fedora Linux 소개 +description: Red Hat이 후원하는 커뮤니티 배포판 Fedora Linux의 특징과 어울리는 사용자. +sources: + - https://fedoraproject.org/ + - https://docs.fedoraproject.org/en-US/releases/lifecycle/ + - https://rpmfusion.org/Configuration +lastReviewed: 2026-10-03 +--- + +Fedora Linux는 Red Hat이 후원하고 전 세계 커뮤니티가 함께 만드는 배포판입니다. 대략 6개월마다 새 버전이 나오고, 각 버전은 두 버전 뒤의 릴리스가 나온 다음 약 한 달까지(약 13개월) 업데이트를 받습니다. 새 커널과 GNOME, 시스템 도구 같은 최신 오픈소스 기술을 빨리 받아들이는 편이며, 여기서 다듬어진 기술은 이후 CentOS Stream과 Red Hat Enterprise Linux로 이어집니다. + +대표 에디션인 **Workstation**은 손을 거의 대지 않은 순정 GNOME 데스크톱을 제공하고, **KDE Plasma Desktop**도 정식 에디션입니다. Xfce, Cinnamon 같은 다른 데스크톱은 'Spins'로, 시스템이 이미지 단위로 업데이트되는 **Silverblue**·**Kinoite** 같은 불변(atomic) 변형도 고를 수 있습니다. 시스템 패키지는 `dnf`로, 데스크톱 앱은 Flatpak으로 설치합니다. + +최신 소프트웨어와 안정성 사이의 균형을 원하는 개발자와 일반 사용자에게 잘 맞습니다. 다만 특허 문제로 일부 미디어 코덱과 NVIDIA 같은 독점 드라이버는 기본으로 들어 있지 않아, 필요하면 RPM Fusion 같은 외부 저장소를 추가해야 합니다. diff --git a/src/content/distros/arch.yaml b/src/content/distros/arch.yaml new file mode 100644 index 0000000..fc5bac1 --- /dev/null +++ b/src/content/distros/arch.yaml @@ -0,0 +1,52 @@ +name: Arch Linux +order: 11 +family: arch +basedOn: null +status: active +releaseModel: rolling +desktops: [none] +packageManagers: [pacman] +initSystem: systemd +installer: archinstall +useCases: [tinkerer, developer, desktop] +difficulty: advanced +architectures: [x86_64] +secureBoot: "no" +immutable: false +origin: + country: CA + firstRelease: 2002-03-11 +requirements: + ramGB: 0.5 + diskGB: 2 + note: + ko: 기본 설치의 공식 최소 사양입니다. 라이브 환경 부팅과 데스크톱 환경에는 메모리와 공간이 더 필요합니다. + en: Official minimum for a base install. Booting the live system and adding a desktop need more memory and space. +links: + home: https://archlinux.org/ + download: https://archlinux.org/download/ + docs: https://wiki.archlinux.org/ + installGuide: https://wiki.archlinux.org/title/Installation_guide + forum: https://bbs.archlinux.org/ + releaseNotes: https://archlinux.org/news/ + source: https://gitlab.archlinux.org/archlinux +tagline: + ko: 최소 구성에서 직접 쌓아 올리는 단순한 롤링 릴리스 배포판 + en: A simple, lightweight rolling-release distro you build up yourself +aliases: [아치, 아치 리눅스, 아치리눅스, archlinux, arch linux] +logo: + file: arch.svg + source: https://sources.archlinux.org/other/artwork/archlinux-artwork-1.6.tar.gz + license: CC BY-NC-SA 3.0 (archlinux-artwork); Arch Linux trademark, used to identify the project + trademark: https://terms.archlinux.org/docs/trademark-policy/ +download: + strategy: mirrors +sources: + - https://archlinux.org/download/ + - https://wiki.archlinux.org/title/Installation_guide + - https://wiki.archlinux.org/title/Arch_Linux + - https://wiki.archlinux.org/title/Unified_Extensible_Firmware_Interface/Secure_Boot + - https://archlinux.org/art/ + - https://sources.archlinux.org/other/artwork/archlinux-artwork-1.6.tar.gz + - https://terms.archlinux.org/docs/trademark-policy/ +lastReviewed: 2026-10-03 diff --git a/src/content/distros/cachyos.yaml b/src/content/distros/cachyos.yaml new file mode 100644 index 0000000..fe8c1cc --- /dev/null +++ b/src/content/distros/cachyos.yaml @@ -0,0 +1,54 @@ +name: CachyOS +order: 6 +family: arch +basedOn: arch +status: active +releaseModel: rolling +desktops: [kde, gnome, xfce, cosmic, hyprland, niri, sway, i3, budgie, cinnamon, mate, lxqt, lxde, openbox] +packageManagers: [pacman] +initSystem: systemd +installer: calamares +useCases: [gaming, desktop, developer] +difficulty: intermediate +architectures: [x86_64] +secureBoot: "no" +immutable: false +origin: + country: DE + firstRelease: 2021 +requirements: + ramGB: 8 + diskGB: 50 + note: + ko: 공식 권장 사양입니다(최소 3GB RAM·30GB, x86-64-v3 CPU 권장). + en: Official recommended specs (3 GB RAM and 30 GB minimum; an x86-64-v3 CPU is recommended). +links: + home: https://cachyos.org/ + download: https://cachyos.org/download/ + docs: https://wiki.cachyos.org/ + installGuide: https://wiki.cachyos.org/installation/installation_on_root/ + forum: https://discuss.cachyos.org/ + releaseNotes: https://wiki.cachyos.org/cachyos_basic/changelogs/gui_installer/ + source: https://github.com/CachyOS +tagline: + ko: CPU에 맞춰 최적화한 패키지와 커널을 쓰는 Arch 기반 배포판 + en: An Arch-based distro with CPU-optimized packages and a tuned kernel +aliases: [캐시OS, 캐시 OS, 캐치OS, cachy, cachy os] +logo: + file: cachyos.svg + source: https://cachyos.org/favicon.svg + license: GPL-2.0-or-later (released by founder Peter Jung on Wikimedia Commons); CachyOS mark, used to identify the project +download: + strategy: redirector +sources: + - https://cachyos.org/ + - https://cachyos.org/download/ + - https://cachyos.org/about/ + - https://wiki.cachyos.org/installation/installation_prepare/ + - https://wiki.cachyos.org/installation/installation_on_root/ + - https://wiki.cachyos.org/installation/desktop_environments/ + - https://wiki.cachyos.org/configuration/secure_boot_setup/ + - https://wiki.cachyos.org/cachyos_basic/download/ + - https://wiki.cachyos.org/cachyos_basic/changelogs/gui_installer/ + - https://commons.wikimedia.org/wiki/File:CachyOS_Logo.svg +lastReviewed: 2026-10-03 diff --git a/src/content/distros/debian.yaml b/src/content/distros/debian.yaml new file mode 100644 index 0000000..74d53fb --- /dev/null +++ b/src/content/distros/debian.yaml @@ -0,0 +1,52 @@ +name: Debian +order: 3 +family: debian +basedOn: null +status: active +releaseModel: fixed +desktops: [gnome, kde, xfce, cinnamon, mate, lxqt, lxde] +packageManagers: [apt, dpkg] +initSystem: systemd +installer: debian-installer +useCases: [server, desktop, developer] +difficulty: intermediate +architectures: [x86_64, aarch64, armhf, ppc64le, riscv64, s390x] +secureBoot: "yes" +immutable: false +origin: + country: US + firstRelease: 1993 +requirements: + ramGB: 2 + diskGB: 10 + note: + ko: 데스크톱 설치 기준 권장 사양입니다(최소 1GB RAM, 1GHz 펜티엄 4급 이상). + en: Recommended for a desktop install (1 GB RAM minimum; Pentium 4 1 GHz class or better). +links: + home: https://www.debian.org/ + download: https://www.debian.org/distrib/ + docs: https://www.debian.org/doc/ + installGuide: https://www.debian.org/releases/stable/installmanual + forum: https://forums.debian.net/ + releaseNotes: https://www.debian.org/releases/stable/releasenotes + source: https://salsa.debian.org/ +tagline: + ko: 수많은 배포판의 뿌리가 된, 자원봉사자들이 만드는 범용 운영체제 + en: The volunteer-built universal operating system that many distros build on +aliases: [데비안, debian gnu/linux] +logo: + file: debian.svg + source: https://www.debian.org/logos/ + license: LGPL-3.0+ / CC BY-SA 3.0 (Debian Open Use Logo); Debian trademark of SPI + trademark: https://www.debian.org/trademark +download: + strategy: mirrors +sources: + - https://www.debian.org/releases/stable/ + - https://www.debian.org/releases/stable/amd64/ch03s04.en.html + - https://www.debian.org/CD/live/ + - https://www.debian.org/doc/manuals/project-history/detailed.en.html + - https://www.debian.org/support + - https://www.debian.org/logos/ + - https://wiki.debian.org/SecureBoot +lastReviewed: 2026-10-03 diff --git a/src/content/distros/deepin.yaml b/src/content/distros/deepin.yaml new file mode 100644 index 0000000..96e5dd1 --- /dev/null +++ b/src/content/distros/deepin.yaml @@ -0,0 +1,52 @@ +name: deepin +order: 35 +family: independent +basedOn: null +status: active +releaseModel: fixed +desktops: [dde] +packageManagers: [apt, dpkg, linyaps] +initSystem: systemd +installer: deepin-installer +useCases: [desktop, beginner] +difficulty: beginner +architectures: [x86_64, aarch64, riscv64] +secureBoot: "no" +immutable: true +origin: + country: CN + firstRelease: 2004 +requirements: + ramGB: 8 + diskGB: 64 + note: + ko: deepin 25 공식 권장 사양입니다(SSD, Intel 6세대 Core 또는 AMD Zen2 이상). 이보다 낮으면 설치가 실패하거나 많이 느릴 수 있다고 안내합니다. + en: Official recommendation for deepin 25 (SSD; Intel 6th-gen Core or AMD Zen 2 and newer). The project warns that installs below this may fail or lag badly. +links: + home: https://www.deepin.org/en/ + download: https://www.deepin.org/en/download/ + docs: https://wiki.deepin.org/en/home + installGuide: https://www.deepin.org/en/deepin-25-installation/ + forum: https://bbs.deepin.org/en + releaseNotes: https://www.deepin.org/en/release-notes/ + source: https://github.com/linuxdeepin +tagline: + ko: 자체 DDE 데스크톱과 불변 시스템을 갖춘 중국의 배포판 + en: A Chinese distro with its own DDE desktop and an immutable core system +aliases: [딥인, 디핀, deepin linux, linux deepin, hiweed linux] +logo: + file: deepin.svg + source: https://github.com/linuxdeepin/dtkwidget + license: LGPL-3.0-or-later (dtkwidget repository); deepin trademark of Wuhan Deepin Technology Co., Ltd. +download: + strategy: mirrors +sources: + - https://www.deepin.org/en/aboutus/ + - https://www.deepin.org/en/download/ + - https://www.deepin.org/en/deepin-25-installation/ + - https://www.deepin.org/en/deepin-25-release/ + - https://www.deepin.org/en/deepin-25-2-release/ + - https://www.deepin.org/en/deepin-23-is-officially-released/ + - https://www.deepin.org/en/deepin-os-v23-alpha-2-released/ + - https://www.deepin.org/en/mirrors/releases/ +lastReviewed: 2026-10-03 diff --git a/src/content/distros/devuan.yaml b/src/content/distros/devuan.yaml new file mode 100644 index 0000000..971e9be --- /dev/null +++ b/src/content/distros/devuan.yaml @@ -0,0 +1,51 @@ +name: Devuan GNU+Linux +order: 46 +family: debian +basedOn: debian +status: active +releaseModel: fixed +desktops: [xfce] +packageManagers: [apt, dpkg] +initSystem: SysVinit +installer: debian-installer +useCases: [server, desktop, tinkerer] +difficulty: intermediate +architectures: [x86_64, aarch64, armhf, ppc64le, riscv64] +secureBoot: partial +immutable: false +origin: + country: INT + firstRelease: 2017-05-25 +requirements: + ramGB: 2 + diskGB: 10 + note: + ko: 공식 권장 사양이 없어 기반인 Debian의 데스크톱 권장 사양을 적었습니다. + en: Devuan publishes no figures of its own; these follow Debian's recommended desktop requirements. +links: + home: https://www.devuan.org/ + download: https://www.devuan.org/get-devuan + installGuide: https://www.devuan.org/os/install + forum: https://dev1galaxy.org/ + releaseNotes: https://www.devuan.org/os/releases + source: https://git.devuan.org/ +tagline: + ko: systemd 없이 init을 직접 고를 수 있게 만든 Debian 포크 + en: A Debian fork without systemd that leaves the choice of init system to you +aliases: [데부안, devuan] +logo: + file: devuan.svg + source: https://commons.wikimedia.org/wiki/File:Devuan-emblem.svg + license: Public domain (simple geometry); Devuan trademark of the Dyne.org foundation +download: + strategy: mirrors +sources: + - https://www.devuan.org/ + - https://www.devuan.org/get-devuan + - https://www.devuan.org/os/init-freedom + - https://www.devuan.org/os/announce/stable-jessie-announce-052517 + - https://www.devuan.org/os/announce/excalibur-release-announce-2025-11-02 + - https://www.devuan.org/os/documentation/install-guides/excalibur/install-devuan + - https://files.devuan.org/devuan_excalibur/Release_notes.txt + - https://files.devuan.org/devuan_excalibur/desktop-live/README_desktop-live.txt +lastReviewed: 2026-10-03 diff --git a/src/content/distros/elementary-os.yaml b/src/content/distros/elementary-os.yaml new file mode 100644 index 0000000..dfed086 --- /dev/null +++ b/src/content/distros/elementary-os.yaml @@ -0,0 +1,50 @@ +name: elementary OS +order: 13 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: fixed +desktops: [pantheon] +packageManagers: [apt, dpkg, flatpak] +initSystem: systemd +installer: elementary-installer +useCases: [desktop, beginner] +difficulty: beginner +architectures: [x86_64, aarch64] +secureBoot: "yes" +immutable: false +origin: + country: US + firstRelease: 2011-03-31 +requirements: + ramGB: 4 + diskGB: 32 + note: + ko: 권장 사양 기준입니다(최신 Core i3급 듀얼코어, SSD). + en: Recommended specs (a recent Core i3-class dual-core CPU and an SSD). +links: + home: https://elementary.io/ + download: https://elementary.io/#download + docs: https://elementary.io/docs + installGuide: https://elementary.io/docs/installation + releaseNotes: https://blog.elementary.io/ + source: https://github.com/elementary +tagline: + ko: 디자인을 중시한 Pantheon 데스크톱의 Ubuntu 기반 배포판 + en: An Ubuntu-based distro built around the design-focused Pantheon desktop +aliases: [엘리멘터리, 엘리멘터리 OS, 엘리멘트리, elementary, elementaryos] +logo: + file: elementary-os.svg + source: https://raw.githubusercontent.com/elementary/brand/master/logomark.svg + license: Trademark of elementary, Inc., used to identify the project + trademark: https://elementary.io/brand +download: + strategy: official-page +sources: + - https://elementary.io/ + - https://elementary.io/docs/installation + - https://elementary.io/brand + - https://blog.elementary.io/ten-years/ + - https://blog.elementary.io/os-8-1-available-now/ + - https://github.com/elementary/os/wiki/OS-8-FAQ +lastReviewed: 2026-10-03 diff --git a/src/content/distros/endeavouros.yaml b/src/content/distros/endeavouros.yaml new file mode 100644 index 0000000..008dbf5 --- /dev/null +++ b/src/content/distros/endeavouros.yaml @@ -0,0 +1,51 @@ +name: EndeavourOS +order: 8 +family: arch +basedOn: arch +status: active +releaseModel: rolling +desktops: [kde, gnome, xfce, cinnamon, budgie, mate, i3, lxqt, lxde] +packageManagers: [pacman, yay] +initSystem: systemd +installer: calamares +useCases: [desktop, developer, tinkerer] +difficulty: intermediate +architectures: [x86_64, aarch64] +secureBoot: "no" +immutable: false +origin: + country: INT + firstRelease: 2019-07-15 +requirements: + ramGB: 4 + diskGB: 15 + note: + ko: 공식 홈페이지의 시스템 요구 사항입니다(64비트 듀얼코어 CPU). + en: System requirements from the official website (64-bit dual-core CPU). +links: + home: https://endeavouros.com/ + download: https://endeavouros.com/download/ + docs: https://discovery.endeavouros.com/ + installGuide: https://discovery.endeavouros.com/live-iso-tricks-tips/ + forum: https://forum.endeavouros.com/ + releaseNotes: https://endeavouros.com/news/ + source: https://github.com/endeavouros-team +tagline: + ko: Arch에 가깝게 유지하는 터미널 중심의 커뮤니티 배포판 + en: A terminal-centric, community-driven distro that stays close to Arch +aliases: [엔데버OS, 엔데버 OS, 엔데버, endeavour os, endeavour, eos] +logo: + file: endeavouros.svg + source: https://endeavouros.com/favicon.svg + license: GPL-3.0 (endeavouros-team/Branding); EndeavourOS mark, used to identify the project +download: + strategy: mirrors +sources: + - https://endeavouros.com/ + - https://endeavouros.com/download/ + - https://endeavouros.com/about-us/ + - https://endeavouros.com/media-images/ + - https://endeavouros.com/endeavouros-arm/ + - https://discovery.endeavouros.com/live-iso-tricks-tips/ + - https://raw.githubusercontent.com/endeavouros-team/Branding/main/LICENSE +lastReviewed: 2026-10-03 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/content/distros/kali-linux.yaml b/src/content/distros/kali-linux.yaml new file mode 100644 index 0000000..41fc021 --- /dev/null +++ b/src/content/distros/kali-linux.yaml @@ -0,0 +1,55 @@ +name: Kali Linux +order: 16 +family: debian +basedOn: debian +status: active +releaseModel: rolling +desktops: [xfce, gnome, kde] +packageManagers: [apt, dpkg] +initSystem: systemd +installer: debian-installer +useCases: [security] +difficulty: advanced +architectures: [x86_64, aarch64, armhf] +secureBoot: "no" +immutable: false +origin: + country: US + firstRelease: 2013-03-13 +requirements: + ramGB: 2 + diskGB: 20 + note: + ko: 기본 Xfce 데스크톱과 기본 도구 묶음(kali-linux-default) 기준 공식 권장치입니다. Burp Suite 같은 무거운 도구는 8GB RAM 이상을 권장합니다. + en: Official guidance for the default Xfce desktop with the kali-linux-default tool set; heavy tools such as Burp Suite call for 8 GB of RAM or more. +links: + home: https://www.kali.org/ + download: https://www.kali.org/get-kali/ + docs: https://www.kali.org/docs/ + installGuide: https://www.kali.org/docs/installation/hard-disk-install/ + forum: https://forums.kali.org/ + releaseNotes: https://www.kali.org/releases/ + source: https://gitlab.com/kalilinux +tagline: + ko: 침투 테스트와 보안 감사 도구를 모은 Debian 기반 배포판 + en: A Debian-based distro built around penetration testing and security auditing tools +aliases: [칼리 리눅스, 칼리, backtrack] +logo: + file: kali-linux.svg + source: https://gitlab.com/kalilinux/documentation/press-pack + license: Trademark of OffSec, used to identify the project (official press pack) + trademark: https://www.kali.org/docs/policy/trademark/ +download: + strategy: redirector +sources: + - https://www.kali.org/docs/installation/hard-disk-install/ + - https://www.kali.org/docs/introduction/what-image-to-download/ + - https://www.kali.org/docs/installation/installation-sizes/ + - https://www.kali.org/docs/introduction/should-i-use-kali-linux/ + - https://www.kali.org/docs/introduction/kali-linux-history/ + - https://www.kali.org/docs/introduction/press-release/ + - https://www.kali.org/docs/general-use/kali-branches/ + - https://www.kali.org/blog/kali-linux-2024-4-release/ + - https://www.kali.org/get-kali/ + - https://www.kali.org/docs/policy/trademark/ +lastReviewed: 2026-10-03 diff --git a/src/content/distros/kde-neon.yaml b/src/content/distros/kde-neon.yaml new file mode 100644 index 0000000..fccc26c --- /dev/null +++ b/src/content/distros/kde-neon.yaml @@ -0,0 +1,50 @@ +name: KDE neon +order: 22 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: semi-rolling +desktops: [kde] +packageManagers: [apt, dpkg, flatpak, snap] +initSystem: systemd +installer: calamares +useCases: [desktop, developer] +difficulty: intermediate +architectures: [x86_64] +secureBoot: "yes" +immutable: false +origin: + country: INT + firstRelease: 2016-06-08 +requirements: + ramGB: 4 + diskGB: 25 + note: + ko: 공식 권장 사양이 없어 일반적인 기준으로 적었습니다. + en: KDE neon publishes no official requirements; these are conservative general values. +links: + home: https://neon.kde.org/ + download: https://neon.kde.org/download + docs: https://neon.kde.org/faq + forum: https://discuss.kde.org/tag/neon + releaseNotes: https://blog.neon.kde.org/ + source: https://invent.kde.org/neon +tagline: + ko: KDE가 직접 만든 최신 Plasma를 담은 Ubuntu LTS 배포판 + en: The latest Plasma and KDE apps, packaged by KDE itself on an Ubuntu LTS base +aliases: [케이디이 네온, KDE 네온, neon, kde neon user edition] +logo: + file: kde-neon.svg + source: https://neon.kde.org/media/128x128.svg + license: KDE neon logo by the KDE community; KDE is a trademark of KDE e.V., used to identify the project +download: + strategy: redirector +sources: + - https://neon.kde.org/ + - https://neon.kde.org/faq + - https://neon.kde.org/download + - https://dot.kde.org/2016/06/08/kde-neon-user-edition-56-available-now/ + - https://blog.neon.kde.org/2020/08/10/kde-neon-rebased-on-20-04/ + - https://community.kde.org/Neon + - https://files.kde.org/neon/images/desktop/user/current/neon-user-desktop-current.manifest +lastReviewed: 2026-10-03 diff --git a/src/content/distros/kubuntu.yaml b/src/content/distros/kubuntu.yaml new file mode 100644 index 0000000..48298a1 --- /dev/null +++ b/src/content/distros/kubuntu.yaml @@ -0,0 +1,51 @@ +name: Kubuntu +order: 17 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: fixed +desktops: [kde] +packageManagers: [apt, dpkg, snap] +initSystem: systemd +installer: calamares +useCases: [desktop, beginner] +difficulty: beginner +architectures: [x86_64] +secureBoot: "yes" +immutable: false +origin: + country: INT + firstRelease: 2005-04-08 +requirements: + ramGB: 4 + diskGB: 25 + note: + ko: 공식 권장 사양이 없어 일반적인 기준으로 적었습니다. + en: No official requirements are published, so these are general guidelines. +links: + home: https://kubuntu.org/ + download: https://kubuntu.org/download/ + forum: https://discourse.ubuntu.com/c/flavors/kubuntu/187 + releaseNotes: https://kubuntu.org/news/ + source: https://github.com/kubuntu-team +tagline: + ko: KDE Plasma 데스크톱을 쓰는 Ubuntu 공식 플레이버 + en: The official Ubuntu flavor built around the KDE Plasma desktop +aliases: [쿠분투, kubuntu linux] +logo: + file: kubuntu.svg + source: https://raw.githubusercontent.com/kubuntu-team/kubuntu-branding/main/Assets/Logo/Square%20-%20Mono%20-%20Symbol.svg + license: CC0 1.0 (Kubuntu brand assets); Kubuntu is a trademark of Canonical Ltd. + trademark: https://canonical.com/legal/intellectual-property-policy +download: + strategy: mirrors +sources: + - https://kubuntu.org/about/ + - https://kubuntu.org/download/ + - https://kubuntu.org/alternative-downloads/ + - https://kubuntu.org/news/kubuntu-26-04-release-notes/ + - https://cdimage.ubuntu.com/kubuntu/releases/26.04.1/release/kubuntu-26.04.1-desktop-amd64.manifest + - https://lists.ubuntu.com/archives/ubuntu-announce/2005-April/000024.html + - https://raw.githubusercontent.com/kubuntu-team/kubuntu-branding/main/README.md + - https://canonical.com/legal/trademarks +lastReviewed: 2026-10-03 diff --git a/src/content/distros/linux-lite.yaml b/src/content/distros/linux-lite.yaml new file mode 100644 index 0000000..c921989 --- /dev/null +++ b/src/content/distros/linux-lite.yaml @@ -0,0 +1,51 @@ +name: Linux Lite +order: 26 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: lts +desktops: [xfce] +packageManagers: [apt, dpkg] +initSystem: systemd +installer: calamares +useCases: [beginner, desktop, lightweight] +difficulty: beginner +architectures: [x86_64] +secureBoot: "no" +immutable: false +origin: + country: NZ + firstRelease: 2012 +requirements: + ramGB: 4 + diskGB: 40 + note: + ko: 공식 최소 사양입니다(1.5GHz 듀얼코어 CPU, 1366×768 이상 화면). + en: Official minimum (1.5 GHz dual-core CPU, 1366×768 or larger display). +links: + home: https://www.linuxliteos.com/ + download: https://www.linuxliteos.com/download.php + docs: https://wiki.linuxliteos.com/en/home + installGuide: https://wiki.linuxliteos.com/en/install + forum: https://www.linuxliteos.com/forums/ + releaseNotes: https://www.linuxliteos.com/forums/forumdisplay.php?fid=11 + source: https://gitlab.com/linuxlite +tagline: + ko: Windows 사용자를 위한 Ubuntu LTS 기반 Xfce 배포판 + en: An Xfce distro on Ubuntu LTS, made for people coming from Windows +aliases: [리눅스 라이트, linuxlite, lite] +logo: + file: linux-lite.svg + source: https://www.linuxliteos.com/artwork.html + license: Official Linux Lite feather logo (artwork pack, used unmodified); Linux Lite trademark, used to identify the project + trademark: https://www.linuxliteos.com/artwork.html +download: + strategy: mirrors +sources: + - https://www.linuxliteos.com/ + - https://www.linuxliteos.com/download.php + - https://www.linuxliteos.com/forums/showthread.php?tid=9866 + - https://wiki.linuxliteos.com/en/install + - https://www.linuxliteos.com/artwork.html + - https://mirrors.linuxliteos.com/status/mirrors +lastReviewed: 2026-10-03 diff --git a/src/content/distros/linux-mint.yaml b/src/content/distros/linux-mint.yaml new file mode 100644 index 0000000..1e25db2 --- /dev/null +++ b/src/content/distros/linux-mint.yaml @@ -0,0 +1,52 @@ +name: Linux Mint +order: 1 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: lts +desktops: [cinnamon, xfce, mate] +packageManagers: [apt, dpkg, flatpak] +initSystem: systemd +installer: other +useCases: [beginner, desktop] +difficulty: beginner +architectures: [x86_64] +secureBoot: partial +immutable: false +origin: + country: IE + firstRelease: 2006-08 +requirements: + ramGB: 4 + diskGB: 20 + note: + ko: 메모리는 권장 사양(최소 2GB), 디스크는 최소 사양(권장 100GB)입니다. + en: RAM is the recommended amount (2 GB minimum); disk is the minimum (100 GB recommended). +links: + home: https://www.linuxmint.com/ + download: https://www.linuxmint.com/download.php + docs: https://linuxmint-user-guide.readthedocs.io/en/latest/ + installGuide: https://linuxmint-installation-guide.readthedocs.io/en/latest/ + forum: https://forums.linuxmint.com/ + releaseNotes: https://blog.linuxmint.com/ + source: https://github.com/linuxmint +tagline: + ko: 쓰기 쉬움을 앞세운 Cinnamon 데스크톱의 Ubuntu 기반 배포판 + en: An easy-to-use Ubuntu-based distro and the home of the Cinnamon desktop +aliases: [리눅스 민트, 민트, mint, lmde] +logo: + file: linux-mint.svg + source: https://raw.githubusercontent.com/linuxmint/mintstick/master/share/icons/hicolor/scalable/status/mintstick-logo-linuxmint.svg + license: GPL-2.0-or-later (mintstick package); Linux Mint trademark, used to identify the project +download: + strategy: mirrors +sources: + - https://www.linuxmint.com/download.php + - https://www.linuxmint.com/edition.php?id=326 + - https://www.linuxmint.com/rel_zena.php + - https://blog.linuxmint.com/?p=4981 + - https://www.linuxmint.com/about.php + - https://www.linuxmint.com/privacy.php + - https://linuxmint-installation-guide.readthedocs.io/en/latest/efi.html + - https://www.linuxmint.com/mirrors.php +lastReviewed: 2026-10-03 diff --git a/src/content/distros/lubuntu.yaml b/src/content/distros/lubuntu.yaml new file mode 100644 index 0000000..0534c5b --- /dev/null +++ b/src/content/distros/lubuntu.yaml @@ -0,0 +1,51 @@ +name: Lubuntu +order: 24 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: fixed +desktops: [lxqt] +packageManagers: [apt, dpkg, snap] +initSystem: systemd +installer: calamares +useCases: [lightweight, desktop, beginner] +difficulty: beginner +architectures: [x86_64] +secureBoot: "yes" +immutable: false +origin: + country: INT + firstRelease: 2011-10-13 +requirements: + ramGB: 2 + diskGB: 20 + note: + ko: 메모리는 Ubuntu 26.04 릴리스 노트 기준이며, 디스크는 공식 수치가 없어 일반적인 기준으로 적었습니다. + en: RAM follows the Ubuntu 26.04 release notes; no official disk figure is published, so that value is a general guideline. +links: + home: https://lubuntu.me/ + download: https://lubuntu.me/downloads/ + docs: https://manual.lubuntu.me/ + forum: https://discourse.ubuntu.com/c/flavors/lubuntu/343 + releaseNotes: https://lubuntu.me/blog/ + source: https://github.com/lubuntu-team +tagline: + ko: 가벼운 Qt 기반 LXQt 데스크톱의 Ubuntu 공식 플레이버 + en: The official Ubuntu flavor with the lightweight, Qt-based LXQt desktop +aliases: [루분투, lubuntu linux] +logo: + file: lubuntu.svg + source: https://raw.githubusercontent.com/lubuntu-team/artwork/ubuntu/plucky/src/usr/share/lxqt/themes/Lubuntu%20Arc/mainmenu.svg + license: GPL-2.0-or-later (lubuntu-artwork package); Lubuntu is a trademark of Canonical Ltd. + trademark: https://canonical.com/legal/intellectual-property-policy +download: + strategy: mirrors +sources: + - https://lubuntu.me/downloads/ + - https://lubuntu.me/lubuntu-26-04-lts-released/ + - https://cdimage.ubuntu.com/lubuntu/releases/26.04.1/release/lubuntu-26.04.1-desktop-amd64.manifest + - https://documentation.ubuntu.com/release-notes/26.04/ + - https://lists.ubuntu.com/archives/ubuntu-announce/2011-October/000153.html + - https://raw.githubusercontent.com/lubuntu-team/artwork/ubuntu/plucky/debian/copyright + - https://canonical.com/legal/trademarks +lastReviewed: 2026-10-03 diff --git a/src/content/distros/manjaro.yaml b/src/content/distros/manjaro.yaml new file mode 100644 index 0000000..0019a4b --- /dev/null +++ b/src/content/distros/manjaro.yaml @@ -0,0 +1,55 @@ +name: Manjaro +order: 9 +family: arch +basedOn: arch +status: active +releaseModel: rolling +desktops: [kde, xfce, gnome, cinnamon, i3, sway] +packageManagers: [pacman, pamac, flatpak] +initSystem: systemd +installer: calamares +useCases: [desktop, beginner] +difficulty: beginner +architectures: [x86_64, aarch64] +secureBoot: "no" +immutable: false +origin: + country: DE + firstRelease: 2011 +requirements: + ramGB: 4 + diskGB: 30 + note: + ko: Manjaro 위키의 권장 사양입니다(듀얼코어 CPU 이상). + en: Recommended in the Manjaro wiki (dual-core CPU or better). +links: + home: https://manjaro.org/ + download: https://manjaro.org/products/download/x86 + docs: https://wiki.manjaro.org/ + installGuide: https://wiki.manjaro.org/index.php?title=Installation_Guides + forum: https://forum.manjaro.org/ + releaseNotes: https://forum.manjaro.org/c/announcements/11 +tagline: + ko: 업데이트를 한 번 더 시험해 내보내는 Arch 기반 롤링 배포판 + en: An Arch-based rolling distro that tests updates before they ship +aliases: [만자로, 만자로 리눅스, manjaro linux] +logo: + file: manjaro.svg + source: https://manjaro.org/logo.svg + license: Public domain (simple geometry); Manjaro registered trademark of Manjaro GmbH & Co. KG + trademark: https://manjaro.org/terms-of-use +download: + strategy: redirector +sources: + - https://manjaro.org/products/download/x86 + - https://manjaro.org/products/download/arm + - https://wiki.manjaro.org/index.php?title=About_Manjaro + - https://wiki.manjaro.org/index.php?title=Manjaro:_A_Different_Kind_of_Beast + - https://wiki.manjaro.org/index.php?title=The_Rolling_Release_Development_Model + - https://wiki.manjaro.org/index.php?title=UEFI_-_Install_Guide + - https://forum.manjaro.org/t/manjaro-26-1-bian-may-released/189526 + - https://download.manjaro.org/kde/26.1.2/manjaro-kde-26.1.2-260910-linux71.iso.pkgs + - https://manjaro.org/team + - https://manjaro.org/imprint + - https://manjaro.org/terms-of-use +lastReviewed: 2026-10-03 diff --git a/src/content/distros/mx-linux.yaml b/src/content/distros/mx-linux.yaml new file mode 100644 index 0000000..739cb47 --- /dev/null +++ b/src/content/distros/mx-linux.yaml @@ -0,0 +1,47 @@ +name: MX Linux +order: 7 +family: debian +basedOn: debian +status: active +releaseModel: fixed +desktops: [xfce, kde, fluxbox] +packageManagers: [apt, dpkg, flatpak] +initSystem: systemd / SysVinit +installer: mx-installer +useCases: [beginner, desktop, lightweight] +difficulty: beginner +architectures: [x86_64, aarch64] +secureBoot: partial +immutable: false +origin: + country: INT + firstRelease: 2014 +requirements: + ramGB: 2 + diskGB: 35 + note: + ko: 공식 매뉴얼의 권장 사양입니다(최소 1GB RAM, 20GB 디스크, 64비트 CPU). + en: Recommended by the official manual (minimum 1 GB RAM and 20 GB disk; 64-bit CPU). +links: + home: https://mxlinux.org/ + download: https://mxlinux.org/download-links/ + docs: https://mxlinux.org/manuals/ + forum: https://forum.mxlinux.org/ + releaseNotes: https://mxlinux.org/blog/ + source: https://github.com/MX-Linux +tagline: + ko: Debian 안정판에 쓰기 쉬운 MX 도구를 더한 중간 무게 배포판 + en: A mid-weight desktop on Debian stable, with MX's own easy system tools +aliases: [엠엑스 리눅스, MX, mxlinux, mx-linux] +logo: + file: mx-linux.svg + source: https://mxrepo.com/mx/repo/pool/main/m/mx-welcome-data/ + license: LGPL-3.0+ (official mx-welcome-data package); MX Linux logo, used to identify the project +download: + strategy: sourceforge +sources: + - https://sourceforge.net/projects/mx-linux/files/Final/ + - https://sourceforge.net/projects/mx-linux/files/Final/Xfce/ + - https://mxrepo.com/mx/repo/pool/main/m/mx-docs/mx-docs-en_202609130mx25_all.deb + - https://mxrepo.com/mx/repo/pool/main/m/mx-welcome-data/ +lastReviewed: 2026-10-03 diff --git a/src/content/distros/parrot-os.yaml b/src/content/distros/parrot-os.yaml new file mode 100644 index 0000000..b4441cb --- /dev/null +++ b/src/content/distros/parrot-os.yaml @@ -0,0 +1,53 @@ +name: ParrotOS +order: 34 +family: debian +basedOn: debian +status: active +releaseModel: semi-rolling +desktops: [kde, mate, lxqt] +packageManagers: [apt, dpkg] +initSystem: systemd +installer: calamares +useCases: [security, privacy, developer] +difficulty: intermediate +architectures: [x86_64, aarch64, riscv64] +secureBoot: "no" +immutable: false +origin: + country: IT + firstRelease: 2013-04-10 +requirements: + ramGB: 8 + diskGB: 40 + note: + ko: Security 에디션 권장 사양입니다(최소 4GB RAM, 쿼드코어 CPU). Home 에디션은 4GB RAM, 20GB 디스크면 됩니다. + en: Recommended for the Security edition (4 GB RAM minimum, quad-core CPU). The Home edition is fine with 4 GB RAM and 20 GB of disk. +links: + home: https://parrotsec.org/ + download: https://parrotsec.org/download/ + docs: https://parrotsec.org/docs/ + installGuide: https://parrotsec.org/docs/installation/installation-parrot/ + forum: https://community.parrotsec.org/ + releaseNotes: https://parrotsec.org/blog/ + source: https://gitlab.com/parrotsec +tagline: + ko: 보안 점검과 개인정보 보호에 맞춘 Debian 기반 배포판 + en: A Debian-based distro for security work and privacy, with a daily-use Home edition +aliases: [패럿, 패럿 OS, parrot os, parrot security, parrotsec] +logo: + file: parrot-os.svg + source: https://parrotsec.org/docs/ + license: Trademark of Parrot Security CIC, used to identify the project +download: + strategy: redirector +sources: + - https://parrotsec.org/docs/ + - https://parrotsec.org/download/ + - https://parrotsec.org/docs/installation/installation-parrot/ + - https://parrotsec.org/docs/installation/dualboot-with-windows/ + - https://parrotsec.org/docs/configuration/desktop-environments/ + - https://parrotsec.org/docs/mirrors/mirrors-list/ + - https://parrotsec.org/blog/2025-12-24-parrot-7.0-release-notes/ + - https://parrotsec.org/blog/2026-06-30-parrot-7.3-release-notes/ + - https://parrotsec.org/blog/2026-07-03-parrot-speaks-riscv/ +lastReviewed: 2026-10-03 diff --git a/src/content/distros/peppermint-os.yaml b/src/content/distros/peppermint-os.yaml new file mode 100644 index 0000000..8eaa137 --- /dev/null +++ b/src/content/distros/peppermint-os.yaml @@ -0,0 +1,50 @@ +name: Peppermint OS +order: 48 +family: debian +basedOn: debian +status: active +releaseModel: fixed +desktops: [xfce] +packageManagers: [apt, dpkg] +initSystem: systemd +installer: calamares +useCases: [lightweight, tinkerer, desktop] +difficulty: intermediate +architectures: [x86_64] +secureBoot: "yes" +immutable: false +origin: + country: US + firstRelease: 2010-05-09 +requirements: + ramGB: 4 + diskGB: 32 + note: + ko: 공식 권장 사양입니다(최소 1GB RAM, 10GB 디스크). + en: Officially recommended (minimum 1 GB RAM and 10 GB disk). +links: + home: https://peppermintos.com/ + download: https://peppermintos.com/trixie-base-downloads/ + docs: https://peppermint_os.codeberg.page/html/ + forum: https://sourceforge.net/p/peppermintos/pepos/ + releaseNotes: https://sourceforge.net/p/peppermintos/pepwiki/BuildDate/ + source: https://codeberg.org/Peppermint_OS +tagline: + ko: 필요한 앱만 골라 까는 최소 구성의 Debian 기반 Xfce 배포판 + en: A minimal Debian-based Xfce desktop where you choose every app yourself +aliases: [페퍼민트, 페퍼민트 OS, peppermint, pepos] +logo: + file: peppermint-os.svg + source: https://commons.wikimedia.org/wiki/File:Peppermint_OS_Master_Banner_1.svg + license: CC BY-SA 4.0 (uploaded by the Peppermint OS team); Peppermint OS trademark +download: + strategy: sourceforge +sources: + - https://peppermintos.com/ + - https://peppermintos.com/about/ + - https://peppermintos.com/trixie-base-downloads/ + - https://peppermintos.com/2025/10/peppermint-os-trixie-base-released/ + - https://peppermintos.com/2010/05/come-and-get-it/ + - https://sourceforge.net/p/peppermintos/pepos/general/thread/af01953291/ + - https://sourceforge.net/p/peppermintos/pepwiki/UEFI%20and%20Secure%20Boot%20Install/ +lastReviewed: 2026-10-03 diff --git a/src/content/distros/pop-os.yaml b/src/content/distros/pop-os.yaml new file mode 100644 index 0000000..4304861 --- /dev/null +++ b/src/content/distros/pop-os.yaml @@ -0,0 +1,49 @@ +name: Pop!_OS +order: 5 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: lts +desktops: [cosmic] +packageManagers: [apt, dpkg, flatpak] +initSystem: systemd +installer: pop-installer +useCases: [desktop, gaming, developer] +difficulty: beginner +architectures: [x86_64, aarch64] +secureBoot: "no" +immutable: false +origin: + country: US + firstRelease: 2017-10 +requirements: + ramGB: 8 + diskGB: 20 + note: + ko: System76 설치 문서의 권장 사양입니다(최소 메모리 4GB). + en: Recommended in System76's install guide (4 GB RAM minimum). +links: + home: https://system76.com/pop + download: https://system76.com/download-pop/ + docs: https://system76.com/support/ + installGuide: https://system76.com/support/install-pop/ + forum: https://chat.pop-os.org/ + source: https://github.com/pop-os +tagline: + ko: System76이 만든 COSMIC 데스크톱의 Ubuntu 기반 배포판 + en: System76's Ubuntu-based distro with its own COSMIC desktop +aliases: [팝OS, 팝 OS, pop os, popos, pop_os] +logo: + file: pop-os.svg + source: https://raw.githubusercontent.com/pop-os/default-settings/master/usr/share/icons/pop-os-branding/round-logos/64/distributor-logo.svg + license: GPL-3.0-or-later (pop-default-settings); Pop!_OS mark of System76, used to identify the project +download: + strategy: redirector +sources: + - https://system76.com/pop + - https://system76.com/download-pop/ + - https://system76.com/support/install-pop/ + - https://system76.com/blog/post/celebrating-5-years-of-pop_os + - https://api.pop-os.org/builds/24.04/generic?arch=amd64 + - https://raw.githubusercontent.com/pop-os/default-settings/master/debian/copyright +lastReviewed: 2026-10-03 diff --git a/src/content/distros/q4os.yaml b/src/content/distros/q4os.yaml new file mode 100644 index 0000000..907b6f4 --- /dev/null +++ b/src/content/distros/q4os.yaml @@ -0,0 +1,51 @@ +name: Q4OS +order: 47 +family: debian +basedOn: debian +status: active +releaseModel: lts +desktops: [kde, trinity] +packageManagers: [apt, dpkg] +initSystem: systemd +installer: calamares +useCases: [beginner, lightweight, desktop] +difficulty: beginner +architectures: [x86_64, aarch64, i686] +secureBoot: "yes" +immutable: false +origin: + country: CZ + firstRelease: 2014 +requirements: + ramGB: 2 + diskGB: 8 + note: + ko: Plasma 데스크톱 기준 공식 최소 사양입니다(Trinity는 512MB RAM, 6GB 디스크). + en: Official minimum for the Plasma desktop (Trinity needs 512 MB RAM and 6 GB disk). +links: + home: https://www.q4os.org/ + download: https://www.q4os.org/downloads1.html + docs: https://www.q4os.org/documents.html + installGuide: https://www.q4os.org/dqa018.html + forum: https://www.q4os.org/forum/ + releaseNotes: https://www.q4os.org/blog.html +tagline: + ko: Plasma나 Trinity를 고르는 Debian 기반 장기 지원 배포판 + en: A Debian-based LTS desktop with a choice of Plasma or the lightweight Trinity +aliases: [큐포스, 큐포에스, q4os andromeda] +logo: + file: q4os.svg + source: https://commons.wikimedia.org/wiki/File:Q4OS_logo.svg + license: Public domain (text logo, vectorized from the official banner); Q4OS trademark +download: + strategy: sourceforge +sources: + - https://www.q4os.org/ + - https://www.q4os.org/downloads1.html + - https://www.q4os.org/blog.html + - https://www.q4os.org/business.html + - https://www.q4os.org/dqa018.html + - https://www.q4os.org/dqa014.html + - https://www.q4os.org/forum/viewtopic.php?id=5030 + - https://www.q4os.org/forum/viewtopic.php?id=3540 +lastReviewed: 2026-10-03 diff --git a/src/content/distros/raspberry-pi-os.yaml b/src/content/distros/raspberry-pi-os.yaml new file mode 100644 index 0000000..2883dcb --- /dev/null +++ b/src/content/distros/raspberry-pi-os.yaml @@ -0,0 +1,52 @@ +name: Raspberry Pi OS +order: 38 +family: debian +basedOn: debian +status: active +releaseModel: fixed +desktops: [lxde] +packageManagers: [apt, dpkg] +initSystem: systemd +installer: raspberry-pi-imager +useCases: [education, tinkerer, lightweight] +difficulty: beginner +architectures: [aarch64, armhf] +secureBoot: "no" +immutable: false +origin: + country: GB + firstRelease: 2012-07 +requirements: + ramGB: 1 + diskGB: 32 + note: + ko: Raspberry Pi 보드 전용입니다. 데스크톱 판은 32GB 이상 SD 카드를 권장하며, 공식 RAM 권장치는 없지만 Pi 3(1GB) 이상에서 쓸 만하다고 안내합니다. PC용 UEFI Secure Boot는 해당되지 않습니다. + en: Runs on Raspberry Pi boards only. A 32 GB or larger SD card is recommended for the desktop image; there is no official RAM figure, but Pi 3 (1 GB) and newer are described as practical. PC-style UEFI Secure Boot does not apply. +links: + home: https://www.raspberrypi.com/software/ + download: https://www.raspberrypi.com/software/operating-systems/ + docs: https://www.raspberrypi.com/documentation/computers/os.html + installGuide: https://www.raspberrypi.com/documentation/computers/getting-started.html + forum: https://forums.raspberrypi.com/ + source: https://github.com/RPi-Distro/pi-gen +tagline: + ko: Raspberry Pi 보드를 위한 Debian 기반 공식 운영체제 + en: The official Debian-based operating system for Raspberry Pi computers +aliases: [라즈베리 파이 OS, 라즈베리파이, raspbian, 라즈비안, raspios] +logo: + file: raspberry-pi-os.svg + source: https://github.com/raspberrypi/documentation + license: Trademark of Raspberry Pi Ltd, used to identify the product (logo from the official documentation site header) + trademark: https://www.raspberrypi.com/trademark-rules/ +download: + strategy: official-page +sources: + - https://www.raspberrypi.com/software/operating-systems/ + - https://www.raspberrypi.com/documentation/computers/os.html + - https://www.raspberrypi.com/documentation/computers/getting-started.html + - https://www.raspberrypi.com/news/trixie-the-new-version-of-raspberry-pi-os/ + - https://www.raspberrypi.com/news/a-new-release-of-raspberry-pi-os/ + - https://www.raspberrypi.com/news/introducing-pixel/ + - https://www.raspberrypi.com/news/raspbian-sneak-peek-from-dom/ + - https://www.raspberrypi.com/trademark-rules/ +lastReviewed: 2026-10-03 diff --git a/src/content/distros/tuxedo-os.yaml b/src/content/distros/tuxedo-os.yaml new file mode 100644 index 0000000..ce24b56 --- /dev/null +++ b/src/content/distros/tuxedo-os.yaml @@ -0,0 +1,47 @@ +name: TUXEDO OS +order: 37 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: semi-rolling +desktops: [kde] +packageManagers: [apt, dpkg, flatpak] +initSystem: systemd +installer: calamares +useCases: [desktop, beginner, gaming] +difficulty: beginner +architectures: [x86_64] +secureBoot: "no" +immutable: false +origin: + country: DE + firstRelease: 2022-10-06 +requirements: + ramGB: 4 + diskGB: 25 + note: + ko: 공식 권장 사양이 없어 일반적인 기준으로 적었습니다. + en: TUXEDO publishes no official requirements; these are conservative general values. +links: + home: https://www.tuxedocomputers.com/en/TUXEDO-OS_1.tuxedo + download: https://os.tuxedocomputers.com/ + docs: https://www.tuxedocomputers.com/en/First-Steps-with-TUXEDO-OS.tuxedo + installGuide: https://www.tuxedocomputers.com/en/Infos/Help-and-Support/Instructions/How-do-I-create-and-start-a-Live-Medium.tuxedo + source: https://gitlab.com/tuxedocomputers/development/tuxedo_os +tagline: + ko: TUXEDO가 자사 노트북·PC에 맞춰 다듬은 KDE Plasma 배포판 + en: A KDE Plasma distro from TUXEDO Computers, tuned for its own laptops and PCs +aliases: [턱시도 OS, 턱시도, tuxedo] +logo: + file: tuxedo-os.svg + source: https://os.tuxedocomputers.com/html/icon.svg + license: Trademark of TUXEDO Computers GmbH, used to identify the project +download: + strategy: official-page +sources: + - https://www.tuxedocomputers.com/en/TUXEDO-OS_1.tuxedo + - https://os.tuxedocomputers.com/ + - https://os.tuxedocomputers.com/TUXEDO-OS_current.manifest + - https://www.tuxedocomputers.com/en/Tailor-made-OS-for-your-TUXEDO-TUXEDO-OS-1-available-as-ISO.tuxedo + - https://www.tuxedocomputers.com/en/Infos/Help-Support/Frequently-asked-questions/What-is-the-differences-between-TUXEDO-OS-and-Ubuntu/Kubuntu-_1.tuxedo +lastReviewed: 2026-10-03 diff --git a/src/content/distros/ubuntu-mate.yaml b/src/content/distros/ubuntu-mate.yaml new file mode 100644 index 0000000..b2a4d0e --- /dev/null +++ b/src/content/distros/ubuntu-mate.yaml @@ -0,0 +1,51 @@ +name: Ubuntu MATE +order: 25 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: fixed +desktops: [mate] +packageManagers: [apt, dpkg, snap] +initSystem: systemd +installer: ubuntu-desktop +useCases: [desktop, lightweight, beginner] +difficulty: beginner +architectures: [x86_64] +secureBoot: "yes" +immutable: false +origin: + country: INT + firstRelease: 2014-10-23 +requirements: + ramGB: 4 + diskGB: 20 + note: + ko: 권장 사양 기준입니다(Core i3 이상, SSD). 최소 사양은 메모리 2GB, 디스크 10GB입니다. + en: Recommended specs (Core i3 or better, SSD). The minimum is 2 GB of RAM and 10 GB of disk. +links: + home: https://ubuntu-mate.org/ + download: https://ubuntu-mate.org/download/ + docs: https://guide.ubuntu-mate.org/ + forum: https://ubuntu-mate.community/ + releaseNotes: https://ubuntu-mate.org/blog/ + source: https://github.com/ubuntu-mate +tagline: + ko: 전통적인 MATE 데스크톱을 쓰는 Ubuntu 공식 플레이버 + en: The official Ubuntu flavor with the traditional MATE desktop +aliases: [우분투 메이트, 우분투 마테, ubuntu-mate] +logo: + file: ubuntu-mate.svg + source: https://ubuntu-mate.org/images/brands/ubuntu-mate.svg + license: CC BY-SA 4.0 (ubuntu-mate.org graphics); Ubuntu is a trademark of Canonical Ltd. + trademark: https://canonical.com/legal/intellectual-property-policy +download: + strategy: mirrors +sources: + - https://ubuntu-mate.org/about/requirements/ + - https://ubuntu-mate.org/about/history/ + - https://ubuntu-mate.org/download/ + - https://ubuntu-mate.org/blog/ubuntu-mate-noble-numbat-release-notes/ + - https://ubuntu-mate.org/blog/ubuntu-mate-utopic-final-release/ + - https://cdimage.ubuntu.com/ubuntu-mate/releases/ + - https://raw.githubusercontent.com/ubuntu-mate/ubuntu-mate.org/master/LICENSE.md +lastReviewed: 2026-10-03 diff --git a/src/content/distros/ubuntu.yaml b/src/content/distros/ubuntu.yaml new file mode 100644 index 0000000..83bf4a7 --- /dev/null +++ b/src/content/distros/ubuntu.yaml @@ -0,0 +1,54 @@ +name: Ubuntu +order: 2 +family: ubuntu +basedOn: debian +status: active +releaseModel: fixed +desktops: [gnome, kde, xfce, lxqt, mate, budgie, cinnamon, ukui] +packageManagers: [apt, dpkg, snap] +initSystem: systemd +installer: ubuntu-desktop +useCases: [desktop, beginner, developer, server] +difficulty: beginner +architectures: [x86_64, aarch64, ppc64le, s390x, riscv64] +secureBoot: "yes" +immutable: false +origin: + country: GB + firstRelease: 2004-10-20 +requirements: + ramGB: 6 + diskGB: 25 + note: + ko: Ubuntu Desktop 26.04 LTS 기준 권장 사양입니다(2GHz 듀얼코어 이상). + en: Recommended for Ubuntu Desktop 26.04 LTS (2 GHz dual-core or better). +links: + home: https://ubuntu.com/ + download: https://ubuntu.com/download/desktop + docs: https://ubuntu.com/desktop/docs/en/latest/ + installGuide: https://ubuntu.com/desktop/docs/en/latest/tutorial/install-ubuntu-desktop/ + forum: https://discourse.ubuntu.com/ + releaseNotes: https://documentation.ubuntu.com/release-notes/ + source: https://code.launchpad.net/ubuntu +tagline: + ko: 6개월마다 새 버전, 2년마다 LTS를 내는 Canonical의 배포판 + en: Canonical's distro, with a new release every six months and an LTS every two years +aliases: [우분투, ubuntu desktop, ubuntu linux] +logo: + file: ubuntu.svg + source: https://assets.ubuntu.com/v1/ce518a18-CoF-2022_solid+O.svg + license: Trademark of Canonical Ltd., used to identify the project (Wikimedia Commons lists the 2022 Circle of Friends as GPL-3.0-or-later) + trademark: https://canonical.com/legal/intellectual-property-policy +download: + strategy: mirrors +sources: + - https://ubuntu.com/download/desktop + - https://ubuntu.com/desktop/flavors + - https://documentation.ubuntu.com/release-notes/26.04/ + - https://documentation.ubuntu.com/security/security-features/platform-protections/secure-boot/ + - https://cdimage.ubuntu.com/releases/26.04/release/ + - https://lists.ubuntu.com/archives/ubuntu-announce/2004-October/000003.html + - https://canonical.com/legal/data-privacy + - https://design.ubuntu.com/brand + - https://canonical.com/legal/trademarks +lastReviewed: 2026-10-03 diff --git a/src/content/distros/xubuntu.yaml b/src/content/distros/xubuntu.yaml new file mode 100644 index 0000000..859162e --- /dev/null +++ b/src/content/distros/xubuntu.yaml @@ -0,0 +1,53 @@ +name: Xubuntu +order: 23 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: fixed +desktops: [xfce] +packageManagers: [apt, dpkg, snap] +initSystem: systemd +installer: ubuntu-desktop +useCases: [desktop, lightweight, beginner] +difficulty: beginner +architectures: [x86_64] +secureBoot: "yes" +immutable: false +origin: + country: INT + firstRelease: 2006-06-01 +requirements: + ramGB: 2 + diskGB: 20 + note: + ko: 권장 사양 기준입니다(1.5GHz 듀얼코어 이상). 최소 사양은 메모리 1GB, 디스크 8.6GB입니다. + en: Recommended specs (1.5 GHz dual-core or better). The minimum is 1 GB of RAM and 8.6 GB of disk. +links: + home: https://xubuntu.org/ + download: https://xubuntu.org/download/ + docs: https://docs.xubuntu.org/ + installGuide: https://docs.xubuntu.org/user/C/installation.html + forum: https://discourse.ubuntu.com/c/flavors/xubuntu/196 + releaseNotes: https://xubuntu.org/releases/ + source: https://github.com/Xubuntu +tagline: + ko: 가볍고 안정적인 Xfce 데스크톱의 Ubuntu 공식 플레이버 + en: The official Ubuntu flavor with the light, stable Xfce desktop +aliases: [주분투, xubuntu linux] +logo: + file: xubuntu.svg + source: https://raw.githubusercontent.com/Xubuntu/xubuntu-marketing/master/logo/icon.svg + license: Xubuntu is a trademark of Canonical Ltd.; official icon used unmodified to identify the project (no license stated in the source repository) + trademark: https://canonical.com/legal/intellectual-property-policy +download: + strategy: mirrors +sources: + - https://xubuntu.org/download/ + - https://xubuntu.org/releases/ + - https://xubuntu.org/release/6.06/ + - https://xubuntu.org/releasedocs/26.04/release-notes/ + - https://xubuntu.org/releasedocs/24.04/release-notes/ + - https://cdimage.ubuntu.com/xubuntu/releases/26.04.1/release/xubuntu-26.04.1-desktop-amd64.manifest + - https://raw.githubusercontent.com/Xubuntu/xubuntu-marketing/master/logo/README.md + - https://canonical.com/legal/trademarks +lastReviewed: 2026-10-03 diff --git a/src/content/distros/zorin-os.yaml b/src/content/distros/zorin-os.yaml new file mode 100644 index 0000000..2d74d26 --- /dev/null +++ b/src/content/distros/zorin-os.yaml @@ -0,0 +1,53 @@ +name: Zorin OS +order: 10 +family: ubuntu +basedOn: ubuntu +status: active +releaseModel: fixed +desktops: [gnome, xfce] +packageManagers: [apt, dpkg, flatpak, snap] +initSystem: systemd +installer: other +useCases: [beginner, desktop] +difficulty: beginner +architectures: [x86_64] +secureBoot: partial +immutable: false +origin: + country: IE + firstRelease: 2009-07-01 +requirements: + ramGB: 2 + diskGB: 15 + note: + ko: Core 에디션 최소 사양입니다(1GHz 듀얼코어 이상). Education은 35GB, Pro는 45GB가 필요합니다. + en: Minimum for the Core edition (1 GHz dual-core or better). Education needs 35 GB and Pro needs 45 GB. +links: + home: https://zorin.com/os/ + download: https://zorin.com/os/download/ + docs: https://help.zorin.com/ + installGuide: https://help.zorin.com/docs/getting-started/install-zorin-os/ + forum: https://forum.zorin.com/ + releaseNotes: https://blog.zorin.com/ +tagline: + ko: Windows에서 옮겨 오기 쉽게 다듬은 Ubuntu 기반 배포판 + en: An Ubuntu-based distro polished for people moving from Windows +aliases: [조린, 조린 OS, zorin, zorinos] +logo: + file: zorin-os.svg + source: https://zorin.com/press/ + license: Registered trademark of Zorin Technology Group Ltd.; press-kit logomark, use permitted for non-commercial purposes + trademark: https://zorin.com/press/ +download: + strategy: redirector +sources: + - https://zorin.com/os/ + - https://zorin.com/os/download/ + - https://zorin.com/about/ + - https://zorin.com/os/archive/ + - https://help.zorin.com/docs/getting-started/system-requirements/ + - https://help.zorin.com/docs/getting-started/install-zorin-os/ + - https://help.zorin.com/docs/getting-started/getting-zorin-os-lite/ + - https://help.zorin.com/docs/apps-games/install-apps/ + - https://zorin.com/press/ +lastReviewed: 2026-10-03 diff --git a/src/content/learn/en/desktop-environments.mdx b/src/content/learn/en/desktop-environments.mdx new file mode 100644 index 0000000..50c9c13 --- /dev/null +++ b/src/content/learn/en/desktop-environments.mdx @@ -0,0 +1,211 @@ +--- +title: Desktop environments and window managers +description: Compare desktop environments such as GNOME, KDE Plasma, Xfce and Cinnamon with tiling window managers like i3, Sway and Hyprland, and learn how to try or install a different desktop. +navTitle: Desktop environments +order: 5 +group: distros +icon: app-grid +related: [kubuntu, linux-mint, xubuntu, pop-os] +sources: + - https://wiki.archlinux.org/title/Desktop_environment + - https://wiki.archlinux.org/title/Window_manager + - https://wiki.archlinux.org/title/Display_manager + - https://www.gnome.org/ + - https://wiki.archlinux.org/title/GNOME + - https://kde.org/plasma-desktop/ + - https://wiki.archlinux.org/title/KDE + - https://blogs.kde.org/2025/11/26/going-all-in-on-a-wayland-future/ + - https://www.xfce.org/about + - https://wiki.archlinux.org/title/Xfce + - https://www.linuxmint.com/download.php + - https://www.linuxmint.com/rel_virginia_whatsnew.php + - https://wiki.archlinux.org/title/Cinnamon + - https://mate-desktop.org/ + - https://lxqt-project.org/about/ + - https://wiki.archlinux.org/title/LXQt + - https://buddiesofbudgie.org/ + - https://wiki.archlinux.org/title/Budgie + - https://system76.com/cosmic + - https://wiki.archlinux.org/title/COSMIC + - https://elementary.io/ + - https://wiki.archlinux.org/title/Pantheon + - https://i3wm.org/ + - https://wiki.archlinux.org/title/I3 + - https://wiki.archlinux.org/title/Wayland + - https://fedoraproject.org/atomic-desktops/ + - https://swaywm.org/ + - https://hypr.land/ + - https://wiki.archlinux.org/title/Hyprland + - https://ubuntu.com/desktop/flavours + - https://wiki.debian.org/DesktopEnvironment + - https://docs.fedoraproject.org/en-US/quick-docs/switching-desktop-environments/ +lastReviewed: 2026-10-03 +--- + +On Windows and macOS, the look of the desktop is part of the operating system. On Linux, the **desktop environment (DE)** is a separate, swappable layer. The same distro can look like a completely different system depending on its desktop — and if you don’t like yours, you can change it without reinstalling. + +This chapter explains what a desktop environment is made of, what sets the major desktops apart, how keyboard-driven tiling window managers work, and how to try a different desktop safely. + +## What makes up a desktop environment + +A desktop environment is a matched set of programs: + +- A **window manager** or **compositor**, which draws windows and decides how they move and overlap. +- **Panels and launchers** — the taskbar, app menu, system tray and notifications. +- A **settings app** for displays, keyboard, network and appearance. +- **Core apps** such as a file manager, terminal, text editor and image viewer. + +On top of that sits a **display manager** — the login screen. GNOME usually uses GDM and KDE Plasma uses SDDM, while LightDM isn’t tied to any one desktop. With several desktops installed, the login screen lets you choose which **session** to start. + +Desktops are built on different **toolkits**. GNOME, Xfce, Cinnamon, MATE, Budgie and Pantheon use **GTK**; KDE Plasma and LXQt use **Qt**; COSMIC uses the Rust toolkit **iced**. Apps built with any toolkit run on any desktop, though themes and dialogs may look a little out of place. Because most desktops follow shared freedesktop.org specifications, basics like app menus, icons and autostart work across all of them. + + + On X11, the window manager, the compositing manager and the display server are separate programs. On Wayland, a single compositor does all three jobs. [X11 and Wayland](../display-servers/) covers the difference in detail. + + +## At a glance + +| Desktop | Toolkit | Default layout | Resource use (rough) | Suits | Typical distros | +|---|---|---|---|---|---| +| GNOME | GTK | Top bar and Activities overview | Medium to high | A focused, minimal workflow; touchpad gestures | Fedora Workstation, Ubuntu | +| KDE Plasma | Qt | Bottom panel and app launcher | Medium | People who love to customize; Windows switchers | Kubuntu, Fedora KDE | +| Xfce | GTK | Classic panel and menu | Low | Older PCs; a light, steady desktop | Xubuntu, Linux Mint Xfce | +| Cinnamon | GTK | Bottom panel and menu | Medium | Newcomers who want a Windows-like layout | Linux Mint | +| MATE | GTK | GNOME 2-style top and bottom panels | Low | Fans of the classic desktop | Ubuntu MATE | +| LXQt | Qt | Bottom panel and menu | Very low | Low-spec machines | Lubuntu | +| Budgie | GTK | Panel, Raven sidebar, choice of layouts | Medium | A tidy desktop that’s easy to personalize | Ubuntu Budgie | +| COSMIC | iced | Panel and dock, optional tiling | Medium | Mixing mouse use with tiling | Pop!\_OS | +| Pantheon | GTK | Top bar and bottom dock | Medium | A simple, polished design | elementary OS | + + + The resource column is a rough ordering, not a measurement. Real memory use varies a lot with the distro’s defaults, the version and whatever apps you have open — a handful of browser tabs often uses more memory than the desktop itself. + + +## The major desktops + +### GNOME + +GNOME is built around the **Activities** overview: press Super to launch apps, switch windows and search, all from one place. It’s designed to give you room to work across workspaces, and its core apps share the same design principles. It’s the default on Fedora Workstation, Ubuntu, Debian, RHEL and others, and some distros adjust its default look. Extensions let you change how it behaves. GNOME runs on Wayland, with X11 apps handled by XWayland. + +### KDE Plasma + +Plasma starts out with a familiar bottom panel and app launcher, but its defining trait is that nearly everything — panel position, colors, fonts, widgets — can be changed. **KRunner** finds apps, files and even calculation results as you type, and **Discover** manages distro packages, Flatpaks and Snaps in one place. The Wayland session is the default, and KDE is moving to drop the X11 session altogether. + +### Xfce + +Xfce is a lightweight GTK desktop that aims to be fast and easy on system resources. Its parts — window manager, panel, file manager and so on — are packaged separately, so you can pick only what you need. Its traditional layout has stayed consistent for years, which makes it familiar and dependable. Wayland support is still experimental. + +### Cinnamon + +Cinnamon is developed primarily by and for Linux Mint and pairs a traditional layout with modern effects. It started as a fork of GNOME but is now a complete, independent desktop. It has long run on X11, and since Cinnamon 6.0 it has also offered a Wayland session as an experimental option. + +### MATE and LXQt + +**MATE** continues GNOME 2, keeping its classic top-and-bottom-panel layout while staying light. **LXQt** is a lightweight Qt desktop that aims for a modern look without slowing the system down. Both are good matches for older laptops and PCs with little memory. LXQt can use different window managers on X11 and also runs on top of several Wayland compositors. + +### Budgie + +Budgie began as part of Solus and is now developed by the Buddies of Budgie project. Its **Raven** sidebar gathers notifications and widgets, and its settings let you switch between a traditional, a Unity-like and a macOS-like layout. Starting with Budgie 10.10 it moved to Wayland and dropped X11 support. + +### COSMIC + +COSMIC is System76’s desktop for Pop!\_OS. It started life as a heavily modified GNOME and was then rebuilt from scratch in Rust. It’s Wayland-only by design and lets you turn automatic **tiling** on or off on top of a normal mouse-driven layout. Fedora offers a COSMIC edition as well. + +### Pantheon + +Pantheon is elementary OS’s desktop, written in Vala with GTK and elementary’s Granite library. It favors a simple, consistent design with a top bar and a bottom dock, and elementary OS is moving to Wayland through its “Secure Session.” + +## Tiling window managers and compositors + +Most desktops use a **stacking** (or floating) model, where windows overlap like sheets of paper. A **tiling** window manager instead arranges windows automatically so they never overlap, and you drive it mostly with keyboard shortcuts. **Dynamic** window managers can switch between the two. + +| Name | Display server | What stands out | +|---|---|---| +| i3 | X11 | A tiling window manager written from scratch, with a simple config file and good documentation | +| Sway | Wayland | A Wayland compositor designed as a drop-in replacement for i3; most i3 configs work as-is | +| Hyprland | Wayland | Dynamic tiling plus animations, rounded corners and blur, extensible with plugins | +| niri | Wayland | A scrollable-tiling compositor that lays windows out in an endless horizontal strip | + +An i3 or Sway configuration looks like this. `$mod` is usually set to the Super key (Mod4); i3’s own default is Alt (Mod1). + +```text +set $mod Mod4 +bindsym $mod+Return exec foot +bindsym $mod+h focus left +bindsym $mod+1 workspace number 1 +bindsym $mod+Shift+q kill +``` + +Tiling window managers often handle only windows, so you choose and configure the rest yourself: a status bar, an app launcher, notifications, a screen locker and a wallpaper tool. The result is light and fits you exactly, but expect to spend some time on the initial setup. + + + If building everything from scratch sounds like a lot, start with a desktop that includes tiling (COSMIC) or a distro edition with Sway preconfigured, such as Fedora Sway Atomic. You can also browse distros that ship [i3](../../distros/?desktop=i3), [Sway](../../distros/?desktop=sway) or [Hyprland](../../distros/?desktop=hyprland). + + +## Trying a different desktop + +From lowest to highest risk, there are three ways to try another desktop: + + + +1. **Boot a live USB** of a distro that ships the desktop you’re curious about — Kubuntu, Xubuntu or Fedora KDE, for example — and use it without installing. +2. **Install it in a virtual machine** such as GNOME Boxes or VirtualBox and live with it for a while, without touching your current system. +3. **Add it to your current system** with the commands below, log out, and pick the new session on the login screen. In GDM, select your user and then use the settings (gear) icon that appears. + + + + + + +On Ubuntu, install an official flavor’s metapackage; on Debian, install a `task-` package. + +```bash +# Ubuntu: KDE Plasma (Kubuntu), Xfce (Xubuntu) +sudo apt install kubuntu-desktop +sudo apt install xubuntu-desktop + +# Debian: KDE Plasma, Xfce +sudo apt install task-kde-desktop +sudo apt install task-xfce-desktop +``` + +If you’re asked to choose a default display manager during installation, keeping the one you already use is fine. + + + + +List the available desktops, then install one by prefixing its name with `@`. + +```bash +dnf environment list --available | grep -i desktop +sudo dnf install @kde-desktop-environment +``` + + + + +```bash +# KDE Plasma +sudo pacman -S plasma-meta +# Xfce plus extra plugins +sudo pacman -S xfce4 xfce4-goodies +# GNOME +sudo pacman -S gnome +``` + + + + + + A full desktop package brings its own settings app, file manager and themes. With several desktops on one system, your app menu fills up with duplicates, themes and settings can interfere with each other, and removing a desktop cleanly later is hard. If you plan to stick with a desktop, test it in a VM or live session first, then install a distro that ships it by default. + + +## Which desktop should you choose? + +- **New to Linux and used to Windows?** Cinnamon or KDE Plasma will feel the least foreign. +- **Want a calm, distraction-free screen?** Try GNOME or Pantheon. +- **Working with modest hardware?** Xfce, MATE and LXQt are the lightest. +- **Care about high-DPI displays, per-monitor scaling or touchpad gestures?** Wayland-first desktops such as GNOME, KDE Plasma and COSMIC have the edge. +- **Prefer to do everything from the keyboard?** Give a tiling setup like Sway, Hyprland or i3 a try. + +You can filter the catalog by desktop: [GNOME](../../distros/?desktop=gnome), [KDE Plasma](../../distros/?desktop=kde), [Xfce](../../distros/?desktop=xfce), [Cinnamon](../../distros/?desktop=cinnamon), [MATE](../../distros/?desktop=mate) and [LXQt](../../distros/?desktop=lxqt). For choosing the distro itself, see [Understanding distributions](../distributions/). diff --git a/src/content/learn/en/display-servers.mdx b/src/content/learn/en/display-servers.mdx new file mode 100644 index 0000000..2883c8a --- /dev/null +++ b/src/content/learn/en/display-servers.mdx @@ -0,0 +1,188 @@ +--- +title: "Display servers: X11 and Wayland" +description: What a display server does, how X11 and Wayland differ, how XWayland, screen sharing and NVIDIA graphics work in practice, and how to check which session you’re running. +navTitle: X11 and Wayland +order: 6 +group: distros +icon: display +related: [fedora, ubuntu, kubuntu, pop-os] +sources: + - https://wayland.freedesktop.org/architecture.html + - https://wayland.freedesktop.org/faq.html + - https://wayland.freedesktop.org/docs/html/ch01.html + - https://www.x.org/wiki/ + - https://wiki.archlinux.org/title/Wayland + - https://wiki.archlinux.org/title/Xorg + - https://wiki.archlinux.org/title/Security + - https://wiki.archlinux.org/title/NVIDIA + - https://wiki.archlinux.org/title/XDG_Desktop_Portal + - https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html + - https://pipewire.org/ + - https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html + - https://www.freedesktop.org/software/systemd/man/latest/loginctl.html + - https://blogs.gnome.org/alatiera/2025/06/08/the-x11-session-removal/ + - https://blogs.kde.org/2025/11/26/going-all-in-on-a-wayland-future/ +lastReviewed: 2026-10-03 +--- + +For an app to open a window, for your keystrokes and clicks to reach the right window, and for the result to appear on your monitor, something has to direct the traffic in between. That something is the **display server**. For decades Linux used **X11** for this job; today most major desktops have moved to **Wayland**. + +Most of the time you won’t notice which one you’re on — but when screen sharing shows a black rectangle or one app looks blurry, the difference between them is usually why. This chapter explains how each works, the problems you’re likely to meet, and how to fix them. + +## What a display server does + +A display server works in two directions: + +- **Input**: it takes keyboard, mouse and touchpad events from the kernel and hands them to the window (app) under the pointer or in focus. +- **Output**: it gathers what each app has drawn, combines everything into one picture, and sends it to the monitor through the kernel’s **KMS (kernel mode setting)**. + +A few terms come up again and again: + +| Term | Meaning | +|---|---| +| Client | An app that opens windows — a browser, a terminal, a game | +| Window manager | Decides where windows go, their size, borders and stacking order | +| Compositor | Combines the contents of all windows into the final image, including shadows and transparency | +| Display server | Sits between clients and the hardware, routing input and output | + +## X11: the long-standing standard + +The **X Window System** — usually just **X11** or **X** — has been the foundation of graphical Unix and Linux desktops for decades. On Linux, the implementation is the X.Org Foundation’s **X.Org Server (Xorg)**. In X, the display server (the X server), the window manager and the compositing manager are separate programs, so you can combine them however you like. X has also long supported **X11 forwarding**, which shows a remote app’s windows on your local screen. + +Over time, its limits became clear: + +- **Complexity.** Font rendering, display configuration and memory management gradually moved into client libraries and the kernel, leaving the X server full of features hardly anyone uses. +- **Detours.** Compositing under X means bouncing between the app, the X server and the compositor, with extra context switches along the way. +- **Security.** X was designed before modern security practices. Any app connected to the X server can snoop on other apps’ keystrokes and screen contents. + +The X.Org project still publishes fixes, but since X11R7.7 it no longer releases the whole stack at once — individual modules are released as needed. + +## Wayland: the compositor is the display server + +**Wayland** isn’t a program but a **protocol** — a set of rules for how a compositor and its apps talk to each other. Under Wayland, the display server, window manager and compositor merge into one, and each desktop’s compositor does all three jobs: GNOME’s Mutter, KDE Plasma’s KWin, Sway, Hyprland and COSMIC’s compositor are all Wayland compositors. That’s why there’s no separate “Wayland server” to install the way there is with Xorg. + +Apps draw their windows into their own buffers and simply tell the compositor what changed; the compositor combines them and hands the result straight to the kernel. Input likewise goes from the kernel through the compositor directly to the right app. + +```text +X11 input: kernel → X server → app + output: app → X server → compositor → X server → kernel (KMS) → monitor + +Wayland input: kernel → compositor → app + output: app → compositor → kernel (KMS) → monitor +``` + +| | X11 | Wayland | +|---|---|---| +| Structure | Separate X server, window manager and compositor | One compositor does it all | +| Isolation between apps | Apps can see each other’s input and windows | Each app only handles its own windows | +| Screenshots and screen sharing | Any app can read the whole screen | Only what the user approves, via portals | +| Remote access | X11 forwarding built in | Separate tools (RDP, VNC, waypipe) | +| Global shortcuts and automation | Apps can grab input freely | Goes through the compositor and portals | +| Legacy apps | Native | Most run through XWayland | + + + If screen recording, global shortcuts or controlling other windows seem not to work on Wayland, that’s usually because arbitrary apps aren’t allowed to do them. These features are offered through official channels — portals and protocol extensions — that ask for your permission. + + +## Running X11 apps with XWayland + +X11 apps that don’t support Wayland yet still run in a Wayland session, because the compositor starts **XWayland** for them when needed. XWayland is an X server that runs as a client of the Wayland compositor, and most X11 apps perform about the same as they do in an X11 session. + +A few caveats: + +- XWayland is still an X server, so apps running through it don’t get Wayland’s isolation from each other. +- Compatibility isn’t perfect, so a few apps may misbehave. +- With fractional scaling (say, 125%), XWayland apps can look blurry. + +To see which apps are running through XWayland, list them with `xlsclients -l`, or run `xeyes` and move the pointer over a window: if the eyes follow it, that window belongs to an XWayland app. On KDE Plasma, the KWin debug console shows this too. + +GTK and Qt apps use Wayland by default, but if one misbehaves you can force it onto XWayland with an environment variable. Older Electron apps, on the other hand, can be switched to Wayland with a command-line flag (recent Electron versions use Wayland by default). + +```bash +GDK_BACKEND=x11 gnome-text-editor # run a GTK app through XWayland +QT_QPA_PLATFORM=xcb kate # run a Qt app through XWayland +code --ozone-platform=wayland # tell an Electron app (e.g. VS Code) to use Wayland +``` + +## Screen sharing and recording: PipeWire and portals + +On X11, any app could read the entire screen at will. On Wayland, apps can’t see other windows, so screen sharing and recording go through an official gateway: the **XDG desktop portal** (`xdg-desktop-portal`). + + + +1. A video-call app or browser asks the portal’s **ScreenCast** interface to share the screen. +2. Your desktop shows a dialog where you choose a **whole monitor** or a **single window**. +3. Once you approve, the selection is delivered to the app as a **PipeWire** stream. You can also let the app remember your choice so you don’t have to pick again next time. + + + +PipeWire is a low-latency multimedia server for audio and video, designed so that even sandboxed apps can handle screen and sound safely. Portals were originally built for Flatpak apps, but regular apps now use them too, the same way on every desktop. + +The portal hands the actual work to a desktop-specific **backend**: `xdg-desktop-portal-gnome` for GNOME, `xdg-desktop-portal-kde` for KDE Plasma, `xdg-desktop-portal-wlr` for wlroots-based compositors such as Sway, and `xdg-desktop-portal-hyprland` for Hyprland. The big desktops install theirs automatically, but if you assembled a Sway or Hyprland setup yourself, install the matching backend or screen sharing won’t work. + +If the shared screen is black or the picker never appears, first check that PipeWire and the portal service are running: + +```bash +systemctl --user status pipewire xdg-desktop-portal +``` + +Remote access follows the same pattern. GNOME and KDE Plasma have their own remote desktop features (RDP, VNC), and wlroots-based compositors use wayvnc. X11 forwarding with `ssh -X` keeps working through XWayland, and `waypipe` does something similar for Wayland-native apps. + +## NVIDIA graphics + +NVIDIA’s proprietary driver and Wayland used to be a rough combination, because the driver only supported a different buffer mechanism (EGLStreams) from everyone else. Things improved a lot once the driver added support for the same **GBM** mechanism other drivers use, and once **explicit sync** — which fixes flickering and out-of-order frames — landed in both the driver and the compositors. KDE now says that cards still supported by NVIDIA work fine on Wayland. + +If you use Wayland on an NVIDIA card: + +- **Keep the driver up to date.** Older drivers, or compositors without explicit sync support, can cause flickering and frames displayed out of order. Very old drivers don’t even support hardware acceleration in XWayland. +- **DRM kernel mode setting (KMS) must be enabled.** Many distros’ current driver packages turn it on by default, but you can check — `Y` means it’s on: + +```bash +cat /sys/module/nvidia_drm/parameters/modeset +``` + +If it prints `N`, add the kernel parameter `nvidia_drm.modeset=1`. How to add kernel parameters depends on your boot loader; see [The boot process](../boot-process/). Installing the driver itself is covered in [Hardware and drivers](../hardware-drivers/). + + + For older cards that NVIDIA no longer supports, the open-source **Nouveau** driver is often the easier path on Wayland than the proprietary one. + + +## Checking which session you’re running + +Open a terminal and run: + +```bash +echo $XDG_SESSION_TYPE +``` + +`wayland` means a Wayland session and `x11` an X11 session. Over SSH or on a text console you’ll see `tty`. `XDG_SESSION_TYPE` is set during login to describe the kind of session, and systemd’s login manager uses it as well. + +A few other ways to check: + +```bash +loginctl show-session "$XDG_SESSION_ID" -p Type # Type=wayland or Type=x11 +echo $WAYLAND_DISPLAY # something like wayland-0 means Wayland +echo $XDG_CURRENT_DESKTOP # the name of your current desktop +``` + +On GNOME, the **About** page in Settings also shows a “Windowing System” entry. If your distro still ships an X11 session, you can switch on the login screen: select your user, then pick a session from the gear menu. + +## Common Wayland problems + +| Symptom | Cause and fix | +|---|---| +| Screen sharing shows black | The portal backend is missing or PipeWire isn’t running. Install the `xdg-desktop-portal-*` package for your desktop | +| Only certain apps look blurry | They’re running through XWayland. Check for an option to run them natively (for Electron, `--ozone-platform=wayland`) | +| An app’s global hotkeys don’t work | Apps can’t secretly grab keystrokes. Use the portal’s global shortcuts feature or set the shortcut in your desktop settings | +| Automation tools like `xdotool` fail | Switch to Wayland tools: `wl-copy`/`wl-paste` for the clipboard, `ydotool` for simulated input, `kdotool` on KDE | +| Shortcuts in a VM or remote desktop act on the host | The compositor sees shortcuts first. When asked whether the app may capture shortcuts, allow it | + + + Pasting environment variables from the internet into global files like `/etc/environment` can break other apps or a future update. Try them on the one affected app first, and only make them permanent in that app’s launcher if they actually help. + + +## Is X11 going away? + +**X11 apps keep working, but X11 login sessions are disappearing.** GNOME disabled its X11 session by default starting with GNOME 49 and scheduled it for removal, and KDE decided that Plasma 6.8 onward will offer only a Wayland session. Both projects continue to support X11 apps through XWayland. + +So if you’re new to Linux, just use your distro’s default Wayland session. If a particular program truly needs an X11 session, choose a desktop that still offers one, such as Xfce, or use an LTS distro that keeps an older desktop release supported for years. See [Desktop environments](../desktop-environments/) for how the desktops compare. diff --git a/src/content/learn/en/distributions.mdx b/src/content/learn/en/distributions.mdx new file mode 100644 index 0000000..b4ed067 --- /dev/null +++ b/src/content/learn/en/distributions.mdx @@ -0,0 +1,205 @@ +--- +title: Understanding Linux distributions +description: What a distribution is made of, how the big families (Debian, Fedora, Arch, SUSE and independents) and release models differ, what immutable “atomic” distros are, and how to pick the right one for you. +navTitle: Distributions +order: 4 +group: distros +icon: grid +related: [ubuntu, fedora, debian, arch] +sources: + - https://www.gnu.org/gnu/linux-and-gnu.html + - https://www.debian.org/releases/ + - https://www.debian.org/derivatives/ + - https://wiki.debian.org/LTS + - https://ubuntu.com/about/release-cycle + - https://ubuntu.com/desktop/flavours + - https://www.linuxmint.com/download_lmde.php + - https://zorin.com/os/ + - https://system76.com/pop/ + - https://docs.fedoraproject.org/en-US/releases/lifecycle/ + - https://www.centos.org/centos-stream/ + - https://access.redhat.com/support/policy/updates/errata + - https://almalinux.org/ + - https://rockylinux.org/about + - https://wiki.archlinux.org/title/Arch_Linux + - https://wiki.archlinux.org/title/Arch-based_distributions + - https://manjaro.org/ + - https://endeavouros.com/ + - https://cachyos.org/ + - https://neon.kde.org/faq + - https://get.opensuse.org/tumbleweed/ + - https://get.opensuse.org/leap/16.0/ + - https://get.opensuse.org/microos/ + - https://www.gentoo.org/get-started/about/ + - https://alpinelinux.org/about/ + - https://voidlinux.org/ + - https://nixos.org/ + - https://docs.fedoraproject.org/en-US/fedora-silverblue/ + - https://docs.fedoraproject.org/en-US/fedora-silverblue/technical-information/ + - https://docs.fedoraproject.org/en-US/fedora-silverblue/updates-upgrades-rollbacks/ + - https://universal-blue.org/ + - https://docs.fedoraproject.org/en-US/bootc/ + - https://ubuntu.com/core +lastReviewed: 2026-10-03 +--- + +Strictly speaking, “Linux” is only the kernel — the core that talks to the hardware. A kernel on its own isn’t something you can use, so projects bundle it with tools, libraries, an installer and a desktop, and ship the result as a complete operating system. That bundle is a **distribution**, or **distro** for short. + +There are hundreds of distros, but most belong to a handful of **families**, and they update in one of a few ways. Once you know those two things, the long list of names becomes far less intimidating. + +## What a distro is made of + +Distros look different on the surface, but underneath they’re assembled from the same kinds of parts. + +| Part | What it does | Examples | +|---|---|---| +| Kernel | Manages hardware and shares it between programs | Linux | +| Base tools and libraries | The shell, file utilities, the C library and other basics | GNU coreutils, glibc, musl | +| Init system | Starts and supervises services after boot | systemd, OpenRC, runit | +| Package manager and repositories | Installs, updates and removes software | APT, DNF, pacman, zypper | +| Installer | Partitions the disk and installs the system | Calamares, Anaconda | +| Desktop environment | The screen, windows, menus and settings | GNOME, KDE Plasma, Xfce | + +This is also why the GNU Project asks people to say “GNU/Linux”: the kernel is Linux, but many of the basic tools on top of it come from GNU. [What is Linux?](../what-is-linux/) covers that background. + +What actually sets distros apart comes down to four things: + +- **Package format and tools** — `.deb` with APT, `.rpm` with DNF, and so on, depending on the family. See [Package management](../package-management/). +- **Release model** — new versions every few months or years, or a continuous stream of updates. +- **Defaults** — which desktop, apps, file system and settings you get out of the box. +- **Who makes it, and why** — a company or a community, and whether it prioritizes convenience or minimalism. + +## Distribution families + +Most new distros aren’t built from scratch. They start from an existing distro’s packages and repositories and add their own defaults and tools on top — hence labels like “Ubuntu-based” or “Arch-based.” Distros in the same family share a package format and commands, so most documentation and know-how carries over. + +| Family | Packages · tools | Well-known members | +|---|---|---| +| Debian · Ubuntu | `.deb` · APT | Debian, Ubuntu, Linux Mint, Pop!\_OS, Zorin OS, Kali Linux | +| Fedora · RHEL | `.rpm` · DNF | Fedora, CentOS Stream, RHEL, AlmaLinux, Rocky Linux | +| Arch | pacman · AUR | Arch Linux, Manjaro, EndeavourOS, CachyOS | +| SUSE | `.rpm` · zypper | openSUSE Tumbleweed, openSUSE Leap, SUSE Linux Enterprise | +| Independent | Each their own | Gentoo, Slackware, Alpine Linux, Void Linux, NixOS | + +The [family tree](../../family/) shows who is based on whom. + +### Debian and Ubuntu + +**Debian** is a long-running community distro that always maintains three branches side by side: **stable** (the official release), **testing** (what will become the next stable) and **unstable**, nicknamed sid, where development happens. Stable is what Debian recommends for everyday use, and the Debian LTS project stretches every stable release’s lifetime to at least five years. + +**Ubuntu** is built on Debian by Canonical. A new version arrives every six months, and every two years one of them is an **LTS (long-term support)** release that gets five years of standard security maintenance. Official *flavors* such as Kubuntu, Xubuntu and Lubuntu swap in a different desktop. + +Many popular distros are in turn built on Ubuntu, including [Linux Mint](../../distros/linux-mint/), [Pop!\_OS](../../distros/pop-os/) and [Zorin OS](../../distros/zorin-os/). Linux Mint also offers LMDE, an edition built directly on Debian instead. This family has the most users, so answers are easy to find, and plenty of commercial software is offered as a `.deb` package. + +### Fedora and Red Hat + +**Fedora** is a community distro sponsored by Red Hat. A new release comes out roughly every six months and each one is maintained for about 13 months. It adopts new technology early, which makes it a good fit if you want a current, clean desktop. + +On the enterprise side, things flow in one direction: technology matures in Fedora, moves into **CentOS Stream**, and from there into **Red Hat Enterprise Linux (RHEL)**. CentOS Stream is a continuously updated “midstream” that sits just ahead of RHEL. RHEL itself follows a ten-year life cycle per major version. **AlmaLinux** and **Rocky Linux** are free, community-run distros that are binary compatible with RHEL; both appeared after Red Hat announced in late 2020 that it would end CentOS Linux in favor of CentOS Stream. They’re common choices for servers. + +### Arch + +**Arch Linux** is an independently developed x86-64 distro that keeps most software at the latest stable version through a rolling release model. It ships software the way upstream developers released it, with minimal changes, and leaves decisions about how to build your system to you. The community-maintained **AUR (Arch User Repository)** and the remarkably thorough **Arch Wiki** are two of its biggest draws. + +Several derivatives make Arch easier to adopt. **Manjaro** tests packages itself and releases them in stages; **EndeavourOS** gives you an easy route to a lightweight, terminal-centric Arch system; and **CachyOS** focuses on performance with packages and a kernel tuned for modern CPUs. + + + The Arch community and developers don’t support Arch-based distros. If something goes wrong on Manjaro or EndeavourOS, start with that project’s forum. The same goes for other families. + + +### SUSE + +**openSUSE Tumbleweed** is a rolling release that tests not just each new package on its own but also groups of packages against each other before shipping them. You can also roll back to an earlier snapshot with a single command. **openSUSE Leap** is a regular-release distro that combines source code from the enterprise **SUSE Linux Enterprise (SLE)** with community work, with stability as its priority. + +### Independent distros + +Some distros don’t belong to any family and do things their own way: + +- **Gentoo** builds software from source on your machine with a tool called Portage, tuned exactly to your needs. It’s configurable enough that the project calls it a “metadistribution.” +- **Alpine Linux** is a tiny distro built on musl libc and BusyBox, using the apk package manager and OpenRC. Its small size makes it popular for container images. +- **Void Linux** is an independent, volunteer-run rolling distro with the XBPS package manager and the runit init system, available in both glibc and musl variants. +- **NixOS** builds your whole system from a declarative configuration file. Updates are designed not to break other packages, and rolling back to a previous state is easy. +- **Slackware** is one of the long-standing distros and sticks to a simple, traditional Unix approach. + +## Release models + +How a distro delivers updates matters as much as which family it’s in. + +| Model | How it works | Upside | Keep in mind | Examples | +|---|---|---|---|---| +| Fixed release | New versions on a schedule; within a version, mostly security and bug fixes | Predictable and stable | You need to upgrade before support ends | Fedora, Ubuntu interim releases, Debian stable | +| Long-term support (LTS) | Selected releases are supported for years | Little maintenance for a long time | New hardware support and features arrive slowly | Ubuntu LTS, RHEL, AlmaLinux, Rocky Linux | +| Rolling | No versions — packages are continuously updated | Always the newest kernel, drivers and apps | Update often and be ready to deal with changes | Arch Linux, openSUSE Tumbleweed, Void Linux | +| Semi-rolling | A fixed or delayed base, with some parts updated continuously | A middle ground between stable and fresh | Means something slightly different for each distro | Manjaro, KDE neon | + +“Semi-rolling” isn’t a formally defined term. Manjaro, for example, holds back Arch packages until they’ve been tested and then releases them in stages, while KDE neon keeps an Ubuntu LTS base and rolls only the KDE software on top. + + + Rolling distros test packages too. The difference is that change never stops, so it’s better to update little and often than to skip months at a time — and to glance at the distro’s news before big updates. + + +You can filter the catalog to [fixed release](../../distros/?model=fixed), [LTS](../../distros/?model=lts), [rolling](../../distros/?model=rolling) or [semi-rolling](../../distros/?model=semi-rolling) distros. + +## Immutable and atomic distros + +A growing group of distros keeps the core system **read-only** and updates the whole operating system as a single image. The new image is prepared next to the running one and swapped in on the next restart. A power cut mid-update can’t leave you with a half-upgraded system, and if the new version misbehaves you simply boot the previous image. + +| | Traditional distro | Immutable / atomic distro | +|---|---|---| +| System files | Edited directly as administrator | Read-only (only config in `/etc` and data in `/var` are writable) | +| Updates | Packages replaced one by one | Whole image replaced, applied on reboot | +| Rolling back | Needs a separate snapshot tool | Boot the previous image | +| Installing apps | Distro packages | Flatpak, containers such as Toolbx, and package layering only when needed | + +Well-known examples: + +- **Fedora Atomic Desktops** — Silverblue (GNOME), Kinoite (KDE Plasma), plus Sway, Budgie and COSMIC variants. +- **Universal Blue images** — built from Fedora Atomic Desktops with extra hardware support and fixes, delivered as container images. The gaming-focused [Bazzite](../../distros/bazzite/) is the best known. +- **openSUSE MicroOS** — transactional updates on top of a read-only Btrfs root. +- **NixOS** — technically a different approach, but its declarative configuration and easy rollbacks offer similar benefits. +- **Ubuntu Core** — an immutable distro for devices and IoT that keeps the kernel, OS and apps separate and updates each independently. + +**Bootable containers (bootc)**, which let you build and ship an entire OS like an ordinary container image, belong to the same movement. On Fedora Atomic, day-to-day commands look like this: + +```bash +rpm-ostree status # show the current and previous images +rpm-ostree upgrade # download a new image (applied after a restart) +rpm-ostree rollback # go back to the previous image +``` + + + If you often tinker deep in the system, need to build drivers or kernel modules yourself, or rely on software whose installer writes into system directories, a traditional distro will probably be less hassle. + + +## Choosing a distro + +There’s no single best distro — only the one that suits your situation. A few questions narrow things down quickly: + +- **What’s it for?** Everyday work, gaming, development, servers and reviving old PCs each have different starting points. +- **How hands-on do you want to be?** If you want to install and go, pick a beginner-friendly distro. If you’d rather assemble everything yourself, Arch or Gentoo will suit you. +- **Stability or freshness?** For a set-and-forget system choose LTS; if new hardware or the latest apps matter, Fedora or a rolling distro is better. +- **Which desktop feels right?** First impressions come from the [desktop environment](../desktop-environments/) more than the distro. + +| If you… | Start by looking at | +|---|---| +| Are new to Linux and like a Windows-style layout | [Linux Mint](../../distros/linux-mint/), [Zorin OS](../../distros/zorin-os/), [Kubuntu](../../distros/kubuntu/) | +| Want current software and a clean default desktop | [Fedora](../../distros/fedora/), [Ubuntu](../../distros/ubuntu/) | +| Mainly play games | [Bazzite](../../distros/bazzite/), [Pop!\_OS](../../distros/pop-os/) — see [Gaming](../gaming/) | +| Have an older or low-spec PC | [Xubuntu](../../distros/xubuntu/), [Linux Lite](../../distros/linux-lite/), [MX Linux](../../distros/mx-linux/) | +| Run servers or always-on machines | [Debian](../../distros/debian/), [AlmaLinux](../../distros/almalinux/), [Rocky Linux](../../distros/rocky-linux/) | +| Want to learn Linux deeply and build your own setup | [Arch Linux](../../distros/arch/), [NixOS](../../distros/nixos/), [Gentoo](../../distros/gentoo/) | + +On Linuxhub, this is the path we suggest: + + + +1. Answer a few questions in the [distro finder](../../finder/) to narrow things down to two or three candidates. +2. Filter the [distro catalog](../../distros/) by use case, family, desktop and release model, and [compare](../../compare/) the ones you like. +3. Boot each candidate from a live USB and try it. Without installing anything, you can check the look and whether Wi-Fi and sound work. +4. Once you’ve decided, read [Preparing to install](../installation-prep/) and install it. + + + + + Almost everything you learn carries over when you switch distros, and within a family even the commands stay the same. Keep your personal files backed up and you can try another distro whenever you like. + diff --git a/src/content/learn/en/history.mdx b/src/content/learn/en/history.mdx new file mode 100644 index 0000000..2cbddca --- /dev/null +++ b/src/content/learn/en/history.mdx @@ -0,0 +1,136 @@ +--- +title: A history of Linux +description: From Unix in 1969 through the GNU Project, Linus Torvalds' 1991 announcement, the first distributions and desktops, and the enterprise and Android eras, to Linux today. +navTitle: History +order: 2 +group: basics +icon: recent +related: [debian, slackware, fedora, ubuntu] +sources: + - https://www.unix.org/unix_history.html + - https://www.unix.org/fun_facts.html + - https://www.kernel.org/category/about.html + - https://www.gnu.org/gnu/initial-announcement.html + - https://www.gnu.org/gnu/thegnuproject.html + - https://lwn.net/2001/0823/a/lt-announcement.php3 + - https://training.linuxfoundation.org/announcements/happy-30th-linux/ + - https://www.kernel.org/pub/linux/kernel/Historic/old-versions/RELNOTES-0.01 + - https://www.kernel.org/pub/linux/kernel/Historic/old-versions/RELNOTES-0.12 + - https://www.kernel.org/pub/linux/kernel/v1.0/ + - https://www.kernel.org/pub/linux/kernel/v2.0/ + - https://www.slackware.com/info/ + - https://www.debian.org/doc/manuals/project-history/intro.en.html + - https://www.debian.org/doc/manuals/project-history/detailed.en.html + - https://www.redhat.com/en/blog/spooktacular-tale-red-hats-halloween-release + - https://www.redhat.com/en/blog/25-things-you-should-know-about-red-hat-0 + - https://kde.org/announcements/announcement/ + - https://www.gnome.org/about/ + - https://opensource.org/history + - https://fedoramagazine.org/announcing-fedora-linux-39/ + - https://lists.ubuntu.com/archives/ubuntu-announce/2004-October/000003.html + - https://android-developers.googleblog.com/2008/09/announcing-android-10-sdk-release-1.html + - https://source.android.com/docs/core/architecture/kernel + - https://www.top500.org/lists/top500/2026/06/ + - https://store.steampowered.com/news/app/1675180/view/3117055056380003048 + - https://store.steampowered.com/steamos +lastReviewed: 2026-10-03 +--- + +Linux didn't appear out of nowhere. It grew out of Unix, born at Bell Labs in the late 1960s, the free software movement of the 1980s, and a new kind of collaboration between developers connected by the internet. This chapter follows that path, sticking to dates that are backed by primary records. + +## Timeline at a glance + +| Year | Event | +|---|---| +| 1969 | Ken Thompson, Dennis Ritchie and colleagues at Bell Labs begin work on Unix | +| 1973 | Unix is rewritten in the C programming language | +| 1975 | The Sixth Edition becomes the first Unix widely available outside Bell Labs | +| 1983 | September 27: Richard Stallman announces the GNU Project | +| 1985 | The Free Software Foundation (FSF) is founded | +| 1988 | POSIX.1, a common standard for Unix-like systems, is published | +| 1991 | August 25: Linus Torvalds first mentions the operating system he is writing | +| 1992 | With release 0.12, Linux moves to the GNU GPL, effective February 1 | +| 1993 | Red Hat is founded (March 26), Slackware's first beta (April), Debian is founded (August 16) | +| 1994 | Linux 1.0 (March); first public Red Hat Linux release (October 31) | +| 1996 | Linux 2.0 (June); the KDE project is announced (October 14) | +| 1997 | The GNOME project starts | +| 1998 | The term "open source" is coined and the Open Source Initiative (OSI) is founded | +| 2002 | Red Hat Enterprise Linux arrives (first called Red Hat Linux Advanced Server) | +| 2003 | Fedora Core 1 is released (November 6) | +| 2004 | Ubuntu 4.10 is released (October 20) | +| 2008 | The Android 1.0 SDK ships alongside the first Android phone, the T-Mobile G1 (September 23) | +| 2022 | Valve's Steam Deck handheld gaming PC launches (February) | + +## Unix: where it all began + +In 1969, Ken Thompson, Dennis Ritchie and others at Bell Labs started building a new operating system on a little-used PDP-7 tucked away in a corner of the lab. That system became Unix. It was first written in PDP-7 assembly language, but in 1973 it was rewritten in C, which freed it from any one machine and made it portable. Writing an operating system in a high-level language that could move between computers was a turning point in OS history. + +The Sixth Edition of 1975 was the first version to spread widely beyond Bell Labs, and the University of California, Berkeley built its BSD line on it. Berkeley's 4.2BSD added TCP/IP, the protocol suite the internet still runs on. As more vendors shipped their own incompatible flavors of Unix, the industry responded with the **POSIX** standard in 1988. + +Linux inherited no Unix code. It was written from scratch following Unix's design and aims to comply with standards like POSIX, which is why it is called **Unix-like**. + +## The GNU Project: a free Unix + +In the 1970s, the MIT Artificial Intelligence Lab had a culture of freely sharing programs and source code. By the early 1980s that was disappearing: software had become a closed product, and you often had to sign a non-disclosure agreement just to get a working copy. In response, Richard Stallman announced the **GNU Project** on September 27, 1983. The goal was a complete, Unix-compatible operating system that anyone could use and share freely. + +- **1984**: Stallman left his job at MIT in January to work on GNU full time, so that MIT couldn't claim ownership of the work. In September he began GNU Emacs. +- **1985**: He set up the **Free Software Foundation (FSF)**, a non-profit to fund development. +- **Copyleft**: GNU came up with a way to keep software free in all future versions and put it into practice as the **GNU GPL**. +- **Core pieces**: FSF staff wrote components that almost every Linux system still includes, such as the GNU C library (glibc) and the **Bash** shell. + +By around 1990 GNU was nearly complete, except for the kernel. The GNU kernel, **Hurd**, was an ambitious design in which a set of server programs running on the Mach microkernel shared the kernel's work. It proved very hard to debug and kept slipping. Linux filled the gap. + +## 1991: "just a hobby" + +On August 25, 1991, Linus Torvalds, then a student at the University of Helsinki, posted to the Usenet newsgroup comp.os.minix. He was writing a free operating system for 386 and 486 PCs, he explained, as a hobby that wouldn't be big and professional like GNU. He had already ported Bash and GCC to it and wanted to know what features people would like. + +The first public source release, version 0.01, supported only a handful of devices on 386 AT machines (hard disk, screen, keyboard and serial ports), and its release notes openly called it immature. Its original license even forbade distributing it for money. In the 0.12 release notes, Torvalds announced a switch to the GNU copyleft, which took effect on February 1, 1992. From then on, GNU tools plus the Linux kernel made an operating system that was free software from top to bottom. + +As developers around the world started sending in code over the internet, progress sped up. Linux 1.0 arrived in March 1994, and Linux 2.0 in June 1996. + +## The first distributions + +Early Linux users had to gather the kernel and GNU tools themselves, write floppy disk images, partition their drives and install programs one by one. **Distributions** appeared to take that work off their hands by bundling a kernel with a ready-made selection of software. + +- **Slackware** put out its first beta in April 1993 with the aim of being the most Unix-like Linux distribution, and it is still around today. +- **Debian** was founded by Ian Murdock on August 16, 1993, as a distribution made openly in the spirit of Linux and GNU. By 1995 many developers were maintaining their own packages, installed with `dpkg`. That community model and packaging system later became the foundation for Ubuntu and many others. +- **Red Hat** was founded on March 26, 1993. Its first public release, a paid beta numbered 0.9, came out on October 31, 1994, earning it the nickname "Halloween". + +To see how distributions branched off from one another, explore the [family tree](../../family/). For choosing one today, read [Distributions](../distributions/). + +## Desktop environments arrive + +In the mid-1990s, graphical Unix and Linux programs were built with a mix of different widget toolkits, so menus, buttons and scroll bars looked and behaved differently from one application to the next. On October 14, 1996, Matthias Ettrich proposed **KDE** (originally the "Kool Desktop Environment"): a complete graphical environment where every application would share the same look and behavior. KDE was built on Troll Tech's Qt toolkit. + +The catch was that Qt was not free software at the time. So in 1997 Miguel de Icaza and Federico Mena started **GNOME**, a desktop built entirely from free software. In November 1998, Qt's developers announced a license change, and the conflict gradually faded. Today GNOME and KDE Plasma are both free software and among the most widely known Linux desktops. + +More on today's desktops is in [Desktop environments](../desktop-environments/), and on the technology underneath them in [Display servers](../display-servers/). + +## Open source and the enterprise era + +Shortly after Netscape announced it would release its browser's source code, a strategy session on February 3, 1998 adopted the label **open source**, suggested by Christine Peterson. Later that month Eric Raymond and Bruce Perens founded the **Open Source Initiative (OSI)**. + +In the 2000s, Linux reached both businesses and home users in earnest: + +- **2002**: Red Hat introduced **Red Hat Enterprise Linux** (first called Red Hat Linux Advanced Server), pioneering a business model in which the software is open and support and certification are sold as subscriptions. +- **November 6, 2003**: the Red Hat-sponsored community project [Fedora](../../distros/fedora/) shipped its first release, Fedora Core 1. +- **October 20, 2004**: [Ubuntu](../../distros/ubuntu/) released its first version, 4.10. It paired the vast software collection of [Debian](../../distros/debian/) with an easy installer, a new release every six months and 18 months of security support for each one. + +## Android and Linux today + +On September 23, 2008, Google released the Android 1.0 SDK on the same day T-Mobile unveiled the first Android phone, the **T-Mobile G1**. Android's kernel is a long-term support (LTS) Linux kernel with Android-specific patches. Apps run very differently than on a desktop distribution, but underneath, the Linux kernel is what drives the hardware. Through Android, the Linux kernel ended up in a great many pockets. + +Here is where Linux stands today: + +- **Supercomputers**: the top systems on the TOP500 list run Linux-based operating systems such as RHEL and HPE Cray OS. +- **Servers and cloud**: a standard operating system for web services and cloud infrastructure. +- **Gaming**: in February 2022 Valve launched the Steam Deck, running SteamOS, which is based on Arch Linux. Proton, a compatibility layer for running Windows games on Linux, makes much of the Steam library playable. See [Gaming](../gaming/) for details. +- **Desktop**: modern desktop environments such as GNOME and KDE Plasma, and app formats like Flatpak, have made Linux much easier to use as an everyday system. + + + +kernel.org still hosts the earliest Linux sources and release notes. The [0.01 release notes](https://www.kernel.org/pub/linux/kernel/Historic/old-versions/RELNOTES-0.01) show just how small the project was at the start. + + + +What began as "just a hobby" has grown into a vast collaborative project, built by individuals and companies together. Why people choose it is the subject of the next chapter, [Why Linux?](../why-linux/). diff --git a/src/content/learn/en/what-is-linux.mdx b/src/content/learn/en/what-is-linux.mdx new file mode 100644 index 0000000..909e95f --- /dev/null +++ b/src/content/learn/en/what-is-linux.mdx @@ -0,0 +1,145 @@ +--- +title: What is Linux? +description: The difference between a kernel and an operating system, how GNU and Linux fit together, what a distribution adds, where Linux runs, and the basics of free software and the GPL in plain words. +navTitle: What is Linux? +order: 1 +group: basics +icon: computer +related: [ubuntu, linux-mint, fedora, debian] +sources: + - https://www.kernel.org/linux.html + - https://www.kernel.org/category/about.html + - https://docs.kernel.org/admin-guide/README.html + - https://docs.kernel.org/process/license-rules.html + - https://www.gnu.org/gnu/linux-and-gnu.html + - https://www.gnu.org/gnu/thegnuproject.html + - https://www.gnu.org/philosophy/free-sw.html + - https://www.gnu.org/licenses/gpl-faq.html + - https://opensource.org/osd + - https://opensource.org/history + - https://opensource.org/licenses + - https://source.android.com/docs/core/architecture/kernel + - https://www.top500.org/lists/top500/2026/06/ + - https://www.yoctoproject.org/ + - https://store.steampowered.com/steamos + - https://www.redhat.com/en/blog/25-things-you-should-know-about-red-hat-0 +lastReviewed: 2026-10-03 +--- + +Linux is a family of operating systems, in the same sense that Windows and macOS are operating systems. Strictly speaking, though, "Linux" is the name of the **kernel**, the core program that talks to the hardware. The "Linux" you download and install is a **distribution**: that kernel bundled with thousands of other programs. This chapter starts with that distinction, then looks at where Linux runs and why anyone is allowed to use, change and share it for free. + +## A kernel is not a whole operating system + +The kernel is the heart of the system. It is the first thing loaded into memory when the computer starts, and it stays in charge until you shut down. Its main jobs: + +- **Sharing the CPU**: deciding which program runs when, so many programs appear to run at once (multitasking). +- **Managing memory**: giving each program its own memory and stopping programs from trampling on each other (virtual memory). +- **Driving hardware**: controlling disks, graphics cards, keyboards and network cards through drivers. +- **Files and networking**: providing a way to store and read files and to send data across the internet. + +Programs never touch the hardware directly. They ask the kernel through a fixed set of entry points called **system calls**. But a kernel on its own can't draw a window or open a web page. A usable operating system needs several layers stacked on top: + +| Layer | What it does | Examples on a Linux system | +|---|---|---| +| Applications | The programs you actually use | Firefox, LibreOffice, games | +| Desktop environment | Windows, menus, settings | GNOME, KDE Plasma, Xfce | +| Graphics stack | Gets pixels onto the screen | Wayland, X11 | +| System services | Booting, logging in, networking | systemd, NetworkManager | +| Core tools and libraries | Shell, file commands, C library | Bash, GNU coreutils, glibc | +| Kernel | Hardware and resource management | Linux | + +You can check which kernel and which distribution you are running from a terminal: + +```bash +uname -r +cat /etc/os-release +``` + +The first command prints the running kernel version; the second shows the distribution's name and release. If the terminal is new to you, [Terminal basics](../terminal-basics/) is a good place to start. + +kernel.org describes Linux as a Unix clone that Linus Torvalds wrote from scratch with help from developers around the internet, aiming for compatibility with Unix standards such as POSIX. So Linux contains no original Unix code, but it is built and behaves like Unix, which is why it's called a **Unix-like** operating system. The Unix story is told in [History of Linux](../history/). + +## GNU and Linux: the naming story + +In the early 1980s Richard Stallman started the **GNU Project** to build a complete Unix-compatible operating system that everyone would be free to use and modify. GNU stands for "GNU's Not Unix", a recursive acronym that contains itself. By around 1990, GNU had nearly everything an operating system needs (compilers, editors, a shell, a C library), but its own kernel, GNU Hurd, was running late. + +In 1991, Linus Torvalds released a Unix-compatible kernel of his own. It started out under a restrictive license, became free software in 1992, and from then on the GNU tools plus the Linux kernel made a complete operating system that was free from top to bottom. + +That is why the Free Software Foundation (FSF) asks people to call the combination **GNU/Linux**: a large share of the system is GNU software, and the GNU Project set out to build a whole operating system from the start. In everyday speech, most people just say "Linux". This guide does the same for readability, and says "the Linux kernel" when it means only the kernel. + + + +Android is built on the Linux kernel too, but the way apps run on it, and most of the software above the kernel, look nothing like a desktop distribution. It's a good reminder that very different operating systems can share one kernel. + + + +## What a distribution adds + +A kernel and the GNU tools still aren't something you can install and start using. A **distribution** picks thousands of programs, builds them to work together, adds an installer and an update mechanism, and ships the result as one product. Ubuntu, Fedora, Debian, Linux Mint and Arch Linux are all distributions. + +Each distribution makes its own choices about: + +- **Package management**: the tool that installs, updates and removes software, and the repositories it pulls from. Debian-based systems use `apt`, Fedora uses `dnf`, and Arch Linux uses `pacman`. +- **Release model**: a **fixed release** that ships a major version on a schedule, or a **rolling release** that updates continuously. +- **Default desktop environment**: what you see on first boot, and which apps come with it. +- **Installer and defaults**: file system, security settings, how drivers are handled. +- **Support and philosophy**: how many years of security updates you get, and whether proprietary drivers and codecs are included out of the box. + +Think of the kernel as an engine: many car makers can use the same engine and still build very different cars. To pick one, see [Distributions](../distributions/); for how software gets installed, [Package management](../package-management/); and for the look and feel, [Desktop environments](../desktop-environments/). + +## Where Linux runs + +The kernel was first written for 386 PCs. Today it runs on many processor architectures, including x86, ARM and PowerPC, in both 32-bit and 64-bit forms. That portability puts it almost everywhere: + +| Area | How Linux is used | +|---|---| +| Servers and cloud | A common operating system for web services, databases and cloud virtual machines, with distributions such as Red Hat Enterprise Linux, Ubuntu Server and Debian. | +| Phones | Android's kernel is a long-term support (LTS) Linux kernel with Android-specific patches on top. | +| Embedded and IoT | Routers, TVs, industrial equipment. Tools such as the Yocto Project build custom Linux systems tailored to a device. | +| Supercomputers | The top systems on the TOP500 list of the world's fastest computers run Linux-based operating systems such as RHEL and HPE Cray OS. | +| Desktops and handhelds | Everyday PCs and laptops, plus devices like Valve's Steam Deck, whose SteamOS is Linux-based. | + +Even if you have never installed Linux, there's a good chance you use it every day through your phone or the web services you rely on. + +## Free software and open source + +Linux didn't spread this far on technology alone; its **license** mattered just as much. The Linux kernel and the GNU programs are **free software**, where "free" refers to freedom, not price. The GNU Project defines free software as software that gives you four freedoms: + +| # | Freedom | In plain words | +|---|---|---| +| 0 | To run | Use the program however you like, for any purpose. | +| 1 | To study and change | Look at how it works and modify it. This requires the source code. | +| 2 | To share | Give copies to other people. | +| 3 | To share your changes | Distribute your modified versions so everyone benefits. | + +Free software doesn't have to be free of charge. Selling copies is allowed, and companies such as Red Hat publish their software openly while selling support and maintenance as subscriptions. + +The term **open source** appeared in 1998, and the Open Source Initiative (OSI), founded that same year, maintains the ten-point Open Source Definition. In practice the two labels cover mostly the same software. The difference is emphasis: the free software movement stresses users' freedom as a matter of principle, while the open source camp highlights the practical benefits of shared code and collaboration. You will also see the umbrella term FOSS (Free and Open Source Software). + +## The GPL in plain words + +The Linux kernel is licensed under version 2 of the **GNU General Public License (GPL)**. The GPL is a **copyleft** license: it uses copyright law in reverse, to keep software free. The essentials: + +- **Anyone can use it, for anything.** Individuals, schools and companies alike. +- **Private changes stay private.** If you modify GPL software and use it only yourself or inside your organization, you don't have to publish anything. +- **If you distribute it, the source goes with it.** Whoever receives a copy, modified or not, must be able to get the source code under the same GPL terms. +- **Freedom is passed on.** When you distribute a modified or combined version of a GPL program, the result must stay free software too. + +Not every license works this way. Among OSI-approved licenses there are **permissive** ones such as MIT, BSD and Apache 2.0, with far lighter conditions. Which license to use is the author's choice. + +The kernel's license comes with one important clarification, the Linux-syscall-note: a program that merely talks to the kernel through system calls is not pulled under the GPL. That's why proprietary software, from commercial games to business applications, can run on Linux without any licensing problem. + + + +The summary above is simplified on purpose. If you distribute software or build a business on it, read the license text and the GNU Project's [GPL FAQ](https://www.gnu.org/licenses/gpl-faq.html) yourself. + + + +## Key takeaways + +- The **Linux kernel** is the core of the operating system: it manages hardware and resources. +- A **distribution** combines the kernel with **GNU tools**, a desktop environment and apps; people usually call the whole thing "Linux". +- Linux runs nearly everywhere: servers, phones, embedded devices, supercomputers and desktops. +- Linux is **free software** that anyone may run, study, change and share, and the kernel is licensed under **GPL version 2**. + +Next, [History of Linux](../history/) follows the road from Unix to the Linux of today. diff --git a/src/content/learn/en/why-linux.mdx b/src/content/learn/en/why-linux.mdx new file mode 100644 index 0000000..1dc0f13 --- /dev/null +++ b/src/content/learn/en/why-linux.mdx @@ -0,0 +1,142 @@ +--- +title: Why use Linux? +description: What Linux does well and, honestly, where it falls short. Covers cost, privacy, the security model and old hardware, the limits around games and commercial software, and the most common myths. +navTitle: Why Linux? +order: 3 +group: basics +icon: star +related: [linux-mint, zorin-os, linux-lite, bazzite] +sources: + - https://www.gnu.org/philosophy/free-sw.html + - https://ubuntu.com/tutorials/try-ubuntu-before-you-install + - https://wiki.debian.org/SecureApt + - https://docs.kernel.org/admin-guide/LSM/index.html + - https://docs.flatpak.org/en/latest/sandbox-permissions.html + - https://www.xfce.org/about + - https://www.libreoffice.org/discover/libreoffice/ + - https://github.com/ValveSoftware/Proton + - https://partner.steamgames.com/doc/steamdeck/proton + - https://www.steamdeck.com/en/verified + - https://store.steampowered.com/steamos + - https://ubuntu.com/server/docs/how-to/graphics/install-nvidia-drivers/ + - https://fwupd.org/ +lastReviewed: 2026-10-03 +--- + +No operating system is the best choice for everyone. Linux has real strengths, but depending on the software and hardware you rely on, it can also get in your way. This chapter lays out both sides, clears up some common myths, and ends with a simple way to find out whether Linux suits you. + +## Free to use, free to share + +Most distributions cost nothing to download, and you can install them on as many computers as you like. There are no product keys and no activation. Beyond the operating system itself, many of the apps you need every day (office suites, web browsers, photo and video editors) are free as well. + +The bigger point is **freedom**. As explained in [What is Linux?](../what-is-linux/), free software can be run, studied, changed and shared by anyone. A school or a community group can set up the same system on dozens of PCs without worrying about licenses. Some enterprise distributions sell support and long-term maintenance as a subscription, but there are plenty of free distributions that are more than enough for personal use. + +## You're in charge of your computer + +On Linux, you get a lot of say in what happens on your machine. + +- **You choose when to update.** Most distributions tell you when updates are available and leave it to you to decide when to install them and when to restart. +- **No online account required.** Most distributions don't ask you to sign in to anything during setup; a local account on your computer is all you need. +- **You can see what's inside.** The source code is public, so anyone can check what a program actually does. Some distributions do offer to send diagnostic data, so look at the choices in the installer and at the distribution's privacy policy. +- **You can remove what you don't need.** Even preinstalled apps can be cleanly removed with the package manager, and you install only what you want. + +## Security by design + +Linux's reputation for security doesn't come from one magic trick. It comes from several layers of design working together. + +| Layer | What it does | +|---|---| +| Separate user and admin roles | Day to day you work in a limited account and use `sudo` to borrow admin rights only when changing the system. A mistake or a malicious program has a much harder time damaging the whole system. | +| Signed repositories | Instead of downloading installers from random websites, you get software from your distribution's repositories, and the package manager checks digital signatures and checksums to detect tampering. | +| Mandatory access control (MAC) | Built on the kernel's Linux Security Module (LSM) framework, features such as SELinux and AppArmor limit what each program is allowed to touch. | +| App sandboxing | Flatpak apps run isolated from the rest of the system by default and reach outside only where you allow it, for example through the file picker. | +| One place for updates | The package manager updates the operating system and your apps together, so security fixes are hard to miss. | + +The permission model is covered in [Users and permissions](../users-permissions/), software sources in [Package management](../package-management/), and hardening in [Security](../security/). + +## New life for old hardware + +A computer that struggles with a modern operating system can often become useful again with Linux. Lightweight desktop environments such as Xfce aim to be fast and easy on system resources, and several distributions use them by default: + +- [Xubuntu](../../distros/xubuntu/): the official Ubuntu flavor with the Xfce desktop +- [Lubuntu](../../distros/lubuntu/): the official Ubuntu flavor with the lightweight LXQt desktop +- [Linux Lite](../../distros/linux-lite/): an Xfce-based distribution designed for people coming from Windows +- [antiX](../../distros/antix/): a Debian-based distribution that stays fast even on very old PCs + + + +Distributions differ in their minimum specs and in which CPUs they support (32-bit or 64-bit). If your machine has very little memory or an old 32-bit processor, check the system requirements on the distribution's page first. Swapping a hard drive for an SSD can also make a big difference. + + + +## Make it yours, and learn along the way + +Linux lets you change almost everything, from the look of the desktop to the internals of the system. + +- **Pick your desktop.** GNOME, KDE Plasma and Xfce look and work very differently. Choose the one you like, then move panels, change shortcuts and swap themes. [Desktop environments](../desktop-environments/) compares them. +- **Mix and match components.** If you want to, you can pick your own file system, boot setup and even kernel. +- **Try before you install.** Most distributions offer a **live session**: boot from a USB stick and use the system without installing anything. Remove the stick and restart, and your computer is exactly as it was. + +Using Linux also teaches you how computers work. Since Linux underpins servers, the cloud, embedded devices and Android, skills like using the terminal, understanding file permissions and managing processes carry straight over into development and IT work. [Terminal basics](../terminal-basics/) and [Development](../development/) are good starting points. + +## The honest downsides + +Linux isn't right for everyone. Check these points before you switch. + +### Some commercial software is missing + +Some professional design and video tools, and some business applications, are only made for Windows or macOS. Security plug-ins required by certain banking or government websites in some countries may also be Windows-only, so check what your bank or agency supports. Often a web version or a Linux alternative fills the gap: + +| Task | Linux options | +|---|---| +| Documents, spreadsheets, slides | LibreOffice, web-based office suites | +| Photo editing and painting | GIMP, Krita | +| Vector graphics | Inkscape | +| Video editing | Kdenlive, Shotcut | + +There's also **Wine**, a compatibility layer that runs Windows programs on Linux. Plenty of programs work well with it, but results vary by program and version, so test anything you depend on for work before committing. + +### Most games work, but not all + +Thanks to **Proton**, Valve's Wine-based compatibility tool, a large part of the Steam library runs on Linux unchanged. The Steam Deck compatibility ratings (Verified, Playable, Unsupported, Unknown) give a good idea of how well a game will run. + +The main obstacle is **anti-cheat** in some online games. Proton supports Easy Anti-Cheat and BattlEye, but each game's developer has to switch that support on. Games whose developers haven't done so won't let you play online on Linux. If competitive multiplayer is your main thing, check your must-play games first. The [Gaming](../gaming/) chapter goes deeper. + +### Some hardware needs extra care + +Most devices work out of the box with drivers built into the kernel. A few may need extra steps: + +- **NVIDIA graphics cards**: getting full performance often means installing the proprietary driver. Some distributions make this easy; Ubuntu, for example, has a dedicated tool, `ubuntu-drivers`. +- **Certain Wi-Fi chips, fingerprint readers and laptop-specific features**: if the manufacturer doesn't provide Linux drivers, these may not work, or support may arrive late. +- **Firmware updates**: when a manufacturer takes part in the LVFS, you can update firmware on Linux with `fwupd`, but not every manufacturer does. + +Booting a live USB before installing and checking Wi-Fi, sound, screen brightness and the touchpad will reveal most of these issues early. Fixes are collected in [Hardware and drivers](../hardware-drivers/). + +### There's a learning curve + +Installing software, where files live and keyboard shortcuts all work differently from Windows or macOS. And because there are so many distributions, the same task can involve slightly different commands on each one. With free distributions, help usually comes from official documentation and community forums rather than a vendor's support line. Wherever distributions differ, this guide splits the instructions into tabs. + +## Common myths + +| Myth | Reality | +|---|---| +| Linux is only for developers and experts | Modern distributions come with graphical installers and app stores; you can do everyday work without ever opening a terminal. | +| Linux can't get viruses | Malware for Linux does exist. Its design simply makes damage easier to contain, so keep the system updated and install software only from trusted sources. | +| Free means low quality | Linux runs supercomputers, servers and phones. It's free because of its license, not because of its quality. | +| Installing Linux means wiping Windows | You can try it from a live USB without installing, or install it alongside Windows and choose at boot (dual boot). | +| You can't game on Linux | Proton runs a large number of Windows games. The exceptions are some online games whose developers haven't enabled Linux anti-cheat support. | +| Linux is a product from one company | The kernel and the software around it are built by individuals, communities and companies together, and distributions come in many varieties for different needs. | + +## Is Linux right for you? + + + +1. **List the software you can't live without.** For each app, check whether there's a Linux version, a web version or a good alternative. +2. **Check your favorite games.** Look up their Steam Deck rating and whether their anti-cheat supports Linux. +3. **Try a live USB.** Boot without installing to see whether your hardware works and whether you like how the system feels. +4. **Pick a distribution.** If you're new, answer a few questions in the [distro finder](../../finder/) to get a recommendation. +5. **Prepare to install.** Plan your backups and decide on dual boot with [Installation prep](../installation-prep/). + + + +If you're coming from Windows or macOS, [Switching to Linux](../switching/) walks you through what's different. diff --git a/src/content/learn/ko/desktop-environments.mdx b/src/content/learn/ko/desktop-environments.mdx new file mode 100644 index 0000000..9f5dc2a --- /dev/null +++ b/src/content/learn/ko/desktop-environments.mdx @@ -0,0 +1,211 @@ +--- +title: 데스크톱 환경과 창 관리자 +description: GNOME, KDE Plasma, Xfce, Cinnamon 같은 데스크톱 환경과 i3·Sway·Hyprland 같은 타일링 창 관리자를 비교하고, 다른 데스크톱을 시험하고 설치하는 방법을 알아봅니다. +navTitle: 데스크톱 환경 +order: 5 +group: distros +icon: app-grid +related: [kubuntu, linux-mint, xubuntu, pop-os] +sources: + - https://wiki.archlinux.org/title/Desktop_environment + - https://wiki.archlinux.org/title/Window_manager + - https://wiki.archlinux.org/title/Display_manager + - https://www.gnome.org/ + - https://wiki.archlinux.org/title/GNOME + - https://kde.org/plasma-desktop/ + - https://wiki.archlinux.org/title/KDE + - https://blogs.kde.org/2025/11/26/going-all-in-on-a-wayland-future/ + - https://www.xfce.org/about + - https://wiki.archlinux.org/title/Xfce + - https://www.linuxmint.com/download.php + - https://www.linuxmint.com/rel_virginia_whatsnew.php + - https://wiki.archlinux.org/title/Cinnamon + - https://mate-desktop.org/ + - https://lxqt-project.org/about/ + - https://wiki.archlinux.org/title/LXQt + - https://buddiesofbudgie.org/ + - https://wiki.archlinux.org/title/Budgie + - https://system76.com/cosmic + - https://wiki.archlinux.org/title/COSMIC + - https://elementary.io/ + - https://wiki.archlinux.org/title/Pantheon + - https://i3wm.org/ + - https://wiki.archlinux.org/title/I3 + - https://wiki.archlinux.org/title/Wayland + - https://fedoraproject.org/atomic-desktops/ + - https://swaywm.org/ + - https://hypr.land/ + - https://wiki.archlinux.org/title/Hyprland + - https://ubuntu.com/desktop/flavours + - https://wiki.debian.org/DesktopEnvironment + - https://docs.fedoraproject.org/en-US/quick-docs/switching-desktop-environments/ +lastReviewed: 2026-10-03 +--- + +Windows와 macOS는 화면 모양이 운영체제에 붙어 있지만, 리눅스에서는 화면을 담당하는 **데스크톱 환경(desktop environment, DE)** 이 따로 떨어진 부품입니다. 그래서 같은 배포판이라도 데스크톱을 바꾸면 전혀 다른 운영체제처럼 보이고, 마음에 들지 않으면 배포판을 그대로 둔 채 데스크톱만 바꿀 수도 있습니다. + +이 장에서는 데스크톱 환경이 무엇으로 이루어지는지, 대표적인 데스크톱의 특징, 키보드 중심의 타일링 창 관리자, 그리고 다른 데스크톱을 안전하게 시험하는 방법을 정리합니다. + +## 데스크톱 환경은 무엇으로 이루어지나 + +데스크톱 환경은 여러 프로그램을 한 벌로 맞춰 놓은 묶음입니다. + +- **창 관리자(window manager)** 또는 **컴포지터(compositor)**: 창을 그리고 옮기고 겹치는 방식을 정합니다. +- **패널과 앱 실행기**: 작업 표시줄, 시작 메뉴, 시스템 트레이, 알림. +- **설정 앱**: 디스플레이, 키보드, 네트워크, 테마를 한곳에서 바꿉니다. +- **기본 앱**: 파일 관리자, 터미널, 텍스트 편집기, 이미지 보기 등. + +여기에 **디스플레이 관리자(display manager)**, 즉 로그인 화면이 붙습니다. GNOME은 GDM, KDE Plasma는 SDDM을 주로 쓰고, LightDM처럼 특정 데스크톱에 묶이지 않은 것도 있습니다. 데스크톱이 여러 개 설치되어 있으면 로그인 화면에서 어느 **세션(session)** 으로 들어갈지 고를 수 있습니다. + +데스크톱마다 바탕이 되는 **툴킷(toolkit)** 이 다릅니다. GNOME, Xfce, Cinnamon, MATE, Budgie, Pantheon은 **GTK**를, KDE Plasma와 LXQt는 **Qt**를 쓰고, COSMIC은 Rust용 **iced**를 씁니다. 어떤 데스크톱에서든 다른 툴킷으로 만든 앱을 실행할 수 있지만, 테마와 대화상자 모양이 조금 어색해 보일 수 있습니다. 여러 데스크톱이 freedesktop.org의 공통 규격을 따르기 때문에 앱 메뉴, 아이콘, 자동 실행 같은 기본 동작은 대부분 호환됩니다. + + + X11에서는 창 관리자, 컴포지팅 관리자, 디스플레이 서버가 따로 나뉘어 있었습니다. Wayland에서는 컴포지터 하나가 이 역할을 모두 맡습니다. 이 차이는 [X11과 Wayland](../display-servers/) 장에서 자세히 다룹니다. + + +## 한눈에 비교하기 + +| 데스크톱 | 툴킷 | 기본 화면 구성 | 자원 사용(대략) | 이런 분께 맞습니다 | 대표 배포판 | +|---|---|---|---|---|---| +| GNOME | GTK | 상단 바와 활동 개요 화면 | 보통~많음 | 단순하고 집중된 작업 흐름, 터치패드 제스처 | Fedora Workstation, Ubuntu | +| KDE Plasma | Qt | 하단 패널과 시작 메뉴 | 보통 | 세세하게 꾸미고 싶은 분, Windows에서 넘어온 분 | Kubuntu, Fedora KDE | +| Xfce | GTK | 패널과 메뉴, 전통적 구성 | 적음 | 오래된 PC, 가볍고 안정적인 환경 | Xubuntu, Linux Mint Xfce | +| Cinnamon | GTK | 하단 패널과 시작 메뉴 | 보통 | Windows와 비슷한 화면을 원하는 입문자 | Linux Mint | +| MATE | GTK | GNOME 2 방식의 위·아래 패널 | 적음 | 고전적인 데스크톱을 좋아하는 분 | Ubuntu MATE | +| LXQt | Qt | 하단 패널과 메뉴 | 아주 적음 | 사양이 낮은 PC | Lubuntu | +| Budgie | GTK | 패널, Raven 사이드바, 레이아웃 선택 | 보통 | 깔끔하면서 손쉽게 꾸미고 싶은 분 | Ubuntu Budgie | +| COSMIC | iced | 패널과 독, 선택형 타일링 | 보통 | 마우스와 타일링을 함께 쓰고 싶은 분 | Pop!\_OS | +| Pantheon | GTK | 상단 바와 하단 독 | 보통 | 단순하고 정돈된 디자인 | elementary OS | + + + 표의 자원 사용은 대략적인 순서일 뿐입니다. 실제 메모리 사용량은 배포판의 기본 설정, 버전, 켜 둔 앱에 따라 크게 달라집니다. 브라우저 탭 몇 개가 데스크톱 자체보다 메모리를 더 쓰는 경우도 흔합니다. + + +## 주요 데스크톱 환경 + +### GNOME + +Super 키 하나로 열리는 **활동 개요(Activities)** 화면에서 앱 실행, 창 전환, 검색을 모두 처리하는 단순한 데스크톱입니다. 작업 공간을 넉넉하게 쓰도록 설계했고, 기본 앱들이 같은 디자인 원칙을 따릅니다. Fedora Workstation, Ubuntu, Debian, RHEL 등 여러 배포판의 기본 데스크톱이며, 배포판에 따라 기본 모양을 조금 바꿔서 넣기도 합니다. 모양을 더 바꾸고 싶다면 확장(extension)을 설치합니다. Wayland에서 동작하며, X11 앱은 XWayland로 실행합니다. + +### KDE Plasma + +기본 모양은 하단 패널과 시작 메뉴로 Windows와 비슷하지만, 패널 위치부터 색상, 글꼴, 위젯까지 거의 모든 것을 바꿀 수 있는 것이 가장 큰 특징입니다. 입력하는 대로 앱·파일·계산 결과를 찾아 주는 **KRunner**, 배포판 패키지와 Flatpak, Snap을 한곳에서 관리하는 **Discover**를 갖추고 있습니다. Wayland 세션이 기본이며, KDE는 X11 세션을 더 이상 포함하지 않는 방향으로 전환하고 있습니다. + +### Xfce + +빠르고 자원을 적게 쓰는 것을 목표로 하는 GTK 기반의 가벼운 데스크톱입니다. 창 관리자, 패널, 파일 관리자 같은 구성 요소가 따로따로 패키지로 나뉘어 있어 필요한 것만 골라 쓸 수 있습니다. 오랫동안 크게 바뀌지 않은 전통적인 화면이라 익숙하고 안정적입니다. Wayland 지원은 아직 실험 단계입니다. + +### Cinnamon + +Linux Mint가 직접 개발하는 데스크톱으로, 전통적인 화면 배치에 현대적인 효과를 더했습니다. GNOME에서 갈라져 나왔지만 지금은 독립된 데스크톱입니다. 오랫동안 X11 위에서 동작해 왔고, Cinnamon 6.0부터 Wayland 세션을 실험 기능으로 함께 제공하기 시작했습니다. + +### MATE와 LXQt + +**MATE**는 GNOME 2를 이어 가는 프로젝트로, 위·아래 패널을 둔 고전적인 화면을 유지하면서 가볍게 동작합니다. **LXQt**는 Qt로 만든 가벼운 데스크톱으로, 시스템을 느리게 만들지 않으면서 현대적인 모양을 내는 것을 목표로 합니다. 둘 다 오래된 노트북이나 메모리가 적은 PC에 잘 맞습니다. LXQt는 X11에서는 창 관리자를 바꿔 쓸 수 있고, 여러 Wayland 컴포지터 위에서도 실행할 수 있습니다. + +### Budgie + +Solus에서 시작해 지금은 Buddies of Budgie가 만드는 GTK 기반 데스크톱입니다. 알림과 위젯을 모아 둔 **Raven** 사이드바가 특징이고, 설정에서 전통적인 배치, Unity 같은 배치, macOS 같은 배치 중에서 고를 수 있습니다. Budgie 10.10부터 Wayland로 옮겨 가면서 X11 지원을 정리했습니다. + +### COSMIC + +System76이 Pop!\_OS를 위해 만든 데스크톱으로, 처음에는 GNOME을 크게 고친 형태였다가 Rust 언어로 처음부터 다시 만들었습니다. 처음부터 Wayland 전용으로 설계했고, 마우스로 쓰는 일반 창 배치에 자동 **타일링(tiling)** 을 켜고 끌 수 있습니다. Fedora에도 COSMIC 에디션이 있습니다. + +### Pantheon + +elementary OS의 데스크톱으로, Vala와 GTK, elementary의 Granite 라이브러리로 만들었습니다. 상단 바와 하단 독으로 이루어진 단순하고 일관된 디자인을 중시합니다. elementary OS는 "보안 세션(Secure Session)"을 중심으로 Wayland 전환을 진행하고 있습니다. + +## 타일링 창 관리자와 컴포지터 + +대부분의 데스크톱은 창을 종이처럼 겹쳐 놓는 **스태킹(stacking, floating)** 방식을 씁니다. 반면 **타일링(tiling)** 방식은 창이 서로 겹치지 않게 화면을 자동으로 나눠 채우고, 주로 키보드 단축키로 조작합니다. 두 방식을 오갈 수 있는 것은 **동적(dynamic)** 창 관리자라고 부릅니다. + +| 이름 | 디스플레이 서버 | 특징 | +|---|---|---| +| i3 | X11 | 처음부터 새로 만든 타일링 창 관리자. 설정 파일이 단순하고 문서가 잘 정리되어 있습니다 | +| Sway | Wayland | i3와 호환되도록 만든 Wayland 컴포지터. 기존 i3 설정을 거의 그대로 쓸 수 있습니다 | +| Hyprland | Wayland | 동적 타일링에 애니메이션, 둥근 모서리, 흐림 효과를 더했고 플러그인으로 확장합니다 | +| niri | Wayland | 창을 옆으로 길게 이어 붙이고 스크롤하며 이동하는 타일링 컴포지터 | + +i3와 Sway의 설정은 이런 모양입니다. `$mod`는 주로 Super 키(Mod4)로 정합니다. i3의 기본값은 Alt(Mod1)입니다. + +```text +set $mod Mod4 +bindsym $mod+Return exec foot +bindsym $mod+h focus left +bindsym $mod+1 workspace number 1 +bindsym $mod+Shift+q kill +``` + +타일링 창 관리자는 창 관리만 맡는 경우가 많아서, 상태 표시줄, 앱 실행기, 알림, 화면 잠금, 배경 화면 같은 나머지 부품은 직접 골라 설치하고 설정해야 합니다. 그만큼 가볍고 내 손에 꼭 맞게 만들 수 있지만 처음 구성하는 데 시간이 듭니다. + + + 처음부터 전부 구성하기 부담스럽다면 타일링이 들어 있는 데스크톱(COSMIC)이나, Sway를 미리 구성해 둔 배포판 에디션(Fedora Sway Atomic 등)으로 시작해 보세요. 카탈로그에서 [i3](../../distros/?desktop=i3), [Sway](../../distros/?desktop=sway), [Hyprland](../../distros/?desktop=hyprland)를 쓰는 배포판을 찾아볼 수 있습니다. + + +## 다른 데스크톱 시험해 보기 + +데스크톱을 바꾸는 방법은 위험이 적은 순서로 세 가지가 있습니다. + + + +1. **라이브 USB로 써 보기**: Kubuntu, Xubuntu, Fedora KDE처럼 원하는 데스크톱을 기본으로 쓰는 배포판을 USB로 부팅해 설치 없이 써 봅니다. +2. **가상 머신(virtual machine)에서 써 보기**: GNOME Boxes나 VirtualBox에 설치해 지금 시스템을 건드리지 않고 오래 써 봅니다. +3. **지금 시스템에 추가 설치하기**: 아래 명령으로 데스크톱을 설치한 뒤 로그아웃하고, 로그인 화면에서 세션을 골라 들어갑니다. GDM에서는 사용자를 고른 뒤 나타나는 설정(톱니바퀴) 아이콘에서 고릅니다. + + + + + + +Ubuntu에서는 공식 플레이버의 메타패키지를, Debian에서는 `task-` 패키지를 설치합니다. + +```bash +# Ubuntu: KDE Plasma(Kubuntu), Xfce(Xubuntu) +sudo apt install kubuntu-desktop +sudo apt install xubuntu-desktop + +# Debian: KDE Plasma, Xfce +sudo apt install task-kde-desktop +sudo apt install task-xfce-desktop +``` + +설치 도중 기본 로그인 화면(디스플레이 관리자)을 고르라는 창이 나오면 지금 쓰는 것을 그대로 골라도 됩니다. + + + + +설치할 수 있는 데스크톱 목록을 확인한 뒤 앞에 `@`를 붙여 설치합니다. + +```bash +dnf environment list --available | grep -i desktop +sudo dnf install @kde-desktop-environment +``` + + + + +```bash +# KDE Plasma +sudo pacman -S plasma-meta +# Xfce와 추가 플러그인 +sudo pacman -S xfce4 xfce4-goodies +# GNOME +sudo pacman -S gnome +``` + + + + + + 전체 데스크톱 패키지는 설정 앱, 파일 관리자, 테마까지 함께 설치합니다. 여러 데스크톱을 한 시스템에 두면 앱 메뉴에 같은 종류의 앱이 겹쳐 보이고, 테마나 설정이 서로 영향을 줄 수 있으며, 나중에 깔끔하게 지우기도 어렵습니다. 오래 쓸 생각이라면 가상 머신이나 라이브 USB로 먼저 확인하고, 마음에 드는 데스크톱을 기본으로 쓰는 배포판을 새로 설치하는 편이 깔끔합니다. + + +## 어떤 데스크톱을 고를까 + +- **처음이고 Windows가 익숙하다면** Cinnamon이나 KDE Plasma가 가장 덜 낯섭니다. +- **단순하고 방해받지 않는 화면을 원한다면** GNOME이나 Pantheon을 써 보세요. +- **PC 사양이 낮다면** Xfce, MATE, LXQt가 부담이 적습니다. +- **고해상도 화면, 모니터마다 다른 배율, 터치패드 제스처가 중요하다면** Wayland가 기본인 GNOME, KDE Plasma, COSMIC이 유리합니다. +- **키보드만으로 빠르게 작업하고 싶다면** Sway, Hyprland, i3 같은 타일링 환경을 시험해 보세요. + +카탈로그에서 데스크톱별로 배포판을 걸러 볼 수 있습니다. [GNOME](../../distros/?desktop=gnome), [KDE Plasma](../../distros/?desktop=kde), [Xfce](../../distros/?desktop=xfce), [Cinnamon](../../distros/?desktop=cinnamon), [MATE](../../distros/?desktop=mate), [LXQt](../../distros/?desktop=lxqt)를 눌러 보세요. 배포판 자체를 고르는 방법은 [배포판 이해하기](../distributions/)에 정리했습니다. diff --git a/src/content/learn/ko/display-servers.mdx b/src/content/learn/ko/display-servers.mdx new file mode 100644 index 0000000..b0c53a7 --- /dev/null +++ b/src/content/learn/ko/display-servers.mdx @@ -0,0 +1,188 @@ +--- +title: "디스플레이 서버: X11과 Wayland" +description: 화면에 창을 그리고 입력을 전달하는 디스플레이 서버가 무엇인지, X11과 Wayland가 어떻게 다른지, XWayland·화면 공유·NVIDIA 같은 실제 문제와 지금 쓰는 세션을 확인하는 방법을 설명합니다. +navTitle: X11과 Wayland +order: 6 +group: distros +icon: display +related: [fedora, ubuntu, kubuntu, pop-os] +sources: + - https://wayland.freedesktop.org/architecture.html + - https://wayland.freedesktop.org/faq.html + - https://wayland.freedesktop.org/docs/html/ch01.html + - https://www.x.org/wiki/ + - https://wiki.archlinux.org/title/Wayland + - https://wiki.archlinux.org/title/Xorg + - https://wiki.archlinux.org/title/Security + - https://wiki.archlinux.org/title/NVIDIA + - https://wiki.archlinux.org/title/XDG_Desktop_Portal + - https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html + - https://pipewire.org/ + - https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html + - https://www.freedesktop.org/software/systemd/man/latest/loginctl.html + - https://blogs.gnome.org/alatiera/2025/06/08/the-x11-session-removal/ + - https://blogs.kde.org/2025/11/26/going-all-in-on-a-wayland-future/ +lastReviewed: 2026-10-03 +--- + +앱이 창을 띄우고, 마우스와 키보드 입력이 올바른 창에 전달되고, 그 결과가 모니터에 나타나기까지는 중간에서 교통정리를 하는 프로그램이 필요합니다. 이것을 **디스플레이 서버(display server)** 라고 합니다. 리눅스에서는 오랫동안 **X11**이 이 역할을 했고, 지금은 대부분의 주요 데스크톱이 **Wayland**로 옮겨 왔습니다. + +평소에는 어느 쪽을 쓰는지 의식할 일이 거의 없지만, 화면 공유가 안 되거나 일부 앱이 흐릿하게 보이는 문제는 대개 이 차이에서 생깁니다. 이 장에서는 두 방식의 구조와 실제로 부딪히는 문제, 해결 방법을 정리합니다. + +## 디스플레이 서버가 하는 일 + +디스플레이 서버는 크게 두 방향의 일을 합니다. + +- **입력 전달**: 커널이 받은 키보드·마우스·터치패드 입력을 보고, 지금 포인터 아래에 있거나 초점을 가진 창(앱)에 넘겨줍니다. +- **화면 출력**: 각 앱이 그린 창의 내용을 모아 하나의 화면으로 합친 뒤, 커널의 **KMS(kernel mode setting)** 를 통해 모니터에 내보냅니다. + +여기서 나오는 용어를 정리하면 다음과 같습니다. + +| 용어 | 뜻 | +|---|---| +| 클라이언트(client) | 창을 띄우는 앱. 브라우저, 터미널, 게임 등 | +| 창 관리자(window manager) | 창의 위치, 크기, 테두리, 겹침 순서를 정합니다 | +| 컴포지터(compositor) | 여러 창의 내용을 합성해 최종 화면을 만듭니다. 그림자와 투명 효과도 여기서 처리합니다 | +| 디스플레이 서버 | 클라이언트와 하드웨어 사이에서 입력과 출력을 중개합니다 | + +## X11: 오랫동안 쓰인 표준 + +**X Window System**, 흔히 **X11** 또는 **X**라고 부르는 방식은 수십 년 동안 리눅스와 유닉스 그래픽 화면의 기반이었습니다. 리눅스에서 쓰는 구현은 X.Org 재단의 **X.Org Server(Xorg)** 입니다. X에서는 디스플레이 서버(X 서버), 창 관리자, 컴포지팅 관리자가 각각 따로 동작하는 프로그램이어서 원하는 창 관리자를 자유롭게 조합할 수 있습니다. 네트워크 너머의 앱 화면을 그대로 띄우는 **X11 포워딩**도 오래전부터 가능했습니다. + +하지만 시대가 바뀌면서 한계가 드러났습니다. + +- **복잡함**: 글꼴 그리기, 화면 설정, 메모리 관리 같은 기능이 점점 클라이언트 라이브러리와 커널로 옮겨 가면서, X 서버 안에는 거의 쓰이지 않는 기능이 많이 남았습니다. +- **돌아가는 경로**: X에서 화면을 합성하려면 앱, X 서버, 컴포지터 사이를 여러 번 오가야 해서 불필요한 문맥 전환(context switch)이 생깁니다. +- **보안**: X의 설계는 현대적인 보안 개념보다 앞서 만들어졌습니다. 같은 X 서버에 연결된 앱은 다른 앱의 키 입력이나 화면 내용을 엿볼 수 있습니다. + +X.Org 프로젝트는 지금도 보안 수정 등을 내놓고 있지만, X11R7.7 이후로는 전체 스택을 한꺼번에 릴리스하지 않고 필요한 모듈만 따로 내고 있습니다. + +## Wayland: 컴포지터가 곧 디스플레이 서버 + +**Wayland**는 프로그램이 아니라 **컴포지터와 앱이 대화하는 방법을 정한 프로토콜(protocol)** 입니다. Wayland에서는 디스플레이 서버, 창 관리자, 컴포지터가 하나로 합쳐져서, 데스크톱마다 자기 컴포지터가 그 역할을 모두 맡습니다. GNOME의 Mutter, KDE Plasma의 KWin, Sway, Hyprland, COSMIC의 컴포지터가 모두 Wayland 컴포지터입니다. 그래서 Xorg처럼 따로 설치하는 "Wayland 서버"는 없습니다. + +앱은 자기 창을 직접 버퍼에 그린 뒤 어디가 바뀌었는지만 컴포지터에 알리고, 컴포지터가 이를 합쳐 커널로 바로 내보냅니다. 입력도 커널에서 컴포지터를 거쳐 곧장 해당 앱으로 갑니다. + +```text +X11 입력: 커널 → X 서버 → 앱 + 출력: 앱 → X 서버 → 컴포지터 → X 서버 → 커널(KMS) → 모니터 + +Wayland 입력: 커널 → 컴포지터 → 앱 + 출력: 앱 → 컴포지터 → 커널(KMS) → 모니터 +``` + +| | X11 | Wayland | +|---|---|---| +| 구조 | X 서버, 창 관리자, 컴포지터가 따로 | 컴포지터 하나가 모두 담당 | +| 앱 사이 격리 | 다른 앱의 입력과 화면을 볼 수 있음 | 앱은 자기 창만 다룹니다 | +| 화면 캡처·공유 | 아무 앱이나 전체 화면을 읽을 수 있음 | 포털을 통해 사용자가 허락한 것만 | +| 원격 접속 | X11 포워딩 내장 | 프로토콜 밖의 도구(RDP, VNC, waypipe) 사용 | +| 전역 단축키·자동화 | 앱이 자유롭게 가로챔 | 컴포지터와 포털을 거쳐야 함 | +| 기존 앱 호환 | 기본 | XWayland로 대부분 실행 | + + + 화면 녹화, 전역 단축키, 다른 창 조작 같은 기능이 Wayland에서 "안 된다"고 느껴진다면, 대부분 아무 앱이나 하지 못하도록 막아 둔 결과입니다. 이런 기능은 사용자의 허락을 받는 공식 경로(포털, 프로토콜 확장)를 통해 제공됩니다. + + +## XWayland로 X11 앱 실행하기 + +아직 Wayland를 지원하지 않는 X11 앱도 Wayland 세션에서 그대로 실행됩니다. 컴포지터가 **XWayland**라는 X 서버를 필요할 때 띄워 주기 때문입니다. XWayland는 Wayland 컴포지터의 클라이언트로 동작하는 X 서버로, 대부분의 X11 앱이 X11 세션과 거의 같은 성능으로 동작합니다. + +다만 알아 둘 점이 있습니다. + +- XWayland는 X 서버이므로, XWayland로 실행되는 앱끼리는 Wayland의 격리 혜택을 받지 못합니다. +- 완벽하게 호환되지는 않아서 일부 앱은 제대로 동작하지 않을 수 있습니다. +- 화면 배율을 소수(예: 125%)로 쓰면 XWayland 앱이 흐릿하게 보일 수 있습니다. + +어떤 앱이 XWayland로 실행 중인지 확인하려면 `xlsclients -l`로 XWayland에 연결된 앱 목록을 보거나, `xeyes`를 띄운 뒤 마우스를 앱 창 위로 옮겨 봅니다. 눈동자가 따라 움직이면 그 창은 XWayland 앱입니다. KDE Plasma에서는 KWin 디버그 콘솔로도 볼 수 있습니다. + +GTK와 Qt 앱은 기본으로 Wayland를 쓰지만, 문제가 있을 때는 환경 변수로 XWayland를 강제할 수 있습니다. 반대로 오래된 Electron 앱은 실행 옵션으로 Wayland를 켤 수 있습니다(최근 Electron은 기본으로 Wayland를 씁니다). + +```bash +GDK_BACKEND=x11 gnome-text-editor # GTK 앱을 XWayland로 실행 +QT_QPA_PLATFORM=xcb kate # Qt 앱을 XWayland로 실행 +code --ozone-platform=wayland # Electron 앱(예: VS Code)에 Wayland 사용 지시 +``` + +## 화면 공유와 녹화: PipeWire와 포털 + +X11에서는 어떤 앱이든 전체 화면을 마음대로 읽을 수 있었습니다. Wayland에서는 앱이 다른 창을 볼 수 없기 때문에, 화면 공유와 녹화는 **XDG 데스크톱 포털(xdg-desktop-portal)** 이라는 공식 창구를 거칩니다. + + + +1. 화상회의 앱이나 브라우저가 포털의 **ScreenCast** 기능에 화면 공유를 요청합니다. +2. 데스크톱이 대화상자를 띄워 공유할 **모니터 전체**나 **특정 창**을 사용자가 고르게 합니다. +3. 허락하면 선택한 화면이 **PipeWire** 스트림으로 앱에 전달됩니다. 다음부터 다시 고르지 않도록 선택을 기억하게 할 수도 있습니다. + + + +PipeWire는 오디오와 비디오를 낮은 지연으로 처리하는 멀티미디어 서버로, 샌드박스 앱도 안전하게 화면과 소리를 다룰 수 있게 설계되었습니다. 포털은 원래 Flatpak 앱을 위해 만들었지만 지금은 일반 앱도 데스크톱과 상관없이 같은 방식으로 씁니다. + +포털은 실제 작업을 데스크톱별 **백엔드(backend)** 에 맡깁니다. GNOME은 `xdg-desktop-portal-gnome`, KDE Plasma는 `xdg-desktop-portal-kde`, Sway 같은 wlroots 기반 컴포지터는 `xdg-desktop-portal-wlr`, Hyprland는 `xdg-desktop-portal-hyprland`를 씁니다. 주요 데스크톱은 이미 설치되어 있지만, Sway나 Hyprland를 직접 구성했다면 맞는 백엔드를 설치해야 화면 공유가 됩니다. + +화면 공유 창이 검게 나오거나 아예 뜨지 않으면 PipeWire와 포털 서비스가 실행 중인지부터 확인합니다. + +```bash +systemctl --user status pipewire xdg-desktop-portal +``` + +원격 접속도 같은 흐름입니다. GNOME과 KDE Plasma에는 자체 원격 데스크톱 기능(RDP, VNC)이 있고, wlroots 기반 컴포지터는 wayvnc를 씁니다. `ssh -X`로 하는 X11 포워딩은 XWayland를 통해 계속 쓸 수 있고, Wayland 앱은 `waypipe`로 비슷하게 원격 실행할 수 있습니다. + +## NVIDIA 그래픽 카드 + +예전에는 NVIDIA 독점 드라이버와 Wayland의 궁합이 좋지 않았습니다. NVIDIA 드라이버가 다른 드라이버들과 다른 버퍼 방식(EGLStreams)만 지원했기 때문입니다. 이후 NVIDIA 드라이버가 다른 드라이버와 같은 **GBM** 방식을 지원하고, 깜빡임과 프레임 순서 문제를 해결하는 **명시적 동기화(explicit sync)** 가 드라이버와 컴포지터 양쪽에 들어오면서 상황이 크게 나아졌습니다. KDE도 제조사가 아직 지원하는 그래픽 카드라면 이제 Wayland에서 잘 동작한다고 밝히고 있습니다. + +NVIDIA 카드에서 Wayland를 쓸 때 확인할 것은 다음과 같습니다. + +- **드라이버를 최신으로 유지합니다.** 오래된 드라이버나 명시적 동기화를 지원하지 않는 컴포지터에서는 화면 깜빡임, 프레임 순서 뒤섞임 같은 문제가 생길 수 있습니다. 아주 오래된 드라이버는 XWayland 하드웨어 가속도 지원하지 않습니다. +- **DRM 커널 모드 설정(KMS)이 켜져 있어야 합니다.** 많은 배포판의 최신 드라이버 패키지는 기본으로 켜 두지만, 직접 확인해 볼 수 있습니다. `Y`가 나오면 켜진 것입니다. + +```bash +cat /sys/module/nvidia_drm/parameters/modeset +``` + +`N`이 나오면 커널 매개변수 `nvidia_drm.modeset=1`을 추가해야 합니다. 커널 매개변수를 넣는 방법은 부트로더마다 다르므로 [부팅 과정](../boot-process/)을 참고하세요. 드라이버 설치 자체는 [하드웨어와 드라이버](../hardware-drivers/)에서 다룹니다. + + + NVIDIA가 더 이상 지원하지 않는 오래된 카드에서는 독점 드라이버 대신 오픈소스 **Nouveau** 드라이버를 쓰는 편이 Wayland에서 더 편할 수 있습니다. + + +## 지금 어떤 세션인지 확인하기 + +터미널을 열고 다음을 실행하면 바로 알 수 있습니다. + +```bash +echo $XDG_SESSION_TYPE +``` + +`wayland`가 나오면 Wayland 세션, `x11`이면 X11 세션입니다. SSH로 접속했거나 그래픽 없는 콘솔에서는 `tty`가 나옵니다. `XDG_SESSION_TYPE`은 로그인 과정에서 세션 종류를 나타내도록 정해지는 환경 변수로, systemd의 로그인 관리자도 이 값을 씁니다. + +다른 방법으로도 확인할 수 있습니다. + +```bash +loginctl show-session "$XDG_SESSION_ID" -p Type # Type=wayland 또는 Type=x11 +echo $WAYLAND_DISPLAY # wayland-0 처럼 나오면 Wayland +echo $XDG_CURRENT_DESKTOP # 지금 쓰는 데스크톱 이름 +``` + +GNOME에서는 설정 앱의 **정보**(About) 화면에 있는 "창 시스템(Windowing System)" 항목으로도 확인할 수 있습니다. 배포판이 X11 세션도 제공한다면 로그인 화면에서 사용자를 고른 뒤 세션 메뉴(톱니바퀴 아이콘)에서 바꿀 수 있습니다. + +## Wayland에서 자주 겪는 문제 + +| 증상 | 원인과 해결 | +|---|---| +| 화면 공유가 검게 나옴 | 포털 백엔드가 없거나 PipeWire가 꺼져 있습니다. 데스크톱에 맞는 `xdg-desktop-portal-*` 패키지를 설치합니다 | +| 일부 앱만 흐릿함 | XWayland로 실행 중인 앱입니다. Wayland로 실행하는 옵션이 있는지 확인합니다(Electron은 `--ozone-platform=wayland`) | +| 앱의 전역 단축키가 안 먹힘 | 앱이 키 입력을 몰래 가로챌 수 없기 때문입니다. 포털의 전역 단축키 기능이나 데스크톱 설정의 단축키를 씁니다 | +| `xdotool` 같은 자동화 도구가 안 됨 | Wayland용 도구로 바꿉니다. 클립보드는 `wl-copy`/`wl-paste`, 입력 흉내는 `ydotool`, KDE에서는 `kdotool` | +| 가상 머신·원격 데스크톱에서 단축키가 바깥에서 먹힘 | 컴포지터가 단축키를 먼저 받습니다. 앱이 단축키를 받아 가도록 허용하라는 요청이 오면 허용합니다 | + + + 인터넷에서 찾은 환경 변수를 `/etc/environment` 같은 전역 설정에 무작정 넣으면 다른 앱이나 다음 업데이트에서 문제를 일으킬 수 있습니다. 먼저 해당 앱 하나에만 적용해 보고, 효과가 있을 때만 그 앱의 실행 설정에 넣으세요. + + +## X11은 사라지나요? + +**X11 앱은 계속 동작하지만, X11 로그인 세션은 줄어들고 있습니다.** GNOME은 GNOME 49부터 X11 세션을 기본으로 끄고 이후 제거하기로 했고, KDE는 Plasma 6.8부터 Wayland 세션만 제공하기로 했습니다. 두 프로젝트 모두 X11 앱은 XWayland로 계속 지원합니다. + +그러니 새로 리눅스를 시작한다면 배포판의 기본값인 Wayland를 그대로 쓰면 됩니다. 특정 프로그램 때문에 X11 세션이 꼭 필요하다면 Xfce처럼 X11 세션을 계속 제공하는 데스크톱을 고르거나, 이전 버전의 데스크톱을 오래 지원하는 LTS 배포판을 쓰는 방법이 있습니다. 데스크톱 환경별 특징은 [데스크톱 환경](../desktop-environments/)에서 볼 수 있습니다. diff --git a/src/content/learn/ko/distributions.mdx b/src/content/learn/ko/distributions.mdx new file mode 100644 index 0000000..c7df707 --- /dev/null +++ b/src/content/learn/ko/distributions.mdx @@ -0,0 +1,205 @@ +--- +title: 리눅스 배포판 이해하기 +description: 배포판이 무엇으로 이루어지는지, 계열(Debian·Fedora·Arch·SUSE·독립)과 릴리스 모델, 불변(atomic) 배포판이 어떻게 다른지 알아보고 나에게 맞는 배포판을 고르는 방법을 정리합니다. +navTitle: 배포판 +order: 4 +group: distros +icon: grid +related: [ubuntu, fedora, debian, arch] +sources: + - https://www.gnu.org/gnu/linux-and-gnu.html + - https://www.debian.org/releases/ + - https://www.debian.org/derivatives/ + - https://wiki.debian.org/LTS + - https://ubuntu.com/about/release-cycle + - https://ubuntu.com/desktop/flavours + - https://www.linuxmint.com/download_lmde.php + - https://zorin.com/os/ + - https://system76.com/pop/ + - https://docs.fedoraproject.org/en-US/releases/lifecycle/ + - https://www.centos.org/centos-stream/ + - https://access.redhat.com/support/policy/updates/errata + - https://almalinux.org/ + - https://rockylinux.org/about + - https://wiki.archlinux.org/title/Arch_Linux + - https://wiki.archlinux.org/title/Arch-based_distributions + - https://manjaro.org/ + - https://endeavouros.com/ + - https://cachyos.org/ + - https://neon.kde.org/faq + - https://get.opensuse.org/tumbleweed/ + - https://get.opensuse.org/leap/16.0/ + - https://get.opensuse.org/microos/ + - https://www.gentoo.org/get-started/about/ + - https://alpinelinux.org/about/ + - https://voidlinux.org/ + - https://nixos.org/ + - https://docs.fedoraproject.org/en-US/fedora-silverblue/ + - https://docs.fedoraproject.org/en-US/fedora-silverblue/technical-information/ + - https://docs.fedoraproject.org/en-US/fedora-silverblue/updates-upgrades-rollbacks/ + - https://universal-blue.org/ + - https://docs.fedoraproject.org/en-US/bootc/ + - https://ubuntu.com/core +lastReviewed: 2026-10-03 +--- + +리눅스(Linux)라는 이름은 엄밀히 말해 운영체제의 심장인 커널(kernel)만을 가리킵니다. 커널만으로는 컴퓨터를 쓸 수 없기 때문에, 여러 프로젝트가 커널에 도구·라이브러리·설치 프로그램·데스크톱을 묶어 바로 쓸 수 있는 운영체제로 만들어 배포합니다. 이것이 **배포판(distribution, 줄여서 distro)** 입니다. + +배포판은 수백 가지가 있지만 대부분 몇 개의 큰 **계열(family)** 로 묶을 수 있고, 업데이트 방식도 몇 가지 유형으로 나뉩니다. 이 두 가지만 알아도 배포판 이름이 훨씬 덜 낯설어집니다. + +## 배포판은 무엇으로 이루어지나 + +배포판마다 겉모습은 달라도 안을 들여다보면 비슷한 부품으로 이루어져 있습니다. + +| 구성 요소 | 하는 일 | 예 | +|---|---|---| +| 커널 | 하드웨어를 관리하고 프로그램에 자원을 나눠 줍니다 | Linux | +| 기본 도구와 라이브러리 | 셸, 파일 도구, C 라이브러리 같은 기초 프로그램 | GNU coreutils, glibc, musl | +| init 시스템 | 부팅 후 서비스를 켜고 관리합니다 | systemd, OpenRC, runit | +| 패키지 관리자와 저장소 | 프로그램을 설치·업데이트·삭제합니다 | APT, DNF, pacman, zypper | +| 설치 프로그램 | 디스크를 나누고 시스템을 설치합니다 | Calamares, Anaconda | +| 데스크톱 환경 | 화면, 창, 메뉴, 설정 앱 | GNOME, KDE Plasma, Xfce | + +GNU 프로젝트가 이 조합을 "GNU/Linux"라고 부르자고 하는 이유도 여기에 있습니다. 커널은 Linux이지만 그 위의 기본 도구 상당수는 GNU 프로젝트에서 왔기 때문입니다. 자세한 배경은 [리눅스란 무엇인가](../what-is-linux/)에서 다룹니다. + +배포판끼리 실제로 다른 점은 크게 네 가지입니다. + +- **패키지 형식과 도구**: `.deb`와 APT, `.rpm`과 DNF처럼 계열마다 다릅니다. 자세한 내용은 [패키지 관리](../package-management/)에서 설명합니다. +- **릴리스 모델**: 몇 달이나 몇 년마다 새 버전을 내는지, 끊임없이 조금씩 업데이트하는지. +- **기본값**: 어떤 데스크톱 환경과 앱, 파일 시스템, 설정을 처음부터 넣어 두는지. +- **운영 주체와 철학**: 회사가 만드는지 커뮤니티가 만드는지, 편의와 단순함 중 무엇을 우선하는지. + +## 배포판 계열 + +새 배포판은 대개 처음부터 만들지 않고, 이미 있는 배포판의 패키지와 저장소를 바탕으로 자기 기본값과 도구를 더해 만듭니다. 그래서 "Ubuntu 기반", "Arch 기반" 같은 말이 나옵니다. 같은 계열이면 패키지 형식과 명령어가 같아서 문서와 노하우를 상당 부분 그대로 쓸 수 있습니다. + +| 계열 | 패키지 형식 · 도구 | 대표 배포판 | +|---|---|---| +| Debian · Ubuntu | `.deb` · APT | Debian, Ubuntu, Linux Mint, Pop!\_OS, Zorin OS, Kali Linux | +| Fedora · RHEL | `.rpm` · DNF | Fedora, CentOS Stream, RHEL, AlmaLinux, Rocky Linux | +| Arch | pacman · AUR | Arch Linux, Manjaro, EndeavourOS, CachyOS | +| SUSE | `.rpm` · zypper | openSUSE Tumbleweed, openSUSE Leap, SUSE Linux Enterprise | +| 독립 | 저마다 다름 | Gentoo, Slackware, Alpine Linux, Void Linux, NixOS | + +계열 사이의 부모·자식 관계는 [배포판 계보](../../family/)에서 나무 모양으로 볼 수 있습니다. + +### Debian과 Ubuntu 계열 + +**Debian**은 커뮤니티가 만드는 오래된 배포판으로, 늘 세 갈래를 함께 운영합니다. 공식 릴리스인 **stable**, 다음 stable을 준비하는 **testing**, 개발이 실제로 일어나는 **unstable**(별칭 sid)입니다. 일반 사용자에게 권하는 것은 stable이며, Debian LTS 프로젝트가 각 stable 릴리스의 지원 기간을 최소 5년까지 늘려 줍니다. + +**Ubuntu**는 Canonical이 Debian을 바탕으로 만드는 배포판입니다. 6개월마다 새 버전이 나오고, 2년마다 나오는 **LTS(장기 지원)** 버전은 5년 동안 표준 보안 업데이트를 받습니다. Kubuntu, Xubuntu, Lubuntu처럼 데스크톱 환경만 바꾼 공식 플레이버(flavor)도 있습니다. + +Ubuntu 위에 다시 만든 배포판도 많습니다. [Linux Mint](../../distros/linux-mint/), [Pop!\_OS](../../distros/pop-os/), [Zorin OS](../../distros/zorin-os/)가 대표적이고, Linux Mint는 Ubuntu 대신 Debian을 직접 바탕으로 한 LMDE도 따로 냅니다. 이 계열은 사용자가 가장 많아 자료를 찾기 쉽고, 상용 프로그램 중에도 `.deb` 패키지를 제공하는 것이 많습니다. + +### Fedora와 Red Hat 계열 + +**Fedora**는 Red Hat이 후원하는 커뮤니티 배포판입니다. 대략 6개월마다 새 버전이 나오고 각 버전은 약 13개월 동안 업데이트됩니다. 새 기술을 빨리 받아들이는 편이라 최신 데스크톱을 깔끔하게 쓰고 싶은 사람에게 잘 맞습니다. + +기업용 쪽은 흐름이 이어집니다. Fedora에서 다듬어진 기술이 **CentOS Stream**을 거쳐 **Red Hat Enterprise Linux(RHEL)** 로 들어갑니다. CentOS Stream은 RHEL보다 한발 앞서 계속 업데이트되는 "중간 단계" 배포판입니다. RHEL은 한 버전을 10년 주기로 지원합니다. **AlmaLinux**와 **Rocky Linux**는 RHEL과 바이너리 호환되는 무료 커뮤니티 배포판으로, 2020년 말 Red Hat이 기존 CentOS Linux를 끝내고 CentOS Stream으로 전환한다고 발표한 뒤 그 자리를 채우려고 생겨났습니다. 서버 용도로 많이 쓰입니다. + +### Arch 계열 + +**Arch Linux**는 독립적으로 개발되는 x86-64용 배포판으로, 롤링 릴리스 방식으로 대부분의 소프트웨어를 최신 안정 버전으로 유지합니다. 원래 개발자(업스트림)가 내놓은 그대로를 최소한으로만 고쳐서 제공하고, 시스템을 어떻게 꾸밀지는 사용자에게 맡깁니다. 커뮤니티가 함께 관리하는 **AUR(Arch User Repository)** 과 내용이 풍부한 **Arch Wiki**가 큰 장점입니다. + +Arch를 더 쉽게 쓰도록 만든 파생 배포판도 많습니다. **Manjaro**는 패키지를 자체적으로 시험한 뒤 단계적으로 내보내고, **EndeavourOS**는 Arch에 가까운 터미널 중심 환경을 쉽게 설치할 수 있게 해 주고, **CachyOS**는 최신 CPU에 맞춰 최적화한 패키지와 커널로 성능에 초점을 둡니다. + + + Arch 커뮤니티와 개발자는 Arch 기반 배포판을 지원하지 않습니다. Manjaro나 EndeavourOS에서 생긴 문제는 해당 배포판의 포럼에 먼저 물어보세요. 다른 계열도 마찬가지입니다. + + +### SUSE 계열 + +**openSUSE Tumbleweed**는 롤링 릴리스 배포판으로, 패키지 하나하나뿐 아니라 여러 패키지 묶음을 서로 맞물려 시험한 뒤 내보냅니다. 명령 하나로 이전 스냅숏으로 되돌릴 수 있는 것도 특징입니다. **openSUSE Leap**은 기업용 **SUSE Linux Enterprise(SLE)** 의 소스에 커뮤니티 개발 성과를 합친 정기 릴리스로, 안정성을 중시합니다. + +### 독립 배포판 + +어느 계열에도 속하지 않고 자기만의 방식을 고수하는 배포판도 있습니다. + +- **Gentoo**: Portage라는 도구로 소프트웨어를 내 컴퓨터에 맞게 직접 빌드해 설치합니다. 스스로를 "메타 배포판"이라 부를 만큼 설정 폭이 넓습니다. +- **Alpine Linux**: musl libc와 BusyBox를 바탕으로 한 아주 작은 배포판입니다. 패키지 관리자 apk와 OpenRC를 쓰며, 작아서 컨테이너 이미지로도 많이 쓰입니다. +- **Void Linux**: 자원봉사자가 만드는 독립 롤링 배포판으로, XBPS 패키지 관리자와 runit init을 씁니다. glibc판과 musl판을 모두 제공합니다. +- **NixOS**: 시스템 전체를 설정 파일 하나에 선언하듯 적어 두면 그대로 만들어 줍니다. 업데이트가 다른 패키지를 망가뜨리지 않도록 설계했고, 이전 상태로 쉽게 되돌릴 수 있습니다. +- **Slackware**: 오랜 역사를 지닌 배포판으로, 단순하고 전통적인 유닉스 방식을 지킵니다. + +## 릴리스 모델 + +배포판을 고를 때 계열만큼 중요한 것이 **업데이트를 어떤 리듬으로 받느냐**입니다. + +| 모델 | 방식 | 좋은 점 | 신경 쓸 점 | 예 | +|---|---|---|---|---| +| 정기 릴리스(fixed) | 정해진 주기로 새 버전을 내고, 버전 안에서는 주로 보안·버그 수정만 합니다 | 예측 가능하고 안정적 | 지원이 끝나기 전에 다음 버전으로 올려야 합니다 | Fedora, Ubuntu 일반 버전, Debian stable | +| 장기 지원(LTS) | 정기 릴리스 중 일부를 수년간 지원합니다 | 오래 손대지 않아도 됩니다 | 새 하드웨어·새 기능 반영이 느립니다 | Ubuntu LTS, RHEL, AlmaLinux, Rocky Linux | +| 롤링(rolling) | 버전 구분 없이 패키지를 계속 최신으로 올립니다 | 항상 최신 커널·드라이버·앱 | 자주 업데이트하고 변화에 대응해야 합니다 | Arch Linux, openSUSE Tumbleweed, Void Linux | +| 세미 롤링(semi-rolling) | 기반은 고정하거나 늦게 따라가고, 일부만 계속 최신으로 올립니다 | 안정성과 최신성의 절충 | 배포판마다 방식이 제각각입니다 | Manjaro, KDE neon | + +세미 롤링은 정해진 정의가 있는 용어가 아니라서 배포판마다 뜻이 조금씩 다릅니다. 예를 들어 Manjaro는 Arch의 패키지를 바로 내보내지 않고 시험을 거친 뒤 단계적으로 내보내고, KDE neon은 Ubuntu LTS를 바탕으로 KDE 소프트웨어만 롤링 방식으로 계속 업데이트합니다. + + + 롤링 배포판도 패키지를 시험한 뒤 내보냅니다. 다만 변화가 끊임없이 들어오므로, 몇 달씩 업데이트를 미루기보다 자주 조금씩 업데이트하고 배포판의 공지(뉴스)를 확인하는 습관이 필요합니다. + + +카탈로그에서 [정기 릴리스](../../distros/?model=fixed), [장기 지원](../../distros/?model=lts), [롤링](../../distros/?model=rolling), [세미 롤링](../../distros/?model=semi-rolling) 배포판만 골라 볼 수 있습니다. + +## 불변(immutable)·아토믹(atomic) 배포판 + +최근 늘어나는 유형으로, 시스템 파일을 **읽기 전용**으로 두고 운영체제 전체를 하나의 이미지처럼 통째로 업데이트합니다. 업데이트는 새 시스템 이미지를 옆에 준비해 두었다가 다시 시작할 때 바꾸는 방식이라, 업데이트 도중 전원이 꺼져도 시스템이 반쯤 망가지는 일이 없고 문제가 생기면 이전 이미지로 되돌리면 됩니다. + +| | 일반 배포판 | 불변·아토믹 배포판 | +|---|---|---| +| 시스템 파일 | 관리자 권한으로 직접 수정 | 읽기 전용(설정 `/etc`와 데이터 `/var`만 쓰기 가능) | +| 업데이트 | 패키지를 하나씩 교체 | 이미지를 통째로 교체, 재부팅 후 적용 | +| 되돌리기 | 별도 스냅숏 도구 필요 | 이전 이미지로 바로 부팅 | +| 앱 설치 | 배포판 패키지 | Flatpak, Toolbx 같은 컨테이너, 꼭 필요할 때만 패키지 레이어링 | + +대표적인 예는 다음과 같습니다. + +- **Fedora Atomic Desktops**: GNOME을 쓰는 Silverblue, KDE Plasma를 쓰는 Kinoite를 비롯해 Sway·Budgie·COSMIC 버전이 있습니다. +- **Universal Blue 계열**: Fedora Atomic Desktops를 바탕으로 하드웨어 지원과 편의 기능을 더해 컨테이너 이미지 방식으로 만든 시스템들로, 게임용 [Bazzite](../../distros/bazzite/)가 유명합니다. +- **openSUSE MicroOS**: 읽기 전용 Btrfs 루트 위에서 트랜잭션 방식으로 업데이트합니다. +- **NixOS**: 엄밀히는 방식이 다르지만, 선언적 설정과 손쉬운 되돌리기로 비슷한 장점을 얻습니다. +- **Ubuntu Core**: 기기·IoT용 불변 배포판으로, 커널과 OS, 앱을 서로 분리해 따로 업데이트합니다. + +운영체제 이미지를 일반 컨테이너 이미지처럼 만들고 배포하는 **부팅 가능한 컨테이너(bootc)** 도 같은 흐름에 있습니다. Fedora Atomic에서는 다음과 같은 명령을 씁니다. + +```bash +rpm-ostree status # 현재 이미지와 이전 이미지 확인 +rpm-ostree upgrade # 새 이미지 받기 (다시 시작하면 적용) +rpm-ostree rollback # 이전 이미지로 되돌리기 +``` + + + 시스템 깊숙한 곳을 자주 고치거나, 특정 드라이버와 커널 모듈을 직접 빌드해야 하거나, 설치 스크립트가 시스템 디렉터리에 파일을 쓰는 프로그램을 많이 쓴다면 일반 배포판이 더 편할 수 있습니다. + + +## 나에게 맞는 배포판 고르기 + +"가장 좋은 배포판"은 없고, 내 상황에 맞는 배포판이 있을 뿐입니다. 다음 질문에 답해 보면 후보가 금방 좁혀집니다. + +- **무엇에 쓰나요?** 일상 사무, 게임, 개발, 서버, 오래된 PC 살리기에 따라 출발점이 다릅니다. +- **얼마나 손을 대고 싶나요?** 설치하자마자 쓰고 싶다면 입문자용 배포판이, 직접 하나씩 구성하고 싶다면 Arch나 Gentoo가 맞습니다. +- **안정과 최신 중 무엇이 중요한가요?** 오래 신경 쓰지 않으려면 LTS, 새 하드웨어나 최신 앱이 중요하면 Fedora나 롤링 배포판이 낫습니다. +- **어떤 화면이 편한가요?** 첫인상은 배포판보다 [데스크톱 환경](../desktop-environments/)이 좌우합니다. + +| 상황 | 먼저 살펴볼 만한 배포판 | +|---|---| +| 리눅스가 처음이고 Windows와 비슷한 화면이 편함 | [Linux Mint](../../distros/linux-mint/), [Zorin OS](../../distros/zorin-os/), [Kubuntu](../../distros/kubuntu/) | +| 최신 소프트웨어와 깔끔한 기본 데스크톱 | [Fedora](../../distros/fedora/), [Ubuntu](../../distros/ubuntu/) | +| 게임 | [Bazzite](../../distros/bazzite/), [Pop!\_OS](../../distros/pop-os/) — [게임](../gaming/) 장 참고 | +| 오래되거나 사양이 낮은 PC | [Xubuntu](../../distros/xubuntu/), [Linux Lite](../../distros/linux-lite/), [MX Linux](../../distros/mx-linux/) | +| 서버, 오래 켜 두는 시스템 | [Debian](../../distros/debian/), [AlmaLinux](../../distros/almalinux/), [Rocky Linux](../../distros/rocky-linux/) | +| 리눅스를 깊이 배우고 직접 구성 | [Arch Linux](../../distros/arch/), [NixOS](../../distros/nixos/), [Gentoo](../../distros/gentoo/) | + +Linuxhub에서는 다음 순서로 고르는 것을 권합니다. + + + +1. [배포판 찾기](../../finder/)에서 몇 가지 질문에 답해 후보를 2~3개로 좁힙니다. +2. [배포판 목록](../../distros/)에서 용도, 계열, 데스크톱 환경, 릴리스 모델로 걸러 보고, 마음에 드는 후보를 [비교](../../compare/)합니다. +3. 후보를 라이브 USB로 부팅해 직접 써 봅니다. 설치하지 않아도 화면과 Wi-Fi, 소리가 잘 되는지 확인할 수 있습니다. +4. 마음을 정했다면 [설치 준비](../installation-prep/)를 읽고 설치합니다. + + + + + 배포판을 바꿔도 리눅스 지식은 대부분 그대로 쓸 수 있습니다. 같은 계열 안에서는 명령어까지 같습니다. 개인 파일을 별도로 백업해 두는 습관만 있으면 부담 없이 다른 배포판을 시험해 볼 수 있습니다. + diff --git a/src/content/learn/ko/history.mdx b/src/content/learn/ko/history.mdx new file mode 100644 index 0000000..9131aa0 --- /dev/null +++ b/src/content/learn/ko/history.mdx @@ -0,0 +1,136 @@ +--- +title: 리눅스의 역사 +description: 1969년 유닉스에서 GNU 프로젝트, 1991년 리누스 토르발스의 발표, 초기 배포판과 데스크톱 환경, 기업과 Android의 시대를 거쳐 오늘에 이르는 리눅스의 발자취를 정리합니다. +navTitle: 리눅스의 역사 +order: 2 +group: basics +icon: recent +related: [debian, slackware, fedora, ubuntu] +sources: + - https://www.unix.org/unix_history.html + - https://www.unix.org/fun_facts.html + - https://www.kernel.org/category/about.html + - https://www.gnu.org/gnu/initial-announcement.html + - https://www.gnu.org/gnu/thegnuproject.html + - https://lwn.net/2001/0823/a/lt-announcement.php3 + - https://training.linuxfoundation.org/announcements/happy-30th-linux/ + - https://www.kernel.org/pub/linux/kernel/Historic/old-versions/RELNOTES-0.01 + - https://www.kernel.org/pub/linux/kernel/Historic/old-versions/RELNOTES-0.12 + - https://www.kernel.org/pub/linux/kernel/v1.0/ + - https://www.kernel.org/pub/linux/kernel/v2.0/ + - https://www.slackware.com/info/ + - https://www.debian.org/doc/manuals/project-history/intro.en.html + - https://www.debian.org/doc/manuals/project-history/detailed.en.html + - https://www.redhat.com/en/blog/spooktacular-tale-red-hats-halloween-release + - https://www.redhat.com/en/blog/25-things-you-should-know-about-red-hat-0 + - https://kde.org/announcements/announcement/ + - https://www.gnome.org/about/ + - https://opensource.org/history + - https://fedoramagazine.org/announcing-fedora-linux-39/ + - https://lists.ubuntu.com/archives/ubuntu-announce/2004-October/000003.html + - https://android-developers.googleblog.com/2008/09/announcing-android-10-sdk-release-1.html + - https://source.android.com/docs/core/architecture/kernel + - https://www.top500.org/lists/top500/2026/06/ + - https://store.steampowered.com/news/app/1675180/view/3117055056380003048 + - https://store.steampowered.com/steamos +lastReviewed: 2026-10-03 +--- + +리눅스는 어느 날 갑자기 나타난 운영체제가 아닙니다. 1960년대 말 벨 연구소에서 태어난 유닉스, 1980년대의 자유 소프트웨어 운동, 그리고 인터넷으로 연결된 개발자들의 협업이 차례로 쌓여 지금의 리눅스가 되었습니다. 이 장에서는 공식 기록으로 확인할 수 있는 날짜를 중심으로 그 흐름을 따라갑니다. + +## 한눈에 보는 연표 + +| 연도 | 사건 | +|---|---| +| 1969 | 벨 연구소(Bell Labs)의 켄 톰프슨(Ken Thompson), 데니스 리치(Dennis Ritchie) 등이 유닉스(Unix) 개발을 시작 | +| 1973 | 유닉스를 C 언어로 다시 작성 | +| 1975 | 유닉스 6판(Sixth Edition)이 벨 연구소 밖으로 널리 퍼짐 | +| 1983 | 9월 27일, 리처드 스톨먼(Richard Stallman)이 GNU 프로젝트를 발표 | +| 1985 | 자유 소프트웨어 재단(FSF) 설립 | +| 1988 | 유닉스 계열의 공통 표준인 POSIX.1 발표 | +| 1991 | 8월 25일, 리누스 토르발스(Linus Torvalds)가 새 운영체제를 만들고 있다고 처음 알림 | +| 1992 | 리눅스 0.12와 함께 GPL로 라이선스를 바꾸기로 하고 2월 1일부터 적용 | +| 1993 | Red Hat 창업(3월 26일), Slackware 첫 베타(4월), Debian 설립(8월 16일) | +| 1994 | 리눅스 1.0(3월), Red Hat Linux 첫 공개판(10월 31일) | +| 1996 | 리눅스 2.0(6월), KDE 프로젝트 발표(10월 14일) | +| 1997 | GNOME 프로젝트 시작 | +| 1998 | "오픈 소스"라는 용어 등장, 오픈 소스 이니셔티브(OSI) 설립 | +| 2002 | Red Hat Enterprise Linux 등장(처음 이름은 Red Hat Linux Advanced Server) | +| 2003 | Fedora Core 1 출시(11월 6일) | +| 2004 | Ubuntu 4.10 출시(10월 20일) | +| 2008 | Android 1.0 SDK 발표와 함께 첫 Android 기기 T-Mobile G1 공개(9월 23일) | +| 2022 | Valve의 휴대용 게임 PC Steam Deck 출시(2월) | + +## 유닉스: 모든 것의 출발점 + +1969년 벨 연구소에서 켄 톰프슨과 데니스 리치 등은 연구소 한구석에서 잘 쓰이지 않던 PDP-7 컴퓨터로 새 운영체제를 만들기 시작했습니다. 이것이 유닉스입니다. 처음에는 PDP-7의 어셈블리어로 작성되었지만, 1973년 C 언어로 다시 쓰이면서 특정 기계에 묶이지 않고 다른 컴퓨터로 옮기기 쉬운 운영체제가 되었습니다. 운영체제를 고급 언어로 작성해 여러 기계에 옮겨 쓴다는 생각은 이후 운영체제의 역사를 바꿔 놓았습니다. + +1975년의 6판은 벨 연구소 밖으로 널리 퍼진 첫 버전이었고, 캘리포니아 대학교 버클리(UC Berkeley)의 BSD도 여기서 갈라져 나왔습니다. 버클리에서 내놓은 4.2BSD에는 지금 인터넷의 바탕인 TCP/IP가 들어갔습니다. 이후 여러 회사가 저마다 유닉스를 내놓으면서 서로 호환되지 않는 문제가 커졌고, 이를 정리하기 위해 1988년 **POSIX** 표준이 나왔습니다. + +리눅스는 유닉스의 코드를 물려받지 않았습니다. 하지만 유닉스의 설계를 따라 처음부터 새로 만들었고 POSIX 같은 표준을 따르는 것을 목표로 하기 때문에 **유닉스 계열(Unix-like)** 운영체제라고 부릅니다. + +## GNU 프로젝트: 자유로운 유닉스를 꿈꾸다 + +1970년대 MIT 인공지능 연구소에는 프로그램과 소스 코드를 자유롭게 나누는 문화가 있었습니다. 그러나 1980년대 들어 소프트웨어가 비공개 상품이 되면서, 실행 파일 하나를 얻는 데에도 비밀 유지 계약에 서명해야 하는 일이 흔해졌습니다. 리처드 스톨먼은 이런 흐름에 맞서 1983년 9월 27일 **GNU 프로젝트**를 발표했습니다. 목표는 유닉스와 호환되면서 누구나 자유롭게 쓰고 나눌 수 있는 완전한 운영체제였습니다. + +- **1984년**: 스톨먼은 1월에 MIT를 그만두고 GNU 소프트웨어 개발에 뛰어들었습니다. MIT에 남아 있으면 학교가 결과물의 소유권을 주장할 수 있었기 때문입니다. 9월에는 GNU Emacs 편집기 작업을 시작했습니다. +- **1985년**: 개발을 뒷받침할 비영리 단체인 **자유 소프트웨어 재단(FSF)**을 세웠습니다. +- **카피레프트(copyleft)**: 소프트웨어가 계속 자유롭게 남도록 하는 장치를 고안하고, 이를 **GNU GPL** 라이선스로 구체화했습니다. +- **핵심 부품**: FSF 사람들은 GNU C 라이브러리(glibc)와 셸 **Bash**처럼 지금도 대부분의 리눅스 시스템에 들어 있는 부품을 만들었습니다. + +1990년 무렵 GNU는 커널을 빼고 거의 완성된 상태였습니다. GNU의 커널인 **Hurd**는 Mach 마이크로커널 위에서 여러 서버 프로그램이 커널의 일을 나눠 맡는 설계였는데, 디버깅이 무척 어려워 완성이 계속 늦어졌습니다. 이 빈자리를 채운 것이 리눅스였습니다. + +## 1991년: "그냥 취미일 뿐" + +1991년 8월 25일, 헬싱키 대학교의 학생이던 리누스 토르발스는 유즈넷(Usenet) 뉴스그룹 comp.os.minix에 글을 하나 올렸습니다. 386·486 PC에서 돌아가는 무료 운영체제를 만들고 있는데, GNU처럼 크고 전문적인 것은 아닌 그저 취미라는 소개였습니다. 그는 이미 Bash와 GCC를 옮겨 돌려 보았다고 밝히며, 사람들이 어떤 기능을 원하는지 의견을 구했습니다. + +첫 공개 소스인 0.01 버전은 386 AT 컴퓨터의 하드디스크, 화면, 키보드, 직렬 포트 정도만 지원했고, 릴리스 노트에서 스스로 아직 성숙한 제품이 아니라고 밝힐 만큼 초기 단계였습니다. 처음에는 돈을 받고 배포하는 것을 금지하는 자체 라이선스를 썼습니다. 그러다 0.12 릴리스 노트에서 토르발스는 GNU의 카피레프트와 맞추겠다고 알렸고, 1992년 2월 1일부터 GPL이 적용되었습니다. 이로써 GNU 도구와 리눅스 커널을 합쳐 처음부터 끝까지 자유 소프트웨어로 된 운영체제를 꾸릴 수 있게 되었습니다. + +인터넷으로 연결된 세계 곳곳의 개발자들이 코드를 보태면서 개발 속도도 빨라졌습니다. 1994년 3월에 리눅스 1.0이, 1996년 6월에 리눅스 2.0이 나왔습니다. + +## 배포판의 탄생 + +초기 리눅스를 쓰려면 커널과 GNU 도구를 직접 구해 플로피 디스크 이미지를 만들고, 파티션을 나누고, 프로그램을 하나씩 설치해야 했습니다. 이 수고를 덜어 주려고 커널과 프로그램을 미리 골라 묶은 **배포판**이 등장했습니다. + +- **Slackware**: 1993년 4월 첫 베타를 냈고, "가장 유닉스다운 리눅스 배포판"을 목표로 지금까지 이어지고 있습니다. +- **Debian**: 1993년 8월 16일 이언 머독(Ian Murdock)이 세웠습니다. 리눅스와 GNU의 정신에 따라 공개적으로 만드는 배포판을 지향했고, 1995년 무렵에는 여러 개발자가 각자 패키지를 맡아 관리하고 `dpkg`로 설치하는 방식을 갖췄습니다. 이 공동체 모델과 패키지 관리 방식은 Ubuntu를 비롯한 수많은 배포판의 바탕이 되었습니다. +- **Red Hat**: 1993년 3월 26일 창업했고, 1994년 10월 31일 유료 베타 성격의 첫 공개판(0.9)을 내놓았습니다. 핼러윈에 나와 "Halloween"이라는 별명이 붙었습니다. + +배포판들이 서로 어떻게 갈라져 나왔는지는 [배포판 가계도](../../family/)에서, 오늘날의 배포판 고르는 법은 [배포판](../distributions/) 장에서 볼 수 있습니다. + +## 데스크톱 환경의 등장 + +1990년대 중반 유닉스와 리눅스의 그래픽 프로그램들은 저마다 다른 위젯 라이브러리로 만들어져, 메뉴와 버튼의 생김새와 동작이 프로그램마다 제각각이었습니다. 1996년 10월 14일 마티아스 에트리히(Matthias Ettrich)는 이 문제를 풀기 위해, 모든 프로그램이 같은 모양과 동작을 갖춘 통합 그래픽 환경을 만들자며 **KDE**(처음 이름은 Kool Desktop Environment) 프로젝트를 제안했습니다. KDE는 Troll Tech의 Qt 라이브러리를 바탕으로 삼았습니다. + +문제는 당시 Qt가 자유 소프트웨어가 아니었다는 점입니다. 그래서 1997년 미겔 데 이카사(Miguel de Icaza)와 페데리코 메나(Federico Mena)가 자유 소프트웨어만으로 데스크톱을 만들겠다며 **GNOME** 프로젝트를 시작했습니다. 1998년 11월 Qt 개발사가 라이선스를 바꾸겠다고 발표하면서 이 갈등은 차츰 풀렸고, 지금은 GNOME과 KDE Plasma 모두 자유 소프트웨어로 개발되는 대표적인 데스크톱 환경입니다. + +데스크톱 환경의 종류와 특징은 [데스크톱 환경](../desktop-environments/)에서, 화면을 그리는 바탕 기술은 [디스플레이 서버](../display-servers/)에서 이어집니다. + +## 오픈 소스와 기업의 시대 + +1998년 넷스케이프(Netscape)가 웹 브라우저의 소스 코드를 공개하겠다고 발표한 직후, 2월 3일 열린 한 전략 회의에서 크리스틴 피터슨(Christine Peterson)이 **오픈 소스(open source)**라는 이름을 제안했습니다. 같은 달 말 에릭 레이먼드(Eric Raymond)와 브루스 페런스(Bruce Perens)가 **오픈 소스 이니셔티브(OSI)**를 세웠습니다. + +2000년대에 들어서면서 리눅스는 기업과 개인 사용자 모두에게 본격적으로 다가갔습니다. + +- **2002년**: Red Hat이 **Red Hat Enterprise Linux**(처음 이름은 Red Hat Linux Advanced Server)를 내놓았습니다. 소프트웨어는 공개하되 지원과 인증을 구독으로 판매하는 사업 모델의 선구자가 되었습니다. +- **2003년 11월 6일**: Red Hat이 후원하는 커뮤니티 배포판 [Fedora](../../distros/fedora/)의 첫 버전 Fedora Core 1이 나왔습니다. +- **2004년 10월 20일**: [Ubuntu](../../distros/ubuntu/)의 첫 버전 4.10이 나왔습니다. [Debian](../../distros/debian/)의 방대한 소프트웨어를 바탕으로 쉬운 설치, 6개월마다 새 버전, 버전마다 18개월 보안 지원을 내세웠습니다. + +## Android와 오늘의 리눅스 + +2008년 9월 23일 Google은 Android 1.0 SDK를 발표했고, 같은 날 T-Mobile이 첫 Android 기기 **T-Mobile G1**을 공개했습니다. Android의 커널은 리눅스의 장기 지원(LTS) 커널에 Android 전용 패치를 더해 만듭니다. 앱이 돌아가는 방식은 데스크톱 배포판과 크게 다르지만, 그 바닥에서 하드웨어를 다루는 것은 리눅스 커널입니다. 이렇게 리눅스 커널은 스마트폰을 통해 수많은 사람의 주머니 속으로 들어갔습니다. + +오늘날 리눅스가 쓰이는 모습은 다음과 같습니다. + +- **슈퍼컴퓨터**: TOP500 순위의 최상위 시스템들은 RHEL, HPE Cray OS 같은 리눅스 기반 운영체제를 씁니다. +- **서버와 클라우드**: 웹 서비스와 클라우드 인프라의 기본 운영체제로 널리 쓰입니다. +- **게임**: 2022년 2월 Valve가 Arch Linux 기반의 SteamOS를 담은 Steam Deck을 내놓았습니다. Windows용 게임을 리눅스에서 돌리는 호환 계층 Proton 덕분에 Steam의 많은 게임을 리눅스에서 즐길 수 있습니다. 자세한 내용은 [게임](../gaming/) 장에서 다룹니다. +- **데스크톱**: GNOME과 KDE Plasma 같은 현대적인 데스크톱 환경, Flatpak 같은 앱 배포 방식이 자리 잡으면서 일상용 운영체제로 쓰기도 한결 쉬워졌습니다. + + + +kernel.org에는 지금도 초기 리눅스의 소스 코드와 릴리스 노트가 남아 있습니다. [0.01 릴리스 노트](https://www.kernel.org/pub/linux/kernel/Historic/old-versions/RELNOTES-0.01)를 읽어 보면 리눅스가 얼마나 작은 프로젝트에서 시작했는지 실감할 수 있습니다. + + + +"취미"로 시작한 커널은 수많은 개인과 기업이 함께 만드는 거대한 공동 작업이 되었습니다. 사람들이 왜 리눅스를 고르는지는 다음 장 [왜 리눅스인가](../why-linux/)에서 이어집니다. diff --git a/src/content/learn/ko/what-is-linux.mdx b/src/content/learn/ko/what-is-linux.mdx new file mode 100644 index 0000000..f532a29 --- /dev/null +++ b/src/content/learn/ko/what-is-linux.mdx @@ -0,0 +1,145 @@ +--- +title: 리눅스란 무엇인가 +description: 커널과 운영체제의 차이, GNU와 리눅스의 관계, 배포판이 하는 일, 리눅스가 쓰이는 곳, 그리고 자유 소프트웨어와 GPL의 기본을 쉬운 말로 정리합니다. +navTitle: 리눅스란 무엇인가 +order: 1 +group: basics +icon: computer +related: [ubuntu, linux-mint, fedora, debian] +sources: + - https://www.kernel.org/linux.html + - https://www.kernel.org/category/about.html + - https://docs.kernel.org/admin-guide/README.html + - https://docs.kernel.org/process/license-rules.html + - https://www.gnu.org/gnu/linux-and-gnu.html + - https://www.gnu.org/gnu/thegnuproject.html + - https://www.gnu.org/philosophy/free-sw.html + - https://www.gnu.org/licenses/gpl-faq.html + - https://opensource.org/osd + - https://opensource.org/history + - https://opensource.org/licenses + - https://source.android.com/docs/core/architecture/kernel + - https://www.top500.org/lists/top500/2026/06/ + - https://www.yoctoproject.org/ + - https://store.steampowered.com/steamos + - https://www.redhat.com/en/blog/25-things-you-should-know-about-red-hat-0 +lastReviewed: 2026-10-03 +--- + +리눅스(Linux)는 Windows나 macOS처럼 컴퓨터를 움직이는 운영체제의 한 갈래입니다. 엄밀히 말하면 "리눅스"는 운영체제 한가운데에서 하드웨어를 다루는 **커널(kernel)**의 이름이고, 우리가 내려받아 설치하는 "리눅스"는 그 커널에 수많은 프로그램을 더해 하나로 묶은 **배포판(distribution)**입니다. 이 장에서는 이 구분부터 시작해 리눅스가 어디에서 쓰이는지, 그리고 누구나 무료로 쓰고 고칠 수 있는 이유가 무엇인지 차근차근 살펴봅니다. + +## 커널과 운영체제는 다릅니다 + +커널은 운영체제의 심장과 같은 프로그램입니다. 컴퓨터를 켜면 가장 먼저 메모리에 올라가 전원이 꺼질 때까지 다음과 같은 일을 맡습니다. + +- **CPU 시간 나누기**: 여러 프로그램이 동시에 돌아가는 것처럼 보이도록 실행 순서를 정합니다(멀티태스킹). +- **메모리 관리**: 프로그램마다 쓸 메모리를 나눠 주고, 서로의 영역을 침범하지 못하게 막습니다(가상 메모리). +- **장치 제어**: 디스크, 그래픽 카드, 키보드, 네트워크 카드 같은 하드웨어를 드라이버(driver)로 다룹니다. +- **파일과 네트워크**: 파일을 저장하고 읽는 방법, 인터넷으로 데이터를 주고받는 방법을 제공합니다. + +프로그램은 하드웨어를 직접 만지지 않고, **시스템 콜(system call)**이라는 정해진 창구로 커널에 일을 부탁합니다. 그런데 커널 혼자서는 화면에 창을 띄우거나 웹 페이지를 열 수 없습니다. 실제로 쓸 수 있는 운영체제가 되려면 아래처럼 여러 층이 함께 있어야 합니다. + +| 층 | 하는 일 | 리눅스 시스템에서의 예 | +|---|---|---| +| 애플리케이션 | 사용자가 직접 쓰는 프로그램 | Firefox, LibreOffice, 게임 | +| 데스크톱 환경 | 창, 메뉴, 설정 화면 | GNOME, KDE Plasma, Xfce | +| 그래픽 스택 | 화면에 그림을 그리는 바탕 | Wayland, X11 | +| 시스템 서비스 | 부팅, 로그인, 네트워크 관리 | systemd, NetworkManager | +| 기본 도구와 라이브러리 | 셸, 파일 명령어, C 라이브러리 | Bash, GNU coreutils, glibc | +| 커널 | 하드웨어와 자원 관리 | Linux | + +지금 쓰는 시스템의 커널 버전과 배포판 이름은 터미널에서 바로 확인할 수 있습니다. + +```bash +uname -r +cat /etc/os-release +``` + +첫 줄은 실행 중인 커널 버전을, 둘째 줄은 배포판 이름과 버전을 보여 줍니다. 터미널이 처음이라면 [터미널 기초](../terminal-basics/)를 먼저 읽어도 좋습니다. + +kernel.org는 리눅스를 리누스 토르발스(Linus Torvalds)가 인터넷의 여러 개발자와 함께 처음부터 새로 작성한 유닉스(Unix) 복제품으로 소개하며, POSIX 같은 유닉스 표준을 따르는 것을 목표로 한다고 설명합니다. 그래서 리눅스는 유닉스의 코드를 물려받지 않았지만 구조와 사용법이 닮은 **유닉스 계열(Unix-like)** 운영체제로 분류됩니다. 유닉스 이야기는 [리눅스의 역사](../history/)에서 자세히 다룹니다. + +## GNU와 리눅스: 이름에 얽힌 이야기 + +1980년대 초 리처드 스톨먼(Richard Stallman)은 누구나 자유롭게 쓰고 고칠 수 있는 유닉스 호환 운영체제를 만들기 위해 **GNU 프로젝트**를 시작했습니다. GNU는 "GNU's Not Unix"의 줄임말로, 이름 안에 자기 자신이 다시 들어 있는 재귀 약어입니다. 1990년 무렵 GNU에는 컴파일러, 편집기, 셸, C 라이브러리처럼 운영체제에 필요한 부품이 거의 다 갖춰졌지만, 정작 커널(GNU Hurd)은 개발이 늦어지고 있었습니다. + +바로 그 무렵인 1991년, 리누스 토르발스가 유닉스와 호환되는 커널을 만들어 공개했습니다. 처음에는 자체 라이선스를 썼지만 1992년 자유 소프트웨어로 바뀌었고, 이때부터 GNU의 도구들과 리눅스 커널을 합쳐 처음부터 끝까지 자유 소프트웨어로 된 운영체제를 꾸릴 수 있게 되었습니다. + +이 때문에 자유 소프트웨어 재단(FSF)은 이 조합을 **GNU/Linux**라고 불러 달라고 요청합니다. 시스템의 상당 부분이 GNU 소프트웨어이고, GNU 프로젝트가 처음부터 완전한 운영체제를 목표로 했다는 이유에서입니다. 반면 일상에서는 그냥 "리눅스"라고 부르는 사람이 대부분입니다. 이 가이드도 읽기 편하도록 운영체제 전체를 "리눅스"라고 부르되, 커널만 가리킬 때는 "리눅스 커널"이라고 구분해 씁니다. + + + +Android도 리눅스 커널을 바탕으로 하지만, 그 위에서 앱이 돌아가는 방식과 기본 구성 요소는 데스크톱 배포판과 크게 다릅니다. 같은 커널 위에 전혀 다른 모습의 운영체제를 세울 수 있다는 좋은 예입니다. + + + +## 배포판이 더하는 것 + +커널과 GNU 도구만으로는 아직 "설치해서 바로 쓰는" 운영체제가 아닙니다. 수천 개의 프로그램을 골라 서로 잘 맞게 빌드하고, 설치 프로그램과 업데이트 방법까지 갖춰 하나로 묶어 내놓는 것이 **배포판**입니다. Ubuntu, Fedora, Debian, Linux Mint, Arch Linux가 모두 배포판입니다. + +배포판은 대체로 다음과 같은 것을 정합니다. + +- **패키지 관리자(package manager)**: 프로그램을 설치·업데이트·삭제하는 도구와, 프로그램을 모아 둔 소프트웨어 저장소(repository). 예를 들어 Debian 계열은 `apt`, Fedora는 `dnf`, Arch Linux는 `pacman`을 씁니다. +- **릴리스 방식**: 정해진 주기마다 큰 버전을 내는 **고정 릴리스(fixed release)**인지, 끊임없이 최신 버전으로 갱신되는 **롤링 릴리스(rolling release)**인지. +- **기본 데스크톱 환경**: 처음 켰을 때 보이는 화면과 함께 설치되는 기본 앱. +- **설치 프로그램과 기본 설정**: 파일 시스템, 보안 설정, 드라이버를 넣는 방식. +- **지원 기간과 철학**: 몇 년 동안 보안 업데이트를 제공할지, 독점(proprietary) 드라이버나 코덱을 기본으로 넣을지. + +같은 엔진을 쓰는 자동차라도 회사마다 차체와 편의 장비가 다르듯, 배포판도 같은 커널 위에서 저마다 다른 경험을 줍니다. 배포판 고르는 법은 [배포판](../distributions/), 프로그램 설치 방식은 [패키지 관리](../package-management/), 화면 구성은 [데스크톱 환경](../desktop-environments/) 장에서 이어집니다. + +## 리눅스가 돌아가는 곳 + +리눅스 커널은 처음에 386 PC용으로 만들어졌지만, 지금은 x86과 ARM, PowerPC를 비롯한 여러 프로세서 구조에서 32비트와 64비트로 모두 돌아갑니다. 덕분에 쓰이는 곳도 매우 넓습니다. + +| 분야 | 어떻게 쓰이나 | +|---|---| +| 서버와 클라우드 | 웹 서비스, 데이터베이스, 클라우드 가상 머신의 운영체제로 널리 쓰입니다. Red Hat Enterprise Linux, Ubuntu Server, Debian 같은 배포판이 대표적입니다. | +| 스마트폰 | Android의 커널은 리눅스의 장기 지원(LTS) 커널에 Android용 패치를 더해 만듭니다. | +| 임베디드와 IoT | 공유기, TV, 산업용 장비처럼 특정 용도의 기기에 들어갑니다. Yocto Project 같은 도구로 기기에 꼭 맞는 리눅스 시스템을 만듭니다. | +| 슈퍼컴퓨터 | 세계에서 가장 빠른 컴퓨터 순위인 TOP500의 최상위 시스템들은 RHEL, HPE Cray OS 같은 리눅스 기반 운영체제를 씁니다. | +| 데스크톱과 게임기 | 개인용 PC와 노트북은 물론, Valve의 휴대용 게임 PC Steam Deck에 들어가는 SteamOS도 리눅스 기반입니다. | + +리눅스를 직접 써 보지 않았더라도, 스마트폰이나 즐겨 찾는 웹 서비스를 통해 이미 매일 리눅스를 쓰고 있을 가능성이 큽니다. + +## 자유 소프트웨어와 오픈 소스 + +리눅스가 이렇게 넓게 퍼진 데에는 기술만큼이나 **라이선스**가 큰 역할을 했습니다. 리눅스 커널과 GNU 프로그램은 **자유 소프트웨어(free software)**입니다. 여기서 "자유(free)"는 가격이 아니라 권리를 뜻합니다. GNU 프로젝트는 다음 네 가지 자유를 모두 보장해야 자유 소프트웨어라고 정의합니다. + +| 번호 | 자유 | 쉽게 말하면 | +|---|---|---| +| 0 | 실행할 자유 | 어떤 목적으로든 원하는 대로 프로그램을 돌릴 수 있습니다. | +| 1 | 연구하고 고칠 자유 | 프로그램이 어떻게 동작하는지 살펴보고 바꿀 수 있습니다. 그러려면 소스 코드가 있어야 합니다. | +| 2 | 다시 나눌 자유 | 복사본을 다른 사람에게 줄 수 있습니다. | +| 3 | 고친 것을 나눌 자유 | 수정한 버전을 배포해 모두가 그 개선의 혜택을 보게 할 수 있습니다. | + +자유 소프트웨어라고 해서 꼭 공짜일 필요는 없습니다. 복사본을 돈을 받고 팔아도 되고, Red Hat처럼 소프트웨어는 공개하면서 기술 지원과 유지보수를 구독 형태로 판매하는 기업도 있습니다. + +1998년에는 **오픈 소스(open source)**라는 용어가 새로 생겼고, 같은 해 세워진 오픈 소스 이니셔티브(OSI)가 10가지 기준으로 된 "오픈 소스 정의"를 관리합니다. 두 용어가 가리키는 소프트웨어는 대부분 겹칩니다. 다만 자유 소프트웨어 진영은 사용자의 자유라는 가치를, 오픈 소스 진영은 소스 공개와 협업이 주는 실용적인 이점을 더 앞세운다는 차이가 있습니다. 둘을 합쳐 FOSS(Free and Open Source Software)라고 부르기도 합니다. + +## GPL을 쉬운 말로 + +리눅스 커널은 **GNU 일반 공중 사용 허가서(GNU General Public License, GPL)** 버전 2를 따릅니다. GPL은 저작권을 거꾸로 활용해 소프트웨어가 계속 자유롭게 남도록 하는 **카피레프트(copyleft)** 라이선스입니다. 핵심만 추리면 다음과 같습니다. + +- **누구나 어떤 목적으로든 쓸 수 있습니다.** 개인, 학교, 기업 모두 마찬가지입니다. +- **고쳐서 혼자 쓰는 것은 자유입니다.** 수정한 버전을 개인이나 회사 안에서만 쓴다면 공개할 의무가 없습니다. +- **배포할 때는 소스도 함께 줘야 합니다.** 수정본을 다른 사람에게 배포한다면, 받는 사람도 같은 GPL 조건으로 소스 코드를 얻을 수 있어야 합니다. +- **자유는 대물림됩니다.** GPL 프로그램을 고치거나 다른 코드와 합쳐 배포하는 경우, 그 결과물도 자유 소프트웨어로 남아야 합니다. + +OSI가 승인한 라이선스 중에는 MIT, BSD, Apache 2.0처럼 이런 조건이 훨씬 가벼운 **허용형(permissive)** 라이선스도 있습니다. 어떤 라이선스를 고르느냐는 만든 사람의 선택입니다. + +리눅스 커널의 라이선스에는 중요한 단서가 하나 붙어 있습니다. 프로그램이 시스템 콜로 커널과 대화하는 것만으로는 GPL 조건이 그 프로그램까지 넘어가지 않는다는 점을 명시한 것입니다(Linux-syscall-note). 덕분에 리눅스 위에서는 자유 소프트웨어뿐 아니라 상용 게임이나 업무용 프로그램 같은 독점 소프트웨어도 문제없이 돌아갈 수 있습니다. + + + +이해를 돕기 위해 개념을 단순하게 정리했습니다. 소프트웨어를 배포하거나 사업에 활용하려면 라이선스 원문과 GNU 프로젝트의 [GPL FAQ](https://www.gnu.org/licenses/gpl-faq.html)를 직접 확인하세요. + + + +## 한눈에 정리 + +- **리눅스 커널**은 하드웨어와 자원을 관리하는 운영체제의 핵심입니다. +- 커널에 **GNU 도구**, 데스크톱 환경, 앱을 더해 묶은 것이 **배포판**이고, 흔히 이 전체를 "리눅스"라고 부릅니다. +- 리눅스는 서버, 스마트폰, 임베디드 기기, 슈퍼컴퓨터, 데스크톱까지 거의 모든 곳에서 돌아갑니다. +- 리눅스는 누구나 쓰고, 연구하고, 고치고, 나눌 수 있는 **자유 소프트웨어**이며, 커널은 **GPL 버전 2**를 따릅니다. + +다음 장 [리눅스의 역사](../history/)에서는 유닉스에서 오늘날의 리눅스까지 이어지는 이야기를 따라갑니다. diff --git a/src/content/learn/ko/why-linux.mdx b/src/content/learn/ko/why-linux.mdx new file mode 100644 index 0000000..ae87d85 --- /dev/null +++ b/src/content/learn/ko/why-linux.mdx @@ -0,0 +1,142 @@ +--- +title: 왜 리눅스를 쓸까 +description: 리눅스를 쓰면 무엇이 좋은지, 그리고 솔직히 무엇이 불편한지 정리합니다. 비용과 프라이버시, 보안 구조, 오래된 PC 활용부터 게임과 상용 프로그램의 한계, 흔한 오해까지 다룹니다. +navTitle: 왜 리눅스인가 +order: 3 +group: basics +icon: star +related: [linux-mint, zorin-os, linux-lite, bazzite] +sources: + - https://www.gnu.org/philosophy/free-sw.html + - https://ubuntu.com/tutorials/try-ubuntu-before-you-install + - https://wiki.debian.org/SecureApt + - https://docs.kernel.org/admin-guide/LSM/index.html + - https://docs.flatpak.org/en/latest/sandbox-permissions.html + - https://www.xfce.org/about + - https://www.libreoffice.org/discover/libreoffice/ + - https://github.com/ValveSoftware/Proton + - https://partner.steamgames.com/doc/steamdeck/proton + - https://www.steamdeck.com/en/verified + - https://store.steampowered.com/steamos + - https://ubuntu.com/server/docs/how-to/graphics/install-nvidia-drivers/ + - https://fwupd.org/ +lastReviewed: 2026-10-03 +--- + +모든 사람에게 가장 좋은 운영체제는 없습니다. 리눅스에는 분명한 장점이 많지만, 어떤 프로그램과 하드웨어를 쓰느냐에 따라 불편할 수도 있습니다. 이 장에서는 장점과 단점을 함께 정리하고, 리눅스를 둘러싼 흔한 오해를 짚어 본 뒤, 나에게 맞는지 확인하는 방법을 소개합니다. + +## 비용 걱정 없이, 마음껏 나눠 쓸 수 있습니다 + +대부분의 배포판은 무료로 내려받아 원하는 만큼 많은 컴퓨터에 설치할 수 있습니다. 제품 키를 입력하거나 정품 인증을 받을 필요도 없습니다. 운영체제뿐 아니라 사무용 프로그램, 웹 브라우저, 사진·영상 편집 도구처럼 일상에 필요한 앱도 무료로 쓸 수 있는 것이 많습니다. + +더 중요한 것은 **자유**입니다. [리눅스란 무엇인가](../what-is-linux/)에서 살펴본 것처럼 자유 소프트웨어는 누구나 실행하고, 연구하고, 고치고, 나눌 수 있습니다. 학교나 단체가 여러 대의 PC에 같은 환경을 설치해 나눠 주어도 라이선스 문제가 생기지 않습니다. 기업용 배포판 중에는 기술 지원과 장기 유지보수를 유료 구독으로 제공하는 것도 있지만, 개인이 쓰기에 충분한 무료 배포판이 많습니다. + +## 내 컴퓨터의 주인은 나 + +리눅스에서는 컴퓨터에서 일어나는 일을 사용자가 결정하는 폭이 넓습니다. + +- **업데이트 시점을 고를 수 있습니다.** 대부분의 배포판은 업데이트가 있다고 알려 주고, 언제 설치하고 언제 다시 시작할지는 사용자에게 맡깁니다. +- **온라인 계정 없이 시작합니다.** 대부분의 배포판은 설치할 때 온라인 계정을 요구하지 않고, 컴퓨터 안의 로컬 계정만으로 쓸 수 있습니다. +- **무엇이 들어 있는지 확인할 수 있습니다.** 소스 코드가 공개되어 있어 누구나 프로그램이 무슨 일을 하는지 검토할 수 있습니다. 진단 정보를 보내는 기능이 있는 배포판도 있으니, 설치 화면의 선택지와 배포판의 개인정보 정책을 한번 확인해 보세요. +- **필요 없는 것은 지웁니다.** 기본으로 깔린 앱도 패키지 관리자로 깔끔하게 지울 수 있고, 원하는 프로그램만 골라 설치할 수 있습니다. + +## 보안은 구조에서 나옵니다 + +리눅스가 비교적 안전하다고 평가받는 이유는 특별한 비결 하나가 아니라 여러 겹의 설계 덕분입니다. + +| 장치 | 하는 일 | +|---|---| +| 일반 사용자와 관리자 분리 | 평소에는 권한이 제한된 계정으로 쓰고, 시스템을 바꿀 때만 `sudo`로 잠시 관리자 권한을 얻습니다. 실수나 악성 프로그램이 시스템 전체를 망가뜨리기 어렵습니다. | +| 서명된 저장소 | 프로그램을 웹에서 아무렇게나 받는 대신 배포판의 저장소에서 받습니다. 패키지 관리자는 저장소의 디지털 서명과 체크섬으로 변조 여부를 확인합니다. | +| 강제 접근 제어(MAC) | 커널의 보안 모듈(LSM) 구조 위에서 SELinux나 AppArmor 같은 기능이 프로그램마다 접근할 수 있는 범위를 제한합니다. | +| 앱 샌드박스 | Flatpak 앱은 기본적으로 시스템과 격리된 채 실행되고, 파일 선택 창처럼 사용자가 허락한 범위에서만 바깥에 접근합니다. | +| 한꺼번에 하는 업데이트 | 운영체제와 앱을 패키지 관리자 하나로 함께 업데이트하므로 보안 패치를 놓치기 어렵습니다. | + +권한 구조는 [사용자와 권한](../users-permissions/)에서, 프로그램 설치와 저장소는 [패키지 관리](../package-management/)에서, 보안 설정 전반은 [보안](../security/) 장에서 자세히 다룹니다. + +## 오래된 PC, 가벼운 PC + +최신 운영체제를 돌리기 버거운 오래된 컴퓨터도 리눅스로 다시 쓸 만해지는 경우가 많습니다. 리눅스에는 Xfce처럼 빠르고 시스템 자원을 적게 쓰는 것을 목표로 하는 가벼운 데스크톱 환경이 있고, 이런 환경을 기본으로 쓰는 배포판도 여럿 있습니다. + +- [Xubuntu](../../distros/xubuntu/): Xfce 데스크톱을 쓰는 Ubuntu 공식 플레이버 +- [Lubuntu](../../distros/lubuntu/): 가벼운 LXQt 데스크톱을 쓰는 Ubuntu 공식 플레이버 +- [Linux Lite](../../distros/linux-lite/): Windows에서 넘어오는 사람을 위해 꾸민 Xfce 기반 배포판 +- [antiX](../../distros/antix/): 아주 오래된 PC에서도 가볍게 돌아가는 Debian 기반 배포판 + + + +배포판마다 최소 사양과 지원하는 CPU 종류(32비트/64비트)가 다릅니다. 메모리가 아주 적거나 오래된 32비트 CPU라면 배포판 페이지의 시스템 요구사항을 먼저 확인하세요. 하드디스크를 SSD로 바꾸면 체감 속도가 더 좋아지기도 합니다. + + + +## 바꾸는 재미, 배우는 재미 + +리눅스에서는 화면 구성부터 시스템 깊숙한 곳까지 원하는 대로 바꿀 수 있습니다. + +- **데스크톱 환경 자체를 고릅니다.** GNOME, KDE Plasma, Xfce처럼 생김새와 사용 방식이 전혀 다른 데스크톱 중 마음에 드는 것을 골라 쓰고, 패널 위치와 단축키, 테마를 바꿀 수 있습니다. 자세한 비교는 [데스크톱 환경](../desktop-environments/)에 있습니다. +- **구성 요소를 조합합니다.** 원한다면 파일 시스템, 부팅 방식, 커널까지 직접 골라 나만의 시스템을 꾸릴 수 있습니다. +- **설치 전에 체험합니다.** 대부분의 배포판은 USB로 부팅해 설치 없이 써 보는 **라이브 세션(live session)**을 제공합니다. USB를 빼고 다시 시작하면 원래 컴퓨터 상태 그대로 돌아갑니다. + +리눅스를 쓰다 보면 자연스럽게 컴퓨터가 동작하는 원리도 배우게 됩니다. 리눅스는 서버와 클라우드, 임베디드 기기, Android의 바탕이기도 해서, 터미널 사용법이나 파일 권한, 프로세스 관리 같은 지식은 개발과 IT 업무에서 그대로 쓸모가 있습니다. [터미널 기초](../terminal-basics/)와 [개발 환경](../development/) 장이 그 출발점입니다. + +## 솔직한 단점 + +리눅스가 모든 사람에게 맞지는 않습니다. 옮겨 오기 전에 다음 내용을 꼭 확인하세요. + +### 일부 상용 프로그램이 없습니다 + +전문 디자인·영상 도구나 회사 업무용 프로그램 가운데는 Windows나 macOS용으로만 나오는 것이 있습니다. 국내 일부 금융·공공 사이트가 요구하는 보안 프로그램도 운영체제 지원 범위를 미리 확인해야 합니다. 많은 경우 웹 버전이나 리눅스용 대안으로 해결할 수 있습니다. + +| 용도 | 리눅스에서 쓸 수 있는 대안 | +|---|---| +| 문서·표·발표 | LibreOffice, 웹 기반 오피스 | +| 사진 편집·그림 | GIMP, Krita | +| 벡터 그래픽 | Inkscape | +| 영상 편집 | Kdenlive, Shotcut | + +Windows 프로그램을 리눅스에서 돌려 주는 호환 계층 **Wine**도 있습니다. 잘 돌아가는 프로그램도 많지만, 결과는 프로그램과 버전에 따라 다르므로 업무에 꼭 필요한 프로그램이라면 미리 시험해 보는 것이 안전합니다. + +### 게임은 대부분 되지만, 전부는 아닙니다 + +Valve가 Wine을 바탕으로 만든 **Proton** 덕분에 Steam의 많은 Windows 게임이 리눅스에서 그대로 실행됩니다. Steam Deck용 호환성 등급(검증됨, 플레이 가능, 지원되지 않음, 알 수 없음)을 보면 게임이 잘 돌아갈지 짐작할 수 있습니다. + +걸림돌은 일부 온라인 게임의 **안티치트(anti-cheat)**입니다. Proton은 Easy Anti-Cheat와 BattlEye를 지원하지만, 게임 개발사가 직접 리눅스 지원을 켜야 동작합니다. 개발사가 이를 켜지 않은 게임은 리눅스에서 접속할 수 없으므로, 온라인 대전 게임을 주로 한다면 하고 싶은 게임부터 확인하세요. 자세한 내용은 [게임](../gaming/) 장에서 다룹니다. + +### 하드웨어에 따라 손이 갈 수 있습니다 + +대부분의 장치는 커널에 들어 있는 드라이버로 설치하자마자 동작합니다. 하지만 몇 가지는 추가 작업이 필요할 수 있습니다. + +- **NVIDIA 그래픽 카드**: 성능을 제대로 쓰려면 독점 드라이버를 설치해야 하는 경우가 많습니다. Ubuntu처럼 전용 도구(`ubuntu-drivers`)로 간단히 설치하게 해 주는 배포판도 있습니다. +- **일부 Wi-Fi 칩, 지문 인식기, 노트북 전용 기능**: 제조사가 리눅스 드라이버를 내놓지 않으면 동작하지 않거나 지원이 늦을 수 있습니다. +- **펌웨어 업데이트**: 제조사가 LVFS에 참여하면 `fwupd`로 리눅스에서도 펌웨어를 업데이트할 수 있지만, 모든 제조사가 참여하지는 않습니다. + +설치하기 전에 라이브 USB로 부팅해 Wi-Fi, 소리, 화면 밝기, 터치패드가 잘 동작하는지 확인하면 이런 문제를 미리 알 수 있습니다. 해결 방법은 [하드웨어와 드라이버](../hardware-drivers/)에 정리되어 있습니다. + +### 익숙해지는 데 시간이 걸립니다 + +프로그램을 설치하는 방법, 파일이 저장되는 위치, 단축키가 Windows나 macOS와 다릅니다. 또 배포판이 다양한 만큼 같은 일도 배포판마다 명령어가 조금씩 다릅니다. 무료 배포판은 회사 고객센터 대신 공식 문서와 커뮤니티 포럼에서 도움을 받는 것이 일반적입니다. 이 가이드는 배포판별 차이가 있는 곳마다 탭으로 나눠 안내합니다. + +## 흔한 오해 + +| 오해 | 실제 | +|---|---| +| 리눅스는 개발자나 전문가만 쓴다 | 요즘 배포판은 그래픽 설치 프로그램과 앱 스토어를 갖추고 있어, 터미널을 거의 쓰지 않고도 일상 작업을 할 수 있습니다. | +| 리눅스에는 바이러스가 없다 | 리눅스를 노리는 악성 코드도 있습니다. 구조상 피해를 줄이기 쉬울 뿐이므로, 업데이트를 꾸준히 하고 믿을 수 있는 곳에서만 프로그램을 받아야 합니다. | +| 무료라서 품질이 떨어진다 | 슈퍼컴퓨터와 서버, 스마트폰의 바탕이 되는 운영체제입니다. 무료인 이유는 품질이 아니라 라이선스에 있습니다. | +| 설치하려면 Windows를 지워야 한다 | 라이브 USB로 설치 없이 체험할 수 있고, Windows와 함께 설치해 부팅할 때 고르는 듀얼 부팅도 가능합니다. | +| 게임은 아예 못 한다 | Proton 덕분에 많은 Windows 게임이 돌아갑니다. 다만 개발사가 리눅스용 안티치트를 켜지 않은 일부 온라인 게임은 예외입니다. | +| 리눅스는 한 회사가 만든 제품이다 | 커널과 수많은 프로그램을 개인, 단체, 기업이 함께 만들고, 배포판도 목적에 따라 매우 다양합니다. | + +## 나에게 맞을까? 확인하는 순서 + + + +1. **꼭 필요한 프로그램 적어 보기**: 매일 쓰는 앱마다 리눅스판이 있는지, 웹에서 쓸 수 있는지, 대안이 있는지 확인합니다. +2. **즐겨 하는 게임 확인하기**: Steam Deck 호환성 등급과 안티치트 지원 여부를 확인합니다. +3. **라이브 USB로 체험하기**: 설치하지 않고 USB로 부팅해 하드웨어가 잘 동작하는지, 화면과 사용 방식이 마음에 드는지 살펴봅니다. +4. **배포판 고르기**: 처음이라면 [배포판 찾기](../../finder/)에서 몇 가지 질문에 답해 나에게 맞는 배포판을 찾아보세요. +5. **설치 준비하기**: 백업과 듀얼 부팅 여부는 [설치 준비](../installation-prep/)에서 확인합니다. + + + +Windows나 macOS에서 옮겨 오는 중이라면 [다른 운영체제에서 옮겨 오기](../switching/) 장이 낯선 부분을 하나씩 짚어 줍니다. diff --git a/src/content/partials/en/boot-usb.mdx b/src/content/partials/en/boot-usb.mdx new file mode 100644 index 0000000..46f6b3b --- /dev/null +++ b/src/content/partials/en/boot-usb.mdx @@ -0,0 +1,41 @@ +{/* + Shared step: boot from the USB drive. Usage: +*/} +import Callout from "~/components/adw/Callout.astro"; +import Kbd from "~/components/adw/Kbd.astro"; + +Restart the PC with the USB drive plugged in and choose it from the **boot menu**. The key that opens the boot menu depends on the manufacturer; press it repeatedly right after powering on. + +| Manufacturer | Boot menu key | Firmware (UEFI) setup key | +|---|---|---| +| Dell | F12 | F2 | +| HP | F9 (or Esc, then F9) | F10 | +| Lenovo | F12 (Novo button on some models) | F1 / F2 | +| ASUS | F8 (laptops: Esc) | F2 / Del | +| Acer | F12 | F2 | +| MSI | F11 | Del | +| Gigabyte | F12 | Del | +| ASRock | F11 | F2 / Del | +| Samsung | F10 / Esc | F2 | +| LG | F10 | F2 | +| Intel Mac | Hold Option ⌥ while powering on | — | + +If the key doesn’t work, you can get there from Windows: **Settings → System → Recovery → Advanced startup → Restart now → Use a device**, then pick the USB drive. + +If the same drive appears twice in the boot menu, choose the entry marked **UEFI**. + +{props.secureBoot === "no" ? ( + + This distribution’s installer won’t boot with Secure Boot enabled. In the firmware (UEFI) setup, open the **Security** or **Boot** menu, set **Secure Boot** to **Disabled**, save, and restart. + +) : props.secureBoot === "partial" ? ( + + Secure Boot support here comes with conditions. If the drive doesn’t boot, temporarily disable Secure Boot in the firmware setup, or follow the steps (such as key enrollment) in the installation section below. + +) : ( + + This distribution boots with Secure Boot enabled; there’s no need to turn it off. + +)} + +Most distributions start a **live session** first. Before installing anything, check that Wi-Fi, sound, display brightness and the other hardware you care about work. diff --git a/src/content/partials/en/dual-boot.mdx b/src/content/partials/en/dual-boot.mdx new file mode 100644 index 0000000..fd25b6c --- /dev/null +++ b/src/content/partials/en/dual-boot.mdx @@ -0,0 +1,15 @@ +{/* + Shared step: prepare a Windows PC for dual boot. Usage: +*/} +import Callout from "~/components/adw/Callout.astro"; + +To install Linux alongside Windows instead of replacing it, prepare Windows first: + +1. **Back up important files.** Resizing partitions rarely fails, but it can. +2. **Find your BitLocker recovery key.** With device encryption on, Windows may ask for it after the boot order changes. Look it up on your [Microsoft account’s recovery key page](https://account.microsoft.com/devices/recoverykey) or suspend BitLocker for the install. +3. **Turn off Fast Startup.** Control Panel → Power Options → Choose what the power buttons do → uncheck **Turn on fast startup**. Otherwise Windows doesn’t fully close its partition, which can cause problems. +4. **Free up space for Linux.** Right-click the Start button, open **Disk Management**, right-click drive C:, and choose **Shrink Volume**. Leave at least 30 GB unallocated, 60 GB or more if you can. + + + The installer uses this unallocated space when you choose an option such as **Install alongside Windows** or **Use free space**. + diff --git a/src/content/partials/en/verify.mdx b/src/content/partials/en/verify.mdx new file mode 100644 index 0000000..cc9b215 --- /dev/null +++ b/src/content/partials/en/verify.mdx @@ -0,0 +1,53 @@ +{/* + Shared step: verify the downloaded image. Usage: + import Verify from "~/content/partials/en/verify.mdx"; + +*/} +import Callout from "~/components/adw/Callout.astro"; +import Checksum from "~/components/mdx/Checksum.astro"; +import IsoName from "~/components/mdx/IsoName.astro"; +import Tab from "~/components/mdx/Tab.astro"; +import Tabs from "~/components/mdx/Tabs.astro"; +import VerifyCommand from "~/components/mdx/VerifyCommand.astro"; + +Use the checksum to confirm the file arrived intact and matches the one on the official server. The commands below use the default image offered on this site (); if you picked another edition, just change the file name. + + + + Open **PowerShell** from the Start menu, go to the folder you downloaded to (usually `Downloads`), and run: + + ```powershell + cd $HOME\Downloads + ``` + + + + + Open **Terminal**, go to your downloads folder, and run: + + ```bash + cd ~/Downloads + ``` + + + + + In a terminal, run: + + ```bash + cd ~/Downloads + ``` + + + + + +The value printed must match the official checksum exactly (letter case doesn’t matter): + + + + + The download is incomplete or corrupted. Delete the file and download it again. If it still doesn’t match, don’t use that file. + + +A checksum proves the file is intact. To also prove it was really published by the project, verify the **GPG signature** as described in the official documentation; the “Signature” link in the download panel gives you the signature file. diff --git a/src/content/partials/en/write-usb.mdx b/src/content/partials/en/write-usb.mdx new file mode 100644 index 0000000..fd6998b --- /dev/null +++ b/src/content/partials/en/write-usb.mdx @@ -0,0 +1,47 @@ +{/* + Shared step: create a bootable USB drive. Usage: +*/} +import Callout from "~/components/adw/Callout.astro"; +import Tab from "~/components/mdx/Tab.astro"; +import Tabs from "~/components/mdx/Tabs.astro"; + +You need a **USB drive of 8 GB or more**. **Everything on it will be erased**, so copy off anything you need first. + + + + **Rufus** ([rufus.ie](https://rufus.ie)) is the most common tool; a portable version runs without installing. + + 1. Start Rufus and pick your USB drive under **Device**. + 2. Under **Boot selection**, click **SELECT** and open the image you downloaded. + 3. Leave **Partition scheme** on **GPT** for any recent PC (UEFI). Choose **MBR** only for very old PCs with a legacy BIOS. + 4. Click **START**. If asked how to write the image, keep the default **ISO Image mode**; if the drive then doesn’t boot, recreate it in **DD Image mode**. + + **balenaEtcher** ([etcher.balena.io](https://etcher.balena.io)) is even simpler: choose the image, choose the drive, click **Flash!**. + + + **balenaEtcher** ([etcher.balena.io](https://etcher.balena.io)) is the easiest: **Flash from file**, **Select target** (your USB drive), then **Flash!**. + + If you’re comfortable in Terminal, `dd` works too. Double-check the `diskN` number: picking the wrong disk erases it. + + ```bash + diskutil list # find your USB drive's diskN + diskutil unmountDisk /dev/diskN + sudo dd if=~/Downloads/image.iso of=/dev/rdiskN bs=4m + diskutil eject /dev/diskN + ``` + + + On GNOME, open **Disks**, select the USB drive, choose **Restore Disk Image…** from the menu at the top right, and pick the image. The **Impression** app on Flathub does the same in one step. + + In a terminal, use `dd`. First identify the USB device (for example `/dev/sdb`) with `lsblk`. + + ```bash + lsblk + sudo dd if=~/Downloads/image.iso of=/dev/sdX bs=4M status=progress oflag=sync + ``` + + + + + Install **Ventoy** ([ventoy.net](https://www.ventoy.net)) on a USB drive once; after that you just copy ISO files onto it and pick one from its boot menu. Handy for trying several distributions. + diff --git a/src/content/partials/ko/boot-usb.mdx b/src/content/partials/ko/boot-usb.mdx new file mode 100644 index 0000000..02f4a2c --- /dev/null +++ b/src/content/partials/ko/boot-usb.mdx @@ -0,0 +1,41 @@ +{/* + 공용 단계: USB로 부팅하기. 사용법: +*/} +import Callout from "~/components/adw/Callout.astro"; +import Kbd from "~/components/adw/Kbd.astro"; + +USB를 꽂은 채로 PC를 다시 켜고, **부팅 메뉴**에서 USB를 고릅니다. 부팅 메뉴를 여는 키는 제조사마다 다릅니다. 전원을 켜자마자 여러 번 눌러 주세요. + +| 제조사 | 부팅 메뉴 키 | 펌웨어(UEFI) 설정 키 | +|---|---|---| +| Dell | F12 | F2 | +| HP | F9 (또는 Esc 뒤 F9) | F10 | +| Lenovo | F12 (일부 모델은 Novo 버튼) | F1 / F2 | +| ASUS | F8 (노트북은 Esc) | F2 / Del | +| Acer | F12 | F2 | +| MSI | F11 | Del | +| Gigabyte | F12 | Del | +| ASRock | F11 | F2 / Del | +| Samsung | F10 / Esc | F2 | +| LG | F10 | F2 | +| Intel Mac | 전원을 켜고 Option ⌥을 누르고 있기 | — | + +키가 맞지 않으면 Windows에서 들어갈 수도 있습니다. **설정 → 시스템 → 복구 → 고급 시작 옵션의 [지금 다시 시작] → 장치 사용**에서 USB를 고르세요. + +부팅 메뉴에 같은 USB가 두 번 보이면 이름에 **UEFI**가 붙은 항목을 고르세요. + +{props.secureBoot === "no" ? ( + + 이 배포판의 설치 이미지는 Secure Boot가 켜진 상태로는 부팅되지 않습니다. 펌웨어(UEFI) 설정의 **Security** 또는 **Boot** 메뉴에서 **Secure Boot**를 **Disabled**로 바꾼 뒤 저장하고 다시 시작하세요. + +) : props.secureBoot === "partial" ? ( + + 이 배포판은 Secure Boot를 조건부로 지원합니다. USB가 부팅되지 않으면 펌웨어 설정에서 Secure Boot를 잠시 끄거나, 아래 설치 단계의 안내(키 등록 등)를 따르세요. + +) : ( + + 이 배포판은 Secure Boot를 켠 상태로 부팅됩니다. 따로 끌 필요가 없습니다. + +)} + +부팅이 되면 대부분의 배포판은 먼저 **라이브 환경**을 띄웁니다. 아무것도 설치하지 않은 상태로 Wi-Fi, 소리, 화면 밝기 같은 하드웨어가 잘 동작하는지 미리 확인해 보세요. diff --git a/src/content/partials/ko/dual-boot.mdx b/src/content/partials/ko/dual-boot.mdx new file mode 100644 index 0000000..bbcef35 --- /dev/null +++ b/src/content/partials/ko/dual-boot.mdx @@ -0,0 +1,15 @@ +{/* + 공용 단계: Windows와 함께 쓰기(듀얼 부팅) 준비. 사용법: +*/} +import Callout from "~/components/adw/Callout.astro"; + +Windows를 지우지 않고 리눅스를 함께 설치하려면, 설치 전에 Windows에서 다음을 준비하세요. + +1. **중요한 파일을 백업합니다.** 파티션을 나누는 작업은 드물게 실패할 수 있습니다. +2. **BitLocker 복구 키를 확인합니다.** 장치 암호화가 켜져 있으면 부팅 순서가 바뀔 때 복구 키를 물을 수 있습니다. [Microsoft 계정의 복구 키 페이지](https://account.microsoft.com/devices/recoverykey)에서 키를 확인해 두거나 BitLocker를 잠시 끄세요. +3. **빠른 시작을 끕니다.** 제어판 → 전원 옵션 → 전원 단추 작동 설정 → **빠른 시작 켜기**의 체크를 해제합니다. 켜져 있으면 Windows 파티션이 완전히 닫히지 않아 문제가 생길 수 있습니다. +4. **리눅스용 공간을 만듭니다.** 시작 버튼을 마우스 오른쪽 단추로 눌러 **디스크 관리**를 열고, C: 드라이브를 오른쪽 단추로 눌러 **볼륨 축소**를 고릅니다. 최소 30GB, 여유가 있다면 60GB 이상을 비워 두세요. + + + 이렇게 비워 둔 공간은 설치 프로그램에서 **빈 공간에 설치** 또는 **Windows와 함께 설치**를 고르면 자동으로 사용됩니다. + diff --git a/src/content/partials/ko/verify.mdx b/src/content/partials/ko/verify.mdx new file mode 100644 index 0000000..043953b --- /dev/null +++ b/src/content/partials/ko/verify.mdx @@ -0,0 +1,53 @@ +{/* + 공용 단계: 내려받은 이미지 검증. 사용법: + import Verify from "~/content/partials/ko/verify.mdx"; + +*/} +import Callout from "~/components/adw/Callout.astro"; +import Checksum from "~/components/mdx/Checksum.astro"; +import IsoName from "~/components/mdx/IsoName.astro"; +import Tab from "~/components/mdx/Tab.astro"; +import Tabs from "~/components/mdx/Tabs.astro"; +import VerifyCommand from "~/components/mdx/VerifyCommand.astro"; + +내려받은 파일이 손상되지 않았고 공식 서버의 파일과 똑같은지 체크섬으로 확인합니다. 아래 명령은 이 사이트에서 기본으로 제공하는 이미지() 기준입니다. 다른 에디션을 받았다면 파일 이름만 바꾸세요. + + + + 시작 메뉴에서 **PowerShell**을 열고, 파일을 내려받은 폴더(보통 `다운로드`)로 이동한 뒤 실행합니다. + + ```powershell + cd $HOME\Downloads + ``` + + + + + **터미널**을 열고 내려받은 폴더로 이동한 뒤 실행합니다. + + ```bash + cd ~/Downloads + ``` + + + + + 터미널에서 실행합니다. + + ```bash + cd ~/Downloads + ``` + + + + + +출력된 값이 공식 체크섬과 정확히 같아야 합니다(대소문자는 상관없습니다). + + + + + 파일이 덜 받아졌거나 손상된 것입니다. 파일을 지우고 다시 내려받으세요. 다시 받아도 값이 다르면 그 파일은 사용하지 마세요. + + +체크섬은 파일이 온전한지 확인해 줍니다. 파일이 정말 그 프로젝트가 만든 것인지까지 확인하려면, 공식 문서의 안내에 따라 **GPG 서명**도 검증하세요. 다운로드 화면의 “서명” 링크에서 서명 파일을 받을 수 있습니다. diff --git a/src/content/partials/ko/write-usb.mdx b/src/content/partials/ko/write-usb.mdx new file mode 100644 index 0000000..fdf3be6 --- /dev/null +++ b/src/content/partials/ko/write-usb.mdx @@ -0,0 +1,47 @@ +{/* + 공용 단계: 부팅 USB 만들기. 사용법: (props.tool 로 배포판 전용 도구를 덧붙일 수 있음) +*/} +import Callout from "~/components/adw/Callout.astro"; +import Tab from "~/components/mdx/Tab.astro"; +import Tabs from "~/components/mdx/Tabs.astro"; + +**8GB 이상의 USB 메모리**를 준비하세요. USB에 있던 내용은 **모두 지워지므로** 필요한 파일은 미리 옮겨 두세요. + + + + **Rufus**([rufus.ie](https://rufus.ie))를 가장 많이 씁니다. 설치 없이 실행되는 포터블 버전도 있습니다. + + 1. Rufus를 실행하고 **장치**에서 USB를 고릅니다. + 2. **부트 선택**의 **선택** 버튼을 눌러 내려받은 이미지 파일을 엽니다. + 3. **파티션 방식**은 최근 PC라면 **GPT**(UEFI)로 둡니다. 아주 오래된 PC(레거시 BIOS)라면 **MBR**을 고릅니다. + 4. **시작**을 누릅니다. 쓰기 방식을 묻는 창이 뜨면 기본값인 **ISO 이미지 모드**를 고르세요. 그 방식으로 부팅되지 않으면 **DD 이미지 모드**로 다시 만드세요. + + 더 간단한 **balenaEtcher**([etcher.balena.io](https://etcher.balena.io))도 있습니다. 이미지 선택 → USB 선택 → **Flash!** 순서로 끝납니다. + + + **balenaEtcher**([etcher.balena.io](https://etcher.balena.io))가 가장 쉽습니다. **Flash from file**로 이미지를 고르고, **Select target**에서 USB를 고른 뒤 **Flash!**를 누릅니다. + + 터미널이 익숙하다면 `dd`로도 만들 수 있습니다. `diskN`의 숫자를 **반드시** 확인하세요. 다른 디스크를 고르면 그 디스크의 데이터가 지워집니다. + + ```bash + diskutil list # USB의 diskN 번호 확인 + diskutil unmountDisk /dev/diskN + sudo dd if=~/Downloads/이미지.iso of=/dev/rdiskN bs=4m + diskutil eject /dev/diskN + ``` + + + GNOME을 쓴다면 **디스크**(GNOME Disks) 앱에서 USB를 고르고 오른쪽 위 메뉴의 **디스크 이미지 복원…**을 누른 뒤 이미지를 고르면 됩니다. Flathub의 **Impression** 앱도 같은 일을 간단하게 해 줍니다. + + 터미널에서는 `dd`를 씁니다. 먼저 `lsblk`로 USB 장치 이름(예: `/dev/sdb`)을 정확히 확인하세요. + + ```bash + lsblk + sudo dd if=~/Downloads/이미지.iso of=/dev/sdX bs=4M status=progress oflag=sync + ``` + + + + + **Ventoy**([ventoy.net](https://www.ventoy.net))를 USB에 한 번 설치해 두면, 그다음부터는 ISO 파일을 USB에 복사하기만 해도 부팅 메뉴에서 골라 쓸 수 있습니다. 여러 배포판을 시험해 볼 때 편합니다. + diff --git a/src/data/geo/countries.json b/src/data/geo/countries.json new file mode 100644 index 0000000..b4803b4 --- /dev/null +++ b/src/data/geo/countries.json @@ -0,0 +1 @@ +{"AD":{"lat":42.55,"lon":1.54,"continent":"EU"},"AE":{"lat":23.47,"lon":54.55,"continent":"AS"},"AF":{"lat":34.16,"lon":66.5,"continent":"AS"},"AG":{"lat":17.35,"lon":-61.79,"continent":"NA"},"AI":{"lat":18.24,"lon":-63.03,"continent":"NA"},"AL":{"lat":40.65,"lon":20.11,"continent":"EU"},"AM":{"lat":40.46,"lon":44.8,"continent":"AS"},"AO":{"lat":-12.18,"lon":17.98,"continent":"AF"},"AQ":{"lat":-79.84,"lon":35.89,"continent":"AN"},"AR":{"lat":-33.5,"lon":-64.17,"continent":"SA"},"AS":{"lat":-14.33,"lon":-170.75,"continent":"OC"},"AT":{"lat":47.52,"lon":14.13,"continent":"EU"},"AU":{"lat":-12.43,"lon":123.59,"continent":"OC"},"AW":{"lat":12.52,"lon":-69.97,"continent":"NA"},"AX":{"lat":60.16,"lon":19.87,"continent":"EU"},"AZ":{"lat":40.4,"lon":47.21,"continent":"AS"},"BA":{"lat":44.09,"lon":18.07,"continent":"EU"},"BB":{"lat":13.16,"lon":-59.57,"continent":"NA"},"BD":{"lat":24.21,"lon":89.68,"continent":"AS"},"BE":{"lat":50.79,"lon":4.8,"continent":"EU"},"BF":{"lat":12.67,"lon":-1.36,"continent":"AF"},"BG":{"lat":42.51,"lon":25.16,"continent":"EU"},"BH":{"lat":26.06,"lon":50.55,"continent":"AS"},"BI":{"lat":-3.33,"lon":29.92,"continent":"AF"},"BJ":{"lat":10.32,"lon":2.35,"continent":"AF"},"BL":{"lat":17.9,"lon":-62.83,"continent":"NA"},"BM":{"lat":32.3,"lon":-64.76,"continent":"NA"},"BN":{"lat":4.45,"lon":114.55,"continent":"AS"},"BO":{"lat":-16.67,"lon":-64.59,"continent":"SA"},"BR":{"lat":-12.1,"lon":-49.56,"continent":"SA"},"BS":{"lat":26.4,"lon":-77.15,"continent":"NA"},"BT":{"lat":27.54,"lon":90.04,"continent":"AS"},"BW":{"lat":-22.1,"lon":24.18,"continent":"AF"},"BY":{"lat":53.82,"lon":28.42,"continent":"EU"},"BZ":{"lat":17.2,"lon":-88.71,"continent":"NA"},"CA":{"lat":60.32,"lon":-101.91,"continent":"NA"},"CD":{"lat":-1.86,"lon":23.46,"continent":"AF"},"CF":{"lat":6.99,"lon":20.91,"continent":"AF"},"CG":{"lat":0.14,"lon":15.9,"continent":"AF"},"CH":{"lat":46.72,"lon":7.46,"continent":"EU"},"CI":{"lat":7.49,"lon":-5.57,"continent":"AF"},"CK":{"lat":-21.22,"lon":-159.79,"continent":"OC"},"CL":{"lat":-38.15,"lon":-72.32,"continent":"SA"},"CM":{"lat":4.59,"lon":12.47,"continent":"AF"},"CN":{"lat":32.5,"lon":106.34,"continent":"AS"},"CO":{"lat":3.37,"lon":-73.17,"continent":"SA"},"CR":{"lat":10.07,"lon":-84.08,"continent":"NA"},"CU":{"lat":21.33,"lon":-77.98,"continent":"NA"},"CV":{"lat":15.07,"lon":-23.64,"continent":"AF"},"CW":{"lat":12.15,"lon":-68.92,"continent":"NA"},"CY":{"lat":34.91,"lon":33.08,"continent":"AS"},"CZ":{"lat":49.88,"lon":15.38,"continent":"EU"},"DE":{"lat":50.96,"lon":9.68,"continent":"EU"},"DJ":{"lat":11.98,"lon":42.5,"continent":"AF"},"DK":{"lat":55.97,"lon":9.02,"continent":"EU"},"DM":{"lat":15.46,"lon":-61.34,"continent":"NA"},"DO":{"lat":19.1,"lon":-70.65,"continent":"NA"},"DZ":{"lat":27.4,"lon":2.81,"continent":"AF"},"EC":{"lat":-1.26,"lon":-78.19,"continent":"SA"},"EE":{"lat":58.72,"lon":25.87,"continent":"EU"},"EG":{"lat":26.19,"lon":29.45,"continent":"AF"},"EH":{"lat":23.97,"lon":-12.63,"continent":"AF"},"ER":{"lat":15.79,"lon":38.29,"continent":"AF"},"ES":{"lat":40.09,"lon":-3.46,"continent":"EU"},"ET":{"lat":8.03,"lon":39.09,"continent":"AF"},"FI":{"lat":63.25,"lon":27.28,"continent":"EU"},"FJ":{"lat":-17.83,"lon":177.98,"continent":"OC"},"FK":{"lat":-51.61,"lon":-58.74,"continent":"SA"},"FM":{"lat":6.89,"lon":158.23,"continent":"OC"},"FO":{"lat":62.19,"lon":-7.06,"continent":"EU"},"FR":{"lat":46.7,"lon":2.55,"continent":"EU"},"GA":{"lat":-0.44,"lon":11.84,"continent":"AF"},"GB":{"lat":54.4,"lon":-2.12,"continent":"EU"},"GD":{"lat":12.11,"lon":-61.68,"continent":"NA"},"GE":{"lat":41.87,"lon":43.74,"continent":"AS"},"GG":{"lat":49.46,"lon":-2.56,"continent":"EU"},"GH":{"lat":7.72,"lon":-1.04,"continent":"AF"},"GL":{"lat":74.32,"lon":-39.34,"continent":"NA"},"GM":{"lat":13.64,"lon":-15.0,"continent":"AF"},"GN":{"lat":10.62,"lon":-10.02,"continent":"AF"},"GQ":{"lat":2.33,"lon":8.99,"continent":"AF"},"GR":{"lat":39.49,"lon":21.73,"continent":"EU"},"GS":{"lat":-55.68,"lon":-31.06,"continent":"OC"},"GT":{"lat":14.98,"lon":-90.5,"continent":"NA"},"GU":{"lat":13.35,"lon":144.7,"continent":"OC"},"GW":{"lat":12.16,"lon":-14.52,"continent":"AF"},"GY":{"lat":5.12,"lon":-58.94,"continent":"SA"},"HK":{"lat":22.45,"lon":114.1,"continent":"AS"},"HM":{"lat":-53.1,"lon":73.51,"continent":"OC"},"HN":{"lat":14.79,"lon":-86.89,"continent":"NA"},"HR":{"lat":45.81,"lon":16.37,"continent":"EU"},"HT":{"lat":19.26,"lon":-72.22,"continent":"NA"},"HU":{"lat":47.09,"lon":19.45,"continent":"EU"},"ID":{"lat":-0.95,"lon":101.89,"continent":"AS"},"IE":{"lat":53.08,"lon":-7.8,"continent":"EU"},"IL":{"lat":30.91,"lon":34.85,"continent":"AS"},"IM":{"lat":54.22,"lon":-4.53,"continent":"EU"},"IN":{"lat":22.69,"lon":79.36,"continent":"AS"},"IO":{"lat":-6.19,"lon":71.35,"continent":"OC"},"IQ":{"lat":33.09,"lon":43.26,"continent":"AS"},"IR":{"lat":32.17,"lon":54.93,"continent":"AS"},"IS":{"lat":64.78,"lon":-18.67,"continent":"EU"},"IT":{"lat":44.73,"lon":11.08,"continent":"EU"},"JE":{"lat":49.22,"lon":-2.09,"continent":"EU"},"JM":{"lat":18.14,"lon":-77.32,"continent":"NA"},"JO":{"lat":30.81,"lon":36.38,"continent":"AS"},"JP":{"lat":36.14,"lon":138.44,"continent":"AS"},"KE":{"lat":0.55,"lon":37.91,"continent":"AF"},"KG":{"lat":41.67,"lon":74.53,"continent":"AS"},"KH":{"lat":12.65,"lon":104.5,"continent":"AS"},"KI":{"lat":1.82,"lon":-157.38,"continent":"OC"},"KM":{"lat":-11.73,"lon":43.32,"continent":"AF"},"KN":{"lat":17.34,"lon":-62.76,"continent":"NA"},"KP":{"lat":39.89,"lon":126.44,"continent":"AS"},"KR":{"lat":36.38,"lon":128.13,"continent":"AS"},"KW":{"lat":29.41,"lon":47.31,"continent":"AS"},"KY":{"lat":19.32,"lon":-81.24,"continent":"NA"},"KZ":{"lat":49.05,"lon":68.69,"continent":"AS"},"LA":{"lat":19.43,"lon":102.53,"continent":"AS"},"LB":{"lat":34.13,"lon":35.99,"continent":"AS"},"LC":{"lat":13.89,"lon":-60.98,"continent":"NA"},"LI":{"lat":47.11,"lon":9.56,"continent":"EU"},"LK":{"lat":7.58,"lon":80.7,"continent":"AS"},"LR":{"lat":6.45,"lon":-9.46,"continent":"AF"},"LS":{"lat":-29.48,"lon":28.25,"continent":"AF"},"LT":{"lat":55.1,"lon":24.09,"continent":"EU"},"LU":{"lat":49.73,"lon":6.08,"continent":"EU"},"LV":{"lat":57.07,"lon":25.46,"continent":"EU"},"LY":{"lat":26.64,"lon":18.01,"continent":"AF"},"MA":{"lat":31.65,"lon":-7.19,"continent":"AF"},"MC":{"lat":43.74,"lon":7.4,"continent":"EU"},"MD":{"lat":47.43,"lon":28.49,"continent":"EU"},"ME":{"lat":42.8,"lon":19.14,"continent":"EU"},"MF":{"lat":18.08,"lon":-63.05,"continent":"NA"},"MG":{"lat":-18.63,"lon":46.7,"continent":"AF"},"MH":{"lat":7.08,"lon":171.19,"continent":"OC"},"MK":{"lat":41.56,"lon":21.56,"continent":"EU"},"ML":{"lat":18.69,"lon":-2.04,"continent":"AF"},"MM":{"lat":21.57,"lon":95.8,"continent":"AS"},"MN":{"lat":46.0,"lon":104.15,"continent":"AS"},"MO":{"lat":22.13,"lon":113.56,"continent":"AS"},"MP":{"lat":15.19,"lon":145.73,"continent":"OC"},"MR":{"lat":19.59,"lon":-9.74,"continent":"AF"},"MS":{"lat":16.74,"lon":-62.19,"continent":"NA"},"MT":{"lat":35.89,"lon":14.43,"continent":"EU"},"MU":{"lat":-20.3,"lon":57.57,"continent":"OC"},"MV":{"lat":4.17,"lon":73.51,"continent":"OC"},"MW":{"lat":-13.39,"lon":33.61,"continent":"AF"},"MX":{"lat":23.92,"lon":-102.29,"continent":"NA"},"MY":{"lat":2.53,"lon":113.84,"continent":"AS"},"MZ":{"lat":-13.94,"lon":37.84,"continent":"AF"},"NA":{"lat":-20.58,"lon":17.11,"continent":"AF"},"NC":{"lat":-21.06,"lon":165.08,"continent":"OC"},"NE":{"lat":17.45,"lon":9.5,"continent":"AF"},"NF":{"lat":-29.03,"lon":167.95,"continent":"OC"},"NG":{"lat":9.44,"lon":7.5,"continent":"AF"},"NI":{"lat":12.67,"lon":-85.07,"continent":"NA"},"NL":{"lat":52.42,"lon":5.61,"continent":"EU"},"NO":{"lat":61.36,"lon":9.68,"continent":"EU"},"NP":{"lat":28.3,"lon":83.64,"continent":"AS"},"NR":{"lat":-0.52,"lon":166.93,"continent":"OC"},"NU":{"lat":-19.05,"lon":-169.86,"continent":"OC"},"NZ":{"lat":-39.76,"lon":172.79,"continent":"OC"},"OM":{"lat":22.12,"lon":57.34,"continent":"AS"},"PA":{"lat":8.72,"lon":-80.35,"continent":"NA"},"PE":{"lat":-12.98,"lon":-72.9,"continent":"SA"},"PF":{"lat":-17.63,"lon":-149.46,"continent":"OC"},"PG":{"lat":-5.7,"lon":143.91,"continent":"OC"},"PH":{"lat":11.2,"lon":122.47,"continent":"AS"},"PK":{"lat":29.33,"lon":68.55,"continent":"AS"},"PL":{"lat":51.99,"lon":19.49,"continent":"EU"},"PM":{"lat":47.04,"lon":-56.33,"continent":"NA"},"PN":{"lat":-24.36,"lon":-128.32,"continent":"OC"},"PR":{"lat":18.23,"lon":-66.48,"continent":"NA"},"PS":{"lat":32.05,"lon":35.29,"continent":"AS"},"PT":{"lat":39.61,"lon":-8.27,"continent":"EU"},"PW":{"lat":7.52,"lon":134.58,"continent":"OC"},"PY":{"lat":-21.67,"lon":-60.15,"continent":"SA"},"QA":{"lat":25.24,"lon":51.14,"continent":"AS"},"RO":{"lat":45.73,"lon":24.97,"continent":"EU"},"RS":{"lat":44.19,"lon":20.79,"continent":"EU"},"RU":{"lat":58.25,"lon":44.69,"continent":"EU"},"RW":{"lat":-1.9,"lon":30.1,"continent":"AF"},"SA":{"lat":23.81,"lon":44.7,"continent":"AS"},"SB":{"lat":-8.03,"lon":159.17,"continent":"OC"},"SC":{"lat":-4.68,"lon":55.48,"continent":"OC"},"SD":{"lat":16.33,"lon":29.26,"continent":"AF"},"SE":{"lat":65.86,"lon":19.02,"continent":"EU"},"SG":{"lat":1.37,"lon":103.82,"continent":"AS"},"SH":{"lat":-15.95,"lon":-5.71,"continent":"OC"},"SI":{"lat":46.06,"lon":14.92,"continent":"EU"},"SK":{"lat":48.73,"lon":19.05,"continent":"EU"},"SL":{"lat":8.62,"lon":-11.76,"continent":"AF"},"SM":{"lat":43.93,"lon":12.44,"continent":"EU"},"SN":{"lat":15.14,"lon":-14.78,"continent":"AF"},"SO":{"lat":3.57,"lon":45.19,"continent":"AF"},"SR":{"lat":4.14,"lon":-55.91,"continent":"SA"},"SS":{"lat":7.23,"lon":30.39,"continent":"AF"},"ST":{"lat":0.97,"lon":7.02,"continent":"AF"},"SV":{"lat":13.69,"lon":-88.89,"continent":"NA"},"SX":{"lat":18.04,"lon":-63.07,"continent":"NA"},"SY":{"lat":35.01,"lon":38.28,"continent":"AS"},"SZ":{"lat":-26.53,"lon":31.47,"continent":"AF"},"TC":{"lat":21.82,"lon":-71.75,"continent":"NA"},"TD":{"lat":15.14,"lon":18.65,"continent":"AF"},"TF":{"lat":-49.3,"lon":69.12,"continent":"OC"},"TG":{"lat":8.81,"lon":1.06,"continent":"AF"},"TH":{"lat":15.46,"lon":101.07,"continent":"AS"},"TJ":{"lat":38.2,"lon":72.59,"continent":"AS"},"TL":{"lat":-8.8,"lon":125.85,"continent":"AS"},"TM":{"lat":39.86,"lon":58.68,"continent":"AS"},"TN":{"lat":33.69,"lon":9.01,"continent":"AF"},"TO":{"lat":-21.21,"lon":-175.16,"continent":"OC"},"TR":{"lat":39.35,"lon":34.51,"continent":"AS"},"TT":{"lat":11.0,"lon":-60.92,"continent":"NA"},"TV":{"lat":-8.51,"lon":179.21,"continent":"OC"},"TW":{"lat":23.65,"lon":120.87,"continent":"AS"},"TZ":{"lat":-6.05,"lon":34.96,"continent":"AF"},"UA":{"lat":49.72,"lon":32.14,"continent":"EU"},"UG":{"lat":1.97,"lon":32.95,"continent":"AF"},"US":{"lat":39.54,"lon":-97.48,"continent":"NA"},"UY":{"lat":-32.96,"lon":-55.97,"continent":"SA"},"UZ":{"lat":41.69,"lon":64.01,"continent":"AS"},"VA":{"lat":41.9,"lon":12.45,"continent":"EU"},"VC":{"lat":13.09,"lon":-61.34,"continent":"NA"},"VE":{"lat":7.18,"lon":-64.6,"continent":"SA"},"VG":{"lat":18.43,"lon":-64.64,"continent":"NA"},"VI":{"lat":17.75,"lon":-64.78,"continent":"NA"},"VN":{"lat":21.72,"lon":105.39,"continent":"AS"},"VU":{"lat":-15.37,"lon":166.91,"continent":"OC"},"WF":{"lat":-14.29,"lon":-178.14,"continent":"OC"},"WS":{"lat":-13.64,"lon":-172.44,"continent":"OC"},"XK":{"lat":42.59,"lon":20.86,"continent":"EU"},"YE":{"lat":15.33,"lon":45.87,"continent":"AS"},"ZA":{"lat":-29.71,"lon":23.67,"continent":"AF"},"ZM":{"lat":-14.66,"lon":26.4,"continent":"AF"},"ZW":{"lat":-18.91,"lon":29.93,"continent":"AF"}} diff --git a/src/data/geo/timezones.json b/src/data/geo/timezones.json new file mode 100644 index 0000000..e0aa96b --- /dev/null +++ b/src/data/geo/timezones.json @@ -0,0 +1 @@ +{"Africa/Abidjan":{"cc":"CI","lat":5.32,"lon":-4.03},"Africa/Accra":{"cc":"GH","lat":5.55,"lon":-0.22},"Africa/Addis_Ababa":{"cc":"ET","lat":9.03,"lon":38.7},"Africa/Algiers":{"cc":"DZ","lat":36.78,"lon":3.05},"Africa/Asmara":{"cc":"ER","lat":15.33,"lon":38.88},"Africa/Asmera":{"cc":"KE","lat":-1.28,"lon":36.82},"Africa/Bamako":{"cc":"ML","lat":12.65,"lon":-8.0},"Africa/Bangui":{"cc":"CF","lat":4.37,"lon":18.58},"Africa/Banjul":{"cc":"GM","lat":13.47,"lon":-16.65},"Africa/Bissau":{"cc":"GW","lat":11.85,"lon":-15.58},"Africa/Blantyre":{"cc":"MW","lat":-15.78,"lon":35.0},"Africa/Brazzaville":{"cc":"CG","lat":-4.27,"lon":15.28},"Africa/Bujumbura":{"cc":"BI","lat":-3.38,"lon":29.37},"Africa/Cairo":{"cc":"EG","lat":30.05,"lon":31.25},"Africa/Casablanca":{"cc":"MA","lat":33.65,"lon":-7.58},"Africa/Ceuta":{"cc":"ES","lat":35.88,"lon":-5.32},"Africa/Conakry":{"cc":"GN","lat":9.52,"lon":-13.72},"Africa/Dakar":{"cc":"SN","lat":14.67,"lon":-17.43},"Africa/Dar_es_Salaam":{"cc":"TZ","lat":-6.8,"lon":39.28},"Africa/Djibouti":{"cc":"DJ","lat":11.6,"lon":43.15},"Africa/Douala":{"cc":"CM","lat":4.05,"lon":9.7},"Africa/El_Aaiun":{"cc":"EH","lat":27.15,"lon":-13.2},"Africa/Freetown":{"cc":"SL","lat":8.5,"lon":-13.25},"Africa/Gaborone":{"cc":"BW","lat":-24.65,"lon":25.92},"Africa/Harare":{"cc":"ZW","lat":-17.83,"lon":31.05},"Africa/Johannesburg":{"cc":"ZA","lat":-26.25,"lon":28.0},"Africa/Juba":{"cc":"SS","lat":4.85,"lon":31.62},"Africa/Kampala":{"cc":"UG","lat":0.32,"lon":32.42},"Africa/Khartoum":{"cc":"SD","lat":15.6,"lon":32.53},"Africa/Kigali":{"cc":"RW","lat":-1.95,"lon":30.07},"Africa/Kinshasa":{"cc":"CD","lat":-4.3,"lon":15.3},"Africa/Lagos":{"cc":"NG","lat":6.45,"lon":3.4},"Africa/Libreville":{"cc":"GA","lat":0.38,"lon":9.45},"Africa/Lome":{"cc":"TG","lat":6.13,"lon":1.22},"Africa/Luanda":{"cc":"AO","lat":-8.8,"lon":13.23},"Africa/Lubumbashi":{"cc":"CD","lat":-11.67,"lon":27.47},"Africa/Lusaka":{"cc":"ZM","lat":-15.42,"lon":28.28},"Africa/Malabo":{"cc":"GQ","lat":3.75,"lon":8.78},"Africa/Maputo":{"cc":"MZ","lat":-25.97,"lon":32.58},"Africa/Maseru":{"cc":"LS","lat":-29.47,"lon":27.5},"Africa/Mbabane":{"cc":"SZ","lat":-26.3,"lon":31.1},"Africa/Mogadishu":{"cc":"SO","lat":2.07,"lon":45.37},"Africa/Monrovia":{"cc":"LR","lat":6.3,"lon":-10.78},"Africa/Nairobi":{"cc":"KE","lat":-1.28,"lon":36.82},"Africa/Ndjamena":{"cc":"TD","lat":12.12,"lon":15.05},"Africa/Niamey":{"cc":"NE","lat":13.52,"lon":2.12},"Africa/Nouakchott":{"cc":"MR","lat":18.1,"lon":-15.95},"Africa/Ouagadougou":{"cc":"BF","lat":12.37,"lon":-1.52},"Africa/Porto-Novo":{"cc":"BJ","lat":6.48,"lon":2.62},"Africa/Sao_Tome":{"cc":"ST","lat":0.33,"lon":6.73},"Africa/Timbuktu":{"cc":"CI","lat":5.32,"lon":-4.03},"Africa/Tripoli":{"cc":"LY","lat":32.9,"lon":13.18},"Africa/Tunis":{"cc":"TN","lat":36.8,"lon":10.18},"Africa/Windhoek":{"cc":"NA","lat":-22.57,"lon":17.1},"America/Adak":{"cc":"US","lat":51.88,"lon":-176.66},"America/Anchorage":{"cc":"US","lat":61.22,"lon":-149.9},"America/Anguilla":{"cc":"AI","lat":18.2,"lon":-63.07},"America/Antigua":{"cc":"AG","lat":17.05,"lon":-61.8},"America/Araguaina":{"cc":"BR","lat":-7.2,"lon":-48.2},"America/Argentina/Buenos_Aires":{"cc":"AR","lat":-34.6,"lon":-58.45},"America/Argentina/Catamarca":{"cc":"AR","lat":-28.47,"lon":-65.78},"America/Argentina/ComodRivadavia":{"cc":"AR","lat":-28.47,"lon":-65.78},"America/Argentina/Cordoba":{"cc":"AR","lat":-31.4,"lon":-64.18},"America/Argentina/Jujuy":{"cc":"AR","lat":-24.18,"lon":-65.3},"America/Argentina/La_Rioja":{"cc":"AR","lat":-29.43,"lon":-66.85},"America/Argentina/Mendoza":{"cc":"AR","lat":-32.88,"lon":-68.82},"America/Argentina/Rio_Gallegos":{"cc":"AR","lat":-51.63,"lon":-69.22},"America/Argentina/Salta":{"cc":"AR","lat":-24.78,"lon":-65.42},"America/Argentina/San_Juan":{"cc":"AR","lat":-31.53,"lon":-68.52},"America/Argentina/San_Luis":{"cc":"AR","lat":-33.32,"lon":-66.35},"America/Argentina/Tucuman":{"cc":"AR","lat":-26.82,"lon":-65.22},"America/Argentina/Ushuaia":{"cc":"AR","lat":-54.8,"lon":-68.3},"America/Aruba":{"cc":"AW","lat":12.5,"lon":-69.97},"America/Asuncion":{"cc":"PY","lat":-25.27,"lon":-57.67},"America/Atikokan":{"cc":"CA","lat":48.76,"lon":-91.62},"America/Atka":{"cc":"US","lat":51.88,"lon":-176.66},"America/Bahia":{"cc":"BR","lat":-12.98,"lon":-38.52},"America/Bahia_Banderas":{"cc":"MX","lat":20.8,"lon":-105.25},"America/Barbados":{"cc":"BB","lat":13.1,"lon":-59.62},"America/Belem":{"cc":"BR","lat":-1.45,"lon":-48.48},"America/Belize":{"cc":"BZ","lat":17.5,"lon":-88.2},"America/Blanc-Sablon":{"cc":"CA","lat":51.42,"lon":-57.12},"America/Boa_Vista":{"cc":"BR","lat":2.82,"lon":-60.67},"America/Bogota":{"cc":"CO","lat":4.6,"lon":-74.08},"America/Boise":{"cc":"US","lat":43.61,"lon":-116.2},"America/Buenos_Aires":{"cc":"AR","lat":-34.6,"lon":-58.45},"America/Cambridge_Bay":{"cc":"CA","lat":69.11,"lon":-105.05},"America/Campo_Grande":{"cc":"BR","lat":-20.45,"lon":-54.62},"America/Cancun":{"cc":"MX","lat":21.08,"lon":-86.77},"America/Caracas":{"cc":"VE","lat":10.5,"lon":-66.93},"America/Catamarca":{"cc":"AR","lat":-28.47,"lon":-65.78},"America/Cayenne":{"cc":"GF","lat":4.93,"lon":-52.33},"America/Cayman":{"cc":"KY","lat":19.3,"lon":-81.38},"America/Chicago":{"cc":"US","lat":41.85,"lon":-87.65},"America/Chihuahua":{"cc":"MX","lat":28.63,"lon":-106.08},"America/Ciudad_Juarez":{"cc":"MX","lat":31.73,"lon":-106.48},"America/Coral_Harbour":{"cc":"PA","lat":8.97,"lon":-79.53},"America/Cordoba":{"cc":"AR","lat":-31.4,"lon":-64.18},"America/Costa_Rica":{"cc":"CR","lat":9.93,"lon":-84.08},"America/Coyhaique":{"cc":"CL","lat":-45.57,"lon":-72.07},"America/Creston":{"cc":"CA","lat":49.1,"lon":-116.52},"America/Cuiaba":{"cc":"BR","lat":-15.58,"lon":-56.08},"America/Curacao":{"cc":"CW","lat":12.18,"lon":-69.0},"America/Danmarkshavn":{"cc":"GL","lat":76.77,"lon":-18.67},"America/Dawson":{"cc":"CA","lat":64.07,"lon":-139.42},"America/Dawson_Creek":{"cc":"CA","lat":55.77,"lon":-120.23},"America/Denver":{"cc":"US","lat":39.74,"lon":-104.98},"America/Detroit":{"cc":"US","lat":42.33,"lon":-83.05},"America/Dominica":{"cc":"DM","lat":15.3,"lon":-61.4},"America/Edmonton":{"cc":"CA","lat":53.55,"lon":-113.47},"America/Eirunepe":{"cc":"BR","lat":-6.67,"lon":-69.87},"America/El_Salvador":{"cc":"SV","lat":13.7,"lon":-89.2},"America/Ensenada":{"cc":"MX","lat":32.53,"lon":-117.02},"America/Fort_Nelson":{"cc":"CA","lat":58.8,"lon":-122.7},"America/Fort_Wayne":{"cc":"US","lat":39.77,"lon":-86.16},"America/Fortaleza":{"cc":"BR","lat":-3.72,"lon":-38.5},"America/Glace_Bay":{"cc":"CA","lat":46.2,"lon":-59.95},"America/Godthab":{"cc":"GL","lat":64.18,"lon":-51.73},"America/Goose_Bay":{"cc":"CA","lat":53.33,"lon":-60.42},"America/Grand_Turk":{"cc":"TC","lat":21.47,"lon":-71.13},"America/Grenada":{"cc":"GD","lat":12.05,"lon":-61.75},"America/Guadeloupe":{"cc":"GP","lat":16.23,"lon":-61.53},"America/Guatemala":{"cc":"GT","lat":14.63,"lon":-90.52},"America/Guayaquil":{"cc":"EC","lat":-2.17,"lon":-79.83},"America/Guyana":{"cc":"GY","lat":6.8,"lon":-58.17},"America/Halifax":{"cc":"CA","lat":44.65,"lon":-63.6},"America/Havana":{"cc":"CU","lat":23.13,"lon":-82.37},"America/Hermosillo":{"cc":"MX","lat":29.07,"lon":-110.97},"America/Indiana/Indianapolis":{"cc":"US","lat":39.77,"lon":-86.16},"America/Indiana/Knox":{"cc":"US","lat":41.3,"lon":-86.62},"America/Indiana/Marengo":{"cc":"US","lat":38.38,"lon":-86.34},"America/Indiana/Petersburg":{"cc":"US","lat":38.49,"lon":-87.28},"America/Indiana/Tell_City":{"cc":"US","lat":37.95,"lon":-86.76},"America/Indiana/Vevay":{"cc":"US","lat":38.75,"lon":-85.07},"America/Indiana/Vincennes":{"cc":"US","lat":38.68,"lon":-87.53},"America/Indiana/Winamac":{"cc":"US","lat":41.05,"lon":-86.6},"America/Indianapolis":{"cc":"US","lat":39.77,"lon":-86.16},"America/Inuvik":{"cc":"CA","lat":68.35,"lon":-133.72},"America/Iqaluit":{"cc":"CA","lat":63.73,"lon":-68.47},"America/Jamaica":{"cc":"JM","lat":17.97,"lon":-76.79},"America/Jujuy":{"cc":"AR","lat":-24.18,"lon":-65.3},"America/Juneau":{"cc":"US","lat":58.3,"lon":-134.42},"America/Kentucky/Louisville":{"cc":"US","lat":38.25,"lon":-85.76},"America/Kentucky/Monticello":{"cc":"US","lat":36.83,"lon":-84.85},"America/Knox_IN":{"cc":"US","lat":41.3,"lon":-86.62},"America/Kralendijk":{"cc":"BQ","lat":12.15,"lon":-68.28},"America/La_Paz":{"cc":"BO","lat":-16.5,"lon":-68.15},"America/Lima":{"cc":"PE","lat":-12.05,"lon":-77.05},"America/Los_Angeles":{"cc":"US","lat":34.05,"lon":-118.24},"America/Louisville":{"cc":"US","lat":38.25,"lon":-85.76},"America/Lower_Princes":{"cc":"SX","lat":18.05,"lon":-63.05},"America/Maceio":{"cc":"BR","lat":-9.67,"lon":-35.72},"America/Managua":{"cc":"NI","lat":12.15,"lon":-86.28},"America/Manaus":{"cc":"BR","lat":-3.13,"lon":-60.02},"America/Marigot":{"cc":"MF","lat":18.07,"lon":-63.08},"America/Martinique":{"cc":"MQ","lat":14.6,"lon":-61.08},"America/Matamoros":{"cc":"MX","lat":25.83,"lon":-97.5},"America/Mazatlan":{"cc":"MX","lat":23.22,"lon":-106.42},"America/Mendoza":{"cc":"AR","lat":-32.88,"lon":-68.82},"America/Menominee":{"cc":"US","lat":45.11,"lon":-87.61},"America/Merida":{"cc":"MX","lat":20.97,"lon":-89.62},"America/Metlakatla":{"cc":"US","lat":55.13,"lon":-131.58},"America/Mexico_City":{"cc":"MX","lat":19.4,"lon":-99.15},"America/Miquelon":{"cc":"PM","lat":47.05,"lon":-56.33},"America/Moncton":{"cc":"CA","lat":46.1,"lon":-64.78},"America/Monterrey":{"cc":"MX","lat":25.67,"lon":-100.32},"America/Montevideo":{"cc":"UY","lat":-34.91,"lon":-56.21},"America/Montreal":{"cc":"CA","lat":43.65,"lon":-79.38},"America/Montserrat":{"cc":"MS","lat":16.72,"lon":-62.22},"America/Nassau":{"cc":"BS","lat":25.08,"lon":-77.35},"America/New_York":{"cc":"US","lat":40.71,"lon":-74.01},"America/Nipigon":{"cc":"CA","lat":43.65,"lon":-79.38},"America/Nome":{"cc":"US","lat":64.5,"lon":-165.41},"America/Noronha":{"cc":"BR","lat":-3.85,"lon":-32.42},"America/North_Dakota/Beulah":{"cc":"US","lat":47.26,"lon":-101.78},"America/North_Dakota/Center":{"cc":"US","lat":47.12,"lon":-101.3},"America/North_Dakota/New_Salem":{"cc":"US","lat":46.84,"lon":-101.41},"America/Nuuk":{"cc":"GL","lat":64.18,"lon":-51.73},"America/Ojinaga":{"cc":"MX","lat":29.57,"lon":-104.42},"America/Panama":{"cc":"PA","lat":8.97,"lon":-79.53},"America/Pangnirtung":{"cc":"CA","lat":63.73,"lon":-68.47},"America/Paramaribo":{"cc":"SR","lat":5.83,"lon":-55.17},"America/Phoenix":{"cc":"US","lat":33.45,"lon":-112.07},"America/Port-au-Prince":{"cc":"HT","lat":18.53,"lon":-72.33},"America/Port_of_Spain":{"cc":"TT","lat":10.65,"lon":-61.52},"America/Porto_Acre":{"cc":"BR","lat":-9.97,"lon":-67.8},"America/Porto_Velho":{"cc":"BR","lat":-8.77,"lon":-63.9},"America/Puerto_Rico":{"cc":"PR","lat":18.47,"lon":-66.11},"America/Punta_Arenas":{"cc":"CL","lat":-53.15,"lon":-70.92},"America/Rainy_River":{"cc":"CA","lat":49.88,"lon":-97.15},"America/Rankin_Inlet":{"cc":"CA","lat":62.82,"lon":-92.08},"America/Recife":{"cc":"BR","lat":-8.05,"lon":-34.9},"America/Regina":{"cc":"CA","lat":50.4,"lon":-104.65},"America/Resolute":{"cc":"CA","lat":74.7,"lon":-94.83},"America/Rio_Branco":{"cc":"BR","lat":-9.97,"lon":-67.8},"America/Rosario":{"cc":"AR","lat":-31.4,"lon":-64.18},"America/Santa_Isabel":{"cc":"MX","lat":32.53,"lon":-117.02},"America/Santarem":{"cc":"BR","lat":-2.43,"lon":-54.87},"America/Santiago":{"cc":"CL","lat":-33.45,"lon":-70.67},"America/Santo_Domingo":{"cc":"DO","lat":18.47,"lon":-69.9},"America/Sao_Paulo":{"cc":"BR","lat":-23.53,"lon":-46.62},"America/Scoresbysund":{"cc":"GL","lat":70.48,"lon":-21.97},"America/Shiprock":{"cc":"US","lat":39.74,"lon":-104.98},"America/Sitka":{"cc":"US","lat":57.18,"lon":-135.3},"America/St_Barthelemy":{"cc":"BL","lat":17.88,"lon":-62.85},"America/St_Johns":{"cc":"CA","lat":47.57,"lon":-52.72},"America/St_Kitts":{"cc":"KN","lat":17.3,"lon":-62.72},"America/St_Lucia":{"cc":"LC","lat":14.02,"lon":-61.0},"America/St_Thomas":{"cc":"VI","lat":18.35,"lon":-64.93},"America/St_Vincent":{"cc":"VC","lat":13.15,"lon":-61.23},"America/Swift_Current":{"cc":"CA","lat":50.28,"lon":-107.83},"America/Tegucigalpa":{"cc":"HN","lat":14.1,"lon":-87.22},"America/Thule":{"cc":"GL","lat":76.57,"lon":-68.78},"America/Thunder_Bay":{"cc":"CA","lat":43.65,"lon":-79.38},"America/Tijuana":{"cc":"MX","lat":32.53,"lon":-117.02},"America/Toronto":{"cc":"CA","lat":43.65,"lon":-79.38},"America/Tortola":{"cc":"VG","lat":18.45,"lon":-64.62},"America/Vancouver":{"cc":"CA","lat":49.27,"lon":-123.12},"America/Virgin":{"cc":"PR","lat":18.47,"lon":-66.11},"America/Whitehorse":{"cc":"CA","lat":60.72,"lon":-135.05},"America/Winnipeg":{"cc":"CA","lat":49.88,"lon":-97.15},"America/Yakutat":{"cc":"US","lat":59.55,"lon":-139.73},"America/Yellowknife":{"cc":"CA","lat":53.55,"lon":-113.47},"Antarctica/Casey":{"cc":"AQ","lat":-66.28,"lon":110.52},"Antarctica/Davis":{"cc":"AQ","lat":-68.58,"lon":77.97},"Antarctica/DumontDUrville":{"cc":"AQ","lat":-66.67,"lon":140.02},"Antarctica/Macquarie":{"cc":"AU","lat":-54.5,"lon":158.95},"Antarctica/Mawson":{"cc":"AQ","lat":-67.6,"lon":62.88},"Antarctica/McMurdo":{"cc":"AQ","lat":-77.83,"lon":166.6},"Antarctica/Palmer":{"cc":"AQ","lat":-64.8,"lon":-64.1},"Antarctica/Rothera":{"cc":"AQ","lat":-67.57,"lon":-68.13},"Antarctica/South_Pole":{"cc":"NZ","lat":-36.87,"lon":174.77},"Antarctica/Syowa":{"cc":"AQ","lat":-69.01,"lon":39.59},"Antarctica/Troll":{"cc":"AQ","lat":-72.01,"lon":2.53},"Antarctica/Vostok":{"cc":"AQ","lat":-78.4,"lon":106.9},"Arctic/Longyearbyen":{"cc":"SJ","lat":78.0,"lon":16.0},"Asia/Aden":{"cc":"YE","lat":12.75,"lon":45.2},"Asia/Almaty":{"cc":"KZ","lat":43.25,"lon":76.95},"Asia/Amman":{"cc":"JO","lat":31.95,"lon":35.93},"Asia/Anadyr":{"cc":"RU","lat":64.75,"lon":177.48},"Asia/Aqtau":{"cc":"KZ","lat":44.52,"lon":50.27},"Asia/Aqtobe":{"cc":"KZ","lat":50.28,"lon":57.17},"Asia/Ashgabat":{"cc":"TM","lat":37.95,"lon":58.38},"Asia/Ashkhabad":{"cc":"TM","lat":37.95,"lon":58.38},"Asia/Atyrau":{"cc":"KZ","lat":47.12,"lon":51.93},"Asia/Baghdad":{"cc":"IQ","lat":33.35,"lon":44.42},"Asia/Bahrain":{"cc":"BH","lat":26.38,"lon":50.58},"Asia/Baku":{"cc":"AZ","lat":40.38,"lon":49.85},"Asia/Bangkok":{"cc":"TH","lat":13.75,"lon":100.52},"Asia/Barnaul":{"cc":"RU","lat":53.37,"lon":83.75},"Asia/Beirut":{"cc":"LB","lat":33.88,"lon":35.5},"Asia/Bishkek":{"cc":"KG","lat":42.9,"lon":74.6},"Asia/Brunei":{"cc":"BN","lat":4.93,"lon":114.92},"Asia/Calcutta":{"cc":"IN","lat":22.53,"lon":88.37},"Asia/Chita":{"cc":"RU","lat":52.05,"lon":113.47},"Asia/Choibalsan":{"cc":"MN","lat":47.92,"lon":106.88},"Asia/Chongqing":{"cc":"CN","lat":31.23,"lon":121.47},"Asia/Chungking":{"cc":"CN","lat":31.23,"lon":121.47},"Asia/Colombo":{"cc":"LK","lat":6.93,"lon":79.85},"Asia/Dacca":{"cc":"BD","lat":23.72,"lon":90.42},"Asia/Damascus":{"cc":"SY","lat":33.5,"lon":36.3},"Asia/Dhaka":{"cc":"BD","lat":23.72,"lon":90.42},"Asia/Dili":{"cc":"TL","lat":-8.55,"lon":125.58},"Asia/Dubai":{"cc":"AE","lat":25.3,"lon":55.3},"Asia/Dushanbe":{"cc":"TJ","lat":38.58,"lon":68.8},"Asia/Famagusta":{"cc":"CY","lat":35.12,"lon":33.95},"Asia/Gaza":{"cc":"PS","lat":31.5,"lon":34.47},"Asia/Harbin":{"cc":"CN","lat":31.23,"lon":121.47},"Asia/Hebron":{"cc":"PS","lat":31.53,"lon":35.09},"Asia/Ho_Chi_Minh":{"cc":"VN","lat":10.75,"lon":106.67},"Asia/Hong_Kong":{"cc":"HK","lat":22.28,"lon":114.15},"Asia/Hovd":{"cc":"MN","lat":48.02,"lon":91.65},"Asia/Irkutsk":{"cc":"RU","lat":52.27,"lon":104.33},"Asia/Istanbul":{"cc":"TR","lat":41.02,"lon":28.97},"Asia/Jakarta":{"cc":"ID","lat":-6.17,"lon":106.8},"Asia/Jayapura":{"cc":"ID","lat":-2.53,"lon":140.7},"Asia/Jerusalem":{"cc":"IL","lat":31.78,"lon":35.22},"Asia/Kabul":{"cc":"AF","lat":34.52,"lon":69.2},"Asia/Kamchatka":{"cc":"RU","lat":53.02,"lon":158.65},"Asia/Karachi":{"cc":"PK","lat":24.87,"lon":67.05},"Asia/Kashgar":{"cc":"CN","lat":43.8,"lon":87.58},"Asia/Kathmandu":{"cc":"NP","lat":27.72,"lon":85.32},"Asia/Katmandu":{"cc":"NP","lat":27.72,"lon":85.32},"Asia/Khandyga":{"cc":"RU","lat":62.66,"lon":135.55},"Asia/Kolkata":{"cc":"IN","lat":22.53,"lon":88.37},"Asia/Krasnoyarsk":{"cc":"RU","lat":56.02,"lon":92.83},"Asia/Kuala_Lumpur":{"cc":"MY","lat":3.17,"lon":101.7},"Asia/Kuching":{"cc":"MY","lat":1.55,"lon":110.33},"Asia/Kuwait":{"cc":"KW","lat":29.33,"lon":47.98},"Asia/Macao":{"cc":"MO","lat":22.2,"lon":113.54},"Asia/Macau":{"cc":"MO","lat":22.2,"lon":113.54},"Asia/Magadan":{"cc":"RU","lat":59.57,"lon":150.8},"Asia/Makassar":{"cc":"ID","lat":-5.12,"lon":119.4},"Asia/Manila":{"cc":"PH","lat":14.59,"lon":120.97},"Asia/Muscat":{"cc":"OM","lat":23.6,"lon":58.58},"Asia/Nicosia":{"cc":"CY","lat":35.17,"lon":33.37},"Asia/Novokuznetsk":{"cc":"RU","lat":53.75,"lon":87.12},"Asia/Novosibirsk":{"cc":"RU","lat":55.03,"lon":82.92},"Asia/Omsk":{"cc":"RU","lat":55.0,"lon":73.4},"Asia/Oral":{"cc":"KZ","lat":51.22,"lon":51.35},"Asia/Phnom_Penh":{"cc":"KH","lat":11.55,"lon":104.92},"Asia/Pontianak":{"cc":"ID","lat":-0.03,"lon":109.33},"Asia/Pyongyang":{"cc":"KP","lat":39.02,"lon":125.75},"Asia/Qatar":{"cc":"QA","lat":25.28,"lon":51.53},"Asia/Qostanay":{"cc":"KZ","lat":53.2,"lon":63.62},"Asia/Qyzylorda":{"cc":"KZ","lat":44.8,"lon":65.47},"Asia/Rangoon":{"cc":"MM","lat":16.78,"lon":96.17},"Asia/Riyadh":{"cc":"SA","lat":24.63,"lon":46.72},"Asia/Saigon":{"cc":"VN","lat":10.75,"lon":106.67},"Asia/Sakhalin":{"cc":"RU","lat":46.97,"lon":142.7},"Asia/Samarkand":{"cc":"UZ","lat":39.67,"lon":66.8},"Asia/Seoul":{"cc":"KR","lat":37.55,"lon":126.97},"Asia/Shanghai":{"cc":"CN","lat":31.23,"lon":121.47},"Asia/Singapore":{"cc":"SG","lat":1.28,"lon":103.85},"Asia/Srednekolymsk":{"cc":"RU","lat":67.47,"lon":153.72},"Asia/Taipei":{"cc":"TW","lat":25.05,"lon":121.5},"Asia/Tashkent":{"cc":"UZ","lat":41.33,"lon":69.3},"Asia/Tbilisi":{"cc":"GE","lat":41.72,"lon":44.82},"Asia/Tehran":{"cc":"IR","lat":35.67,"lon":51.43},"Asia/Tel_Aviv":{"cc":"IL","lat":31.78,"lon":35.22},"Asia/Thimbu":{"cc":"BT","lat":27.47,"lon":89.65},"Asia/Thimphu":{"cc":"BT","lat":27.47,"lon":89.65},"Asia/Tokyo":{"cc":"JP","lat":35.65,"lon":139.74},"Asia/Tomsk":{"cc":"RU","lat":56.5,"lon":84.97},"Asia/Ujung_Pandang":{"cc":"ID","lat":-5.12,"lon":119.4},"Asia/Ulaanbaatar":{"cc":"MN","lat":47.92,"lon":106.88},"Asia/Ulan_Bator":{"cc":"MN","lat":47.92,"lon":106.88},"Asia/Urumqi":{"cc":"CN","lat":43.8,"lon":87.58},"Asia/Ust-Nera":{"cc":"RU","lat":64.56,"lon":143.23},"Asia/Vientiane":{"cc":"LA","lat":17.97,"lon":102.6},"Asia/Vladivostok":{"cc":"RU","lat":43.17,"lon":131.93},"Asia/Yakutsk":{"cc":"RU","lat":62.0,"lon":129.67},"Asia/Yangon":{"cc":"MM","lat":16.78,"lon":96.17},"Asia/Yekaterinburg":{"cc":"RU","lat":56.85,"lon":60.6},"Asia/Yerevan":{"cc":"AM","lat":40.18,"lon":44.5},"Atlantic/Azores":{"cc":"PT","lat":37.73,"lon":-25.67},"Atlantic/Bermuda":{"cc":"BM","lat":32.28,"lon":-64.77},"Atlantic/Canary":{"cc":"ES","lat":28.1,"lon":-15.4},"Atlantic/Cape_Verde":{"cc":"CV","lat":14.92,"lon":-23.52},"Atlantic/Faeroe":{"cc":"FO","lat":62.02,"lon":-6.77},"Atlantic/Faroe":{"cc":"FO","lat":62.02,"lon":-6.77},"Atlantic/Jan_Mayen":{"cc":"DE","lat":52.5,"lon":13.37},"Atlantic/Madeira":{"cc":"PT","lat":32.63,"lon":-16.9},"Atlantic/Reykjavik":{"cc":"IS","lat":64.15,"lon":-21.85},"Atlantic/South_Georgia":{"cc":"GS","lat":-54.27,"lon":-36.53},"Atlantic/St_Helena":{"cc":"SH","lat":-15.92,"lon":-5.7},"Atlantic/Stanley":{"cc":"FK","lat":-51.7,"lon":-57.85},"Australia/ACT":{"cc":"AU","lat":-33.87,"lon":151.22},"Australia/Adelaide":{"cc":"AU","lat":-34.92,"lon":138.58},"Australia/Brisbane":{"cc":"AU","lat":-27.47,"lon":153.03},"Australia/Broken_Hill":{"cc":"AU","lat":-31.95,"lon":141.45},"Australia/Canberra":{"cc":"AU","lat":-33.87,"lon":151.22},"Australia/Currie":{"cc":"AU","lat":-42.88,"lon":147.32},"Australia/Darwin":{"cc":"AU","lat":-12.47,"lon":130.83},"Australia/Eucla":{"cc":"AU","lat":-31.72,"lon":128.87},"Australia/Hobart":{"cc":"AU","lat":-42.88,"lon":147.32},"Australia/LHI":{"cc":"AU","lat":-31.55,"lon":159.08},"Australia/Lindeman":{"cc":"AU","lat":-20.27,"lon":149.0},"Australia/Lord_Howe":{"cc":"AU","lat":-31.55,"lon":159.08},"Australia/Melbourne":{"cc":"AU","lat":-37.82,"lon":144.97},"Australia/NSW":{"cc":"AU","lat":-33.87,"lon":151.22},"Australia/North":{"cc":"AU","lat":-12.47,"lon":130.83},"Australia/Perth":{"cc":"AU","lat":-31.95,"lon":115.85},"Australia/Queensland":{"cc":"AU","lat":-27.47,"lon":153.03},"Australia/South":{"cc":"AU","lat":-34.92,"lon":138.58},"Australia/Sydney":{"cc":"AU","lat":-33.87,"lon":151.22},"Australia/Tasmania":{"cc":"AU","lat":-42.88,"lon":147.32},"Australia/Victoria":{"cc":"AU","lat":-37.82,"lon":144.97},"Australia/West":{"cc":"AU","lat":-31.95,"lon":115.85},"Australia/Yancowinna":{"cc":"AU","lat":-31.95,"lon":141.45},"Brazil/Acre":{"cc":"BR","lat":-9.97,"lon":-67.8},"Brazil/DeNoronha":{"cc":"BR","lat":-3.85,"lon":-32.42},"Brazil/East":{"cc":"BR","lat":-23.53,"lon":-46.62},"Brazil/West":{"cc":"BR","lat":-3.13,"lon":-60.02},"CET":{"cc":"BE","lat":50.83,"lon":4.33},"Canada/Atlantic":{"cc":"CA","lat":44.65,"lon":-63.6},"Canada/Central":{"cc":"CA","lat":49.88,"lon":-97.15},"Canada/Eastern":{"cc":"CA","lat":43.65,"lon":-79.38},"Canada/Mountain":{"cc":"CA","lat":53.55,"lon":-113.47},"Canada/Newfoundland":{"cc":"CA","lat":47.57,"lon":-52.72},"Canada/Pacific":{"cc":"CA","lat":49.27,"lon":-123.12},"Canada/Saskatchewan":{"cc":"CA","lat":50.4,"lon":-104.65},"Canada/Yukon":{"cc":"CA","lat":60.72,"lon":-135.05},"Chile/Continental":{"cc":"CL","lat":-33.45,"lon":-70.67},"Chile/EasterIsland":{"cc":"CL","lat":-27.15,"lon":-109.43},"Cuba":{"cc":"CU","lat":23.13,"lon":-82.37},"EET":{"cc":"GR","lat":37.97,"lon":23.72},"EST":{"cc":"PA","lat":8.97,"lon":-79.53},"Egypt":{"cc":"EG","lat":30.05,"lon":31.25},"Eire":{"cc":"IE","lat":53.33,"lon":-6.25},"Europe/Amsterdam":{"cc":"NL","lat":52.37,"lon":4.9},"Europe/Andorra":{"cc":"AD","lat":42.5,"lon":1.52},"Europe/Astrakhan":{"cc":"RU","lat":46.35,"lon":48.05},"Europe/Athens":{"cc":"GR","lat":37.97,"lon":23.72},"Europe/Belfast":{"cc":"GB","lat":51.51,"lon":-0.13},"Europe/Belgrade":{"cc":"RS","lat":44.83,"lon":20.5},"Europe/Berlin":{"cc":"DE","lat":52.5,"lon":13.37},"Europe/Bratislava":{"cc":"SK","lat":48.15,"lon":17.12},"Europe/Brussels":{"cc":"BE","lat":50.83,"lon":4.33},"Europe/Bucharest":{"cc":"RO","lat":44.43,"lon":26.1},"Europe/Budapest":{"cc":"HU","lat":47.5,"lon":19.08},"Europe/Busingen":{"cc":"DE","lat":47.7,"lon":8.68},"Europe/Chisinau":{"cc":"MD","lat":47.0,"lon":28.83},"Europe/Copenhagen":{"cc":"DK","lat":55.67,"lon":12.58},"Europe/Dublin":{"cc":"IE","lat":53.33,"lon":-6.25},"Europe/Gibraltar":{"cc":"GI","lat":36.13,"lon":-5.35},"Europe/Guernsey":{"cc":"GG","lat":49.45,"lon":-2.54},"Europe/Helsinki":{"cc":"FI","lat":60.17,"lon":24.97},"Europe/Isle_of_Man":{"cc":"IM","lat":54.15,"lon":-4.47},"Europe/Istanbul":{"cc":"TR","lat":41.02,"lon":28.97},"Europe/Jersey":{"cc":"JE","lat":49.18,"lon":-2.11},"Europe/Kaliningrad":{"cc":"RU","lat":54.72,"lon":20.5},"Europe/Kiev":{"cc":"UA","lat":50.43,"lon":30.52},"Europe/Kirov":{"cc":"RU","lat":58.6,"lon":49.65},"Europe/Kyiv":{"cc":"UA","lat":50.43,"lon":30.52},"Europe/Lisbon":{"cc":"PT","lat":38.72,"lon":-9.13},"Europe/Ljubljana":{"cc":"SI","lat":46.05,"lon":14.52},"Europe/London":{"cc":"GB","lat":51.51,"lon":-0.13},"Europe/Luxembourg":{"cc":"LU","lat":49.6,"lon":6.15},"Europe/Madrid":{"cc":"ES","lat":40.4,"lon":-3.68},"Europe/Malta":{"cc":"MT","lat":35.9,"lon":14.52},"Europe/Mariehamn":{"cc":"AX","lat":60.1,"lon":19.95},"Europe/Minsk":{"cc":"BY","lat":53.9,"lon":27.57},"Europe/Monaco":{"cc":"MC","lat":43.7,"lon":7.38},"Europe/Moscow":{"cc":"RU","lat":55.76,"lon":37.62},"Europe/Nicosia":{"cc":"CY","lat":35.17,"lon":33.37},"Europe/Oslo":{"cc":"NO","lat":59.92,"lon":10.75},"Europe/Paris":{"cc":"FR","lat":48.87,"lon":2.33},"Europe/Podgorica":{"cc":"ME","lat":42.43,"lon":19.27},"Europe/Prague":{"cc":"CZ","lat":50.08,"lon":14.43},"Europe/Riga":{"cc":"LV","lat":56.95,"lon":24.1},"Europe/Rome":{"cc":"IT","lat":41.9,"lon":12.48},"Europe/Samara":{"cc":"RU","lat":53.2,"lon":50.15},"Europe/San_Marino":{"cc":"SM","lat":43.92,"lon":12.47},"Europe/Sarajevo":{"cc":"BA","lat":43.87,"lon":18.42},"Europe/Saratov":{"cc":"RU","lat":51.57,"lon":46.03},"Europe/Simferopol":{"cc":"RU","lat":44.95,"lon":34.1},"Europe/Skopje":{"cc":"MK","lat":41.98,"lon":21.43},"Europe/Sofia":{"cc":"BG","lat":42.68,"lon":23.32},"Europe/Stockholm":{"cc":"SE","lat":59.33,"lon":18.05},"Europe/Tallinn":{"cc":"EE","lat":59.42,"lon":24.75},"Europe/Tirane":{"cc":"AL","lat":41.33,"lon":19.83},"Europe/Tiraspol":{"cc":"MD","lat":47.0,"lon":28.83},"Europe/Ulyanovsk":{"cc":"RU","lat":54.33,"lon":48.4},"Europe/Uzhgorod":{"cc":"UA","lat":50.43,"lon":30.52},"Europe/Vaduz":{"cc":"LI","lat":47.15,"lon":9.52},"Europe/Vatican":{"cc":"VA","lat":41.9,"lon":12.45},"Europe/Vienna":{"cc":"AT","lat":48.22,"lon":16.33},"Europe/Vilnius":{"cc":"LT","lat":54.68,"lon":25.32},"Europe/Volgograd":{"cc":"RU","lat":48.73,"lon":44.42},"Europe/Warsaw":{"cc":"PL","lat":52.25,"lon":21.0},"Europe/Zagreb":{"cc":"HR","lat":45.8,"lon":15.97},"Europe/Zaporozhye":{"cc":"UA","lat":50.43,"lon":30.52},"Europe/Zurich":{"cc":"CH","lat":47.38,"lon":8.53},"GB":{"cc":"GB","lat":51.51,"lon":-0.13},"GB-Eire":{"cc":"GB","lat":51.51,"lon":-0.13},"HST":{"cc":"US","lat":21.31,"lon":-157.86},"Hongkong":{"cc":"HK","lat":22.28,"lon":114.15},"Iceland":{"cc":"CI","lat":5.32,"lon":-4.03},"Indian/Antananarivo":{"cc":"MG","lat":-18.92,"lon":47.52},"Indian/Chagos":{"cc":"IO","lat":-7.33,"lon":72.42},"Indian/Christmas":{"cc":"CX","lat":-10.42,"lon":105.72},"Indian/Cocos":{"cc":"CC","lat":-12.17,"lon":96.92},"Indian/Comoro":{"cc":"KM","lat":-11.68,"lon":43.27},"Indian/Kerguelen":{"cc":"TF","lat":-49.35,"lon":70.22},"Indian/Mahe":{"cc":"SC","lat":-4.67,"lon":55.47},"Indian/Maldives":{"cc":"MV","lat":4.17,"lon":73.5},"Indian/Mauritius":{"cc":"MU","lat":-20.17,"lon":57.5},"Indian/Mayotte":{"cc":"YT","lat":-12.78,"lon":45.23},"Indian/Reunion":{"cc":"RE","lat":-20.87,"lon":55.47},"Iran":{"cc":"IR","lat":35.67,"lon":51.43},"Israel":{"cc":"IL","lat":31.78,"lon":35.22},"Jamaica":{"cc":"JM","lat":17.97,"lon":-76.79},"Japan":{"cc":"JP","lat":35.65,"lon":139.74},"Kwajalein":{"cc":"MH","lat":9.08,"lon":167.33},"Libya":{"cc":"LY","lat":32.9,"lon":13.18},"MET":{"cc":"BE","lat":50.83,"lon":4.33},"MST":{"cc":"US","lat":33.45,"lon":-112.07},"Mexico/BajaNorte":{"cc":"MX","lat":32.53,"lon":-117.02},"Mexico/BajaSur":{"cc":"MX","lat":23.22,"lon":-106.42},"Mexico/General":{"cc":"MX","lat":19.4,"lon":-99.15},"NZ":{"cc":"NZ","lat":-36.87,"lon":174.77},"NZ-CHAT":{"cc":"NZ","lat":-43.95,"lon":-176.55},"Navajo":{"cc":"US","lat":39.74,"lon":-104.98},"PRC":{"cc":"CN","lat":31.23,"lon":121.47},"Pacific/Apia":{"cc":"WS","lat":-13.83,"lon":-171.73},"Pacific/Auckland":{"cc":"NZ","lat":-36.87,"lon":174.77},"Pacific/Bougainville":{"cc":"PG","lat":-6.22,"lon":155.57},"Pacific/Chatham":{"cc":"NZ","lat":-43.95,"lon":-176.55},"Pacific/Chuuk":{"cc":"FM","lat":7.42,"lon":151.78},"Pacific/Easter":{"cc":"CL","lat":-27.15,"lon":-109.43},"Pacific/Efate":{"cc":"VU","lat":-17.67,"lon":168.42},"Pacific/Enderbury":{"cc":"KI","lat":-2.78,"lon":-171.72},"Pacific/Fakaofo":{"cc":"TK","lat":-9.37,"lon":-171.23},"Pacific/Fiji":{"cc":"FJ","lat":-18.13,"lon":178.42},"Pacific/Funafuti":{"cc":"TV","lat":-8.52,"lon":179.22},"Pacific/Galapagos":{"cc":"EC","lat":-0.9,"lon":-89.6},"Pacific/Gambier":{"cc":"PF","lat":-23.13,"lon":-134.95},"Pacific/Guadalcanal":{"cc":"SB","lat":-9.53,"lon":160.2},"Pacific/Guam":{"cc":"GU","lat":13.47,"lon":144.75},"Pacific/Honolulu":{"cc":"US","lat":21.31,"lon":-157.86},"Pacific/Johnston":{"cc":"US","lat":21.31,"lon":-157.86},"Pacific/Kanton":{"cc":"KI","lat":-2.78,"lon":-171.72},"Pacific/Kiritimati":{"cc":"KI","lat":1.87,"lon":-157.33},"Pacific/Kosrae":{"cc":"FM","lat":5.32,"lon":162.98},"Pacific/Kwajalein":{"cc":"MH","lat":9.08,"lon":167.33},"Pacific/Majuro":{"cc":"MH","lat":7.15,"lon":171.2},"Pacific/Marquesas":{"cc":"PF","lat":-9.0,"lon":-139.5},"Pacific/Midway":{"cc":"UM","lat":28.22,"lon":-177.37},"Pacific/Nauru":{"cc":"NR","lat":-0.52,"lon":166.92},"Pacific/Niue":{"cc":"NU","lat":-19.02,"lon":-169.92},"Pacific/Norfolk":{"cc":"NF","lat":-29.05,"lon":167.97},"Pacific/Noumea":{"cc":"NC","lat":-22.27,"lon":166.45},"Pacific/Pago_Pago":{"cc":"AS","lat":-14.27,"lon":-170.7},"Pacific/Palau":{"cc":"PW","lat":7.33,"lon":134.48},"Pacific/Pitcairn":{"cc":"PN","lat":-25.07,"lon":-130.08},"Pacific/Pohnpei":{"cc":"FM","lat":6.97,"lon":158.22},"Pacific/Ponape":{"cc":"SB","lat":-9.53,"lon":160.2},"Pacific/Port_Moresby":{"cc":"PG","lat":-9.5,"lon":147.17},"Pacific/Rarotonga":{"cc":"CK","lat":-21.23,"lon":-159.77},"Pacific/Saipan":{"cc":"MP","lat":15.2,"lon":145.75},"Pacific/Samoa":{"cc":"AS","lat":-14.27,"lon":-170.7},"Pacific/Tahiti":{"cc":"PF","lat":-17.53,"lon":-149.57},"Pacific/Tarawa":{"cc":"KI","lat":1.42,"lon":173.0},"Pacific/Tongatapu":{"cc":"TO","lat":-21.13,"lon":-175.2},"Pacific/Truk":{"cc":"PG","lat":-9.5,"lon":147.17},"Pacific/Wake":{"cc":"UM","lat":19.28,"lon":166.62},"Pacific/Wallis":{"cc":"WF","lat":-13.3,"lon":-176.17},"Pacific/Yap":{"cc":"PG","lat":-9.5,"lon":147.17},"Poland":{"cc":"PL","lat":52.25,"lon":21.0},"Portugal":{"cc":"PT","lat":38.72,"lon":-9.13},"ROC":{"cc":"TW","lat":25.05,"lon":121.5},"ROK":{"cc":"KR","lat":37.55,"lon":126.97},"Singapore":{"cc":"SG","lat":1.28,"lon":103.85},"Turkey":{"cc":"TR","lat":41.02,"lon":28.97},"US/Alaska":{"cc":"US","lat":61.22,"lon":-149.9},"US/Aleutian":{"cc":"US","lat":51.88,"lon":-176.66},"US/Arizona":{"cc":"US","lat":33.45,"lon":-112.07},"US/Central":{"cc":"US","lat":41.85,"lon":-87.65},"US/East-Indiana":{"cc":"US","lat":39.77,"lon":-86.16},"US/Eastern":{"cc":"US","lat":40.71,"lon":-74.01},"US/Hawaii":{"cc":"US","lat":21.31,"lon":-157.86},"US/Indiana-Starke":{"cc":"US","lat":41.3,"lon":-86.62},"US/Michigan":{"cc":"US","lat":42.33,"lon":-83.05},"US/Mountain":{"cc":"US","lat":39.74,"lon":-104.98},"US/Pacific":{"cc":"US","lat":34.05,"lon":-118.24},"US/Samoa":{"cc":"AS","lat":-14.27,"lon":-170.7},"W-SU":{"cc":"RU","lat":55.76,"lon":37.62},"WET":{"cc":"PT","lat":38.72,"lon":-9.13}} diff --git a/src/data/history.json b/src/data/history.json new file mode 100644 index 0000000..7d8695a --- /dev/null +++ b/src/data/history.json @@ -0,0 +1,254 @@ +[ + { + "slug": "fedora", + "version": "44", + "channel": "stable", + "date": "2026-04-28", + "detected": "2026-10-03" + }, + { + "slug": "kubuntu", + "version": "26.04.1", + "channel": "lts", + "date": "2026-04-23", + "detected": "2026-10-03" + }, + { + "slug": "kubuntu", + "version": "26.04", + "channel": "lts", + "date": "2026-04-23", + "detected": "2026-10-03" + }, + { + "slug": "lubuntu", + "version": "26.04.1", + "channel": "lts", + "date": "2026-04-23", + "detected": "2026-10-03" + }, + { + "slug": "lubuntu", + "version": "26.04", + "channel": "lts", + "date": "2026-04-23", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu", + "version": "26.04.1", + "channel": "lts", + "date": "2026-04-23", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu", + "version": "26.04", + "channel": "lts", + "date": "2026-04-23", + "detected": "2026-10-03" + }, + { + "slug": "xubuntu", + "version": "26.04.1", + "channel": "lts", + "date": "2026-04-23", + "detected": "2026-10-03" + }, + { + "slug": "xubuntu", + "version": "26.04", + "channel": "lts", + "date": "2026-04-23", + "detected": "2026-10-03" + }, + { + "slug": "linux-mint", + "version": "22.3", + "channel": "lts", + "date": "2026-01-11", + "detected": "2026-10-03" + }, + { + "slug": "pop-os", + "version": "24.04", + "channel": "lts", + "date": "2025-12-11", + "detected": "2026-10-03" + }, + { + "slug": "fedora", + "version": "43", + "channel": "stable", + "date": "2025-10-28", + "detected": "2026-10-03" + }, + { + "slug": "debian", + "version": "13.7", + "channel": "stable", + "date": "2025-08-09", + "detected": "2026-10-03" + }, + { + "slug": "kubuntu", + "version": "24.04.5", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "kubuntu", + "version": "24.04.3", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "lubuntu", + "version": "24.04.5", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "lubuntu", + "version": "24.04.3", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu", + "version": "24.04.5", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu", + "version": "24.04.4", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu", + "version": "24.04.3", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu-mate", + "version": "24.04.5", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu-mate", + "version": "24.04.3", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "xubuntu", + "version": "24.04.5", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "xubuntu", + "version": "24.04.3", + "channel": "lts", + "date": "2024-04-25", + "detected": "2026-10-03" + }, + { + "slug": "linux-mint", + "version": "21.3", + "channel": "lts", + "date": "2024-01-12", + "detected": "2026-10-03" + }, + { + "slug": "pop-os", + "version": "22.04", + "channel": "lts", + "date": "2022-04-25", + "detected": "2026-10-03" + }, + { + "slug": "kubuntu", + "version": "22.04.5", + "channel": "lts", + "date": "2022-04-21", + "detected": "2026-10-03" + }, + { + "slug": "lubuntu", + "version": "22.04.5", + "channel": "lts", + "date": "2022-04-21", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu", + "version": "22.04.5", + "channel": "lts", + "date": "2022-04-21", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu-mate", + "version": "22.04.5", + "channel": "lts", + "date": "2022-04-21", + "detected": "2026-10-03" + }, + { + "slug": "xubuntu", + "version": "22.04.5", + "channel": "lts", + "date": "2022-04-21", + "detected": "2026-10-03" + }, + { + "slug": "kubuntu", + "version": "20.04.6", + "channel": "lts", + "date": "2020-04-23", + "detected": "2026-10-03" + }, + { + "slug": "lubuntu", + "version": "20.04.5", + "channel": "lts", + "date": "2020-04-23", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu", + "version": "20.04.6", + "channel": "lts", + "date": "2020-04-23", + "detected": "2026-10-03" + }, + { + "slug": "ubuntu-mate", + "version": "20.04.6", + "channel": "lts", + "date": "2020-04-23", + "detected": "2026-10-03" + }, + { + "slug": "xubuntu", + "version": "20.04.6", + "channel": "lts", + "date": "2020-04-23", + "detected": "2026-10-03" + } +] diff --git a/src/data/mirrors/arch.json b/src/data/mirrors/arch.json new file mode 100644 index 0000000..8ad10f5 --- /dev/null +++ b/src/data/mirrors/arch.json @@ -0,0 +1,2913 @@ +{ + "slug": "arch", + "sources": [ + "https://archlinux.org/mirrors/status/json/" + ], + "updated": "2026-10-03", + "mirrors": [ + { + "url": "https://al.arch.niranjan.co/", + "country": "AL", + "name": "al.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.juancho.com.ar/", + "country": "AR", + "name": "archlinux.juancho.com.ar", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://at-vie.soulharsh007.dev/archlinux/", + "country": "AT", + "name": "at-vie.soulharsh007.dev", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://at.arch.mirror.kescher.at/", + "country": "AT", + "name": "at.arch.mirror.kescher.at", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://at.arch.niranjan.co/", + "country": "AT", + "name": "at.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://at.mirrors.cicku.me/archlinux/", + "country": "AT", + "name": "at.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.alwyzon.net/archlinux/", + "country": "AT", + "name": "mirror.alwyzon.net", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.mirror.digitalpacific.com.au/", + "country": "AU", + "name": "archlinux.mirror.digitalpacific.com.au", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://au.arch.niranjan.co/", + "country": "AU", + "name": "au.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://au.mirrors.cicku.me/archlinux/", + "country": "AU", + "name": "au.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://gsl-syd.mm.fcix.net/archlinux/", + "country": "AU", + "name": "gsl-syd.mm.fcix.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.aarnet.edu.au/pub/archlinux/", + "country": "AU", + "name": "mirror.aarnet.edu.au", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://syd.mirror.rackspace.com/archlinux/", + "country": "AU", + "name": "syd.mirror.rackspace.com", + "lat": null, + "lon": null, + "score": 6 + }, + { + "url": "https://mirror.ourhost.az/archlinux/", + "country": "AZ", + "name": "mirror.ourhost.az", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.limda.net/archlinux/", + "country": "BD", + "name": "mirror.limda.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.xeonbd.com/archlinux/", + "country": "BD", + "name": "mirror.xeonbd.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://archlinux.mirror-services.net/archlinux/", + "country": "BE", + "name": "archlinux.mirror-services.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.1ago.be/archlinux/", + "country": "BE", + "name": "mirror.1ago.be", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.telepoint.bg/archlinux/", + "country": "BG", + "name": "mirror.telepoint.bg", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.uni-plovdiv.net/archlinux/", + "country": "BG", + "name": "mirrors.uni-plovdiv.net", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.c3sl.ufpr.br/", + "country": "BR", + "name": "archlinux.c3sl.ufpr.br", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://br.mirrors.cicku.me/archlinux/", + "country": "BR", + "name": "br.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.ufscar.br/archlinux/", + "country": "BR", + "name": "mirror.ufscar.br", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.ic.unicamp.br/archlinux/", + "country": "BR", + "name": "mirrors.ic.unicamp.br", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://archlinux-mirror.techticity.com/", + "country": "CA", + "name": "archlinux-mirror.techticity.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ca.mirror.cx/archlinux/", + "country": "CA", + "name": "ca.mirror.cx", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ca.mirrors.cicku.me/archlinux/", + "country": "CA", + "name": "ca.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ca.mirrors.mk/archlinux/", + "country": "CA", + "name": "ca.mirrors.mk", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.quantum5.ca/archlinux/", + "country": "CA", + "name": "mirror.quantum5.ca", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.scd31.com/arch/", + "country": "CA", + "name": "mirror.scd31.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.xenyth.net/archlinux/", + "country": "CA", + "name": "mirror.xenyth.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirrors.pablonara.com/archlinux/", + "country": "CA", + "name": "mirrors.pablonara.com", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://muug.ca/mirror/archlinux/", + "country": "CA", + "name": "muug.ca", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ch.mirrors.cicku.me/archlinux/", + "country": "CH", + "name": "ch.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.arch-linux.ch/archlinux/", + "country": "CH", + "name": "mirror.arch-linux.ch", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.hb9hil.org/archlinux/", + "country": "CH", + "name": "mirror.hb9hil.org", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.metanet.ch/archlinux/", + "country": "CH", + "name": "mirror.metanet.ch", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://mirror.puzzle.ch/archlinux/", + "country": "CH", + "name": "mirror.puzzle.ch", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://pkg.adfinis-on-exoscale.ch/archlinux-pkgbuild/", + "country": "CH", + "name": "pkg.adfinis-on-exoscale.ch", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://pkg.adfinis-on-exoscale.ch/archlinux/", + "country": "CH", + "name": "pkg.adfinis-on-exoscale.ch", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://theswissbay.ch/archlinux/", + "country": "CH", + "name": "theswissbay.ch", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.hnd.cl/archlinux/", + "country": "CL", + "name": "mirror.hnd.cl", + "lat": null, + "lon": null, + "score": 8 + }, + { + "url": "https://mirror.nyist.edu.cn/archlinux/", + "country": "CN", + "name": "mirror.nyist.edu.cn", + "lat": null, + "lon": null, + "score": 7 + }, + { + "url": "https://mirrors.aliyun.com/archlinux/", + "country": "CN", + "name": "mirrors.aliyun.com", + "lat": null, + "lon": null, + "score": 8 + }, + { + "url": "https://mirrors.jlu.edu.cn/archlinux/", + "country": "CN", + "name": "mirrors.jlu.edu.cn", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.nju.edu.cn/archlinux/", + "country": "CN", + "name": "mirrors.nju.edu.cn", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirrors.shanghaitech.edu.cn/archlinux/", + "country": "CN", + "name": "mirrors.shanghaitech.edu.cn", + "lat": null, + "lon": null, + "score": 16 + }, + { + "url": "https://mirrors.sjtug.sjtu.edu.cn/archlinux/", + "country": "CN", + "name": "mirrors.sjtug.sjtu.edu.cn", + "lat": null, + "lon": null, + "score": 17 + }, + { + "url": "https://mirrors.ustc.edu.cn/archlinux/", + "country": "CN", + "name": "mirrors.ustc.edu.cn", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirrors.xjtu.edu.cn/archlinux/", + "country": "CN", + "name": "mirrors.xjtu.edu.cn", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://mirrors.atlas.net.co/archlinux/", + "country": "CO", + "name": "mirrors.atlas.net.co", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://archlinux.nic.cz/archlinux/", + "country": "CZ", + "name": "archlinux.nic.cz", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://ftp.sh.cvut.cz/arch/", + "country": "CZ", + "name": "ftp.sh.cvut.cz", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://gluttony.sin.cvut.cz/arch/", + "country": "CZ", + "name": "gluttony.sin.cvut.cz", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.dkm.cz/archlinux/", + "country": "CZ", + "name": "mirror.dkm.cz", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.jensgutermuth.de/", + "country": "DE", + "name": "arch.jensgutermuth.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.ljkx.org/", + "country": "DE", + "name": "arch.ljkx.org", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.mirror.cloud.thatcyberlynx.de/", + "country": "DE", + "name": "arch.mirror.cloud.thatcyberlynx.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.owochle.app/", + "country": "DE", + "name": "arch.owochle.app", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://arch.phinau.de/", + "country": "DE", + "name": "arch.phinau.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.unixpeople.org/", + "country": "DE", + "name": "arch.unixpeople.org", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.richard-neumann.de/", + "country": "DE", + "name": "archlinux.richard-neumann.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.roshak.xyz/", + "country": "DE", + "name": "archlinux.roshak.xyz", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://archlinux.thaller.ws/", + "country": "DE", + "name": "archlinux.thaller.ws", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://berlin.mirror.pkgbuild.com/", + "country": "DE", + "name": "berlin.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://de.arch.mirror.kescher.at/", + "country": "DE", + "name": "de.arch.mirror.kescher.at", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://de.arch.niranjan.co/", + "country": "DE", + "name": "de.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://de.mirror.shibe.party/archlinux/", + "country": "DE", + "name": "de.mirror.shibe.party", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://de.mirrors.cicku.me/archlinux/", + "country": "DE", + "name": "de.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://de.mirrors.mk/archlinux/", + "country": "DE", + "name": "de.mirrors.mk", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://files.hadiko.de/pub/dists/arch/", + "country": "DE", + "name": "files.hadiko.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://frankfurt.mirror.pkgbuild.com/", + "country": "DE", + "name": "frankfurt.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftp.agdsn.de/pub/mirrors/archlinux/", + "country": "DE", + "name": "ftp.agdsn.de", + "lat": null, + "lon": null, + "score": 7 + }, + { + "url": "https://ftp.fau.de/archlinux/", + "country": "DE", + "name": "ftp.fau.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftp.halifax.rwth-aachen.de/archlinux/", + "country": "DE", + "name": "ftp.halifax.rwth-aachen.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftp.spline.inf.fu-berlin.de/mirrors/archlinux/", + "country": "DE", + "name": "ftp.spline.inf.fu-berlin.de", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ftp.tu-chemnitz.de/pub/linux/archlinux/", + "country": "DE", + "name": "ftp.tu-chemnitz.de", + "lat": null, + "lon": null, + "score": 14 + }, + { + "url": "https://mirror.23m.com/archlinux/", + "country": "DE", + "name": "mirror.23m.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.as20647.net/archlinux/", + "country": "DE", + "name": "mirror.as20647.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.cmt.de/archlinux/", + "country": "DE", + "name": "mirror.cmt.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.diyarciftci.xyz/archlinux/", + "country": "DE", + "name": "mirror.diyarciftci.xyz", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://mirror.dogado.de/archlinux/", + "country": "DE", + "name": "mirror.dogado.de", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.fra10.de.leaseweb.net/archlinux/", + "country": "DE", + "name": "mirror.fra10.de.leaseweb.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.hugo-betrugo.de/archlinux/", + "country": "DE", + "name": "mirror.hugo-betrugo.de", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.informatik.tu-freiberg.de/arch/", + "country": "DE", + "name": "mirror.informatik.tu-freiberg.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.ipb.de/archlinux/", + "country": "DE", + "name": "mirror.ipb.de", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.kumi.systems/archlinux/", + "country": "DE", + "name": "mirror.kumi.systems", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.lcarilla.de/archlinux/", + "country": "DE", + "name": "mirror.lcarilla.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.metalgamer.eu/archlinux/", + "country": "DE", + "name": "mirror.metalgamer.eu", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.moson.org/arch/", + "country": "DE", + "name": "mirror.moson.org", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.netcologne.de/archlinux/", + "country": "DE", + "name": "mirror.netcologne.de", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.pseudoform.org/", + "country": "DE", + "name": "mirror.pseudoform.org", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.selfnet.de/archlinux/", + "country": "DE", + "name": "mirror.selfnet.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.stephanie.is/archlinux/", + "country": "DE", + "name": "mirror.stephanie.is", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.sunred.org/archlinux/", + "country": "DE", + "name": "mirror.sunred.org", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.ubrco.de/archlinux/", + "country": "DE", + "name": "mirror.ubrco.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.wtnet.de/archlinux/", + "country": "DE", + "name": "mirror.wtnet.de", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirrors.aminvakil.com/archlinux/", + "country": "DE", + "name": "mirrors.aminvakil.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.foxmire.space/archlinux/", + "country": "DE", + "name": "mirrors.foxmire.space", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.janbruckner.de/archlinux/", + "country": "DE", + "name": "mirrors.janbruckner.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.niyawe.de/archlinux/", + "country": "DE", + "name": "mirrors.niyawe.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.purring.online/arch/", + "country": "DE", + "name": "mirrors.purring.online", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://os.codefionn.eu/archlinux/", + "country": "DE", + "name": "os.codefionn.eu", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://packages.oth-regensburg.de/archlinux/", + "country": "DE", + "name": "packages.oth-regensburg.de", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.group.one/archlinux/", + "country": "DK", + "name": "mirror.group.one", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.dotsrc.org/archlinux/", + "country": "DK", + "name": "mirrors.dotsrc.org", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.cspacehostings.com/archlinux/", + "country": "EE", + "name": "mirror.cspacehostings.com", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://mirrors.xtom.ee/archlinux/", + "country": "EE", + "name": "mirrors.xtom.ee", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://es.mirrors.cicku.me/archlinux/", + "country": "ES", + "name": "es.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftp.rediris.es/mirror/archlinux/", + "country": "ES", + "name": "ftp.rediris.es", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.es.cdn-perfprod.com/archlinux/", + "country": "ES", + "name": "mirror.es.cdn-perfprod.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.raiolanetworks.com/archlinux/", + "country": "ES", + "name": "mirror.raiolanetworks.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.yhtez.xyz/", + "country": "FI", + "name": "arch.yhtez.xyz", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.doridian.net/", + "country": "FI", + "name": "archlinux.doridian.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://cdnmirror.com/archlinux/", + "country": "FI", + "name": "cdnmirror.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://dornogal.falcao.org/archlinux/", + "country": "FI", + "name": "dornogal.falcao.org", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://fi.arch.niranjan.co/", + "country": "FI", + "name": "fi.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.falcao.org/archlinux/", + "country": "FI", + "name": "mirror.falcao.org", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.wuki.li/archlinux/", + "country": "FI", + "name": "mirror.wuki.li", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.syxpi.fr/arch/", + "country": "FR", + "name": "arch.syxpi.fr", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://arch.yourlabs.org/", + "country": "FR", + "name": "arch.yourlabs.org", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.mailtunnel.eu/", + "country": "FR", + "name": "archlinux.mailtunnel.eu", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.mirrors.ovh.net/archlinux/", + "country": "FR", + "name": "archlinux.mirrors.ovh.net", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://f.matthieul.dev/mirror/archlinux/", + "country": "FR", + "name": "f.matthieul.dev", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://fr.mirror.shibe.party/archlinux/", + "country": "FR", + "name": "fr.mirror.shibe.party", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://fr.mirrors.cicku.me/archlinux/", + "country": "FR", + "name": "fr.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftp.u-strasbg.fr/linux/distributions/archlinux/", + "country": "FR", + "name": "ftp.u-strasbg.fr", + "lat": null, + "lon": null, + "score": 7 + }, + { + "url": "https://mirror.cosmiclinux.com/archlinux/", + "country": "FR", + "name": "mirror.cosmiclinux.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.cyberbits.eu/archlinux/", + "country": "FR", + "name": "mirror.cyberbits.eu", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.fr.cdn-perfprod.com/archlinux/", + "country": "FR", + "name": "mirror.fr.cdn-perfprod.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.its-tps.fr/archlinux/", + "country": "FR", + "name": "mirror.its-tps.fr", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.oldsql.cc/archlinux/", + "country": "FR", + "name": "mirror.oldsql.cc", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.peeres-telecom.fr/archlinux/", + "country": "FR", + "name": "mirror.peeres-telecom.fr", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.rznet.fr/archlinux/", + "country": "FR", + "name": "mirror.rznet.fr", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.smayzy.ovh/archlinux/", + "country": "FR", + "name": "mirror.smayzy.ovh", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.thekinrar.fr/archlinux/", + "country": "FR", + "name": "mirror.thekinrar.fr", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.theo546.fr/archlinux/", + "country": "FR", + "name": "mirror.theo546.fr", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.trap.moe/archlinux/", + "country": "FR", + "name": "mirror.trap.moe", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.wormhole.eu/archlinux/", + "country": "FR", + "name": "mirror.wormhole.eu", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirrors.eric.ovh/arch/", + "country": "FR", + "name": "mirrors.eric.ovh", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.gandi.net/archlinux/", + "country": "FR", + "name": "mirrors.gandi.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirrors.jtremesay.org/archlinux/", + "country": "FR", + "name": "mirrors.jtremesay.org", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.uk.mirror.allworldit.com/archlinux/", + "country": "GB", + "name": "archlinux.uk.mirror.allworldit.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://gb.mirrors.cicku.me/archlinux/", + "country": "GB", + "name": "gb.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://lon.mirror.rackspace.com/archlinux/", + "country": "GB", + "name": "lon.mirror.rackspace.com", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://london.mirror.pkgbuild.com/", + "country": "GB", + "name": "london.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.server.net/archlinux/", + "country": "GB", + "name": "mirror.server.net", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.st2projects.com/archlinux/", + "country": "GB", + "name": "mirror.st2projects.com", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirrors.melbourne.co.uk/archlinux/", + "country": "GB", + "name": "mirrors.melbourne.co.uk", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.ukfast.co.uk/sites/archlinux.org/", + "country": "GB", + "name": "mirrors.ukfast.co.uk", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://repo.c48.uk/arch/", + "country": "GB", + "name": "repo.c48.uk", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://uk.arch.niranjan.co/", + "country": "GB", + "name": "uk.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://www.mirrorservice.org/sites/ftp.archlinux.org/", + "country": "GB", + "name": "www.mirrorservice.org", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://archlinux.grena.ge/", + "country": "GE", + "name": "archlinux.grena.ge", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://repo.greeklug.gr/data/pub/linux/archlinux/", + "country": "GR", + "name": "repo.greeklug.gr", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://arch-mirror.wtako.net/", + "country": "HK", + "name": "arch-mirror.wtako.net", + "lat": null, + "lon": null, + "score": 36 + }, + { + "url": "https://hk.mirrors.cicku.me/archlinux/", + "country": "HK", + "name": "hk.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://hkg.mirror.rackspace.com/archlinux/", + "country": "HK", + "name": "hkg.mirror.rackspace.com", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror-hk.koddos.net/archlinux/", + "country": "HK", + "name": "mirror-hk.koddos.net", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.xtom.com.hk/archlinux/", + "country": "HK", + "name": "mirror.xtom.com.hk", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ftp.ek-cer.hu/pub/mirrors/ftp.archlinux.org/", + "country": "HU", + "name": "ftp.ek-cer.hu", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.citrahost.com/archlinux/", + "country": "ID", + "name": "mirror.citrahost.com", + "lat": null, + "lon": null, + "score": 14 + }, + { + "url": "https://archlinux.interhost.co.il/", + "country": "IL", + "name": "archlinux.interhost.co.il", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.isoc.org.il/pub/archlinux/", + "country": "IL", + "name": "mirror.isoc.org.il", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://archlinux.kushwanthreddy.com/", + "country": "IN", + "name": "archlinux.kushwanthreddy.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://in.arch.niranjan.co/", + "country": "IN", + "name": "in.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://in.mirrors.cicku.me/archlinux/", + "country": "IN", + "name": "in.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.4v1.in/archlinux/", + "country": "IN", + "name": "mirror.4v1.in", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.bom.kat.cx/archlinux/", + "country": "IN", + "name": "mirror.bom.kat.cx", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.dawn.org.in/arch/", + "country": "IN", + "name": "mirror.dawn.org.in", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.maa.albony.in/archlinux/", + "country": "IN", + "name": "mirror.maa.albony.in", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.sahil.world/archlinux/", + "country": "IN", + "name": "mirror.sahil.world", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.nxtgen.com/archlinux-mirror/", + "country": "IN", + "name": "mirrors.nxtgen.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.saswata.cc/archlinux/", + "country": "IN", + "name": "mirrors.saswata.cc", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.famaserver.com/archlinux/", + "country": "IR", + "name": "mirror.famaserver.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://is.mirror.flokinet.net/archlinux/", + "country": "IS", + "name": "is.mirror.flokinet.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://arch.mirror.hyperbit.it/", + "country": "IT", + "name": "arch.mirror.hyperbit.it", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://it.mirrors.cicku.me/archlinux/", + "country": "IT", + "name": "it.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftp.jaist.ac.jp/pub/Linux/ArchLinux/", + "country": "JP", + "name": "ftp.jaist.ac.jp", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://ftp.yz.yamagata-u.ac.jp/pub/linux/archlinux/", + "country": "JP", + "name": "ftp.yz.yamagata-u.ac.jp", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://jp.mirrors.cicku.me/archlinux/", + "country": "JP", + "name": "jp.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.hashy0917.net/archlinux/", + "country": "JP", + "name": "mirror.hashy0917.net", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.rain.ne.jp/archlinux/", + "country": "JP", + "name": "mirror.rain.ne.jp", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirrors.cat.net/archlinux/", + "country": "JP", + "name": "mirrors.cat.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://www.miraa.jp/archlinux/", + "country": "JP", + "name": "www.miraa.jp", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://ftp.io.kr/", + "country": "KR", + "name": "ftp.io.kr", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ftp.lanet.kr/pub/archlinux/", + "country": "KR", + "name": "ftp.lanet.kr", + "lat": null, + "lon": null, + "score": 12 + }, + { + "url": "https://mirror.funami.tech/arch/", + "country": "KR", + "name": "mirror.funami.tech", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.hemino.net/archlinux/", + "country": "KR", + "name": "mirror.hemino.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.keiminem.com/archlinux/", + "country": "KR", + "name": "mirror.keiminem.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.krfoss.org/archlinux/", + "country": "KR", + "name": "mirror.krfoss.org", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.pileus.kr/archlinux/", + "country": "KR", + "name": "mirror.pileus.kr", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.siwoo.org/archlinux/", + "country": "KR", + "name": "mirror.siwoo.org", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.techlabs.co.kr/archlinux/", + "country": "KR", + "name": "mirror.techlabs.co.kr", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.wane.kr/archlinux/", + "country": "KR", + "name": "mirror.wane.kr", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror2.keiminem.com/archlinux/", + "country": "KR", + "name": "mirror2.keiminem.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.ps.kz/archlinux/", + "country": "KZ", + "name": "mirror.ps.kz", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.sinirlan.net/archlinux/", + "country": "LT", + "name": "mirror.sinirlan.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.lu.stratonexus.net/archlinux/", + "country": "LU", + "name": "mirror.lu.stratonexus.net", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://archlinux.koyanet.lv/archlinux/", + "country": "LV", + "name": "archlinux.koyanet.lv", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ftp.linux.edu.lv/archlinux/", + "country": "LV", + "name": "ftp.linux.edu.lv", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.abderraziq.com/archlinux/", + "country": "MA", + "name": "mirror.abderraziq.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://md.arch.niranjan.co/", + "country": "MD", + "name": "md.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.hosthink.net/arch/", + "country": "MD", + "name": "mirror.hosthink.net", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.ihost.md/archlinux/", + "country": "MD", + "name": "mirror.ihost.md", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.mangohost.net/archlinux/", + "country": "MD", + "name": "mirror.mangohost.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.t-home.mk/archlinux/", + "country": "MK", + "name": "mirror.t-home.mk", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://mk.mirrors.mk/archlinux/", + "country": "MK", + "name": "mk.mirrors.mk", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://arch.jsc.mx/", + "country": "MX", + "name": "arch.jsc.mx", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://lidsol.fi-b.unam.mx/archlinux/", + "country": "MX", + "name": "lidsol.fi-b.unam.mx", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://ams.nl.mirrors.bjg.at/arch/", + "country": "NL", + "name": "ams.nl.mirrors.bjg.at", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.mirrors.lavatech.top/", + "country": "NL", + "name": "arch.mirrors.lavatech.top", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://archlinux.mirror.liteserver.nl/", + "country": "NL", + "name": "archlinux.mirror.liteserver.nl", + "lat": null, + "lon": null, + "score": 8 + }, + { + "url": "https://archlinux.mirror.wearetriple.com/", + "country": "NL", + "name": "archlinux.mirror.wearetriple.com", + "lat": null, + "lon": null, + "score": 15 + }, + { + "url": "https://mirror.ams1.nl.leaseweb.net/archlinux/", + "country": "NL", + "name": "mirror.ams1.nl.leaseweb.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.bouwhuis.network/archlinux/", + "country": "NL", + "name": "mirror.bouwhuis.network", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.cj2.nl/archlinux/", + "country": "NL", + "name": "mirror.cj2.nl", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.iusearchbtw.nl/", + "country": "NL", + "name": "mirror.iusearchbtw.nl", + "lat": null, + "lon": null, + "score": 14 + }, + { + "url": "https://mirror.nl.cdn-perfprod.com/archlinux/", + "country": "NL", + "name": "mirror.nl.cdn-perfprod.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.nyaa.vc/archlinux/", + "country": "NL", + "name": "mirror.nyaa.vc", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.xtom.nl/archlinux/", + "country": "NL", + "name": "mirrors.xtom.nl", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://nl.arch.niranjan.co/", + "country": "NL", + "name": "nl.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://nl.mirror.cx/archlinux/", + "country": "NL", + "name": "nl.mirror.cx", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://nl.mirror.flokinet.net/archlinux/", + "country": "NL", + "name": "nl.mirror.flokinet.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://nl.mirrors.cicku.me/archlinux/", + "country": "NL", + "name": "nl.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://nl.mirrors.mk/archlinux/", + "country": "NL", + "name": "nl.mirrors.mk", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://archlinux.lysakermoen.com/", + "country": "NO", + "name": "archlinux.lysakermoen.com", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.archlinux.no/", + "country": "NO", + "name": "mirror.archlinux.no", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.neuf.no/archlinux/", + "country": "NO", + "name": "mirror.neuf.no", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://no.mirror.cx/archlinux/", + "country": "NO", + "name": "no.mirror.cx", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.nepalicloud.com/archlinux/", + "country": "NP", + "name": "mirrors.nepalicloud.com", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.2degrees.nz/archlinux/", + "country": "NZ", + "name": "mirror.2degrees.nz", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.fsmg.org.nz/archlinux/", + "country": "NZ", + "name": "mirror.fsmg.org.nz", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://nz.arch.niranjan.co/", + "country": "NZ", + "name": "nz.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.midov.pl/arch/", + "country": "PL", + "name": "arch.midov.pl", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.sakamoto.pl/", + "country": "PL", + "name": "arch.sakamoto.pl", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftp.psnc.pl/linux/archlinux/", + "country": "PL", + "name": "ftp.psnc.pl", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.alldaydev.com/archlinux/", + "country": "PL", + "name": "mirror.alldaydev.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.juniorjpdj.pl/archlinux/", + "country": "PL", + "name": "mirror.juniorjpdj.pl", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftp.rnl.tecnico.ulisboa.pt/pub/archlinux/", + "country": "PT", + "name": "ftp.rnl.tecnico.ulisboa.pt", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://glua.ua.pt/pub/archlinux/", + "country": "PT", + "name": "glua.ua.pt", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.barata.pt/archlinux/", + "country": "PT", + "name": "mirror.barata.pt", + "lat": null, + "lon": null, + "score": 13 + }, + { + "url": "https://mirrors.up.pt/pub/archlinux/", + "country": "PT", + "name": "mirrors.up.pt", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirrors.hosterion.ro/archlinux/", + "country": "RO", + "name": "mirrors.hosterion.ro", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.nxthost.com/archlinux/", + "country": "RO", + "name": "mirrors.nxthost.com", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirrors.pidginhost.com/arch/", + "country": "RO", + "name": "mirrors.pidginhost.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ro.arch.niranjan.co/", + "country": "RO", + "name": "ro.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ro.mirror.flokinet.net/archlinux/", + "country": "RO", + "name": "ro.mirror.flokinet.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror1.sox.rs/archlinux/", + "country": "RS", + "name": "mirror1.sox.rs", + "lat": null, + "lon": null, + "score": 12 + }, + { + "url": "https://archlinux.gay/archlinux/", + "country": "RU", + "name": "archlinux.gay", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.cachy-arch.ru/archlinux/", + "country": "RU", + "name": "mirror.cachy-arch.ru", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.kamtv.ru/archlinux/", + "country": "RU", + "name": "mirror.kamtv.ru", + "lat": null, + "lon": null, + "score": 6 + }, + { + "url": "https://mirror.kpfu.ru/archlinux/", + "country": "RU", + "name": "mirror.kpfu.ru", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.truenetwork.ru/archlinux/", + "country": "RU", + "name": "mirror.truenetwork.ru", + "lat": null, + "lon": null, + "score": 9 + }, + { + "url": "https://mirror.yandex.ru/archlinux/", + "country": "RU", + "name": "mirror.yandex.ru", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://repository.su/archlinux/", + "country": "RU", + "name": "repository.su", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ru.mirrors.cicku.me/archlinux/", + "country": "RU", + "name": "ru.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://wan.metrosg.ru/archlinux/", + "country": "RU", + "name": "wan.metrosg.ru", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.maeen.sa/arch-mirror/", + "country": "SA", + "name": "mirror.maeen.sa", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ftp.ludd.ltu.se/mirrors/archlinux/", + "country": "SE", + "name": "ftp.ludd.ltu.se", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://ftp.lysator.liu.se/pub/archlinux/", + "country": "SE", + "name": "ftp.lysator.liu.se", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ftp.myrveln.se/pub/linux/archlinux/", + "country": "SE", + "name": "ftp.myrveln.se", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.accum.se/mirror/archlinux/", + "country": "SE", + "name": "mirror.accum.se", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.bahnhof.net/pub/archlinux/", + "country": "SE", + "name": "mirror.bahnhof.net", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.braindrainlan.nu/archlinux/", + "country": "SE", + "name": "mirror.braindrainlan.nu", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.osbeck.com/archlinux/", + "country": "SE", + "name": "mirror.osbeck.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.retropc.se/archlinux/", + "country": "SE", + "name": "mirror.retropc.se", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.tedwall.se/archlinux/", + "country": "SE", + "name": "mirror.tedwall.se", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.zyner.org/mirror/archlinux/", + "country": "SE", + "name": "mirror.zyner.org", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://umea.mirror.pkgbuild.com/", + "country": "SE", + "name": "umea.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.jingk.ai/archlinux/", + "country": "SG", + "name": "mirror.jingk.ai", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.sg.cdn-perfprod.com/archlinux/", + "country": "SG", + "name": "mirror.sg.cdn-perfprod.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.sg.gs/archlinux/", + "country": "SG", + "name": "mirror.sg.gs", + "lat": null, + "lon": null, + "score": 6 + }, + { + "url": "https://sg.arch.niranjan.co/", + "country": "SG", + "name": "sg.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://sg.mirrors.cicku.me/archlinux/", + "country": "SG", + "name": "sg.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://singapore.mirror.pkgbuild.com/", + "country": "SG", + "name": "singapore.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.archlinux.si/", + "country": "SI", + "name": "mirror.archlinux.si", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.tux.si/arch/", + "country": "SI", + "name": "mirror.tux.si", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://www.sooftware.com/mirrors/Arch-Linux/", + "country": "SI", + "name": "www.sooftware.com", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://ftp.energotel.sk/pub/linux/arch/", + "country": "SK", + "name": "ftp.energotel.sk", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.cyberbits.asia/archlinux/", + "country": "TH", + "name": "mirror.cyberbits.asia", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.kku.ac.th/archlinux/", + "country": "TH", + "name": "mirror.kku.ac.th", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.safiabidi.com/", + "country": "TN", + "name": "mirror.safiabidi.com", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://tr.arch.niranjan.co/", + "country": "TR", + "name": "tr.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archlinux.ccns.ncku.edu.tw/archlinux/", + "country": "TW", + "name": "archlinux.ccns.ncku.edu.tw", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://archlinux.cs.nycu.edu.tw/", + "country": "TW", + "name": "archlinux.cs.nycu.edu.tw", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.twds.com.tw/archlinux/", + "country": "TW", + "name": "mirror.twds.com.tw", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://taipei.mirror.pkgbuild.com/", + "country": "TW", + "name": "taipei.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://tw.mirrors.cicku.me/archlinux/", + "country": "TW", + "name": "tw.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://distrohub.kyiv.ua/archlinux/", + "country": "UA", + "name": "distrohub.kyiv.ua", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.hostiko.network/archlinux/", + "country": "UA", + "name": "mirror.hostiko.network", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.mirohost.net/archlinux/", + "country": "UA", + "name": "mirror.mirohost.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.reitarovskyi.com.ua/archlinux/", + "country": "UA", + "name": "mirrors.reitarovskyi.com.ua", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://repo.hyron.dev/archlinux/", + "country": "UA", + "name": "repo.hyron.dev", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://arch-mirror.brightlight.today/", + "country": "US", + "name": "arch-mirror.brightlight.today", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://arch.goober.cloud/", + "country": "US", + "name": "arch.goober.cloud", + "lat": null, + "lon": null, + "score": 26 + }, + { + "url": "https://arch.hugeblank.dev/", + "country": "US", + "name": "arch.hugeblank.dev", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://arch.miningtcup.me/", + "country": "US", + "name": "arch.miningtcup.me", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://arch.mirror.constant.com/", + "country": "US", + "name": "arch.mirror.constant.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://arch.mirror.k0.ae/", + "country": "US", + "name": "arch.mirror.k0.ae", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://arch.mirror.marcusspencer.us:4443/archlinux/", + "country": "US", + "name": "arch.mirror.marcusspencer.us", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://archmirror1.octyl.net/", + "country": "US", + "name": "archmirror1.octyl.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://codingflyboy.mm.fcix.net/archlinux/", + "country": "US", + "name": "codingflyboy.mm.fcix.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://coresite.mm.fcix.net/archlinux/", + "country": "US", + "name": "coresite.mm.fcix.net", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://dfw.mirror.rackspace.com/archlinux/", + "country": "US", + "name": "dfw.mirror.rackspace.com", + "lat": null, + "lon": null, + "score": 6 + }, + { + "url": "https://forksystems.mm.fcix.net/archlinux/", + "country": "US", + "name": "forksystems.mm.fcix.net", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftp.osuosl.org/pub/archlinux/", + "country": "US", + "name": "ftp.osuosl.org", + "lat": null, + "lon": null, + "score": 8 + }, + { + "url": "https://iad.mirror.rackspace.com/archlinux/", + "country": "US", + "name": "iad.mirror.rackspace.com", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://iad.mirrors.misaka.one/archlinux/", + "country": "US", + "name": "iad.mirrors.misaka.one", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://irltoolkit.mm.fcix.net/archlinux/", + "country": "US", + "name": "irltoolkit.mm.fcix.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://losangeles.mirror.pkgbuild.com/", + "country": "US", + "name": "losangeles.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.arizona.edu/archlinux/", + "country": "US", + "name": "mirror.arizona.edu", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.clarkson.edu/archlinux/", + "country": "US", + "name": "mirror.clarkson.edu", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.colonelhosting.com/archlinux/", + "country": "US", + "name": "mirror.colonelhosting.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.cs.odu.edu/archlinux/", + "country": "US", + "name": "mirror.cs.odu.edu", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.fcix.net/archlinux/", + "country": "US", + "name": "mirror.fcix.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.givebytes.net/archlinux/", + "country": "US", + "name": "mirror.givebytes.net", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirror.kalem.dev/archlinux/", + "country": "US", + "name": "mirror.kalem.dev", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.lug.umbc.edu/archlinux/", + "country": "US", + "name": "mirror.lug.umbc.edu", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.mra.sh/archlinux/", + "country": "US", + "name": "mirror.mra.sh", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.pilotfiber.com/archlinux/", + "country": "US", + "name": "mirror.pilotfiber.com", + "lat": null, + "lon": null, + "score": 15 + }, + { + "url": "https://mirror.pit.teraswitch.com/archlinux/", + "country": "US", + "name": "mirror.pit.teraswitch.com", + "lat": null, + "lon": null, + "score": 7 + }, + { + "url": "https://mirror.sfo12.us.leaseweb.net/archlinux/", + "country": "US", + "name": "mirror.sfo12.us.leaseweb.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror.tzulo.com/archlinux/", + "country": "US", + "name": "mirror.tzulo.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.umd.edu/archlinux/", + "country": "US", + "name": "mirror.umd.edu", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirror.us.cdn-perfprod.com/archlinux/", + "country": "US", + "name": "mirror.us.cdn-perfprod.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.wdc1.us.leaseweb.net/archlinux/", + "country": "US", + "name": "mirror.wdc1.us.leaseweb.net", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirror2.givebytes.net/archlinux/", + "country": "US", + "name": "mirror2.givebytes.net", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.bloomu.edu/archlinux/", + "country": "US", + "name": "mirrors.bloomu.edu", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://mirrors.iu13.net/archlinux/", + "country": "US", + "name": "mirrors.iu13.net", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirrors.kernel.org/archlinux/", + "country": "US", + "name": "mirrors.kernel.org", + "lat": null, + "lon": null, + "score": 4 + }, + { + "url": "https://mirrors.lahansons.com/archlinux/", + "country": "US", + "name": "mirrors.lahansons.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.logal.dev/archlinux/", + "country": "US", + "name": "mirrors.logal.dev", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.lug.mtu.edu/archlinux/", + "country": "US", + "name": "mirrors.lug.mtu.edu", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.mit.edu/archlinux/", + "country": "US", + "name": "mirrors.mit.edu", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.ocf.berkeley.edu/archlinux/", + "country": "US", + "name": "mirrors.ocf.berkeley.edu", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirrors.rit.edu/archlinux/", + "country": "US", + "name": "mirrors.rit.edu", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.shr.cx/arch/", + "country": "US", + "name": "mirrors.shr.cx", + "lat": null, + "lon": null, + "score": 14 + }, + { + "url": "https://mirrors.xtom.com/archlinux/", + "country": "US", + "name": "mirrors.xtom.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://nnenix.mm.fcix.net/archlinux/", + "country": "US", + "name": "nnenix.mm.fcix.net", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ohioix.mm.fcix.net/archlinux/", + "country": "US", + "name": "ohioix.mm.fcix.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://opencolo.mm.fcix.net/archlinux/", + "country": "US", + "name": "opencolo.mm.fcix.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://ord.mirror.rackspace.com/archlinux/", + "country": "US", + "name": "ord.mirror.rackspace.com", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://plug-mirror.rcac.purdue.edu/archlinux/", + "country": "US", + "name": "plug-mirror.rcac.purdue.edu", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://southfront.mm.fcix.net/archlinux/", + "country": "US", + "name": "southfront.mm.fcix.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://us.arch.niranjan.co/", + "country": "US", + "name": "us.arch.niranjan.co", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://us.mirrors.cicku.me/archlinux/", + "country": "US", + "name": "us.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://us.mirrors.mk/archlinux/", + "country": "US", + "name": "us.mirrors.mk", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://volico.mm.fcix.net/archlinux/", + "country": "US", + "name": "volico.mm.fcix.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://yonderly.org/mirrors/archlinux/", + "country": "US", + "name": "yonderly.org", + "lat": null, + "lon": null, + "score": 15 + }, + { + "url": "https://ziply.mm.fcix.net/archlinux/", + "country": "US", + "name": "ziply.mm.fcix.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://zxcvfdsa.com/arch/", + "country": "US", + "name": "zxcvfdsa.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirrors.huongnguyen.dev/arch/", + "country": "VN", + "name": "mirrors.huongnguyen.dev", + "lat": null, + "lon": null, + "score": 3 + }, + { + "url": "https://mirrors.nguyenhoang.cloud/archlinux/", + "country": "VN", + "name": "mirrors.nguyenhoang.cloud", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://archlinux.mirror.net.za/", + "country": "ZA", + "name": "archlinux.mirror.net.za", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://archlinux.za.mirror.allworldit.com/archlinux/", + "country": "ZA", + "name": "archlinux.za.mirror.allworldit.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://johannesburg.mirror.pkgbuild.com/", + "country": "ZA", + "name": "johannesburg.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://mirrors.urbanwave.co.za/archlinux/", + "country": "ZA", + "name": "mirrors.urbanwave.co.za", + "lat": null, + "lon": null, + "score": 5 + }, + { + "url": "https://za.mirrors.cicku.me/archlinux/", + "country": "ZA", + "name": "za.mirrors.cicku.me", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://fastly.mirror.pkgbuild.com/", + "country": null, + "name": "fastly.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 1 + }, + { + "url": "https://ftpmirror.infania.net/mirror/archlinux/", + "country": null, + "name": "ftpmirror.infania.net", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://geo.mirror.pkgbuild.com/", + "country": null, + "name": "geo.mirror.pkgbuild.com", + "lat": null, + "lon": null, + "score": 2 + }, + { + "url": "https://mirror.rackspace.com/archlinux/", + "country": null, + "name": "mirror.rackspace.com", + "lat": null, + "lon": null, + "score": 3 + } + ] +} diff --git a/src/data/mirrors/debian.json b/src/data/mirrors/debian.json new file mode 100644 index 0000000..ebe1a6d --- /dev/null +++ b/src/data/mirrors/debian.json @@ -0,0 +1,393 @@ +{ + "slug": "debian", + "sources": [ + "https://mirror-master.debian.org/status/Mirrors.masterlist" + ], + "updated": "2026-10-03", + "mirrors": [ + { + "url": "https://debian-mirror.behostings.net/debian-cd/", + "country": "BE", + "name": "debian-mirror.behostings.net", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.as35701.net/debian-cd/", + "country": "BE", + "name": "mirror.as35701.net", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.blue3.com.br/debian-cd/", + "country": "BR", + "name": "mirror.blue3.com.br", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.lzu.edu.cn/debian-cd/", + "country": "CN", + "name": "mirror.lzu.edu.cn", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.nic.cz/debian-cd/", + "country": "CZ", + "name": "debian.nic.cz", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.dkm.cz/debian-cd/", + "country": "CZ", + "name": "mirror.dkm.cz", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian-cd.repulsive.eu/", + "country": "DE", + "name": "debian-cd.repulsive.eu", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.netcologne.de/debian-cd/", + "country": "DE", + "name": "debian.netcologne.de", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.uni-due.de/debian-cd/", + "country": "DE", + "name": "debian.uni-due.de", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp-stud.hs-esslingen.de/debian-cd/", + "country": "DE", + "name": "ftp-stud.hs-esslingen.de", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.fau.de/debian-cd/", + "country": "DE", + "name": "ftp.fau.de", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.gwdg.de/debian-cd/", + "country": "DE", + "name": "ftp.gwdg.de", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.halifax.rwth-aachen.de/debian-cd/", + "country": "DE", + "name": "ftp.halifax.rwth-aachen.de", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.hosteurope.de/mirror/ftp.debian.org/debian-cd/", + "country": "DE", + "name": "ftp.hosteurope.de", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.uni-hannover.de/debian/debian-cd/", + "country": "DE", + "name": "ftp.uni-hannover.de", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.checkdomain.de/debian-cd/", + "country": "DE", + "name": "mirror.checkdomain.de", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.caliu.cat/debian-cd/", + "country": "ES", + "name": "ftp.caliu.cat", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.cica.es/debian-cd/", + "country": "ES", + "name": "ftp.cica.es", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.crifo.org/debian-cd/", + "country": "FR", + "name": "ftp.crifo.org", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrorservice.org/sites/cdimage.debian.org/debian-cd/", + "country": "GB", + "name": "mirrorservice.org", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.grena.ge/debian-cd/", + "country": "GE", + "name": "debian.grena.ge", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.bme.hu/debian-cd/", + "country": "HU", + "name": "ftp.bme.hu", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://kartolo.sby.datautama.net.id/debian-cd/", + "country": "ID", + "name": "kartolo.sby.datautama.net.id", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.connesi.it/", + "country": "IT", + "name": "debian.connesi.it", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.jaist.ac.jp/debian-cd/", + "country": "JP", + "name": "ftp.jaist.ac.jp", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.riken.jp/Linux/debian/debian-cd/", + "country": "JP", + "name": "ftp.riken.jp", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.yz.yamagata-u.ac.jp/debian-cd/", + "country": "JP", + "name": "ftp.yz.yamagata-u.ac.jp", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.siwoo.org/debian-cd/", + "country": "KR", + "name": "mirror.siwoo.org", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://lidsol.fi-b.unam.mx/debian-cd/", + "country": "MX", + "name": "lidsol.fi-b.unam.mx", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.lagoon.nc/debian-cd/", + "country": "NC", + "name": "debian.lagoon.nc", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.snt.utwente.nl/debian-cd/", + "country": "NL", + "name": "debian.snt.utwente.nl", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.duocast.net/debian-cd/", + "country": "NL", + "name": "mirror.duocast.net", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.icm.edu.pl/pub/Linux/debian-cd/", + "country": "PL", + "name": "ftp.icm.edu.pl", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.up.pt/debian-cd/", + "country": "PT", + "name": "mirrors.up.pt", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.pidginhost.com/debian-cd/", + "country": "RO", + "name": "mirrors.pidginhost.com", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.psn.ru/debian-cd/", + "country": "RU", + "name": "ftp.psn.ru", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.truenetwork.ru/debian-cd/", + "country": "RU", + "name": "mirror.truenetwork.ru", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.maeen.sa/debian-cd/", + "country": "SA", + "name": "mirror.maeen.sa", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.acc.umu.se/debian-cd/", + "country": "SE", + "name": "ftp.acc.umu.se", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.cloudforest.co.th/debian-cd/", + "country": "TH", + "name": "mirrors.cloudforest.co.th", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.cs.nycu.edu.tw/debian-cd/", + "country": "TW", + "name": "debian.cs.nycu.edu.tw", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.twds.com.tw/debian-cd/", + "country": "TW", + "name": "mirror.twds.com.tw", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.volia.net/debian-cd/", + "country": "UA", + "name": "debian.volia.net", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.osuosl.org/debian-cdimage/", + "country": "US", + "name": "debian.osuosl.org", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.cogentco.com/debian-cd/", + "country": "US", + "name": "mirror.cogentco.com", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.steadfast.net/debian-cd/", + "country": "US", + "name": "mirror.steadfast.net", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.lug.mtu.edu/debian-cd/", + "country": "US", + "name": "mirrors.lug.mtu.edu", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://debian.as3741.net/debian-cd/", + "country": "ZA", + "name": "debian.as3741.net", + "lat": null, + "lon": null, + "score": null + } + ] +} diff --git a/src/data/mirrors/ubuntu.json b/src/data/mirrors/ubuntu.json new file mode 100644 index 0000000..ed12542 --- /dev/null +++ b/src/data/mirrors/ubuntu.json @@ -0,0 +1,2393 @@ +{ + "slug": "ubuntu", + "sources": [ + "https://api.launchpad.net/devel/ubuntu/cdimage_mirrors" + ], + "updated": "2026-10-03", + "mirrors": [ + { + "url": "https://ae.releases.ubuntu.com/", + "country": "AE", + "name": "Root License", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.sitsa.com.ar/ubuntu-releases/", + "country": "AR", + "name": "SiTSA Telecomunicaciones", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://releases.ubuntu.unc.edu.ar/", + "country": "AR", + "name": "Universidad Nacional de Córdoba", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.zero.com.ar/releases/", + "country": "AR", + "name": "Zero.com.ar", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.kumi.systems/ubuntureleases/", + "country": "AT", + "name": "Kumi Systems", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.anexia.at/ubuntu-releases/", + "country": "AT", + "name": "ANEXIA Internetdienstleistungs GmbH", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://gsl-syd.mm.fcix.net/ubuntu-releases/", + "country": "AU", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://lchs.mm.fcix.net/ubuntu-releases/", + "country": "AU", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.aarnet.edu.au/pub/ubuntu/releases/", + "country": "AU", + "name": "AARNet Pty Ltd", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.internet.asn.au/pub/ubuntu/releases/", + "country": "AU", + "name": "Internet Association of Australia Inc.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.ourhost.az/ubuntu-releases/", + "country": "AZ", + "name": "OUR Host", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.yer.az/ubuntu/releases/", + "country": "AZ", + "name": "YER Hosting", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.xeonbd.com/ubuntu-releases/", + "country": "BD", + "name": "XeonBD", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.belnet.be/ubuntu-releases/", + "country": "BE", + "name": "Belnet", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.unix-solutions.be/ubuntu-releases/", + "country": "BE", + "name": "Unix-Solutions", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.mirror-services.net/releases/", + "country": "BE", + "name": "BOINC Team Belgium", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.telepoint.bg/ubuntu-releases/", + "country": "BG", + "name": "Telepoint.bg", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.neterra.net/ubuntu/releases/", + "country": "BG", + "name": "Neterra LTD", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.netix.net/ubuntu/releases/", + "country": "BG", + "name": "Netix Ltd.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.storpool.com/ubuntu/releases/", + "country": "BG", + "name": "StorPool Storage", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.ipacct.com/releases/", + "country": "BG", + "name": "IPACCT", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://deb.campolargo.pr.gov.br/ubuntu/releases/", + "country": "BR", + "name": "Prefeitura Municipal de Campo Largo", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.pop-sc.rnp.br/mirror/ubuntu-releases/", + "country": "BR", + "name": "PoP-SC/RNP", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.uepg.br/ubuntu-releases/", + "country": "BR", + "name": "Universidade Estadual de Ponta Grossa", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.univassouras.edu.br/releases/", + "country": "BR", + "name": "Univassouras", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://gpl.savoirfairelinux.net/pub/mirrors/ubuntu-releases/", + "country": "CA", + "name": "Savoir-faire Linux", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://lesnet.mm.fcix.net/ubuntu-releases/", + "country": "CA", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.cpsc.ucalgary.ca/mirror/ubuntu.com/releases/", + "country": "CA", + "name": "University of Calgary", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.csclub.uwaterloo.ca/ubuntu-releases/", + "country": "CA", + "name": "University of Waterloo Computer Science Club", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.digitaloceans.dev/ubuntu-releases/", + "country": "CA", + "name": "mirror.digitaloceans.dev", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.hep.gg/ubuntu-releases/", + "country": "CA", + "name": "Team Hydra / Hep.gg", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.it.ubc.ca/ubuntu-releases/", + "country": "CA", + "name": "The University of British Columbia", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.quantum5.ca/ubuntu-releases/", + "country": "CA", + "name": "Quantum", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.xenyth.net/ubuntu-releases/", + "country": "CA", + "name": "GoCodeIT Inc", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://muug.ca/mirror/ubuntu-releases/", + "country": "CA", + "name": "Manitoba Unix User Group", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://yxeix.mm.fcix.net/ubuntu-releases/", + "country": "CA", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ipng.mm.fcix.net/ubuntu-releases/", + "country": "CH", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.gofoss.xyz/ubuntu-releases/", + "country": "CH", + "name": "GoFoss", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.init7.net/ubuntu-releases/", + "country": "CH", + "name": "Init7 (Schweiz) AG", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.solnet.ch/ubuntu-releases/", + "country": "CH", + "name": "BSE Software GmbH, SolNet", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://elmirror.cl/ubuntu-releases/", + "country": "CL", + "name": "ElMirror.cl", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.hnd.cl/ubuntu-releases/", + "country": "CL", + "name": "Hostednode SpA", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.sjtu.edu.cn/ubuntu-cd/", + "country": "CN", + "name": "Shanghai Jiao Tong University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.nyist.edu.cn/ubuntu-releases/", + "country": "CN", + "name": "Nanyang Institute of Technology", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.hit.edu.cn/ubuntu-releases/", + "country": "CN", + "name": "Harbin Institute of Technology", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.qlu.edu.cn/ubuntu-releases/", + "country": "CN", + "name": "Qilu Univertity of Technology", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.tuna.tsinghua.edu.cn/ubuntu-releases/", + "country": "CN", + "name": "Tsinghua University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.xjtu.edu.cn/ubuntu-releases/", + "country": "CN", + "name": "Xi'an Jiaotong University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.zju.edu.cn/ubuntu-releases/", + "country": "CN", + "name": "Zhejiang University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://repo.huaweicloud.com/ubuntu-releases/", + "country": "CN", + "name": "Huawei Cloud", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.atlas.net.co/ubuntu-releases/", + "country": "CO", + "name": "Atlas Internet", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://cz.releases.ubuntu.com/releases/", + "country": "CZ", + "name": "CZ.NIC, z.s.p.o.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.dkm.cz/ubuntu-releases/", + "country": "CZ", + "name": "Vodafone Czech Republic a. s.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.it4i.cz/ubuntu-releases/", + "country": "CZ", + "name": "IT4Innovations National Supercomputing Center", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://drmirror.physi.uni-heidelberg.de/ubuntu-releases/", + "country": "DE", + "name": "Physikalisches Institut Uni Heidelberg", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.fau.de/ubuntu-releases/", + "country": "DE", + "name": "Universität Erlangen-Nürnberg", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.halifax.rwth-aachen.de/ubuntu-releases/", + "country": "DE", + "name": "RWTH Aachen University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.tu-chemnitz.de/pub/linux/ubuntu-releases/", + "country": "DE", + "name": "Technische Universität Chemnitz", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp5.gwdg.de/pub/linux/debian/ubuntu/iso/", + "country": "DE", + "name": "GWDG", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://level66.mm.fcix.net/ubuntu-releases/", + "country": "DE", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.23m.com/ubuntu-releases/", + "country": "DE", + "name": "23M GmbH", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.de.leaseweb.net/ubuntu-releases/", + "country": "DE", + "name": "LeaseWeb Germany GmbH", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.dogado.de/ubuntu-releases/", + "country": "DE", + "name": "dogado GmbH", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.faigner.de/ubuntu/", + "country": "DE", + "name": "FAigner", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.imt-systems.com/ubuntu/", + "country": "DE", + "name": "IMT-Systems GmbH", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.kamp.de/ubuntu-releases/", + "country": "DE", + "name": "KAMP Netzwerkdienste GmbH", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.kkg.berlin/ubuntu-releases/", + "country": "DE", + "name": "KKG Berlin", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.level66.network/ubuntu-releases/", + "country": "DE", + "name": "level66.network UG (haftungsbeschränkt)", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.netzwerge.de/ubuntu-releases/", + "country": "DE", + "name": "Netzwerge GmbH", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.scaleuptech.com/ubuntu-releases/", + "country": "DE", + "name": "ScaleUp Technologies GmbH & Co. KG", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.wtnet.de/ubuntu-releases/", + "country": "DE", + "name": "wilhelm.tel GmbH", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.xtom.de/ubuntu-releases/", + "country": "DE", + "name": "xTom", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://releases.ubuntu.repulsive.eu/", + "country": "DE", + "name": "releases.ubuntu.repulsive.eu", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.netsite.dk/ubuntu/releases/", + "country": "DK", + "name": "Netsite", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.one.com/ubuntu-cd/", + "country": "DK", + "name": "One.com", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.c0urier.net/linux/ubuntu-cd/", + "country": "DK", + "name": "c0urier.net", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.dotsrc.org/ubuntu-cd/", + "country": "DK", + "name": "dotsrc.org", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.fibianet.dk/ubuntu-releases/", + "country": "DK", + "name": "Fibia P/S", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.cedia.org.ec/ubuntu-releases/", + "country": "EC", + "name": "Consorcio Ecuatoriano para Desarrollo de Internet Avanzado - CEDIA", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.linux.ec/ubuntu-releases/", + "country": "EC", + "name": "NodoVIP", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.xtom.ee/ubuntu-releases/", + "country": "EE", + "name": "xTom", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.caliu.cat/pub/distribucions/ubuntu/releases/", + "country": "ES", + "name": "Caliu", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.rediris.es/mirror/ubuntu-releases/", + "country": "ES", + "name": "RedIRIS", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://labs.eif.urjc.es/mirror/ubuntu-releases/", + "country": "ES", + "name": "Laboratorios Docentes Linux EIF - URJC (Universidad Rey Juan Carlos)", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.redparra.com/ubuntureleases/", + "country": "ES", + "name": "Redparra", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://softlibre.unizar.es/ubuntu/releases/", + "country": "ES", + "name": "University of Zaragoza", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubunturel.javinator9889.com/", + "country": "ES", + "name": "Javinator9889", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.5i.fi/ubuntu-releases/", + "country": "FI", + "name": "mirror.5i.fi", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.fi.ossplanet.net/ubuntu-releases/", + "country": "FI", + "name": "Ubuntu-TW / OSSPlanet", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://www.nic.funet.fi/pub/mirrors/releases.ubuntu.com/", + "country": "FI", + "name": "CSC / Funet", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.bakertelekom.fr/Ubuntu/", + "country": "FR", + "name": "Baker Telekom", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.johnnybegood.fr/ubuntu-cd/", + "country": "FR", + "name": "mirror.johnnybegood.fr", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.ircam.fr/pub/ubuntu/releases/", + "country": "FR", + "name": "Ircam", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.mirrors.ovh.net/ubuntu-releases/", + "country": "FR", + "name": "OVHcloud", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.univ-nantes.fr/ubuntu-cd/", + "country": "FR", + "name": "Université de Nantes", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://www-ftp.lip6.fr/pub/linux/distributions/Ubuntu/releases/", + "country": "FR", + "name": "LIP6/Sorbonne Université", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ask4.mm.fcix.net/ubuntu-releases/", + "country": "GB", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://b4sh.mm.fcix.net/ubuntu-releases/", + "country": "GB", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://divergentnetworks.mm.fcix.net/ubuntu-releases/", + "country": "GB", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.freethought-internet.co.uk/ubuntu-releases/", + "country": "GB", + "name": "Freethought Internet", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.server.net/ubuntu-releases/", + "country": "GB", + "name": "Server.net", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.vinehost.net/ubuntu-releases/", + "country": "GB", + "name": "VineHost", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.20i.com/pub/releases.ubuntu.com/", + "country": "GB", + "name": "20i.com", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.vinters.com/ubuntu-releases/", + "country": "GB", + "name": "Vinters", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://releases.ubuntu.com/", + "country": "GB", + "name": "Canonical Ltd.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://www.mirrorservice.org/sites/releases.ubuntu.com/", + "country": "GB", + "name": "University Of Kent UK Mirror Service", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.ordunet.ge/ubuntu-releases/", + "country": "GE", + "name": "Ordunet", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.grena.ge/releases/", + "country": "GE", + "name": "GRENA", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror-hk.koddos.net/ubuntu-releases/", + "country": "HK", + "name": "KoDDoS", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.01link.hk/ubuntu-releases/", + "country": "HK", + "name": "01Link Network Services Limited", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.xtom.com.hk/ubuntu-releases/", + "country": "HK", + "name": "xTom", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.niif.hu/ubuntu-releases/", + "country": "HU", + "name": "Governmental Agency for IT Development, Hungary", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://linux.domainesia.com/ubuntu/iso-release/", + "country": "ID", + "name": "DomaiNesia", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.citrahost.com/ubuntu-releases/", + "country": "ID", + "name": "Citrahost by Citranet", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.gi.co.id/ubuntu-releases/", + "country": "ID", + "name": "PT Global Intermedia Nusantara", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.jagoanhosting.id/ubuntu-releases/", + "country": "ID", + "name": "mirror.jagoanhosting.id", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.nevacloud.com/ubuntu/iso-release/", + "country": "ID", + "name": "Nevacloud", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.papua.go.id/ubuntu-releases/", + "country": "ID", + "name": "Pemerintah Provinsi Papua", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.unair.ac.id/ubuntu-cd/", + "country": "ID", + "name": "Universitas Airlangga", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://repo.usk.ac.id/iso/ubuntu/", + "country": "ID", + "name": "Universitas Syiah Kuala", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.pablosec.com/", + "country": "IE", + "name": "Pablosec Solutions", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.isoc.org.il/pub/ubuntu-releases/", + "country": "IL", + "name": "Israel Internet Association", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.ajl.albony.in/ubuntu-releases/", + "country": "IN", + "name": "Shrirang Kahale", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.bharatdatacenter.com/ubuntu-releases/", + "country": "IN", + "name": "Bharat Datacenter", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ir.releases.ubuntu.com/", + "country": "IR", + "name": "HostBaran", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ir.ubuntu.sindad.cloud/releases/", + "country": "IR", + "name": "Sindad LLC", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.famaserver.com/cd-image/", + "country": "IR", + "name": "FamaServer", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.parsdev.com/ubuntu-releases/", + "country": "IR", + "name": "ParsDev", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.rasanegaar.com/ubuntu-cd/", + "country": "IR", + "name": "Rasanegar", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.rasanegar.com/ubuntu-cd/", + "country": "IR", + "name": "Rasanegar ", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.mobinhost.com/releases/", + "country": "IR", + "name": "Mobinhost", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.opensource.is/ubuntu-releases/", + "country": "IS", + "name": "Opin Kerfi", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://it1.mirror.vhosting-it.com/ubuntu-releases/", + "country": "IT", + "name": "VHosting Solution (IT1 - Milan)", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://it2.mirror.vhosting-it.com/ubuntu-releases/", + "country": "IT", + "name": "VHosting Solution (IT2 - Turin)", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.mirror.garr.it/releases/", + "country": "IT", + "name": "Consortium GARR", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.maru.co.jp/ubuntu-releases/", + "country": "JP", + "name": "OTO GK", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.riken.jp/Linux/ubuntu-releases/", + "country": "JP", + "name": "RIKEN", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.tsukuba.wide.ad.jp/Linux/ubuntu-releases/", + "country": "JP", + "name": "Tsukuba WIDE", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.udx.icscoe.jp/Linux/ubuntu-releases/", + "country": "JP", + "name": "ICSCoE", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.yz.yamagata-u.ac.jp/pub/linux/ubuntu/releases/", + "country": "JP", + "name": "Yamagata University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.hashy0917.net/ubuntu-releases/", + "country": "JP", + "name": "hashy10G", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.lc/ubuntu-releases/", + "country": "JP", + "name": "mirror.lc - Releases", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.krnk.org/ubuntu-releases/", + "country": "JP", + "name": "KuronekoServer", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.mirror.ac.ke/ubuntu-release/", + "country": "KE", + "name": "KENET", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.kaist.ac.kr/ubuntu-cd/", + "country": "KR", + "name": "KAIST FTP", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.lanet.kr/ubuntu-releases/", + "country": "KR", + "name": "LANET", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://kr.mirror.tefexia.net/ubuntu-releases/", + "country": "KR", + "name": "Tefexia LC", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.kakao.com/ubuntu-releases/", + "country": "KR", + "name": "Kakao Corp.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.koreapixel.kr/ubuntu-releases/", + "country": "KR", + "name": "Daon Mirror", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.krfoss.org/ubuntu-releases/", + "country": "KR", + "name": "ROKFOSS PROJECT - CD", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.mahiro.kr/ubuntu-releases/", + "country": "KR", + "name": "Mahiro Network", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.siwoo.org/ubuntu-cd/", + "country": "KR", + "name": "Archive of Siwoo-CD", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.textcord.xyz/ubuntu-releases/", + "country": "KR", + "name": "TextCord", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.wane.kr/ubuntu-releases/", + "country": "KR", + "name": "WANE MIRROR", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.wonchan.net/ubuntu-cd/", + "country": "KR", + "name": "Wonchan Mirror - Ubuntu CD", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.zzunipark.com/ubuntu-releases/", + "country": "KR", + "name": "zzuniMirror", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.krfoss.org/ubuntu-releases/", + "country": "KR", + "name": "ROKFOSS PROJECT - CD", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.fst.kz/ubuntu-cd/", + "country": "KZ", + "name": "Fst.kz", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.hoster.kz/ubuntu-releases/", + "country": "KZ", + "name": "LLP Kompaniya Hoster.KZ", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.ps.kz/ubuntu-releases/", + "country": "KZ", + "name": "PS Internet Company LLC", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.bacloud.com/ubuntu-mirror/releases/", + "country": "LT", + "name": "Informacinės sistemos ir technologijos, UAB logotipas Informacinės sistemos ir technologijos, UAB", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu-release.mirror.serveriai.lt/", + "country": "LT", + "name": "Interneto Vizija", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.linux.edu.lv/ubuntu-releases/", + "country": "LV", + "name": "University of Latvia", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.koyanet.lv/releases/", + "country": "LV", + "name": "koyanet.lv", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.marwan.ma/ubuntu-releases/", + "country": "MA", + "name": "MARWAN", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.ihost.md/ubuntu-releases/", + "country": "MD", + "name": "iHost", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.mangohost.net/ubuntu-releases/", + "country": "MD", + "name": "MangoHost", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.t-home.mk/ubuntu-releases/", + "country": "MK", + "name": "Macedonian Telecom", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.gbnetwork.com/ubuntu-releases/", + "country": "MY", + "name": "GB Network Solutions", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.ipserverone.com/ubuntu-releases/", + "country": "MY", + "name": "IP Serverone Solutions Sdn Bhd", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.tuxuri.com/releases/", + "country": "MY", + "name": "Tuxuri", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntureleases.mirror.thegigabit.com/", + "country": "MY", + "name": "Gigabit Hosting Sdn Bhd", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.nluug.nl/os/Linux/distr/ubuntu-releases/", + "country": "NL", + "name": "Nluug", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.snt.utwente.nl/pub/linux/ubuntu-releases/", + "country": "NL", + "name": "University of Twente", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.snt.utwente.nl/pub/os/linux/ubuntu-releases/", + "country": "NL", + "name": "University of Twente, The Netherlands", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.mijn.host/ubuntu-releases/", + "country": "NL", + "name": "mijn.host", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.nl.altushost.com/ubuntu-releases/", + "country": "NL", + "name": "AltusHost B.V.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.nl.leaseweb.net/ubuntu-releases/", + "country": "NL", + "name": "LeaseWeb Global B.V.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.previder.nl/ubuntu-releases/", + "country": "NL", + "name": "Previder B.V.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.tngnet.com/releases/", + "country": "NL", + "name": "TNGNET B.V.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.hostiserver.com/ubuntu-releases/", + "country": "NL", + "name": "IT Services Company S.R.O.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.xtom.nl/ubuntu-releases/", + "country": "NL", + "name": "xTom", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.mirror.wearetriple.com/releases/", + "country": "NL", + "name": "Triple", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.hi.no/releases/", + "country": "NO", + "name": "Institute of Marine Research", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.nepalicloud.com/ubuntu-releases/", + "country": "NP", + "name": "Nepali Cloud", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.2degrees.nz/ubuntu-releases/", + "country": "NZ", + "name": "2degrees", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.icm.edu.pl/pub/Linux/ubuntu-releases/", + "country": "PL", + "name": "ICM - Interdisciplinary Centre for Mathematical and Computational Modelling", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.man.lodz.pl/ubuntu-releases/", + "country": "PL", + "name": "Miejska Sieć Komputerowa LODMAN / Politechnika Łódzka UCI", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.upr.edu/ubuntu-releases/", + "country": "PR", + "name": "University of Puerto Rico", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.rnl.tecnico.ulisboa.pt/pub/ubuntu/releases/", + "country": "PT", + "name": "Rede das Novas Licenciaturas - Técnico Lisboa", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://glua.ua.pt/pub/ubuntu-releases/", + "country": "PT", + "name": "Grupo de Linux da Universidade de Aveiro", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.up.pt/pub/ubuntu-releases/", + "country": "PT", + "name": "Universidade do Porto", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.lug.ro/ubuntu-releases/", + "country": "RO", + "name": "ftp.lug.ro", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.efect.ro/ubuntu/releases/", + "country": "RO", + "name": "efectRO", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.flo.c-f.ro/ubuntu-releases/", + "country": "RO", + "name": "Cyber_Folks", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.chroot.ro/ubuntu-releases/", + "country": "RO", + "name": "Chroot", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.hostico.ro/ubuntu/releases/", + "country": "RO", + "name": "Hostico", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.nav.ro/ubuntu-releases/", + "country": "RO", + "name": "NAV Communications", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.nxthost.com/ubuntu-releases/", + "country": "RO", + "name": "NXTHOST.COM", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.pidginhost.com/ubuntu-releases/", + "country": "RO", + "name": "Pidgin Host", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror1.sox.rs/ubuntu/releases/", + "country": "RS", + "name": "Serbian Open eXchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.corbina.net/ubuntu-cd/", + "country": "RU", + "name": "Beeline", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.docker.ru/ubuntu-releases/", + "country": "RU", + "name": "mirror.docker.ru-release", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.team-host.ru/ubuntu-cdimage/", + "country": "RU", + "name": "team-host", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.timeweb.ru/ubuntu-releases/", + "country": "RU", + "name": "TIMEWEB", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.truenetwork.ru/ubuntu-releases/", + "country": "RU", + "name": "Truenetwork", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.yandex.ru/ubuntu-releases/", + "country": "RU", + "name": "Yandex", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.maeen.sa/apt-mirror-releases/", + "country": "SA", + "name": "Maeen Network", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.lysator.liu.se/ubuntu-releases/", + "country": "SE", + "name": "Lysator", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftpmirror.infania.net/mirror/ubuntu-releases/", + "country": "SE", + "name": "Infania Networks", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.bahnhof.net/ubuntu-releases/", + "country": "SE", + "name": "Bahnhof", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.improve.nordlo.cloud/ubuntu-cd/", + "country": "SE", + "name": "Nordlo Group AB", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.retropc.se/ubuntu-releases/", + "country": "SE", + "name": "Swedish Retro Computer Association", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu-releases.braindrainlan.nu/", + "country": "SE", + "name": "Brain Drain Lan", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.arnes.si/pub/mirrors/ubuntu-cdimage/", + "country": "SI", + "name": "Arnes", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.cmkl.ac.th/ubuntu-releases/", + "country": "TH", + "name": "CMKL University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.kku.ac.th/ubuntu-releases/", + "country": "TH", + "name": "Khon Kaen University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.cloudforest.co.th/ubuntu-releases/", + "country": "TH", + "name": "CLOUDFOREST CO., LTD.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu-releases.sit.kmutt.ac.th/", + "country": "TH", + "name": "School of Information Technology, KMUTT", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.linux.org.tr/ubuntu-releases/", + "country": "TR", + "name": "Linux Kullanıcıları Derneği", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.ulak.net.tr/ubuntu-releases/", + "country": "TR", + "name": "ULAKBIM", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.alastyr.com/ubuntu/ubuntu-releases/", + "country": "TR", + "name": "ALASTYR", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.domainhizmetleri.com/ubuntu-releases/", + "country": "TR", + "name": "Domainhizmetleri.com", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.rabisu.com/ubuntu/ubuntu-releases/", + "country": "TR", + "name": "Rabisu", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.verinomi.com/ubuntu/ubuntu-releases/", + "country": "TR", + "name": "Verinomi", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.veritescil.com.tr/ubuntu-releases/", + "country": "TR", + "name": "UPCELL ANKARA DATACENTER", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://free.nchc.org.tw/ubuntu-cd/", + "country": "TW", + "name": "NCHC, Taiwan", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.tc.edu.tw/iso/Ubuntu/", + "country": "TW", + "name": "TaiChung City Education Network Center", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.twds.com.tw/ubuntu-releases/", + "country": "TW", + "name": "Taiwan Digital Streaming Co.", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.ccns.ncku.edu.tw/ubuntu-cd/", + "country": "TW", + "name": "NCKU CCNS", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://distrohub.kyiv.ua/ubuntucd/", + "country": "UA", + "name": "Distrohub", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.hostiko.network/ubuntu-releases/", + "country": "UA", + "name": "Hostiko", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu-releases.ip-connect.vn.ua/", + "country": "UA", + "name": "IP-Connect LLC", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.volia.net/ubuntu-releases/", + "country": "UA", + "name": "Volia Broadband", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://abqix.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://cfhcable.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://codingflyboy.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "FCIX/CodingFlyBoy MicroMirror", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://cofractal-ewr.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://cofractal-sea.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://coresite-atl.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://coresite.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://forksystems.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ftp.ussg.iu.edu/linux/ubuntu-releases/", + "country": "US", + "name": "ftp.ussg.iu.edu", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://gigsouth.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://insect.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://irltoolkit.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://isos.ubuntu.mirror.constant.com/", + "country": "US", + "name": "Constant.com", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ix-denver.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://lolhost.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.arizona.edu/ubuntu-releases/", + "country": "US", + "name": "University of Arizona", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.clarkson.edu/ubuntu-releases/", + "country": "US", + "name": "Clarkson University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.cs.jmu.edu/pub/ubuntu-iso/", + "country": "US", + "name": "James Madison University Department of Computer Science", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.cs.odu.edu/ubuntu-releases/", + "country": "US", + "name": "Old Dominion University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.lstn.net/ubuntu-releases/", + "country": "US", + "name": "Limestone Networks", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.mrjester.net/ubuntu/release/", + "country": "US", + "name": "MrJester", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.nexcess.net/ubuntu-releases/", + "country": "US", + "name": "Nexcess", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.pilotfiber.com/ubuntu-iso/", + "country": "US", + "name": "Pilot Fiber", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.pnl.gov/releases/", + "country": "US", + "name": "Pacific NorthWest National Lab", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.steadfastnet.com/ubuntu-releases/", + "country": "US", + "name": "Steadfast Networks", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.techrich.hk/ubuntu/", + "country": "US", + "name": "Techrich", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.umd.edu/ubuntu-iso/", + "country": "US", + "name": "University of Maryland", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.us.leaseweb.net/ubuntu-releases/", + "country": "US", + "name": "LeaseWeb USA, Inc", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirror.wayne.edu/ubuntu/releases/", + "country": "US", + "name": "Wayne State University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.bmcc.edu/ubuntu-isos/", + "country": "US", + "name": "Bay Mills Community College", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.cat.pdx.edu/ubuntu-releases/", + "country": "US", + "name": "Portland State University", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.gigenet.com/ubuntu-releases/", + "country": "US", + "name": "GigeNET", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.gigenet.com/ubuntucd/", + "country": "US", + "name": "GigeNET", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.iu13.net/ubuntu-releases/", + "country": "US", + "name": "Lancaster-Lebanon IU13", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.lug.mtu.edu/ubuntu-releases/", + "country": "US", + "name": "MTU Linux Users Group", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.mit.edu/ubuntu-releases/", + "country": "US", + "name": "MIT SIPB", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.netmanageit.com/ubuntu-releases/", + "country": "US", + "name": "NetmanageIT", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.ocf.berkeley.edu/ubuntu-releases/", + "country": "US", + "name": "Open Computing Facility at UC Berkeley", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.rit.edu/ubuntu-releases/", + "country": "US", + "name": "Rochester Institute of Technology", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.sarak.as/ubuntu-releases/", + "country": "US", + "name": "Sarakas", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.tscak.com/ubuntu-releases/", + "country": "US", + "name": "Snowcloud Services", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.xmission.com/ubuntu-cd/", + "country": "US", + "name": "Xmission", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://mirrors.xtom.com/ubuntu-releases/", + "country": "US", + "name": "xTom", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://nnenix.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://nocix.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://nullroute.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ohioix.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://opencolo.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://paducahix.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ryamer.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://solidrock.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://southfront.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.mirror.shastacoe.net/ubuntu-releases/", + "country": "US", + "name": "Shasta County Office of Education", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.mirrors.hoobly.com/", + "country": "US", + "name": "Hoobly Classifieds", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.osuosl.org/releases/", + "country": "US", + "name": "OSU Open Source Lab", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://veronanetworks.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://volico.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ziply.mm.fcix.net/ubuntu-releases/", + "country": "US", + "name": "Fremont Cabal Internet Exchange", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://repo.cure.edu.uy/ubuntu/distribuciones/", + "country": "UY", + "name": "UDELAR-CURE", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://repos.interior.edu.uy/releases/", + "country": "UY", + "name": "Universidad de la República", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu.repo.cure.edu.uy/ubuntu-releases/", + "country": "UY", + "name": "UDELAR-CURE", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://vn-mirrors.vhost.vn/ubuntu-release/", + "country": "VN", + "name": "vHost", + "lat": null, + "lon": null, + "score": null + }, + { + "url": "https://ubuntu-releases.mirror.net.za/", + "country": "ZA", + "name": "South African Mirror Project", + "lat": null, + "lon": null, + "score": null + } + ] +} diff --git a/src/data/releases/arch.json b/src/data/releases/arch.json new file mode 100644 index 0000000..a8f6331 --- /dev/null +++ b/src/data/releases/arch.json @@ -0,0 +1,75 @@ +{ + "slug": "arch", + "sources": [ + "https://archlinux.org/releng/releases/json/" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "2026.10.01", + "channel": "rolling", + "codename": null, + "releaseDate": "2026-10-01", + "eol": null, + "notesUrl": "https://archlinux.org/releng/releases/2026.10.01/", + "editions": [ + { + "id": "live", + "name": "Live ISO", + "desktop": "none", + "kind": "live", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "archlinux-2026.10.01-x86_64.iso", + "path": "iso/2026.10.01/archlinux-2026.10.01-x86_64.iso", + "url": "https://geo.mirror.pkgbuild.com/iso/2026.10.01/archlinux-2026.10.01-x86_64.iso", + "size": 1640497152, + "checksum": { + "type": "sha256", + "value": "684ded26c63240ff4a41e8c25ee84ea6da233f557364821f13d12c2b0a9059a5", + "url": "https://archlinux.org/iso/2026.10.01/sha256sums.txt" + }, + "signatureUrl": "https://archlinux.org/iso/2026.10.01/archlinux-2026.10.01-x86_64.iso.sig", + "torrentUrl": "https://archlinux.org/releng/releases/2026.10.01/torrent/" + } + ] + } + ] + }, + { + "version": "2026.09.01", + "channel": "rolling", + "codename": null, + "releaseDate": "2026-09-01", + "eol": null, + "notesUrl": "https://archlinux.org/releng/releases/2026.09.01/", + "editions": [ + { + "id": "live", + "name": "Live ISO", + "desktop": "none", + "kind": "live", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "archlinux-2026.09.01-x86_64.iso", + "path": "iso/2026.09.01/archlinux-2026.09.01-x86_64.iso", + "url": "https://geo.mirror.pkgbuild.com/iso/2026.09.01/archlinux-2026.09.01-x86_64.iso", + "size": 1608286208, + "checksum": { + "type": "sha256", + "value": "be8458032f8105e60ee2a3067f950b6e3c007ee51b38dac50e8b48e765561c91", + "url": "https://archlinux.org/iso/2026.09.01/sha256sums.txt" + }, + "signatureUrl": "https://archlinux.org/iso/2026.09.01/archlinux-2026.09.01-x86_64.iso.sig", + "torrentUrl": "https://archlinux.org/releng/releases/2026.09.01/torrent/" + } + ] + } + ] + } + ] +} diff --git a/src/data/releases/debian.json b/src/data/releases/debian.json new file mode 100644 index 0000000..2d4fc40 --- /dev/null +++ b/src/data/releases/debian.json @@ -0,0 +1,221 @@ +{ + "slug": "debian", + "sources": [ + "https://deb.debian.org/debian/dists/stable/Release", + "https://cdimage.debian.org/debian-cd/current/", + "https://cdimage.debian.org/debian-cd/current-live/", + "https://endoflife.date/debian" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "13.7", + "channel": "stable", + "codename": "Trixie", + "releaseDate": "2025-08-09", + "eol": "2030-06-30", + "notesUrl": "https://www.debian.org/releases/trixie/releasenotes", + "editions": [ + { + "id": "netinst", + "name": "netinst", + "desktop": null, + "kind": "netinst", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "debian-13.7.0-amd64-netinst.iso", + "path": "13.7.0/amd64/iso-cd/debian-13.7.0-amd64-netinst.iso", + "url": "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-13.7.0-amd64-netinst.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355", + "url": "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/SHA256SUMS.sign", + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "debian-13.7.0-arm64-netinst.iso", + "path": "13.7.0/arm64/iso-cd/debian-13.7.0-arm64-netinst.iso", + "url": "https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/debian-13.7.0-arm64-netinst.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "6e93fa1759bd9d4b0fc11e938987de6967ee7de5297dac1be27c3a75cc17024b", + "url": "https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/SHA256SUMS.sign", + "torrentUrl": null + } + ] + }, + { + "id": "live-gnome", + "name": "Live GNOME", + "desktop": "gnome", + "kind": "live", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "debian-live-13.7.0-amd64-gnome.iso", + "path": "13.7.0-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-gnome.iso", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-gnome.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "e94859a83b305cae5125dc9c6080ced040c59d099ea7381e7f300fd1dba87a17", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS.sign", + "torrentUrl": null + } + ] + }, + { + "id": "live-kde", + "name": "Live KDE Plasma", + "desktop": "kde", + "kind": "live", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "debian-live-13.7.0-amd64-kde.iso", + "path": "13.7.0-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-kde.iso", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-kde.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "37dedc921f50325665c75829597126b3eeb70ff5cd8c511a7230ace09ae939be", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS.sign", + "torrentUrl": null + } + ] + }, + { + "id": "live-xfce", + "name": "Live Xfce", + "desktop": "xfce", + "kind": "live", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "debian-live-13.7.0-amd64-xfce.iso", + "path": "13.7.0-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-xfce.iso", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-xfce.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "379fcf159fa72a2ed8e3b74f4047df6061bc65adf5217e3d194e41a26fdb85e4", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS.sign", + "torrentUrl": null + } + ] + }, + { + "id": "live-cinnamon", + "name": "Live Cinnamon", + "desktop": "cinnamon", + "kind": "live", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "debian-live-13.7.0-amd64-cinnamon.iso", + "path": "13.7.0-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-cinnamon.iso", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-cinnamon.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "678b7c67ca3ab507a457fe1a0495e4030564177ea17387af1c743c969cdc8e6c", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS.sign", + "torrentUrl": null + } + ] + }, + { + "id": "live-mate", + "name": "Live MATE", + "desktop": "mate", + "kind": "live", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "debian-live-13.7.0-amd64-mate.iso", + "path": "13.7.0-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-mate.iso", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-mate.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "504f8a6e6eede8450c90a848bca52a04b2bf9e8859be6fc533435c33ce114bb9", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS.sign", + "torrentUrl": null + } + ] + }, + { + "id": "live-lxqt", + "name": "Live LXQt", + "desktop": "lxqt", + "kind": "live", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "debian-live-13.7.0-amd64-lxqt.iso", + "path": "13.7.0-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-lxqt.iso", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-lxqt.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "40413e0d337dd104a85441c657d13485e481dcc54227bd56696e6e33457219fb", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS.sign", + "torrentUrl": null + } + ] + }, + { + "id": "live-lxde", + "name": "Live LXDE", + "desktop": "lxde", + "kind": "live", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "debian-live-13.7.0-amd64-lxde.iso", + "path": "13.7.0-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-lxde.iso", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/debian-live-13.7.0-amd64-lxde.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "45c48b8782c958125728663b41b17fa7b9300eafd802c2f5f761c831d0b4d48b", + "url": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/SHA256SUMS.sign", + "torrentUrl": null + } + ] + } + ] + } + ] +} diff --git a/src/data/releases/fedora.json b/src/data/releases/fedora.json new file mode 100644 index 0000000..bbf4fb3 --- /dev/null +++ b/src/data/releases/fedora.json @@ -0,0 +1,1846 @@ +{ + "slug": "fedora", + "sources": [ + "https://fedoraproject.org/releases.json", + "https://endoflife.date/fedora" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "45-beta", + "channel": "beta", + "codename": null, + "releaseDate": null, + "eol": null, + "notesUrl": null, + "editions": [ + { + "id": "workstation", + "name": "Workstation", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Workstation-Live-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Workstation/x86_64/iso/Fedora-Workstation-Live-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Workstation/x86_64/iso/Fedora-Workstation-Live-45_Beta-1.3.x86_64.iso", + "size": 2981865472, + "checksum": { + "type": "sha256", + "value": "71367760b4cfda9cb5a1dbe9875a2c5fd89ef8333e6fac7508c366e650207905", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Workstation-Live-45_Beta-1.3.aarch64.iso", + "path": "fedora/linux/releases/test/45_Beta/Workstation/aarch64/iso/Fedora-Workstation-Live-45_Beta-1.3.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Workstation/aarch64/iso/Fedora-Workstation-Live-45_Beta-1.3.aarch64.iso", + "size": 2900705280, + "checksum": { + "type": "sha256", + "value": "fffb36568eb9a519aea2290a959570ad6df4acb87ada5e48cb86834ca047e5f8", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "kde", + "name": "KDE Plasma Desktop", + "desktop": "kde", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-KDE-Desktop-Live-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/KDE/x86_64/iso/Fedora-KDE-Desktop-Live-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/KDE/x86_64/iso/Fedora-KDE-Desktop-Live-45_Beta-1.3.x86_64.iso", + "size": 3584274432, + "checksum": { + "type": "sha256", + "value": "cd2321a8537667e0017efc3371bac191d7747f834da8fc8c30c88f00a480811e", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-KDE-Desktop-Live-45_Beta-1.3.aarch64.iso", + "path": "fedora/linux/releases/test/45_Beta/KDE/aarch64/iso/Fedora-KDE-Desktop-Live-45_Beta-1.3.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/KDE/aarch64/iso/Fedora-KDE-Desktop-Live-45_Beta-1.3.aarch64.iso", + "size": 3416629248, + "checksum": { + "type": "sha256", + "value": "3599fdeb3aa934db2730ef4e2d07f9b7259d47d83e4ceb106f7b2e8e1599a85c", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-KDE-Desktop-Live-45_Beta-1.3.ppc64le.iso", + "path": "fedora-secondary/releases/test/45_Beta/KDE/ppc64le/iso/Fedora-KDE-Desktop-Live-45_Beta-1.3.ppc64le.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/test/45_Beta/KDE/ppc64le/iso/Fedora-KDE-Desktop-Live-45_Beta-1.3.ppc64le.iso", + "size": 3255631872, + "checksum": { + "type": "sha256", + "value": "21674388ccf65bcd28353217944993fe1cca3ca591ae4547b7fecf0ab2b51486", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "server", + "name": "Server", + "desktop": null, + "kind": "server", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Server-dvd-x86_64-45_Beta-1.3.iso", + "path": "fedora/linux/releases/test/45_Beta/Server/x86_64/iso/Fedora-Server-dvd-x86_64-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Server/x86_64/iso/Fedora-Server-dvd-x86_64-45_Beta-1.3.iso", + "size": 4294180864, + "checksum": { + "type": "sha256", + "value": "ca6f37258d3ccc35845cdd4eaa25f36b3f22e460fa06b4b66336fd8ed7c39cca", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Server-dvd-aarch64-45_Beta-1.3.iso", + "path": "fedora/linux/releases/test/45_Beta/Server/aarch64/iso/Fedora-Server-dvd-aarch64-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Server/aarch64/iso/Fedora-Server-dvd-aarch64-45_Beta-1.3.iso", + "size": 4075683840, + "checksum": { + "type": "sha256", + "value": "05292211793482d24ea41b7648563c0a154f4b62e2984f02d28056bb40c622f3", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Server-dvd-ppc64le-45_Beta-1.3.iso", + "path": "fedora-secondary/releases/test/45_Beta/Server/ppc64le/iso/Fedora-Server-dvd-ppc64le-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/test/45_Beta/Server/ppc64le/iso/Fedora-Server-dvd-ppc64le-45_Beta-1.3.iso", + "size": 3308978176, + "checksum": { + "type": "sha256", + "value": "8596247f31d3ddd786e37a37350cda2e53f7384fba6dea5cadad0ae4029336e7", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "s390x", + "format": "iso", + "file": "Fedora-Server-dvd-s390x-45_Beta-1.3.iso", + "path": "fedora-secondary/releases/test/45_Beta/Server/s390x/iso/Fedora-Server-dvd-s390x-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/test/45_Beta/Server/s390x/iso/Fedora-Server-dvd-s390x-45_Beta-1.3.iso", + "size": 2623864832, + "checksum": { + "type": "sha256", + "value": "a0a1477b9267533b24c77523cd9b2c55f6ff6399f454c63ec03f894bb97b3c72", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "server-netinst", + "name": "Server (netinstall)", + "desktop": null, + "kind": "netinst", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Server-netinst-x86_64-45_Beta-1.3.iso", + "path": "fedora/linux/releases/test/45_Beta/Server/x86_64/iso/Fedora-Server-netinst-x86_64-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Server/x86_64/iso/Fedora-Server-netinst-x86_64-45_Beta-1.3.iso", + "size": 1324955648, + "checksum": { + "type": "sha256", + "value": "426f8f5280053889d1b93ed8159a817ca577ffb6f47a81253f58acd8e49280e9", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Server-netinst-aarch64-45_Beta-1.3.iso", + "path": "fedora/linux/releases/test/45_Beta/Server/aarch64/iso/Fedora-Server-netinst-aarch64-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Server/aarch64/iso/Fedora-Server-netinst-aarch64-45_Beta-1.3.iso", + "size": 1406869504, + "checksum": { + "type": "sha256", + "value": "31dad075a79020515e58140c5778f6bdf04efa7e4a3b41182bb5e31992c27bd8", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Server-netinst-ppc64le-45_Beta-1.3.iso", + "path": "fedora-secondary/releases/test/45_Beta/Server/ppc64le/iso/Fedora-Server-netinst-ppc64le-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/test/45_Beta/Server/ppc64le/iso/Fedora-Server-netinst-ppc64le-45_Beta-1.3.iso", + "size": 1255585792, + "checksum": { + "type": "sha256", + "value": "33c25030a4f8e446ad543f53ca9cb0b8b67bad1e40a09ac5f9dd273062168294", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "s390x", + "format": "iso", + "file": "Fedora-Server-netinst-s390x-45_Beta-1.3.iso", + "path": "fedora-secondary/releases/test/45_Beta/Server/s390x/iso/Fedora-Server-netinst-s390x-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/test/45_Beta/Server/s390x/iso/Fedora-Server-netinst-s390x-45_Beta-1.3.iso", + "size": 670525440, + "checksum": { + "type": "sha256", + "value": "e501f5d42f84c4fa6a9f3a5a53298c99446eb7c5ace4cde053e62a951655fcfe", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "everything", + "name": "Everything (netinstall)", + "desktop": null, + "kind": "netinst", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Everything-netinst-x86_64-45_Beta-1.3.iso", + "path": "fedora/linux/releases/test/45_Beta/Everything/x86_64/iso/Fedora-Everything-netinst-x86_64-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Everything/x86_64/iso/Fedora-Everything-netinst-x86_64-45_Beta-1.3.iso", + "size": 1313835008, + "checksum": { + "type": "sha256", + "value": "5d5a7592e6759c37367ac3ef93212307f484c6ca8f5fb6a4c368efa59ecdaf00", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Everything-netinst-aarch64-45_Beta-1.3.iso", + "path": "fedora/linux/releases/test/45_Beta/Everything/aarch64/iso/Fedora-Everything-netinst-aarch64-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Everything/aarch64/iso/Fedora-Everything-netinst-aarch64-45_Beta-1.3.iso", + "size": 1396703232, + "checksum": { + "type": "sha256", + "value": "5489a6a830eb9843a0a0771a836aecaa6f1833b4ac06308fa8e27d637072b210", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Everything-netinst-ppc64le-45_Beta-1.3.iso", + "path": "fedora-secondary/releases/test/45_Beta/Everything/ppc64le/iso/Fedora-Everything-netinst-ppc64le-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/test/45_Beta/Everything/ppc64le/iso/Fedora-Everything-netinst-ppc64le-45_Beta-1.3.iso", + "size": 1244028928, + "checksum": { + "type": "sha256", + "value": "70e837079906332d1f117e4b3c8d73282a5ba37e21ede1ec82ec59877abcd470", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "s390x", + "format": "iso", + "file": "Fedora-Everything-netinst-s390x-45_Beta-1.3.iso", + "path": "fedora-secondary/releases/test/45_Beta/Everything/s390x/iso/Fedora-Everything-netinst-s390x-45_Beta-1.3.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/test/45_Beta/Everything/s390x/iso/Fedora-Everything-netinst-s390x-45_Beta-1.3.iso", + "size": 659478528, + "checksum": { + "type": "sha256", + "value": "5450e3982d90f3292a0f86aa019a1770f0cb2d6da7ec0b7aeecd64fd9c5bdac2", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "silverblue", + "name": "Silverblue", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Silverblue-Installer-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Silverblue/x86_64/iso/Fedora-Silverblue-Installer-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Silverblue/x86_64/iso/Fedora-Silverblue-Installer-45_Beta-1.3.x86_64.iso", + "size": 3253975040, + "checksum": { + "type": "sha256", + "value": "5a0d75fde6f978178f13a4c3373f0916d6ac259adcd13f7c57ed29398ea1189b", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Silverblue-Installer-45_Beta-1.3.aarch64.iso", + "path": "fedora/linux/releases/test/45_Beta/Silverblue/aarch64/iso/Fedora-Silverblue-Installer-45_Beta-1.3.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Silverblue/aarch64/iso/Fedora-Silverblue-Installer-45_Beta-1.3.aarch64.iso", + "size": 3153141760, + "checksum": { + "type": "sha256", + "value": "cda78b8c355442fe1f704b0519cedb3a038011cf49df75f9528321c0f994cac5", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "kinoite", + "name": "Kinoite", + "desktop": "kde", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Kinoite-Installer-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Kinoite/x86_64/iso/Fedora-Kinoite-Installer-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Kinoite/x86_64/iso/Fedora-Kinoite-Installer-45_Beta-1.3.x86_64.iso", + "size": 3968667648, + "checksum": { + "type": "sha256", + "value": "770ed28b32d60260e46813e20dd13a5eee223d93ea3626e647db5ec8f5ea7b7d", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Kinoite-Installer-45_Beta-1.3.aarch64.iso", + "path": "fedora/linux/releases/test/45_Beta/Kinoite/aarch64/iso/Fedora-Kinoite-Installer-45_Beta-1.3.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Kinoite/aarch64/iso/Fedora-Kinoite-Installer-45_Beta-1.3.aarch64.iso", + "size": 3825942528, + "checksum": { + "type": "sha256", + "value": "79a0b6011213563ac3d26823ae8ca2895e917d287e2a9b4c43d691468cfe9bef", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "xfce", + "name": "Xfce", + "desktop": "xfce", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Xfce-Live-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-Xfce-Live-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-Xfce-Live-45_Beta-1.3.x86_64.iso", + "size": 3044065280, + "checksum": { + "type": "sha256", + "value": "bbbfa9b4477caa257e2981ce13db5964fadc22683780a82c3bbe18bfda4f583d", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Xfce-Live-45_Beta-1.3.aarch64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/aarch64/iso/Fedora-Xfce-Live-45_Beta-1.3.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/aarch64/iso/Fedora-Xfce-Live-45_Beta-1.3.aarch64.iso", + "size": 2893799424, + "checksum": { + "type": "sha256", + "value": "b769e44b72b8a7c89bb1663332d2f076371bffea3c1e72a41becdcf18031aa59", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "cinnamon", + "name": "Cinnamon", + "desktop": "cinnamon", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Cinnamon-Live-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-Cinnamon-Live-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-Cinnamon-Live-45_Beta-1.3.x86_64.iso", + "size": 3364052992, + "checksum": { + "type": "sha256", + "value": "71fae7165e2308eea9025771b29d7db49ce03389aa9079c5206b94ac1f402af8", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "lxqt", + "name": "LXQt", + "desktop": "lxqt", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-LXQt-Live-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-LXQt-Live-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-LXQt-Live-45_Beta-1.3.x86_64.iso", + "size": 2595600384, + "checksum": { + "type": "sha256", + "value": "597d5d54af99d0a1729567db0763cf96ebfc70c79877dbb6d32d9a0e1b2ea4fd", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-LXQt-Live-45_Beta-1.3.aarch64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/aarch64/iso/Fedora-LXQt-Live-45_Beta-1.3.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/aarch64/iso/Fedora-LXQt-Live-45_Beta-1.3.aarch64.iso", + "size": 2465863680, + "checksum": { + "type": "sha256", + "value": "f4d4771cd5dbc4b63ebfa8138cd53a2bdcbbbd1baa8446dc6f7a50c0e14aa1ec", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "budgie", + "name": "Budgie", + "desktop": "budgie", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Budgie-Live-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-Budgie-Live-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-Budgie-Live-45_Beta-1.3.x86_64.iso", + "size": 3187652608, + "checksum": { + "type": "sha256", + "value": "8b8b3c19ec7b04846e626c972aca2c6a876e4e9237bc9862c0f317e91e2652b0", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "cosmic", + "name": "COSMIC", + "desktop": "cosmic", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-COSMIC-Live-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-COSMIC-Live-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-COSMIC-Live-45_Beta-1.3.x86_64.iso", + "size": 3320791040, + "checksum": { + "type": "sha256", + "value": "b9cada6c69c40cdb82508920510265d1c886919fc5ecb73509bdf80e85a48442", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-COSMIC-Live-45_Beta-1.3.aarch64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/aarch64/iso/Fedora-COSMIC-Live-45_Beta-1.3.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/aarch64/iso/Fedora-COSMIC-Live-45_Beta-1.3.aarch64.iso", + "size": 3129647104, + "checksum": { + "type": "sha256", + "value": "737148353721905bf98369fa9a8e4246742230e936eafada5feba2589fa520ec", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "sway", + "name": "Sway", + "desktop": "sway", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Sway-Live-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-Sway-Live-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-Sway-Live-45_Beta-1.3.x86_64.iso", + "size": 2515959808, + "checksum": { + "type": "sha256", + "value": "1bb292150fb68aaa99ee089c383e1e3e667d68dfdefd431e2e5ef4f715b3b32e", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "i3", + "name": "i3", + "desktop": "i3", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-i3-Live-45_Beta-1.3.x86_64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-i3-Live-45_Beta-1.3.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/x86_64/iso/Fedora-i3-Live-45_Beta-1.3.x86_64.iso", + "size": 2529562624, + "checksum": { + "type": "sha256", + "value": "69e6b7f0d726d01e2549e6bb012fb22a42144ac7133e0b262ebc0acdb4b3a676", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-i3-Live-45_Beta-1.3.aarch64.iso", + "path": "fedora/linux/releases/test/45_Beta/Spins/aarch64/iso/Fedora-i3-Live-45_Beta-1.3.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/test/45_Beta/Spins/aarch64/iso/Fedora-i3-Live-45_Beta-1.3.aarch64.iso", + "size": 2407921664, + "checksum": { + "type": "sha256", + "value": "78a64e6636d005303554ddc7fe19266c31199fd52dd1f8eb922c67a21ddc5dc1", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + } + ] + }, + { + "version": "44", + "channel": "stable", + "codename": null, + "releaseDate": "2026-04-28", + "eol": "2027-06-02", + "notesUrl": "https://docs.fedoraproject.org/en-US/fedora/f44/release-notes/", + "editions": [ + { + "id": "workstation", + "name": "Workstation", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Workstation-Live-44-1.7.x86_64.iso", + "path": "fedora/linux/releases/44/Workstation/x86_64/iso/Fedora-Workstation-Live-44-1.7.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Workstation/x86_64/iso/Fedora-Workstation-Live-44-1.7.x86_64.iso", + "size": 2851612672, + "checksum": { + "type": "sha256", + "value": "1620295f6a00c27c3208f0c00b8ece4eab1ec69b9002152d97488bf26a426ddf", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Workstation-Live-44-1.7.aarch64.iso", + "path": "fedora/linux/releases/44/Workstation/aarch64/iso/Fedora-Workstation-Live-44-1.7.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Workstation/aarch64/iso/Fedora-Workstation-Live-44-1.7.aarch64.iso", + "size": 2689781760, + "checksum": { + "type": "sha256", + "value": "162ba3c552a2d241c7c63ec26777af0255ee1b5a135adc0be986ceed999933ef", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Workstation-Live-44-1.7.ppc64le.iso", + "path": "fedora-secondary/releases/44/Workstation/ppc64le/iso/Fedora-Workstation-Live-44-1.7.ppc64le.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/44/Workstation/ppc64le/iso/Fedora-Workstation-Live-44-1.7.ppc64le.iso", + "size": 2488926208, + "checksum": { + "type": "sha256", + "value": "461587a61e75f6012118d3e62f0f2439022e8b9c09071a73a7e57b580ec16e36", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "kde", + "name": "KDE Plasma Desktop", + "desktop": "kde", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-KDE-Desktop-Live-44-1.7.x86_64.iso", + "path": "fedora/linux/releases/44/KDE/x86_64/iso/Fedora-KDE-Desktop-Live-44-1.7.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/KDE/x86_64/iso/Fedora-KDE-Desktop-Live-44-1.7.x86_64.iso", + "size": 3368683520, + "checksum": { + "type": "sha256", + "value": "c8295961d4c41adbf785a31a17c21a971d3b7415fda72dcad0c11c49577bf03a", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-KDE-Desktop-Live-44-1.7.aarch64.iso", + "path": "fedora/linux/releases/44/KDE/aarch64/iso/Fedora-KDE-Desktop-Live-44-1.7.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/KDE/aarch64/iso/Fedora-KDE-Desktop-Live-44-1.7.aarch64.iso", + "size": 3119904768, + "checksum": { + "type": "sha256", + "value": "61b39df9bb19aff38b7b7c81d7b2a647aa43948b6683c8467091e0edce833a94", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-KDE-Desktop-Live-44-1.7.ppc64le.iso", + "path": "fedora-secondary/releases/44/KDE/ppc64le/iso/Fedora-KDE-Desktop-Live-44-1.7.ppc64le.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/44/KDE/ppc64le/iso/Fedora-KDE-Desktop-Live-44-1.7.ppc64le.iso", + "size": 3033858048, + "checksum": { + "type": "sha256", + "value": "22468ad32c329db7c95a3b90e681043ffac0e3ae472418a39493ecac4b566e16", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "server", + "name": "Server", + "desktop": null, + "kind": "server", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Server-dvd-x86_64-44-1.7.iso", + "path": "fedora/linux/releases/44/Server/x86_64/iso/Fedora-Server-dvd-x86_64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Server/x86_64/iso/Fedora-Server-dvd-x86_64-44-1.7.iso", + "size": 3913023488, + "checksum": { + "type": "sha256", + "value": "85837793bfa36db6bc709b4cecd2ec116951b87d9c53c3d95eb2fac8dcf7cf1f", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Server-dvd-aarch64-44-1.7.iso", + "path": "fedora/linux/releases/44/Server/aarch64/iso/Fedora-Server-dvd-aarch64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Server/aarch64/iso/Fedora-Server-dvd-aarch64-44-1.7.iso", + "size": 3662544896, + "checksum": { + "type": "sha256", + "value": "ba8372682294d0d76f79427cae1273d36891b192ac9bf0f0f9de4e97a7cbe218", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Server-dvd-ppc64le-44-1.7.iso", + "path": "fedora-secondary/releases/44/Server/ppc64le/iso/Fedora-Server-dvd-ppc64le-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/44/Server/ppc64le/iso/Fedora-Server-dvd-ppc64le-44-1.7.iso", + "size": 3013869568, + "checksum": { + "type": "sha256", + "value": "d0d11c768e2933a421e81db2606eafd512f9d75a15e0d591277837d2c97c908b", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "s390x", + "format": "iso", + "file": "Fedora-Server-dvd-s390x-44-1.7.iso", + "path": "fedora-secondary/releases/44/Server/s390x/iso/Fedora-Server-dvd-s390x-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/44/Server/s390x/iso/Fedora-Server-dvd-s390x-44-1.7.iso", + "size": 2351562752, + "checksum": { + "type": "sha256", + "value": "1012057fa848d8479848cba6365ab8b6c3799d4f3d78f94a2074e2859110ae18", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "server-netinst", + "name": "Server (netinstall)", + "desktop": null, + "kind": "netinst", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Server-netinst-x86_64-44-1.7.iso", + "path": "fedora/linux/releases/44/Server/x86_64/iso/Fedora-Server-netinst-x86_64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Server/x86_64/iso/Fedora-Server-netinst-x86_64-44-1.7.iso", + "size": 1228384256, + "checksum": { + "type": "sha256", + "value": "ae20c06bea746913cadea7d80463e13f4bf55bee4df2918111c921c674b70283", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Server-netinst-aarch64-44-1.7.iso", + "path": "fedora/linux/releases/44/Server/aarch64/iso/Fedora-Server-netinst-aarch64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Server/aarch64/iso/Fedora-Server-netinst-aarch64-44-1.7.iso", + "size": 1257783296, + "checksum": { + "type": "sha256", + "value": "a93ebd0322cda5a439039710b727ac1899a06e1c11876cfdf7f27c25b8262cc3", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Server-netinst-ppc64le-44-1.7.iso", + "path": "fedora-secondary/releases/44/Server/ppc64le/iso/Fedora-Server-netinst-ppc64le-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/44/Server/ppc64le/iso/Fedora-Server-netinst-ppc64le-44-1.7.iso", + "size": 1160095744, + "checksum": { + "type": "sha256", + "value": "db84e9e9e2ca979f322c877a96584633226d0c4d8052b97ec5cdcdfcc4c7c17f", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "s390x", + "format": "iso", + "file": "Fedora-Server-netinst-s390x-44-1.7.iso", + "path": "fedora-secondary/releases/44/Server/s390x/iso/Fedora-Server-netinst-s390x-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/44/Server/s390x/iso/Fedora-Server-netinst-s390x-44-1.7.iso", + "size": 588064768, + "checksum": { + "type": "sha256", + "value": "313ea37cdd9fbacb4ff501a0233adebb3b55c623651e98ad784a60a49126b4c9", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "everything", + "name": "Everything (netinstall)", + "desktop": null, + "kind": "netinst", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Everything-netinst-x86_64-44-1.7.iso", + "path": "fedora/linux/releases/44/Everything/x86_64/iso/Fedora-Everything-netinst-x86_64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Everything/x86_64/iso/Fedora-Everything-netinst-x86_64-44-1.7.iso", + "size": 1217329152, + "checksum": { + "type": "sha256", + "value": "bd285201494dd0ba09b54d05ac707de1401668b8512a573edb5922dcf9d7067e", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Everything-netinst-aarch64-44-1.7.iso", + "path": "fedora/linux/releases/44/Everything/aarch64/iso/Fedora-Everything-netinst-aarch64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Everything/aarch64/iso/Fedora-Everything-netinst-aarch64-44-1.7.iso", + "size": 1247682560, + "checksum": { + "type": "sha256", + "value": "814801fbdda7492d9ee24dab8426c72b29f1635fe98d4ba675ede7e81189d57e", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Everything-netinst-ppc64le-44-1.7.iso", + "path": "fedora-secondary/releases/44/Everything/ppc64le/iso/Fedora-Everything-netinst-ppc64le-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/44/Everything/ppc64le/iso/Fedora-Everything-netinst-ppc64le-44-1.7.iso", + "size": 1148528640, + "checksum": { + "type": "sha256", + "value": "95e63afad3ea52af38940603a173ac4ea229db717549bcf607c5b7f3ced284ce", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "s390x", + "format": "iso", + "file": "Fedora-Everything-netinst-s390x-44-1.7.iso", + "path": "fedora-secondary/releases/44/Everything/s390x/iso/Fedora-Everything-netinst-s390x-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/44/Everything/s390x/iso/Fedora-Everything-netinst-s390x-44-1.7.iso", + "size": 577001472, + "checksum": { + "type": "sha256", + "value": "62b00a71b8222494f4e347ca18be76ad7ce599ee49e0b5a3f33906110133b2ab", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "silverblue", + "name": "Silverblue", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Silverblue-ostree-x86_64-44-1.7.iso", + "path": "fedora/linux/releases/44/Silverblue/x86_64/iso/Fedora-Silverblue-ostree-x86_64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Silverblue/x86_64/iso/Fedora-Silverblue-ostree-x86_64-44-1.7.iso", + "size": 3331284992, + "checksum": { + "type": "sha256", + "value": "9ccec9b075a0a4358a7209b2667156aaeeec3a6a3c8d71cada45b57fca9b4682", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Silverblue-ostree-aarch64-44-1.7.iso", + "path": "fedora/linux/releases/44/Silverblue/aarch64/iso/Fedora-Silverblue-ostree-aarch64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Silverblue/aarch64/iso/Fedora-Silverblue-ostree-aarch64-44-1.7.iso", + "size": 3163932672, + "checksum": { + "type": "sha256", + "value": "0793b987858dba46cb9e0727ad5fa8e819caff055d6d8ca703dff355fb4383cc", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "kinoite", + "name": "Kinoite", + "desktop": "kde", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Kinoite-ostree-x86_64-44-1.7.iso", + "path": "fedora/linux/releases/44/Kinoite/x86_64/iso/Fedora-Kinoite-ostree-x86_64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Kinoite/x86_64/iso/Fedora-Kinoite-ostree-x86_64-44-1.7.iso", + "size": 4092461056, + "checksum": { + "type": "sha256", + "value": "4a944312b4e861ab625fd9786957174ef122a8a406bbb54caba7665e0d9f0e92", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Kinoite-ostree-aarch64-44-1.7.iso", + "path": "fedora/linux/releases/44/Kinoite/aarch64/iso/Fedora-Kinoite-ostree-aarch64-44-1.7.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Kinoite/aarch64/iso/Fedora-Kinoite-ostree-aarch64-44-1.7.iso", + "size": 3884711936, + "checksum": { + "type": "sha256", + "value": "37a8476c947ec3c0a0ce5cde65a8462c71488715b09ab5196714dddf42708c4a", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "xfce", + "name": "Xfce", + "desktop": "xfce", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Xfce-Live-44-1.7.x86_64.iso", + "path": "fedora/linux/releases/44/Spins/x86_64/iso/Fedora-Xfce-Live-44-1.7.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/x86_64/iso/Fedora-Xfce-Live-44-1.7.x86_64.iso", + "size": 2928986112, + "checksum": { + "type": "sha256", + "value": "55ea8cb52ac16e62f436e37f9fdb4e978d7b9f75814a9d42e8b69d05e3b496ad", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Xfce-Live-44-1.7.aarch64.iso", + "path": "fedora/linux/releases/44/Spins/aarch64/iso/Fedora-Xfce-Live-44-1.7.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/aarch64/iso/Fedora-Xfce-Live-44-1.7.aarch64.iso", + "size": 2666934272, + "checksum": { + "type": "sha256", + "value": "c707af264df01b70fdf7abfd4fea44ee2942a6080d4353f4e61b23fe951c049e", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "cinnamon", + "name": "Cinnamon", + "desktop": "cinnamon", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Cinnamon-Live-44-1.7.x86_64.iso", + "path": "fedora/linux/releases/44/Spins/x86_64/iso/Fedora-Cinnamon-Live-44-1.7.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/x86_64/iso/Fedora-Cinnamon-Live-44-1.7.x86_64.iso", + "size": 3263983616, + "checksum": { + "type": "sha256", + "value": "fa2dc8d20357d0bd4443b724617498802d3ed772e2f154d3d3690e711595a74b", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "lxqt", + "name": "LXQt", + "desktop": "lxqt", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-LXQt-Live-44-1.7.x86_64.iso", + "path": "fedora/linux/releases/44/Spins/x86_64/iso/Fedora-LXQt-Live-44-1.7.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/x86_64/iso/Fedora-LXQt-Live-44-1.7.x86_64.iso", + "size": 2443081728, + "checksum": { + "type": "sha256", + "value": "019c11c72df9f4fb47b1524c9d55541c09f7658d54507bc30197729bcd5f7245", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-LXQt-Live-44-1.7.aarch64.iso", + "path": "fedora/linux/releases/44/Spins/aarch64/iso/Fedora-LXQt-Live-44-1.7.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/aarch64/iso/Fedora-LXQt-Live-44-1.7.aarch64.iso", + "size": 2206396416, + "checksum": { + "type": "sha256", + "value": "1325c70ce55ab699686084b30cb910f54080766dc1d10a2704045ab6979ad247", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "budgie", + "name": "Budgie", + "desktop": "budgie", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Budgie-Live-44-1.7.x86_64.iso", + "path": "fedora/linux/releases/44/Spins/x86_64/iso/Fedora-Budgie-Live-44-1.7.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/x86_64/iso/Fedora-Budgie-Live-44-1.7.x86_64.iso", + "size": 3084500992, + "checksum": { + "type": "sha256", + "value": "b6298c8a99530b8a923f43b2bf6fa89065465dee9a00cb6952b10eea45f43592", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "cosmic", + "name": "COSMIC", + "desktop": "cosmic", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-COSMIC-Live-44-1.7.x86_64.iso", + "path": "fedora/linux/releases/44/Spins/x86_64/iso/Fedora-COSMIC-Live-44-1.7.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/x86_64/iso/Fedora-COSMIC-Live-44-1.7.x86_64.iso", + "size": 3166636032, + "checksum": { + "type": "sha256", + "value": "86ed49a1f0c32ab3e19846b692b0a1b35fa827154069f32b1ec0e08d42bb20e6", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-COSMIC-Live-44-1.7.aarch64.iso", + "path": "fedora/linux/releases/44/Spins/aarch64/iso/Fedora-COSMIC-Live-44-1.7.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/aarch64/iso/Fedora-COSMIC-Live-44-1.7.aarch64.iso", + "size": 2899554304, + "checksum": { + "type": "sha256", + "value": "2ac66a7231d05acc5c52a4a83f2f9b8a37240fa68f4bc4e614d4e64c50daa912", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "sway", + "name": "Sway", + "desktop": "sway", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Sway-Live-44-1.7.x86_64.iso", + "path": "fedora/linux/releases/44/Spins/x86_64/iso/Fedora-Sway-Live-44-1.7.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/x86_64/iso/Fedora-Sway-Live-44-1.7.x86_64.iso", + "size": 2397937664, + "checksum": { + "type": "sha256", + "value": "d18c6e88661ebf86f99cf939b804c7ad51fdcb4f591f6c732e027d2a418b1f05", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "i3", + "name": "i3", + "desktop": "i3", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-i3-Live-44-1.7.x86_64.iso", + "path": "fedora/linux/releases/44/Spins/x86_64/iso/Fedora-i3-Live-44-1.7.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/x86_64/iso/Fedora-i3-Live-44-1.7.x86_64.iso", + "size": 2450966528, + "checksum": { + "type": "sha256", + "value": "f2647f8604e0be0cacc70a2725a99764bd4af4f22fabe4304f6817e246e80964", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-i3-Live-44-1.7.aarch64.iso", + "path": "fedora/linux/releases/44/Spins/aarch64/iso/Fedora-i3-Live-44-1.7.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Spins/aarch64/iso/Fedora-i3-Live-44-1.7.aarch64.iso", + "size": 2208172032, + "checksum": { + "type": "sha256", + "value": "8e69c5a688260f8e0e782dd414973f6c090dbb4fdbe31cf7b87434ccb80b89f1", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + } + ] + }, + { + "version": "43", + "channel": "stable", + "codename": null, + "releaseDate": "2025-10-28", + "eol": "2026-12-09", + "notesUrl": "https://docs.fedoraproject.org/en-US/fedora/f43/release-notes/", + "editions": [ + { + "id": "workstation", + "name": "Workstation", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Workstation-Live-43-1.6.x86_64.iso", + "path": "fedora/linux/releases/43/Workstation/x86_64/iso/Fedora-Workstation-Live-43-1.6.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Workstation/x86_64/iso/Fedora-Workstation-Live-43-1.6.x86_64.iso", + "size": 2742190080, + "checksum": { + "type": "sha256", + "value": "2a4a16c009244eb5ab2198700eb04103793b62407e8596f30a3e0cc8ac294d77", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Workstation-Live-43-1.6.aarch64.iso", + "path": "fedora/linux/releases/43/Workstation/aarch64/iso/Fedora-Workstation-Live-43-1.6.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Workstation/aarch64/iso/Fedora-Workstation-Live-43-1.6.aarch64.iso", + "size": 2566119424, + "checksum": { + "type": "sha256", + "value": "73e91eb64022b59ed0b19fb706dc2053034dc0abbaec03f59fc7754a29777cfb", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Workstation-Live-43-1.6.ppc64le.iso", + "path": "fedora-secondary/releases/43/Workstation/ppc64le/iso/Fedora-Workstation-Live-43-1.6.ppc64le.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/43/Workstation/ppc64le/iso/Fedora-Workstation-Live-43-1.6.ppc64le.iso", + "size": 2423586816, + "checksum": { + "type": "sha256", + "value": "4d1b60222d24878f2963e6db282731bd6db9d18d4f442d63d2efdb069e087b54", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "kde", + "name": "KDE Plasma Desktop", + "desktop": "kde", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-KDE-Desktop-Live-43-1.6.x86_64.iso", + "path": "fedora/linux/releases/43/KDE/x86_64/iso/Fedora-KDE-Desktop-Live-43-1.6.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/KDE/x86_64/iso/Fedora-KDE-Desktop-Live-43-1.6.x86_64.iso", + "size": 3265843200, + "checksum": { + "type": "sha256", + "value": "181fe3e265fb5850c929f5afb7bdca91bb433b570ef39ece4a7076187435fdab", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-KDE-Desktop-Live-43-1.6.aarch64.iso", + "path": "fedora/linux/releases/43/KDE/aarch64/iso/Fedora-KDE-Desktop-Live-43-1.6.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/KDE/aarch64/iso/Fedora-KDE-Desktop-Live-43-1.6.aarch64.iso", + "size": 3090956288, + "checksum": { + "type": "sha256", + "value": "884c651b543a0a2922074859da5e7720595b6a113c3ee1a51fd996a1a0492218", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-KDE-Desktop-Live-43-1.6.ppc64le.iso", + "path": "fedora-secondary/releases/43/KDE/ppc64le/iso/Fedora-KDE-Desktop-Live-43-1.6.ppc64le.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/43/KDE/ppc64le/iso/Fedora-KDE-Desktop-Live-43-1.6.ppc64le.iso", + "size": 2982281216, + "checksum": { + "type": "sha256", + "value": "ae17930865ca9c5ea6cb9c342164c0ee10e0e2c3a4a7879b2078ed59d0739e6d", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "server", + "name": "Server", + "desktop": null, + "kind": "server", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Server-dvd-x86_64-43-1.6.iso", + "path": "fedora/linux/releases/43/Server/x86_64/iso/Fedora-Server-dvd-x86_64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Server/x86_64/iso/Fedora-Server-dvd-x86_64-43-1.6.iso", + "size": 3484483584, + "checksum": { + "type": "sha256", + "value": "aca06983bef83da9b43144c1a2ff4c8483e4745167c17f53725c16a16742e643", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Server-dvd-aarch64-43-1.6.iso", + "path": "fedora/linux/releases/43/Server/aarch64/iso/Fedora-Server-dvd-aarch64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Server/aarch64/iso/Fedora-Server-dvd-aarch64-43-1.6.iso", + "size": 3358326784, + "checksum": { + "type": "sha256", + "value": "cbe57647fe9cedf59b01ea72d4946a4da3c37090dd162d074cfffa1ffedc4874", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Server-dvd-ppc64le-43-1.6.iso", + "path": "fedora-secondary/releases/43/Server/ppc64le/iso/Fedora-Server-dvd-ppc64le-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/43/Server/ppc64le/iso/Fedora-Server-dvd-ppc64le-43-1.6.iso", + "size": 3073507328, + "checksum": { + "type": "sha256", + "value": "fb51a1a3f39d22adf9b3a5934226a73de4256e38cdedaa089556c0a54dd0839b", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "s390x", + "format": "iso", + "file": "Fedora-Server-dvd-s390x-43-1.6.iso", + "path": "fedora-secondary/releases/43/Server/s390x/iso/Fedora-Server-dvd-s390x-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/43/Server/s390x/iso/Fedora-Server-dvd-s390x-43-1.6.iso", + "size": 2435514368, + "checksum": { + "type": "sha256", + "value": "ab280de662c6fc4f31872e8f30deb24fba192b3e773af22a307c0fea80f2dd10", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "server-netinst", + "name": "Server (netinstall)", + "desktop": null, + "kind": "netinst", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Server-netinst-x86_64-43-1.6.iso", + "path": "fedora/linux/releases/43/Server/x86_64/iso/Fedora-Server-netinst-x86_64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Server/x86_64/iso/Fedora-Server-netinst-x86_64-43-1.6.iso", + "size": 1182896128, + "checksum": { + "type": "sha256", + "value": "16fd70ddae2c7de13e485637c3da1fb385dd8220389f988279ea3b3d561243cc", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Server-netinst-aarch64-43-1.6.iso", + "path": "fedora/linux/releases/43/Server/aarch64/iso/Fedora-Server-netinst-aarch64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Server/aarch64/iso/Fedora-Server-netinst-aarch64-43-1.6.iso", + "size": 1198647296, + "checksum": { + "type": "sha256", + "value": "c41d16e78058f1e7b8c8984eac8227e29051e3b762b279db680d76cfcd0f011f", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Server-netinst-ppc64le-43-1.6.iso", + "path": "fedora-secondary/releases/43/Server/ppc64le/iso/Fedora-Server-netinst-ppc64le-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/43/Server/ppc64le/iso/Fedora-Server-netinst-ppc64le-43-1.6.iso", + "size": 1130993664, + "checksum": { + "type": "sha256", + "value": "04ba201ff72e85b47fcc7d6f8a27bda737bdbbd14a04dd617e1c19fa9550aaad", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "s390x", + "format": "iso", + "file": "Fedora-Server-netinst-s390x-43-1.6.iso", + "path": "fedora-secondary/releases/43/Server/s390x/iso/Fedora-Server-netinst-s390x-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/43/Server/s390x/iso/Fedora-Server-netinst-s390x-43-1.6.iso", + "size": 578955264, + "checksum": { + "type": "sha256", + "value": "2926972e606884685e26255d9968df929c43abb32200b0fbee0150f8af71bddb", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "everything", + "name": "Everything (netinstall)", + "desktop": null, + "kind": "netinst", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Everything-netinst-x86_64-43-1.6.iso", + "path": "fedora/linux/releases/43/Everything/x86_64/iso/Fedora-Everything-netinst-x86_64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/iso/Fedora-Everything-netinst-x86_64-43-1.6.iso", + "size": 1172207616, + "checksum": { + "type": "sha256", + "value": "f4d06a40ce4fb4a84705e1a1f01ca1328f95a2120d422ba5f719af5df62d0099", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Everything-netinst-aarch64-43-1.6.iso", + "path": "fedora/linux/releases/43/Everything/aarch64/iso/Fedora-Everything-netinst-aarch64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/aarch64/iso/Fedora-Everything-netinst-aarch64-43-1.6.iso", + "size": 1188573184, + "checksum": { + "type": "sha256", + "value": "169aa239a72029cf640bbbb3782673ba5ddfed002de4d84a0ab878feb1bdba9e", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "ppc64le", + "format": "iso", + "file": "Fedora-Everything-netinst-ppc64le-43-1.6.iso", + "path": "fedora-secondary/releases/43/Everything/ppc64le/iso/Fedora-Everything-netinst-ppc64le-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/43/Everything/ppc64le/iso/Fedora-Everything-netinst-ppc64le-43-1.6.iso", + "size": 1119477760, + "checksum": { + "type": "sha256", + "value": "e2080f1bd227e41b3aa186a86c891c7e7a4c535167289affa47f28dbd982ed5c", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "s390x", + "format": "iso", + "file": "Fedora-Everything-netinst-s390x-43-1.6.iso", + "path": "fedora-secondary/releases/43/Everything/s390x/iso/Fedora-Everything-netinst-s390x-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora-secondary/releases/43/Everything/s390x/iso/Fedora-Everything-netinst-s390x-43-1.6.iso", + "size": 567277568, + "checksum": { + "type": "sha256", + "value": "c2c4aa409e5ca4ddca07fc4d1f7efa030e73ace705e4464c55b4cf8fd287af24", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "silverblue", + "name": "Silverblue", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Silverblue-ostree-x86_64-43-1.6.iso", + "path": "fedora/linux/releases/43/Silverblue/x86_64/iso/Fedora-Silverblue-ostree-x86_64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Silverblue/x86_64/iso/Fedora-Silverblue-ostree-x86_64-43-1.6.iso", + "size": 3175817216, + "checksum": { + "type": "sha256", + "value": "d0d84ba4a857faab545f0f5a42ecb239d565bd03518daf39fb3fe0608007cdaa", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Silverblue-ostree-aarch64-43-1.6.iso", + "path": "fedora/linux/releases/43/Silverblue/aarch64/iso/Fedora-Silverblue-ostree-aarch64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Silverblue/aarch64/iso/Fedora-Silverblue-ostree-aarch64-43-1.6.iso", + "size": 3021877248, + "checksum": { + "type": "sha256", + "value": "4c14f1d7475cd716eba037fbfe9c0e52f09e6ccf7544514e068273f8ac8ff208", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "kinoite", + "name": "Kinoite", + "desktop": "kde", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Kinoite-ostree-x86_64-43-1.6.iso", + "path": "fedora/linux/releases/43/Kinoite/x86_64/iso/Fedora-Kinoite-ostree-x86_64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Kinoite/x86_64/iso/Fedora-Kinoite-ostree-x86_64-43-1.6.iso", + "size": 3905603584, + "checksum": { + "type": "sha256", + "value": "7c792ab7ab4a184d0d560cf5c2fc3480cdcadbb69f2b8a1d0999447a8f8062b6", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Kinoite-ostree-aarch64-43-1.6.iso", + "path": "fedora/linux/releases/43/Kinoite/aarch64/iso/Fedora-Kinoite-ostree-aarch64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Kinoite/aarch64/iso/Fedora-Kinoite-ostree-aarch64-43-1.6.iso", + "size": 3729197056, + "checksum": { + "type": "sha256", + "value": "a1ee65afe262c1502eb6b8f4986256e2e543c8ebbae4d2cb14c33e47f4dc26aa", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "xfce", + "name": "Xfce", + "desktop": "xfce", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Xfce-Live-43-1.6.x86_64.iso", + "path": "fedora/linux/releases/43/Spins/x86_64/iso/Fedora-Xfce-Live-43-1.6.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/x86_64/iso/Fedora-Xfce-Live-43-1.6.x86_64.iso", + "size": 2816727040, + "checksum": { + "type": "sha256", + "value": "81dff1b667055b81707dd419f418422213464c094499673cfefd54a8aa6561e1", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-Xfce-Live-43-1.6.aarch64.iso", + "path": "fedora/linux/releases/43/Spins/aarch64/iso/Fedora-Xfce-Live-43-1.6.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/aarch64/iso/Fedora-Xfce-Live-43-1.6.aarch64.iso", + "size": 2656995328, + "checksum": { + "type": "sha256", + "value": "c0c559f87060aa490820c5b41449ae46ea525cb5d0c81822e7f27b3db3e94603", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "cinnamon", + "name": "Cinnamon", + "desktop": "cinnamon", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Cinnamon-Live-x86_64-43-1.6.iso", + "path": "fedora/linux/releases/43/Spins/x86_64/iso/Fedora-Cinnamon-Live-x86_64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/x86_64/iso/Fedora-Cinnamon-Live-x86_64-43-1.6.iso", + "size": 2974912512, + "checksum": { + "type": "sha256", + "value": "01baf0238638a3665a2522d6675f0b54730abfc2933ba8a3f5211df54bb536e5", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "lxqt", + "name": "LXQt", + "desktop": "lxqt", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-LXQt-Live-43-1.6.x86_64.iso", + "path": "fedora/linux/releases/43/Spins/x86_64/iso/Fedora-LXQt-Live-43-1.6.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/x86_64/iso/Fedora-LXQt-Live-43-1.6.x86_64.iso", + "size": 2305206272, + "checksum": { + "type": "sha256", + "value": "81187a6fd43e5afd1d4fa3db7aee81b49b8c48f4ea4f247f1c7ffaeb2c8d17df", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-LXQt-Live-43-1.6.aarch64.iso", + "path": "fedora/linux/releases/43/Spins/aarch64/iso/Fedora-LXQt-Live-43-1.6.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/aarch64/iso/Fedora-LXQt-Live-43-1.6.aarch64.iso", + "size": 2165884928, + "checksum": { + "type": "sha256", + "value": "8970ba2956841957f32dc99d67dfd8c1f8f8bb1f4aa5f3caadec73777c648390", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "budgie", + "name": "Budgie", + "desktop": "budgie", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Budgie-Live-43-1.6.x86_64.iso", + "path": "fedora/linux/releases/43/Spins/x86_64/iso/Fedora-Budgie-Live-43-1.6.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/x86_64/iso/Fedora-Budgie-Live-43-1.6.x86_64.iso", + "size": 2940565504, + "checksum": { + "type": "sha256", + "value": "8fb077e4e49c7cd7407e8d4048fc9f45863802c40a1c0aadd5693085942e69f0", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "cosmic", + "name": "COSMIC", + "desktop": "cosmic", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-COSMIC-Live-43-1.6.x86_64.iso", + "path": "fedora/linux/releases/43/Spins/x86_64/iso/Fedora-COSMIC-Live-43-1.6.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/x86_64/iso/Fedora-COSMIC-Live-43-1.6.x86_64.iso", + "size": 3034408960, + "checksum": { + "type": "sha256", + "value": "8cbaacf3ebd6c1da19c01811bbec6f7d4ab9491141634bfa6870ee96ea313e2a", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-COSMIC-Live-43-1.6.aarch64.iso", + "path": "fedora/linux/releases/43/Spins/aarch64/iso/Fedora-COSMIC-Live-43-1.6.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/aarch64/iso/Fedora-COSMIC-Live-43-1.6.aarch64.iso", + "size": 2848464896, + "checksum": { + "type": "sha256", + "value": "8143b59b06d108160f849e2dfcf08ea262950e49f5dae9401d0989d3f015619d", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "sway", + "name": "Sway", + "desktop": "sway", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-Sway-Live-x86_64-43-1.6.iso", + "path": "fedora/linux/releases/43/Spins/x86_64/iso/Fedora-Sway-Live-x86_64-43-1.6.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/x86_64/iso/Fedora-Sway-Live-x86_64-43-1.6.iso", + "size": 2065707008, + "checksum": { + "type": "sha256", + "value": "41d08d2e5b99a9f255eddc9aa7c310fea5436de1cc90085e57f5520c937d8bd6", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "i3", + "name": "i3", + "desktop": "i3", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "Fedora-i3-Live-43-1.6.x86_64.iso", + "path": "fedora/linux/releases/43/Spins/x86_64/iso/Fedora-i3-Live-43-1.6.x86_64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/x86_64/iso/Fedora-i3-Live-43-1.6.x86_64.iso", + "size": 2348883968, + "checksum": { + "type": "sha256", + "value": "c3b8ae5b03e2407d354e1123da0eac9ddf423914a35a0a32d6dc4fc8a8219f84", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "Fedora-i3-Live-43-1.6.aarch64.iso", + "path": "fedora/linux/releases/43/Spins/aarch64/iso/Fedora-i3-Live-43-1.6.aarch64.iso", + "url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Spins/aarch64/iso/Fedora-i3-Live-43-1.6.aarch64.iso", + "size": 2207567872, + "checksum": { + "type": "sha256", + "value": "1c524fb604212001a64b042456c483a888c8799b81af9f46ad036e2f7953bb74", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + } + ] + } + ] +} diff --git a/src/data/releases/kubuntu.json b/src/data/releases/kubuntu.json new file mode 100644 index 0000000..af39164 --- /dev/null +++ b/src/data/releases/kubuntu.json @@ -0,0 +1,109 @@ +{ + "slug": "kubuntu", + "sources": [ + "https://endoflife.date/ubuntu", + "https://cdimage.ubuntu.com/kubuntu/releases/" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "26.04.1", + "channel": "lts", + "codename": "Resolute Raccoon", + "releaseDate": "2026-04-23", + "eol": "2031-05-29", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "kde", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "kubuntu-26.04.1-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/kubuntu/releases/26.04/release/kubuntu-26.04.1-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "831e4d4bb85098339ba43d3502cd6619b27e76daf37246a084cd68a6413090b8", + "url": "https://cdimage.ubuntu.com/kubuntu/releases/26.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/kubuntu/releases/26.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/kubuntu/releases/26.04/release/kubuntu-26.04.1-desktop-amd64.iso.torrent" + } + ] + } + ] + }, + { + "version": "24.04.5", + "channel": "lts", + "codename": "Noble Numbat", + "releaseDate": "2024-04-25", + "eol": "2029-05-31", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "kde", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "kubuntu-24.04.5-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/kubuntu/releases/24.04/release/kubuntu-24.04.5-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "ae59e59c54337bcd8f8e60825b4a3f58797121ae0e89855524ffc92b8075c1d6", + "url": "https://cdimage.ubuntu.com/kubuntu/releases/24.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/kubuntu/releases/24.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/kubuntu/releases/24.04/release/kubuntu-24.04.5-desktop-amd64.iso.torrent" + } + ] + } + ] + }, + { + "version": "22.04.5", + "channel": "lts", + "codename": "Jammy Jellyfish", + "releaseDate": "2022-04-21", + "eol": "2027-06-01", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "kde", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "kubuntu-22.04.5-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/kubuntu/releases/22.04/release/kubuntu-22.04.5-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "a3e2c4b1bc190cebaa8083c0d23ea2555e223d86b885570474a423b6de818888", + "url": "https://cdimage.ubuntu.com/kubuntu/releases/22.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/kubuntu/releases/22.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/kubuntu/releases/22.04/release/kubuntu-22.04.5-desktop-amd64.iso.torrent" + } + ] + } + ] + } + ] +} diff --git a/src/data/releases/linux-mint.json b/src/data/releases/linux-mint.json new file mode 100644 index 0000000..544ec41 --- /dev/null +++ b/src/data/releases/linux-mint.json @@ -0,0 +1,168 @@ +{ + "slug": "linux-mint", + "sources": [ + "https://endoflife.date/linuxmint", + "https://pub.linuxmint.io/stable/" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "22.3", + "channel": "lts", + "codename": "Zena", + "releaseDate": "2026-01-11", + "eol": "2029-04-30", + "notesUrl": "https://linuxmint.com/rel_zena.php", + "editions": [ + { + "id": "cinnamon", + "name": "Cinnamon Edition", + "desktop": "cinnamon", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "linuxmint-22.3-cinnamon-64bit.iso", + "path": "stable/22.3/linuxmint-22.3-cinnamon-64bit.iso", + "url": "https://pub.linuxmint.io/stable/22.3/linuxmint-22.3-cinnamon-64bit.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "a081ab202cfda17f6924128dbd2de8b63518ac0531bcfe3f1a1b88097c459bd4", + "url": "https://pub.linuxmint.io/stable/22.3/sha256sum.txt" + }, + "signatureUrl": "https://pub.linuxmint.io/stable/22.3/sha256sum.txt.gpg", + "torrentUrl": null + } + ] + }, + { + "id": "xfce", + "name": "Xfce Edition", + "desktop": "xfce", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "linuxmint-22.3-xfce-64bit.iso", + "path": "stable/22.3/linuxmint-22.3-xfce-64bit.iso", + "url": "https://pub.linuxmint.io/stable/22.3/linuxmint-22.3-xfce-64bit.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "45a835b5dddaf40e84d776549e0b19b3fbd49673b6cc6434ebddbfcd217df776", + "url": "https://pub.linuxmint.io/stable/22.3/sha256sum.txt" + }, + "signatureUrl": "https://pub.linuxmint.io/stable/22.3/sha256sum.txt.gpg", + "torrentUrl": null + } + ] + }, + { + "id": "mate", + "name": "MATE Edition", + "desktop": "mate", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "linuxmint-22.3-mate-64bit.iso", + "path": "stable/22.3/linuxmint-22.3-mate-64bit.iso", + "url": "https://pub.linuxmint.io/stable/22.3/linuxmint-22.3-mate-64bit.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "7609294da613b75eea89bb918292125e9f06418a368136fb190466e15bf8c373", + "url": "https://pub.linuxmint.io/stable/22.3/sha256sum.txt" + }, + "signatureUrl": "https://pub.linuxmint.io/stable/22.3/sha256sum.txt.gpg", + "torrentUrl": null + } + ] + } + ] + }, + { + "version": "21.3", + "channel": "lts", + "codename": "Virginia", + "releaseDate": "2024-01-12", + "eol": "2027-04-30", + "notesUrl": "https://linuxmint.com/rel_virginia.php", + "editions": [ + { + "id": "cinnamon", + "name": "Cinnamon Edition", + "desktop": "cinnamon", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "linuxmint-21.3-cinnamon-64bit.iso", + "path": "stable/21.3/linuxmint-21.3-cinnamon-64bit.iso", + "url": "https://pub.linuxmint.io/stable/21.3/linuxmint-21.3-cinnamon-64bit.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "5aa24abbc616807ab754a6a3b586f24460b0c213b6cacb0bf8b9a80b65013ecc", + "url": "https://pub.linuxmint.io/stable/21.3/sha256sum.txt" + }, + "signatureUrl": "https://pub.linuxmint.io/stable/21.3/sha256sum.txt.gpg", + "torrentUrl": null + } + ] + }, + { + "id": "xfce", + "name": "Xfce Edition", + "desktop": "xfce", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "linuxmint-21.3-xfce-64bit.iso", + "path": "stable/21.3/linuxmint-21.3-xfce-64bit.iso", + "url": "https://pub.linuxmint.io/stable/21.3/linuxmint-21.3-xfce-64bit.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "b284afcc298cc6f5da6ab4d483318c453b2074485974b71b16fdfc7256527cb1", + "url": "https://pub.linuxmint.io/stable/21.3/sha256sum.txt" + }, + "signatureUrl": "https://pub.linuxmint.io/stable/21.3/sha256sum.txt.gpg", + "torrentUrl": null + } + ] + }, + { + "id": "mate", + "name": "MATE Edition", + "desktop": "mate", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "linuxmint-21.3-mate-64bit.iso", + "path": "stable/21.3/linuxmint-21.3-mate-64bit.iso", + "url": "https://pub.linuxmint.io/stable/21.3/linuxmint-21.3-mate-64bit.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "c7b0c703476fcf7cfcfc66974b60984f35479fe1f2f054b00bc3c4cb97f37687", + "url": "https://pub.linuxmint.io/stable/21.3/sha256sum.txt" + }, + "signatureUrl": "https://pub.linuxmint.io/stable/21.3/sha256sum.txt.gpg", + "torrentUrl": null + } + ] + } + ] + } + ] +} diff --git a/src/data/releases/lubuntu.json b/src/data/releases/lubuntu.json new file mode 100644 index 0000000..cc1961f --- /dev/null +++ b/src/data/releases/lubuntu.json @@ -0,0 +1,109 @@ +{ + "slug": "lubuntu", + "sources": [ + "https://endoflife.date/ubuntu", + "https://cdimage.ubuntu.com/lubuntu/releases/" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "26.04.1", + "channel": "lts", + "codename": "Resolute Raccoon", + "releaseDate": "2026-04-23", + "eol": "2031-05-29", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "lxqt", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "lubuntu-26.04.1-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/lubuntu/releases/26.04/release/lubuntu-26.04.1-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "a8038585420f2270845549c6f493e0ec98e68d4b2c49571cbe500a4b5fab3062", + "url": "https://cdimage.ubuntu.com/lubuntu/releases/26.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/lubuntu/releases/26.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/lubuntu/releases/26.04/release/lubuntu-26.04.1-desktop-amd64.iso.torrent" + } + ] + } + ] + }, + { + "version": "24.04.5", + "channel": "lts", + "codename": "Noble Numbat", + "releaseDate": "2024-04-25", + "eol": "2029-05-31", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "lxqt", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "lubuntu-24.04.5-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/lubuntu/releases/24.04/release/lubuntu-24.04.5-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "303a769f681014c801844e33d7918842878067eb644e3c418d699db25f4b4bc6", + "url": "https://cdimage.ubuntu.com/lubuntu/releases/24.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/lubuntu/releases/24.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/lubuntu/releases/24.04/release/lubuntu-24.04.5-desktop-amd64.iso.torrent" + } + ] + } + ] + }, + { + "version": "22.04.5", + "channel": "lts", + "codename": "Jammy Jellyfish", + "releaseDate": "2022-04-21", + "eol": "2027-06-01", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "lxqt", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "lubuntu-22.04.5-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/lubuntu/releases/22.04/release/lubuntu-22.04.5-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "8ebc2104d822205263d78cc1bee58d17f3e8d8de7aa8e2f249ac2093ec0dd4bc", + "url": "https://cdimage.ubuntu.com/lubuntu/releases/22.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/lubuntu/releases/22.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/lubuntu/releases/22.04/release/lubuntu-22.04.5-desktop-amd64.iso.torrent" + } + ] + } + ] + } + ] +} diff --git a/src/data/releases/pop-os.json b/src/data/releases/pop-os.json new file mode 100644 index 0000000..31b4aad --- /dev/null +++ b/src/data/releases/pop-os.json @@ -0,0 +1,152 @@ +{ + "slug": "pop-os", + "sources": [ + "https://api.pop-os.org/builds/", + "https://endoflife.date/pop-os" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "24.04", + "channel": "lts", + "codename": null, + "releaseDate": "2025-12-11", + "eol": "2029-05-31", + "notesUrl": null, + "editions": [ + { + "id": "generic", + "name": "Intel/AMD", + "desktop": "cosmic", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "pop-os_24.04_amd64_generic_28.iso", + "path": null, + "url": "https://iso.pop-os.org/24.04/amd64/generic/28/pop-os_24.04_amd64_generic_28.iso", + "size": 3526164480, + "checksum": { + "type": "sha256", + "value": "8d78e500fc95da765a91cddcf2ab7ba71ce4e7997e4f008456d4d872ee8f8221", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "pop-os_24.04_arm64_generic_3.iso", + "path": null, + "url": "https://iso.pop-os.org/24.04/arm64/generic/3/pop-os_24.04_arm64_generic_3.iso", + "size": 2888884224, + "checksum": { + "type": "sha256", + "value": "7b4cce0e92dc5c903464e7e7c33760c4417f1400165e9ecfcd064fbceb68ef22", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "nvidia", + "name": "NVIDIA", + "desktop": "cosmic", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "pop-os_24.04_amd64_nvidia_28.iso", + "path": null, + "url": "https://iso.pop-os.org/24.04/amd64/nvidia/28/pop-os_24.04_amd64_nvidia_28.iso", + "size": 3934945280, + "checksum": { + "type": "sha256", + "value": "bd867a6b93810769b2189471cfc2d7b6b9c642213c7bf827de511be5f42ce666", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + }, + { + "arch": "aarch64", + "format": "iso", + "file": "pop-os_24.04_arm64_nvidia_3.iso", + "path": null, + "url": "https://iso.pop-os.org/24.04/arm64/nvidia/3/pop-os_24.04_arm64_nvidia_3.iso", + "size": 3213494272, + "checksum": { + "type": "sha256", + "value": "8bc00f77a60baf5d189c0dc924523df0f67daa92c944c2f3203fe17f1b70ac50", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + } + ] + }, + { + "version": "22.04", + "channel": "lts", + "codename": null, + "releaseDate": "2022-04-25", + "eol": "2027-04-25", + "notesUrl": null, + "editions": [ + { + "id": "generic", + "name": "Intel/AMD", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "pop-os_22.04_amd64_intel_58.iso", + "path": null, + "url": "https://iso.pop-os.org/22.04/amd64/intel/58/pop-os_22.04_amd64_intel_58.iso", + "size": 2751463424, + "checksum": { + "type": "sha256", + "value": "4e1c5e391062c79dc611ce383c2a709fecac36798ebd81444a734fd41252608e", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + }, + { + "id": "nvidia", + "name": "NVIDIA", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "pop-os_22.04_amd64_nvidia_58.iso", + "path": null, + "url": "https://iso.pop-os.org/22.04/amd64/nvidia/58/pop-os_22.04_amd64_nvidia_58.iso", + "size": 3242065920, + "checksum": { + "type": "sha256", + "value": "2b622525da3fd4deb913540810ffe68651a529acd57bcf44bb7f69df6161fec0", + "url": null + }, + "signatureUrl": null, + "torrentUrl": null + } + ] + } + ] + } + ] +} diff --git a/src/data/releases/ubuntu-mate.json b/src/data/releases/ubuntu-mate.json new file mode 100644 index 0000000..ea4ffe8 --- /dev/null +++ b/src/data/releases/ubuntu-mate.json @@ -0,0 +1,76 @@ +{ + "slug": "ubuntu-mate", + "sources": [ + "https://endoflife.date/ubuntu", + "https://cdimage.ubuntu.com/ubuntu-mate/releases/" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "24.04.5", + "channel": "lts", + "codename": "Noble Numbat", + "releaseDate": "2024-04-25", + "eol": "2029-05-31", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "mate", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "ubuntu-mate-24.04.5-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/ubuntu-mate/releases/24.04/release/ubuntu-mate-24.04.5-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "60430d35da5c0bdb3d84a2fdf4052495256fc26ec300d7cd5a9933a534f0422c", + "url": "https://cdimage.ubuntu.com/ubuntu-mate/releases/24.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/ubuntu-mate/releases/24.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/ubuntu-mate/releases/24.04/release/ubuntu-mate-24.04.5-desktop-amd64.iso.torrent" + } + ] + } + ] + }, + { + "version": "22.04.5", + "channel": "lts", + "codename": "Jammy Jellyfish", + "releaseDate": "2022-04-21", + "eol": "2027-06-01", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "mate", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "ubuntu-mate-22.04.5-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/ubuntu-mate/releases/22.04/release/ubuntu-mate-22.04.5-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "b382a3aebffc916cce71369e0c09719dd819b8b6ab308d4396ac31c1ada867c4", + "url": "https://cdimage.ubuntu.com/ubuntu-mate/releases/22.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/ubuntu-mate/releases/22.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/ubuntu-mate/releases/22.04/release/ubuntu-mate-22.04.5-desktop-amd64.iso.torrent" + } + ] + } + ] + } + ] +} diff --git a/src/data/releases/ubuntu.json b/src/data/releases/ubuntu.json new file mode 100644 index 0000000..10bd838 --- /dev/null +++ b/src/data/releases/ubuntu.json @@ -0,0 +1,253 @@ +{ + "slug": "ubuntu", + "sources": [ + "https://endoflife.date/ubuntu", + "https://releases.ubuntu.com/" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "26.04.1", + "channel": "lts", + "codename": "Resolute Raccoon", + "releaseDate": "2026-04-23", + "eol": "2031-05-29", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "ubuntu-26.04.1-desktop-amd64.iso", + "path": "26.04/ubuntu-26.04.1-desktop-amd64.iso", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04.1-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "601e30fbf5d97759367c632e2c33630665039b7e2158fd068403da3ccf1bda1f", + "url": "https://releases.ubuntu.com/26.04/SHA256SUMS" + }, + "signatureUrl": "https://releases.ubuntu.com/26.04/SHA256SUMS.gpg", + "torrentUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04.1-desktop-amd64.iso.torrent" + }, + { + "arch": "aarch64", + "format": "iso", + "file": "ubuntu-26.04.1-desktop-arm64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/releases/26.04/release/ubuntu-26.04.1-desktop-arm64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "c54d196489d3c867975fb3bbb72ca52ec2e137456e305481f81096304e4d2517", + "url": "https://cdimage.ubuntu.com/releases/26.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/releases/26.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/releases/26.04/release/ubuntu-26.04.1-desktop-arm64.iso.torrent" + } + ] + }, + { + "id": "server", + "name": "Server", + "desktop": null, + "kind": "server", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "ubuntu-26.04.1-live-server-amd64.iso", + "path": "26.04/ubuntu-26.04.1-live-server-amd64.iso", + "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04.1-live-server-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "cc8a95cde20f6ced61a322420de00f10cc3c90ced545daa46cb9c1a117f1d927", + "url": "https://releases.ubuntu.com/26.04/SHA256SUMS" + }, + "signatureUrl": "https://releases.ubuntu.com/26.04/SHA256SUMS.gpg", + "torrentUrl": "https://releases.ubuntu.com/26.04/ubuntu-26.04.1-live-server-amd64.iso.torrent" + }, + { + "arch": "aarch64", + "format": "iso", + "file": "ubuntu-26.04.1-live-server-arm64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/releases/26.04/release/ubuntu-26.04.1-live-server-arm64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "af78753c94b924c85c4964c670692a853f7c80299a4064e317edad95462e3168", + "url": "https://cdimage.ubuntu.com/releases/26.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/releases/26.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/releases/26.04/release/ubuntu-26.04.1-live-server-arm64.iso.torrent" + } + ] + } + ] + }, + { + "version": "24.04.4", + "channel": "lts", + "codename": "Noble Numbat", + "releaseDate": "2024-04-25", + "eol": "2029-05-31", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "ubuntu-24.04.4-desktop-amd64.iso", + "path": "24.04/ubuntu-24.04.4-desktop-amd64.iso", + "url": "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "3a4c9877b483ab46d7c3fbe165a0db275e1ae3cfe56a5657e5a47c2f99a99d1e", + "url": "https://releases.ubuntu.com/24.04/SHA256SUMS" + }, + "signatureUrl": "https://releases.ubuntu.com/24.04/SHA256SUMS.gpg", + "torrentUrl": "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-desktop-amd64.iso.torrent" + }, + { + "arch": "aarch64", + "format": "iso", + "file": "ubuntu-24.04.5-desktop-arm64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.5-desktop-arm64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "2be09ca883921bff6d8e6b0bfbafd13e32436553b7086f33bce3a4c5bad8bd14", + "url": "https://cdimage.ubuntu.com/releases/24.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/releases/24.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.5-desktop-arm64.iso.torrent" + } + ] + }, + { + "id": "server", + "name": "Server", + "desktop": null, + "kind": "server", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "ubuntu-24.04.5-live-server-amd64.iso", + "path": "24.04/ubuntu-24.04.5-live-server-amd64.iso", + "url": "https://releases.ubuntu.com/24.04/ubuntu-24.04.5-live-server-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "97f3d7ffb032c3eb3b23d2c8be9cc76e60c2c1f2c0146ba5ba9fe01cafae0fd8", + "url": "https://releases.ubuntu.com/24.04/SHA256SUMS" + }, + "signatureUrl": "https://releases.ubuntu.com/24.04/SHA256SUMS.gpg", + "torrentUrl": "https://releases.ubuntu.com/24.04/ubuntu-24.04.5-live-server-amd64.iso.torrent" + }, + { + "arch": "aarch64", + "format": "iso", + "file": "ubuntu-24.04.5-live-server-arm64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.5-live-server-arm64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "f12484464c7d4d73e2167e0a7dbda1e669f8fae9bcf778dc0944937034b320cb", + "url": "https://cdimage.ubuntu.com/releases/24.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/releases/24.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.5-live-server-arm64.iso.torrent" + } + ] + } + ] + }, + { + "version": "22.04.5", + "channel": "lts", + "codename": "Jammy Jellyfish", + "releaseDate": "2022-04-21", + "eol": "2027-06-01", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "gnome", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "ubuntu-22.04.5-desktop-amd64.iso", + "path": "22.04/ubuntu-22.04.5-desktop-amd64.iso", + "url": "https://releases.ubuntu.com/22.04/ubuntu-22.04.5-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "bfd1cee02bc4f35db939e69b934ba49a39a378797ce9aee20f6e3e3e728fefbf", + "url": "https://releases.ubuntu.com/22.04/SHA256SUMS" + }, + "signatureUrl": "https://releases.ubuntu.com/22.04/SHA256SUMS.gpg", + "torrentUrl": "https://releases.ubuntu.com/22.04/ubuntu-22.04.5-desktop-amd64.iso.torrent" + } + ] + }, + { + "id": "server", + "name": "Server", + "desktop": null, + "kind": "server", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "ubuntu-22.04.5-live-server-amd64.iso", + "path": "22.04/ubuntu-22.04.5-live-server-amd64.iso", + "url": "https://releases.ubuntu.com/22.04/ubuntu-22.04.5-live-server-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "9bc6028870aef3f74f4e16b900008179e78b130e6b0b9a140635434a46aa98b0", + "url": "https://releases.ubuntu.com/22.04/SHA256SUMS" + }, + "signatureUrl": "https://releases.ubuntu.com/22.04/SHA256SUMS.gpg", + "torrentUrl": "https://releases.ubuntu.com/22.04/ubuntu-22.04.5-live-server-amd64.iso.torrent" + }, + { + "arch": "aarch64", + "format": "iso", + "file": "ubuntu-22.04.5-live-server-arm64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/releases/22.04/release/ubuntu-22.04.5-live-server-arm64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "eafec62cfe760c30cac43f446463e628fada468c2de2f14e0e2bc27295187505", + "url": "https://cdimage.ubuntu.com/releases/22.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/releases/22.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/releases/22.04/release/ubuntu-22.04.5-live-server-arm64.iso.torrent" + } + ] + } + ] + } + ] +} diff --git a/src/data/releases/xubuntu.json b/src/data/releases/xubuntu.json new file mode 100644 index 0000000..bbbd5f0 --- /dev/null +++ b/src/data/releases/xubuntu.json @@ -0,0 +1,155 @@ +{ + "slug": "xubuntu", + "sources": [ + "https://endoflife.date/ubuntu", + "https://cdimage.ubuntu.com/xubuntu/releases/" + ], + "updated": "2026-10-03", + "releases": [ + { + "version": "26.04.1", + "channel": "lts", + "codename": "Resolute Raccoon", + "releaseDate": "2026-04-23", + "eol": "2031-05-29", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "xfce", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "xubuntu-26.04.1-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/xubuntu/releases/26.04/release/xubuntu-26.04.1-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "0cd5160f72ae6b7a26be7a29248b60cbd579b9e6aa8fbd274e95264da0ade43d", + "url": "https://cdimage.ubuntu.com/xubuntu/releases/26.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/xubuntu/releases/26.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/xubuntu/releases/26.04/release/xubuntu-26.04.1-desktop-amd64.iso.torrent" + } + ] + }, + { + "id": "minimal", + "name": "Minimal", + "desktop": "xfce", + "kind": "minimal", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "xubuntu-26.04.1-minimal-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/xubuntu/releases/26.04/release/xubuntu-26.04.1-minimal-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "f057788b4597321fd13b8daa54fd4492de1b39a1c35372e7fa8d73f77dc109ff", + "url": "https://cdimage.ubuntu.com/xubuntu/releases/26.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/xubuntu/releases/26.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/xubuntu/releases/26.04/release/xubuntu-26.04.1-minimal-amd64.iso.torrent" + } + ] + } + ] + }, + { + "version": "24.04.5", + "channel": "lts", + "codename": "Noble Numbat", + "releaseDate": "2024-04-25", + "eol": "2029-05-31", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "xfce", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "xubuntu-24.04.5-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/xubuntu/releases/24.04/release/xubuntu-24.04.5-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "4ed869ebf2b9f2f6118c1b60b9ed07576e8fe3d5cdfe10aae06869e669878b81", + "url": "https://cdimage.ubuntu.com/xubuntu/releases/24.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/xubuntu/releases/24.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/xubuntu/releases/24.04/release/xubuntu-24.04.5-desktop-amd64.iso.torrent" + } + ] + }, + { + "id": "minimal", + "name": "Minimal", + "desktop": "xfce", + "kind": "minimal", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "xubuntu-24.04.5-minimal-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/xubuntu/releases/24.04/release/xubuntu-24.04.5-minimal-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "9d47f975696ce496b8be882b6d7cc9079c1ea89fb625ac2c86caa63a4f180033", + "url": "https://cdimage.ubuntu.com/xubuntu/releases/24.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/xubuntu/releases/24.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/xubuntu/releases/24.04/release/xubuntu-24.04.5-minimal-amd64.iso.torrent" + } + ] + } + ] + }, + { + "version": "22.04.5", + "channel": "lts", + "codename": "Jammy Jellyfish", + "releaseDate": "2022-04-21", + "eol": "2027-06-01", + "notesUrl": null, + "editions": [ + { + "id": "desktop", + "name": "Desktop", + "desktop": "xfce", + "kind": "desktop", + "artifacts": [ + { + "arch": "x86_64", + "format": "iso", + "file": "xubuntu-22.04.5-desktop-amd64.iso", + "path": null, + "url": "https://cdimage.ubuntu.com/xubuntu/releases/22.04/release/xubuntu-22.04.5-desktop-amd64.iso", + "size": null, + "checksum": { + "type": "sha256", + "value": "864e2cf7f23a9e76a8e56d9187af5654d0155bac449e9e3063c696ec1281346c", + "url": "https://cdimage.ubuntu.com/xubuntu/releases/22.04/release/SHA256SUMS" + }, + "signatureUrl": "https://cdimage.ubuntu.com/xubuntu/releases/22.04/release/SHA256SUMS.gpg", + "torrentUrl": "https://cdimage.ubuntu.com/xubuntu/releases/22.04/release/xubuntu-22.04.5-desktop-amd64.iso.torrent" + } + ] + } + ] + } + ] +} diff --git a/src/data/status.json b/src/data/status.json new file mode 100644 index 0000000..5f7e9eb --- /dev/null +++ b/src/data/status.json @@ -0,0 +1,74 @@ +{ + "arch": { + "releases": { + "failingSince": null, + "lastError": null + }, + "mirrors": { + "failingSince": null, + "lastError": null + } + }, + "debian": { + "releases": { + "failingSince": null, + "lastError": null + }, + "mirrors": { + "failingSince": null, + "lastError": null + } + }, + "fedora": { + "releases": { + "failingSince": null, + "lastError": null + } + }, + "kubuntu": { + "releases": { + "failingSince": null, + "lastError": null + } + }, + "linux-mint": { + "releases": { + "failingSince": null, + "lastError": null + } + }, + "lubuntu": { + "releases": { + "failingSince": null, + "lastError": null + } + }, + "pop-os": { + "releases": { + "failingSince": null, + "lastError": null + } + }, + "ubuntu": { + "releases": { + "failingSince": null, + "lastError": null + }, + "mirrors": { + "failingSince": null, + "lastError": null + } + }, + "ubuntu-mate": { + "releases": { + "failingSince": null, + "lastError": null + } + }, + "xubuntu": { + "releases": { + "failingSince": null, + "lastError": null + } + } +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..f3fc117 --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,337 @@ +/** + * 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", + }, + 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", + origin: "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.", + 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..4ce3a0f --- /dev/null +++ b/src/i18n/ko.ts @@ -0,0 +1,329 @@ +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: "맨 위로", + }, + 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: "자동 — 가장 가까운 공식 서버", + origin: "공식 서버", + 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: "찾으시는 페이지가 없거나 다른 곳으로 옮겨졌습니다.", + home: "홈으로 가기", + }, +}; diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro new file mode 100644 index 0000000..6b13319 --- /dev/null +++ b/src/layouts/Base.astro @@ -0,0 +1,134 @@ +--- +/** + * 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 "~/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, + 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/pages/[...locale]/design.astro b/src/pages/[...locale]/design.astro new file mode 100644 index 0000000..bbd2b54 --- /dev/null +++ b/src/pages/[...locale]/design.astro @@ -0,0 +1,189 @@ +--- +/** + * Component showcase for design review (docs/plan.md §4). Built only in dev or + * when LINUXHUB_SHOWCASE=1 — never part of the production site. + */ +import { getCollection } from "astro:content"; +import ActionRow from "~/components/adw/ActionRow.astro"; +import Badge from "~/components/adw/Badge.astro"; +import Banner from "~/components/adw/Banner.astro"; +import BoxedList from "~/components/adw/BoxedList.astro"; +import Button from "~/components/adw/Button.astro"; +import Callout from "~/components/adw/Callout.astro"; +import Card from "~/components/adw/Card.astro"; +import ExpanderRow from "~/components/adw/ExpanderRow.astro"; +import Kbd from "~/components/adw/Kbd.astro"; +import StatusPage from "~/components/adw/StatusPage.astro"; +import ViewSwitcher from "~/components/adw/ViewSwitcher.astro"; +import DistroCard from "~/components/DistroCard.astro"; +import { LOCALES, localeParam } from "~/i18n"; +import Base from "~/layouts/Base.astro"; + +export function getStaticPaths() { + if (!import.meta.env.DEV && !process.env.LINUXHUB_SHOWCASE) return []; + return LOCALES.map((locale) => ({ params: { locale: localeParam(locale) }, props: { locale } })); +} + +const { locale } = Astro.props; +const distros = (await getCollection("distros")) + .sort((a, b) => a.data.order - b.data.order) + .slice(0, 6); +const swatches = [ + "accent-bg-color", + "accent-color", + "destructive-color", + "success-color", + "warning-color", + "window-bg-color", + "view-bg-color", + "card-bg-color", + "headerbar-bg-color", + "sidebar-bg-color", +]; +--- + + + + + +
      +
      +

      리눅스, 바로 시작하기

      +

      Adwaita Sans · 한글은 시스템 고딕 · 12px card radius · 9px buttons

      +

      Title 1 — 배포판 목록

      +

      Title 2 — 설치 가이드

      +

      Title 3 — 부팅 USB 만들기

      +

      Title 4 — 체크섬 확인

      +

      + 본문 텍스트입니다. Linux Mint는 Ubuntu LTS를 기반으로 하는 입문자 친화적 배포판입니다. + 링크 스타일과 sudo apt update 인라인 코드, Ctrl + K. +

      +
      + +
      + + + + + + + + +
      + +
      + + Debian 계열 + LTS + 지원 중 + 곧 지원 종료 + 지원 종료 +
      + +
      + {distros.map((d, i) => )} +
      + +
      + + + + + + + + + +

      SHA256 3f1c…9ab2

      +
      + +
      + +
      + 체크섬은 항상 공식 원본 서버의 값을 보여줍니다. + Ventoy를 쓰면 USB 하나에 여러 ISO를 넣을 수 있습니다. + 설치 중 디스크를 지우면 데이터가 모두 사라집니다. + BitLocker를 해제하기 전에 복구 키를 백업하세요. +
      + +
      +
      sha256sum -c SHA256SUMS --ignore-missing
      +sudo dnf upgrade --refresh
      +
      + +
      + {swatches.map((s) => --{s})} +
      + + + + + + +
      + + + diff --git a/src/pages/[...locale]/distros/[slug]/index.astro b/src/pages/[...locale]/distros/[slug]/index.astro new file mode 100644 index 0000000..a63ab05 --- /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..d31921b --- /dev/null +++ b/src/pages/[...locale]/distros/[slug]/install/index.astro @@ -0,0 +1,175 @@ +--- +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 new file mode 100644 index 0000000..d003c05 --- /dev/null +++ b/src/pages/[...locale]/index.astro @@ -0,0 +1,218 @@ +--- +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("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..68832de --- /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..e852c80 --- /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/pages/og/[slug].png.ts b/src/pages/og/[slug].png.ts new file mode 100644 index 0000000..e7198b8 --- /dev/null +++ b/src/pages/og/[slug].png.ts @@ -0,0 +1,35 @@ +/** Build-time Open Graph images: /og/default.png, /og/learn.png and /og/.png. */ +import type { APIRoute, GetStaticPaths } from "astro"; +import { getDistros } from "~/lib/catalog"; +import { readLogo, renderOg } from "~/lib/og"; + +export const getStaticPaths: GetStaticPaths = async () => [ + { + params: { slug: "default" }, + props: { + title: "Linuxhub", + subtitle: + "Find, download and install the right Linux — official mirrors, step-by-step guides.", + }, + }, + { + params: { slug: "learn" }, + props: { + title: "The Linux Guide", + subtitle: "Everything you need to understand and use Linux, from the kernel to everyday use.", + }, + }, + ...(await getDistros()).map((d) => ({ + params: { slug: d.id }, + props: { title: d.data.name, subtitle: d.data.tagline.en, logo: d.data.logo.file }, + })), +]; + +export const GET: APIRoute = ({ props }) => { + const png = renderOg({ + title: props.title as string, + subtitle: props.subtitle as string, + ...(props.logo ? { logoSvg: readLogo(props.logo as string) } : {}), + }); + return new Response(new Blob([new Uint8Array(png)], { type: "image/png" })); +}; diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts new file mode 100644 index 0000000..7b211da --- /dev/null +++ b/src/pages/robots.txt.ts @@ -0,0 +1,14 @@ +import type { APIRoute } from "astro"; + +export const GET: APIRoute = ({ site }) => + new Response( + [ + "User-agent: *", + "Allow: /", + "Disallow: /api/", + "", + `Sitemap: ${new URL("/sitemap-index.xml", site).href}`, + "", + ].join("\n"), + { headers: { "content-type": "text/plain; charset=utf-8" } }, + ); 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/copy-code.ts b/src/scripts/copy-code.ts new file mode 100644 index 0000000..54ee5f1 --- /dev/null +++ b/src/scripts/copy-code.ts @@ -0,0 +1,44 @@ +/** + * Adds an Adwaita "copy" button to every code block inside `.prose`, and wires + * any `[data-copy]` button (its value is the text to copy). + */ +import { toast } from "./toast"; + +const COPY_ICON = + ''; + +const root = document.documentElement; +const labels = { + copy: root.lang === "ko" ? "복사" : "Copy", + copied: root.lang === "ko" ? "클립보드에 복사했습니다" : "Copied to clipboard", + failed: root.lang === "ko" ? "복사하지 못했습니다" : "Could not copy", +}; + +async function copy(text: string, button: HTMLElement) { + try { + await navigator.clipboard.writeText(text); + button.dataset.copied = ""; + toast(labels.copied); + window.setTimeout(() => delete button.dataset.copied, 1500); + } catch { + toast(labels.failed); + } +} + +for (const pre of document.querySelectorAll(".prose pre")) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "adw-button flat image-button code-copy"; + button.setAttribute("aria-label", labels.copy); + button.title = labels.copy; + button.innerHTML = COPY_ICON; + button.addEventListener("click", () => + copy(pre.querySelector("code")?.innerText ?? pre.innerText, button), + ); + pre.append(button); +} + +document.addEventListener("click", (event) => { + const target = (event.target as Element | null)?.closest("[data-copy]"); + if (target?.dataset.copy) void copy(target.dataset.copy, target); +}); diff --git a/src/scripts/download.ts b/src/scripts/download.ts new file mode 100644 index 0000000..3b2c980 --- /dev/null +++ b/src/scripts/download.ts @@ -0,0 +1,325 @@ +/** + * 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; + // The server-rendered link points at the official default; rebuild it + // from the data so it uses the chosen mirror + the file's relative path. + await loadReleases(); + cascade(snapshot()); + }; + 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-init.ts b/src/scripts/theme-init.ts new file mode 100644 index 0000000..73241ed --- /dev/null +++ b/src/scripts/theme-init.ts @@ -0,0 +1,6 @@ +/** + * Render-blocking theme bootstrap, inlined in so a stored light/dark + * choice applies before first paint (no flash). Its SHA-256 is registered with + * the page CSP at build time (see Base.astro). + */ +export const THEME_INIT = `try{var t=localStorage.getItem("lh-theme");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t}catch(e){}`; diff --git a/src/scripts/theme.ts b/src/scripts/theme.ts new file mode 100644 index 0000000..3e232af --- /dev/null +++ b/src/scripts/theme.ts @@ -0,0 +1,38 @@ +/** + * Style switcher (system / light / dark) in the main menu. The initial + * data-theme is applied by the inline script in before first paint; + * this module only wires the radio buttons. + */ +const KEY = "lh-theme"; +type Choice = "system" | "light" | "dark"; + +function read(): Choice { + try { + const v = localStorage.getItem(KEY); + return v === "light" || v === "dark" ? v : "system"; + } catch { + return "system"; + } +} + +function apply(choice: Choice) { + const root = document.documentElement; + if (choice === "system") delete root.dataset.theme; + else root.dataset.theme = choice; + try { + if (choice === "system") localStorage.removeItem(KEY); + else localStorage.setItem(KEY, choice); + } catch { + /* storage unavailable (private mode): the choice lasts for this page */ + } +} + +const current = read(); +for (const input of document.querySelectorAll('input[name="lh-theme"]')) { + input.checked = input.value === current; + input.addEventListener("change", () => { + if (input.checked) apply(input.value as Choice); + }); +} + +export {}; diff --git a/src/scripts/toast.ts b/src/scripts/toast.ts new file mode 100644 index 0000000..5d404b0 --- /dev/null +++ b/src/scripts/toast.ts @@ -0,0 +1,22 @@ +/** + * libadwaita AdwToast: a small dark pill at the bottom of the window that + * announces a transient message (also to screen readers via aria-live). + */ +let region: HTMLElement | null = null; +let timer: number | undefined; + +export function toast(message: string, ms = 2500): void { + if (!region) { + region = document.createElement("div"); + region.className = "adw-toast"; + region.setAttribute("role", "status"); + region.setAttribute("aria-live", "polite"); + document.body.append(region); + } + region.textContent = message; + region.dataset.visible = ""; + window.clearTimeout(timer); + timer = window.setTimeout(() => { + if (region) delete region.dataset.visible; + }, ms); +} 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 new file mode 100644 index 0000000..4202d5f --- /dev/null +++ b/src/styles/base.css @@ -0,0 +1,322 @@ +/* + * Global base styles: reset, typography, links, focus ring, utilities. + * Component styles live next to their components (Astro scoped styles). + */ + +@layer reset, base, utilities; + +@layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + } + + * { + margin: 0; + } + + html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + hanging-punctuation: first last; + } + + img, + svg, + video { + display: block; + max-inline-size: 100%; + } + + img, + video { + block-size: auto; + } + + input, + button, + textarea, + select { + font: inherit; + color: inherit; + letter-spacing: inherit; + } + + button { + cursor: pointer; + } + + p, + li, + h1, + h2, + h3, + h4 { + overflow-wrap: break-word; + } + + ul[role="list"], + ol[role="list"] { + list-style: none; + padding: 0; + } +} + +@layer base { + html { + background: var(--window-bg-color); + color: var(--window-fg-color); + font-family: var(--font-sans); + font-size: 100%; + line-height: 1.6; + font-feature-settings: "kern", "calt"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + scroll-padding-block-start: calc(var(--headerbar-height) + var(--space-2)); + } + + body { + min-block-size: 100dvh; + display: flex; + flex-direction: column; + word-break: keep-all; /* keep Korean words intact when wrapping */ + } + + h1, + h2, + h3, + h4 { + line-height: 1.25; + text-wrap: balance; + color: inherit; + } + + h1 { + font-size: var(--text-title-1); + font-weight: 800; + letter-spacing: -0.015em; + } + + h2 { + font-size: var(--text-title-2); + font-weight: 800; + letter-spacing: -0.01em; + } + + h3 { + font-size: var(--text-title-3); + font-weight: 700; + } + + h4 { + font-size: var(--text-title-4); + font-weight: 700; + } + + p { + text-wrap: pretty; + } + + a { + color: var(--link-color); + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; + } + + a:hover { + text-decoration-thickness: 2px; + } + + code, + kbd, + samp, + pre { + font-family: var(--font-mono); + font-size: 0.9em; + } + + ::selection { + background: color-mix(in srgb, var(--accent-bg-color) 30%, transparent); + } + + /* libadwaita focus ring: 2px, 50% accent, animated in. */ + :focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + border-radius: 4px; + } + + :focus:not(:focus-visible) { + outline: none; + } + + hr { + border: 0; + border-block-start: 1px solid var(--border-color); + } + + /* Native cross-document view transitions (no JS). */ + @view-transition { + navigation: auto; + } + + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: 160ms; + animation-timing-function: var(--ease-out-quad); + } + + @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; + } + + @view-transition { + navigation: none; + } + } +} + +@layer utilities { + .container { + inline-size: 100%; + max-inline-size: calc(var(--content-width) + 2 * var(--gutter)); + margin-inline: auto; + padding-inline: var(--gutter); + } + + .visually-hidden { + position: absolute !important; + inline-size: 1px; + block-size: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + + .dim-label { + opacity: var(--dim-opacity); + } + + .numeric { + font-variant-numeric: tabular-nums; + } + + .caption { + font-size: var(--text-caption); + } + + .caption-heading { + font-size: var(--text-caption); + font-weight: 700; + } + + .heading { + font-weight: 700; + } + + .title-display { + font-size: var(--text-display); + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.15; + } + + .accent { + color: var(--accent-color); + } + + .success { + color: var(--success-color); + } + + .warning { + color: var(--warning-color); + } + + .error { + color: var(--error-color); + } + + [hidden] { + display: none !important; + } +} + +/* libadwaita AdwToast (created by src/scripts/toast.ts) */ +.adw-toast { + position: fixed; + inset-inline: 0; + inset-block-end: calc(80px + env(safe-area-inset-bottom)); + z-index: 50; + inline-size: fit-content; + max-inline-size: calc(100vw - 32px); + margin-inline: auto; + padding: 10px 20px; + border-radius: var(--radius-pill); + background: var(--toast-bg-color); + color: var(--toast-fg-color); + box-shadow: var(--shadow-toast); + font-weight: 700; + opacity: 0; + translate: 0 8px; + pointer-events: none; + transition: + opacity var(--duration) var(--ease-out-quad), + translate var(--duration) var(--ease-out-quad); +} + +.adw-toast[data-visible] { + opacity: 1; + translate: 0 0; +} + +@media (min-width: 900px) { + .adw-toast { + inset-block-end: 24px; + } +} + +/* libadwaita dropdown (AdwComboRow / GtkDropDown look) on a native