diff --git a/apps/cli/release-topics.ts b/apps/cli/release-topics.ts index dc76800..d181db5 100755 --- a/apps/cli/release-topics.ts +++ b/apps/cli/release-topics.ts @@ -21,14 +21,14 @@ * tagged commit — the last main commit before the finish. Problems still open * at the finish are left out: the release records what finished, and an open * optional problem is tracked by its issue (or its Review day), not here. - * apps/docs/lib/releases.ts reads these `### Closed …` tables back for the - * docs `/changelog` timeline, so their row shape is its contract. + * The docs site (prdlk/cs `lib/releases.ts`) reads these `### Closed …` + * tables back for its `/changelog` timeline, so their row shape is its contract. * * The tag is annotated and backdated (tagger date = finish), so * `git tag --sort=taggerdate` reads as the campaign's history even for tags * minted by a backfill. The release's own published date cannot be backdated * (the API has no field for it), so the docs changelog dates each entry from - * this timever tag instead (apps/docs/lib/releases.ts) + * this timever tag instead (prdlk/cs `lib/releases.ts`) * — the tag format is that contract. * * Reconciles instead of reacting: every finished topic or review without a @@ -59,7 +59,7 @@ const DRY = process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true"; -/** The docs site (apps/docs/blume.config.ts `deployment.site`); sync publishes `/` short links. */ +/** The docs site (prdlk/cs `blume.config.ts` `deployment.site`); it publishes sync's `/` short links. */ const DOCS_SITE = "https://lc.prd.lk"; const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668"; diff --git a/apps/cli/sync.ts b/apps/cli/sync.ts index d062262..2e46fd1 100755 --- a/apps/cli/sync.ts +++ b/apps/cli/sync.ts @@ -1,6 +1,13 @@ #!/usr/bin/env bun /** - * Sync work/1 leetcode solutions into apps/docs/content/ pages. + * Sync work/1 leetcode solutions into docs/ pages, plus docs/site.json. + * + * docs/ is content only — MDX pages and one generated JSON manifest, no Blume + * config. The docs site itself (config, components, islands, the Appendix) + * lives in prdlk/cs and reads docs/ over HTTP with Blume's `mdxRemote()` + * source, which fetches `.md`/`.mdx` and nothing else: no `meta.ts`. So the + * sidebar meta a folder `meta.ts` used to carry, and the `/` short links, + * travel in `docs/site.json` instead, which the site fetches at the same ref. * * - New problems get a full page (frontmatter, warning, examples, * constraints, solution) in the gold-standard format. @@ -15,9 +22,9 @@ * `topicTags` once — when the page is created, or on any later run that * finds the key missing — and never refreshed after, so a sync with nothing * new makes no request. Delete the key to refetch it. The page itself - * renders no badges: the docs MDX `wrapper` - * (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the - * `video:` key `videos.ts` writes into the badge row under the description. + * renders no badges: the site's MDX `wrapper` (prdlk/cs + * `components/ProblemBadges.astro`) turns `topics:` and the `video:` key + * `videos.ts` writes into the badge row under the description. * - Every planned page (a problem the README plan lists) carries its GitHub * problem issue as frontmatter `issue:` (the URL of the `problem`-labelled * issue titled `LC · …`), rendered by the same wrapper as a GitHub @@ -27,18 +34,11 @@ * (never on a sync with nothing new); offline, or with no `gh` auth, the * page is written without it and the next sync retries. Delete the key to * refetch it. - * - Every tag a page carries that no campaign topic teaches (not a key of - * `apps/docs/topic-landings.ts`) belongs in the Appendix glossary, - * `apps/docs/appendix.ts`, which is hand-written: sync only checks it, - * warning about a carried tag with no term (its badge falls back to - * LeetCode's list), a term whose tag a topic now teaches, and a term icon - * that is missing, a topic's (`TOPIC_ICONS`), or another term's — every - * badge linking to the term shows it. * - * The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`, - * ordered by topic number via a script-owned `meta.ts`). A problem outside the - * plan (warm-ups) has no topic, so it groups under the data-structure CATEGORY - * its file sits under in `work/1///` instead. + * The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`). + * A problem outside the plan (warm-ups) has no topic, so it groups under the + * data-structure CATEGORY its file sits under in + * `work/1///` instead. * * Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`. * The phase folder is a Blume `(group)` — a sidebar section adding no route @@ -49,31 +49,33 @@ * `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either, * and Blume strips the `_` prefix from the route while sorting by it, so * the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves - * an `index.mdx`, and never removes a folder that still holds one. A phase - * folder's `meta.ts` is written once, when missing, then hand-owned (its - * title is yours to rename); a topic's is script-owned. An unplanned category - * group has no phase and sits at the content root. + * an `index.mdx`, and never removes a folder that still holds one. An + * unplanned category group has no phase and sits at the docs/ root. + * + * `docs/site.json` is wholly script-owned and rewritten whenever it differs: + * - `folders`: Blume folder meta keyed by folder path under docs/ — exactly + * the key a `meta.ts` in that folder would have. A phase is `N. Name` in + * phase order; a topic is its exact name (the folder slug loses `/` and + * case), curriculum order, `TOPIC_ICONS` icon and `display: "page"`, so it + * is one sidebar row drilling into its own panel. `(problems)` has none: + * Blume's humanized "Problems" is the label. + * - `redirects`: one `{ from: "/", to: "//" }` per page, in + * number order — exact root-relative paths; Blume prepends the deployment + * base itself. * * Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves * of problems whose page already exists (see work.ts), and a fresh scaffold * there would otherwise wipe the page's `## Solution`. - * - * Every page also gets a short link: `/` → `//` (so - * `/leetcode/217` opens Contains Duplicate). The rules live in the generated - * `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact - * root-relative paths and prepends the deployment base itself. */ -import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises"; +import { mkdir, readdir, rename, rmdir } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { APPENDIX } from "../docs/appendix.ts"; -import { TOPIC_LANDINGS } from "../docs/topic-landings.ts"; import { github } from "./github.ts"; const ROOT = join(import.meta.dir, "..", ".."); const WORK = join(ROOT, "work", "1"); -const DOCS = join(ROOT, "apps", "docs", "content"); -const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts"); +const DOCS = join(ROOT, "docs"); +const MANIFEST = join(DOCS, "site.json"); type Lang = "js" | "py"; @@ -327,32 +329,34 @@ function phaseDir(phase: Phase): string { return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`; } -/** - * Blume `(group)` meta for a phase: the overview page's heading, in phase - * order. Written only when the folder has none — the title is hand-owned after - * that. Nothing is lost by never rewriting it: the order and name it is built - * from are also the folder name (`phaseDir`), so a README change to either - * lands in a new folder that gets a fresh meta. - */ -function phaseMeta(phase: Phase): string { - return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`; +/** A sidebar group's Blume folder meta, as `docs/site.json` carries it. */ +interface FolderMeta { + title: string; + order: number; + icon?: string; + display?: "page"; } /** - * Blume folder meta for a topic: exact label (the folder slug loses `/` and - * case), curriculum order, the sidebar icon, and `display: "page"` so the - * topic is one row under its phase heading that drills into its own panel - * (the landing page, then its `(problems)` list). An unplanned category group - * (warm-ups) has no README number and sorts at 0, ahead of phase 1, so the - * sidebar opens on the basics. + * Folder meta for a phase `(group)`: the overview page's heading, `N. Name`, + * in phase order. Derived like everything in the manifest, so a README rename + * renames the heading — and lands in a new folder (`phaseDir`) anyway. */ -function groupMeta(topic: Topic): string { +function phaseMeta(phase: Phase): FolderMeta { + return { title: `${phase.order}. ${phase.name}`, order: phase.order }; +} + +/** + * Folder meta for a topic: exact label, curriculum order, the sidebar icon, + * and `display: "page"` so the topic is one row under its phase heading that + * drills into its own panel (the landing page, then its `(problems)` list). + * An unplanned category group (warm-ups) has no README number and sorts at 0, + * ahead of phase 1, so the sidebar opens on the basics. + */ +function groupMeta(topic: Topic): FolderMeta { const icon = TOPIC_ICONS[topic.name]; if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`); - const fields = [`title: ${JSON.stringify(topic.name)}`, `order: ${topic.order ?? 0}`]; - if (icon) fields.push(`icon: ${JSON.stringify(icon)}`); - fields.push(`display: "page"`); - return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`; + return { title: topic.name, order: topic.order ?? 0, ...(icon ? { icon } : {}), display: "page" }; } // ── LeetCode topic tags ────────────────────────────────────────── @@ -399,14 +403,6 @@ function topicsYaml(tags: TopicTag[]): string { return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n"); } -/** The frontmatter's `topics:` as sync wrote them (or a human edited them); [] when absent. */ -function frontmatterTopics(page: string): TopicTag[] { - const fm = page.match(/^---\n([\s\S]*?)\n---/)?.[1]; - const data: unknown = fm ? Bun.YAML.parse(fm) : undefined; - if (!data || typeof data !== "object" || !("topics" in data) || !Array.isArray(data.topics)) return []; - return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string"); -} - // ── GitHub problem issues ──────────────────────────────────────── let issueUrls: Promise | undefined> | undefined; @@ -701,11 +697,9 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s const report: [string, string, string][] = []; const ordered = [...solutions.values()].sort((a, b) => a.num - b.num); -const groups = new Map(); // topic folder -> topic, for meta.ts -const phases = new Map(); // phase folder -> phase, for meta.ts +const folders = new Map(); // folder under docs/ -> its sidebar meta, for site.json const vacated = new Set(); // folders a page moved out of; removed below if empty -const redirects: string[] = []; // `{ from: "/", to: "//" }` rows, numeric order -const tags = new Map(); // every tag slug any problem page carries -> tag +const redirects: { from: string; to: string }[] = []; // one per page, numeric order for (const sol of ordered) { const langs = Object.keys(sol.code).sort().join("+"); @@ -715,13 +709,12 @@ for (const sol of ordered) { // leetcode-number prefix: numeric sidebar order, prefix stripped from the URL. const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category }; const dir = topicDir(topic); - groups.set(dir, topic); - if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase); + if (!folders.has(dir)) folders.set(dir, groupMeta(topic)); + if (topic.phase) folders.set(phaseDir(topic.phase), phaseMeta(topic.phase)); const name = `${dir}/(problems)/${sol.num}_${sol.slug}.mdx`; const existing = pages.get(sol.num); await mkdir(join(DOCS, dirname(name)), { recursive: true }); - let written: string; // the page as it stands after this run, for its topics if (existing) { const page = await Bun.file(join(DOCS, existing)).text(); const at = page.indexOf("## Solution"); @@ -743,89 +736,41 @@ for (const sol of ordered) { actions.push("updated"); } report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]); - written = next; } else { - const page = renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol)); - await Bun.write(join(DOCS, name), page); + await Bun.write(join(DOCS, name), renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol))); report.push([workRef, name, "created"]); - written = page; } - for (const tag of frontmatterTopics(written)) tags.set(tag.slug, tag); - // The route drops the phase `(group)` and the page's number prefix. - redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`); + // The route drops the phase `(group)`, `(problems)` and the number prefix. + redirects.push({ from: `/${sol.num}`, to: `/${slugify(topic.name)}/${sol.slug}` }); } -// Script-owned short links, one per page; imported by blume.config.ts. +// The site's one generated input besides the pages; script-owned, rewritten +// only when it differs. Folders sorted by path so a diff reads by location. { - const module = [ - "// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.", - "// `/leetcode/` → that problem's page; exact paths, base added by Blume.", - "export default [", - ...redirects, - "];", - "", - ].join("\n"); - if (!(await Bun.file(REDIRECTS).exists()) || (await Bun.file(REDIRECTS).text()) !== module) { - await Bun.write(REDIRECTS, module); - report.push([`${redirects.length} redirects`, "apps/docs/redirects.ts", "written"]); + const manifest = `${JSON.stringify( + { folders: Object.fromEntries([...folders].sort(([a], [b]) => (a < b ? -1 : 1))), redirects }, + null, + 2, + )}\n`; + if (!(await Bun.file(MANIFEST).exists()) || (await Bun.file(MANIFEST).text()) !== manifest) { + await Bun.write(MANIFEST, manifest); + report.push([`${folders.size} folders, ${redirects.length} redirects`, "docs/site.json", "written"]); } } -// Every carried tag no campaign topic teaches is an Appendix term, filed by -// hand in apps/docs/appendix.ts: warn about the gaps and the dead weight. A -// term's icon is also the icon on every problem-page badge linking to its -// card, so it must not be a topic group's (`TOPIC_ICONS`), so a term badge -// never reads as a topic's, nor another term's. -for (const tag of [...tags.values()].sort((a, b) => a.slug.localeCompare(b.slug))) { - if (!TOPIC_LANDINGS[tag.slug] && !APPENDIX[tag.slug]) - console.warn(`tag ${tag.slug}: no Appendix term; add ${JSON.stringify(tag.name)} to apps/docs/appendix.ts`); -} -const topicIcons = new Set(Object.values(TOPIC_ICONS)); -const termIcons = new Map(); // icon -> the term that took it first -for (const [slug, term] of Object.entries(APPENDIX)) { - if (TOPIC_LANDINGS[slug]) console.warn(`appendix ${slug}: a campaign topic teaches it now; delete its term`); - if (topicIcons.has(term.icon)) console.warn(`appendix ${slug}: icon ${JSON.stringify(term.icon)} is a topic's (TOPIC_ICONS); pick another`); - else if (termIcons.has(term.icon)) console.warn(`appendix ${slug}: icon ${JSON.stringify(term.icon)} is ${termIcons.get(term.icon)}'s too; pick another`); - else termIcons.set(term.icon, slug); -} - -// Phase metas: created when missing (an empty file counts as missing), never -// rewritten, so a renamed phase title survives every sync. -for (const [dir, phase] of phases) { - const file = Bun.file(join(DOCS, dir, "meta.ts")); - if ((await file.exists()) && file.size > 0) continue; - await Bun.write(file, phaseMeta(phase)); - report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]); -} - -// Script-owned folder metas — one per live topic folder — rewritten only when -// they differ. -const metas: [label: string, dir: string, meta: string][] = [...groups].map(([dir, topic]) => [ - `topic ${topic.order ?? "-"}`, - dir, - groupMeta(topic), -]); -for (const [label, dir, meta] of metas) { - const path = join(DOCS, dir, "meta.ts"); - if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue; - await Bun.write(path, meta); - report.push([label, `${dir}/meta.ts`, "written"]); -} - -// A folder every page left behind is dead: drop its meta and the folder itself, -// then the phase folder above it if that emptied too. A folder that still holds -// anything else (a hand-written index.mdx, another topic) stops the climb. +// A folder every page left behind is dead: drop it, then the folders above it +// that emptied too (`(problems)`, its topic, the phase). A folder that still +// holds anything (a hand-written index.mdx, another topic) stops the climb. for (const start of vacated) { for (let dir = start; dir !== "."; dir = dirname(dir)) { - if (groups.has(dir) || phases.has(dir)) break; + if (folders.has(dir)) break; let left: string[]; try { left = await readdir(join(DOCS, dir)); } catch { break; // already removed via a sibling's climb } - if (left.some((f) => f !== "meta.ts")) break; - if (left.length) await unlink(join(DOCS, dir, "meta.ts")); + if (left.length) break; await rmdir(join(DOCS, dir)); report.push(["", dir, "removed (empty)"]); } diff --git a/apps/cli/videos.ts b/apps/cli/videos.ts index c746e08..b66815e 100755 --- a/apps/cli/videos.ts +++ b/apps/cli/videos.ts @@ -3,8 +3,8 @@ * Find the NeetCode explanation video for every docs page that has none. * * A finished solution page carries frontmatter `video: ` — - * NeetCode's walkthrough of the problem — which the docs MDX `wrapper` - * (`apps/docs/components/ProblemBadges.astro`) renders as the red + * NeetCode's walkthrough of the problem — which the site's MDX `wrapper` + * (prdlk/cs `components/ProblemBadges.astro`) renders as the red * "Explanation" badge beside the topic badges. `sync.ts` never writes that key * (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without * one. This script lists every numbered page lacking `video:` and looks each @@ -25,7 +25,7 @@ import { join } from "node:path"; import { printTable } from "./report.ts"; import { ROOT } from "./work.ts"; -const DOCS = join(ROOT, "apps", "docs", "content"); +const DOCS = join(ROOT, "docs"); const WRITE = process.argv.includes("--write"); /** Channel handles searched in order; the first exact match wins. */