#!/usr/bin/env bun /** * The docs theme is a FORCED COPY, and this is its writer and its gate. * * sync.ts check diff the site's copies against this dev-kit's * canonical bytes; exit 1 on drift, naming the file. * sync.ts sync rewrite the copies from the canonical. * * `` is the docs site's package root (the directory holding * `astro.config.mjs`); it defaults to the current directory. * * WHY A COPY, when the dev-kit's rule is to single-source anything consumable. * A docs site is a STANDALONE package: it carries its own bun.lock and its own * node_modules, outside the monorepo's workspaces, because Astro's dependency * tree has no business in the product install. So it cannot depend on the * dev-kit the way every other consumer does. That is not a preference — it is * checked: adding `"@norskvideo/ctl-dev-kit": "file:../../dev-kit"` to * norsk-ctl's website and installing fails outright, because bun then has to * resolve the dev-kit's own `workspace:*` dependencies (ctl-sdk, foundation, * test-harness, product-template-schema) from a directory that is not in a * workspace: * * error: @norskvideo/ctl-sdk@workspace:* failed to resolve * * A product repo outside this monorepo installs the published tarball and hits * no such wall — but it would then consume the theme by a second mechanism, and * one theme reaching two sites two ways is exactly the drift this package * exists to stop. So every site, here and elsewhere, gets the same deal: copies * at fixed paths, rewritten by `sync`, and `check` in its test suite. * * The copies land at the paths Starlight and Astro already expect * (`src/custom.css`, `src/components/*.astro`, `src/content.config.ts`), which * is also why the canonical directory mirrors a site's own tree: the map below * is an identity. Keeping the component paths identical is load-bearing for * more than tidiness — Astro derives a component's scoped-style class from its * path, so moving one rewrites a class attribute on every page. * * One canonical subdirectory is NOT under `src/`: `public/` mirrors the site's * `public/`, because `starlight.ts` already names the favicon * (`favicon: "/favicon.png"`) and a path the theme dictates whose bytes each * site supplies for itself is half a convention. Both sites' copies were * byte-identical when it moved here, so the gate gained a file rather than the * sites gaining a difference. * * Canonical bytes are resolved relative to THIS file, so the script works both * workspace-symlinked and installed from the published tarball — the same * arrangement as conventions/check-drift.ts. */ import { cpSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; const CANONICAL = dirname(fileURLToPath(import.meta.url)); /** The site subdirectory the canonical tree is copied into. */ export const SITE_DIR = "src"; /** The one canonical subdirectory that mirrors the site's root, not its `src/` * — Astro serves `public/` verbatim, and `starlight.ts` names `/favicon.png`. */ export const PUBLIC_DIR = "public"; /** Where a canonical file lands in a site, relative to the site root. */ export function sitePath(rel: string): string { return rel === PUBLIC_DIR || rel.startsWith(`${PUBLIC_DIR}/`) ? rel : join(SITE_DIR, rel); } /** * Every canonical file, as a path relative to this directory — which is also * its path relative to the site's `src/` ({@link sitePath}, bar `public/`). * Derived from the tree rather than listed, so a file added here reaches every * site without a second edit; the script's own source and its test are the only * exclusions. */ export function themeFiles(canonical: string = CANONICAL): string[] { const out: string[] = []; const walk = (dir: string) => { for (const name of readdirSync(dir).sort()) { const p = join(dir, name); if (statSync(p).isDirectory()) { walk(p); continue; } const rel = relative(canonical, p); if (rel === "sync.ts" || rel === "sync.test.ts" || rel === "README.md") continue; out.push(rel); } }; walk(canonical); return out; } export interface ThemeDrift { ok: boolean; problems: string[]; } const RESYNC = "Edit the dev-kit source and re-run `sync`, never hand-edit the copy."; /** Compare a site's copies against the canonical bytes. Text is diffed by line * so the failure names the drift; binaries (the woff2) compare byte-wise. */ export function checkTheme(siteRoot: string, canonical: string = CANONICAL): ThemeDrift { const problems: string[] = []; for (const rel of themeFiles(canonical)) { const at = sitePath(rel); const want = readFileSync(join(canonical, rel)); let got: Buffer; try { got = readFileSync(join(siteRoot, at)); } catch { problems.push(`${at}: missing — the docs theme is not synced. ${RESYNC}`); continue; } if (got.equals(want)) continue; problems.push(`${at}: ${firstDiff(got, want)}. ${RESYNC}`); } return { ok: problems.length === 0, problems }; } function firstDiff(got: Buffer, want: Buffer): string { // A woff2 has no lines worth naming; say so rather than printing binary. if (got.includes(0) || want.includes(0)) return `differs from the canonical (${want.length} bytes, found ${got.length})`; const a = got.toString("utf8").split("\n"); const e = want.toString("utf8").split("\n"); for (let i = 0; i < Math.max(a.length, e.length); i++) { if (a[i] !== e[i]) { return `line ${i + 1}: expected ${JSON.stringify(e[i] ?? "")}, found ${JSON.stringify(a[i] ?? "")}`; } } return "content differs"; } /** Rewrite the site's copies from the canonical bytes. */ export function syncTheme(siteRoot: string, canonical: string = CANONICAL): string[] { const written: string[] = []; for (const rel of themeFiles(canonical)) { const at = sitePath(rel); const to = join(siteRoot, at); mkdirSync(dirname(to), { recursive: true }); cpSync(join(canonical, rel), to); written.push(at); } return written; } if (import.meta.main) { const [mode, site] = process.argv.slice(2); const siteRoot = site ?? process.cwd(); if (mode === "sync") { for (const f of syncTheme(siteRoot)) console.log(`wrote ${f}`); } else if (mode === "check") { const { ok, problems } = checkTheme(siteRoot); if (!ok) { console.error("docs theme drift:"); for (const p of problems) console.error(` - ${p}`); process.exit(1); } console.log(`docs theme: ${themeFiles().length} files in step with the dev-kit.`); } else { console.error("usage: sync.ts check|sync [site-root]"); process.exit(2); } }