diff --git a/apps/cli/sync.ts b/apps/cli/sync.ts index 2592235..0026e37 100755 --- a/apps/cli/sync.ts +++ b/apps/cli/sync.ts @@ -21,11 +21,13 @@ * - Every tag a page carries that no campaign topic teaches (not a key of * `apps/docs/topic-landings.ts`) gets a placeholder guide page, * `guides/.mdx`, with `list:` pointing at LeetCode's problem list for - * the tag. Created once (a missing or empty file), then hand-owned; the tag's - * badge on a problem page links to it. A guide may be filed into a category - * `(group)` folder (e.g. `guides/(2-algorithms)/`) — no route segment, so + * the tag. Created once (a missing or empty file), then hand-owned except + * for `sidebar.badge`, which sync keeps at the guide's backlink count (the + * problem pages carrying the tag; dropped at zero); the tag's badge on a + * problem page links to it. A guide may be filed into a category `(group)` + * folder (e.g. `guides/(2-algorithms)/`) — no route segment, so * `/guides/` holds — and is found there by slug; a new placeholder - * lands at the `guides/` root for a human to file. + * lands at the `guides/` root for a human to file (and give an `icon:`). * * 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 @@ -418,6 +420,27 @@ function guidePage(tag: TopicTag): string { /** Folder meta for `guides/`: its own header tab (`navigation.tabs` in blume.config.ts), so only the label. */ const GUIDES_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Guides" });\n`; +/** + * A guide's frontmatter with `sidebar.badge` set to its backlink count — the + * one key sync owns on a hand-owned guide — or dropped (with a `sidebar:` left + * empty) at zero, so the sidebar shows a count only where there is something + * to count. + */ +function withBacklinkBadge(page: string, count: number): string { + const fm = page.match(/^---\n[\s\S]*?\n---/); + if (!fm) return page; + let head = fm[0].replace(/^ {2}badge: .*\n/m, ""); + if (count > 0) { + const line = ` badge: '${count}'`; + head = /^sidebar:$/m.test(head) + ? head.replace(/^sidebar:$/m, `sidebar:\n${line}`) + : head.replace(/\n---$/, `\nsidebar:\n${line}\n---`); + } else { + head = head.replace(/^sidebar:\n(?! {2})/m, ""); + } + return head + page.slice(fm[0].length); +} + // ── inline-code formatting heuristics ──────────────────────────── /** Identifiers worth backticking in prose, harvested from the code. */ @@ -662,6 +685,7 @@ const phases = new Map(); // phase folder -> phase, for meta.ts 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 tagged = new Map(); // tag slug -> how many problem pages carry it for (const sol of ordered) { const langs = Object.keys(sol.code).sort().join("+"); @@ -677,6 +701,7 @@ for (const sol of ordered) { const existing = pages.get(sol.num); await mkdir(join(DOCS, dir), { 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"); @@ -698,12 +723,16 @@ for (const sol of ordered) { actions.push("updated"); } report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]); - for (const tag of frontmatterTopics(next)) tags.set(tag.slug, tag); + written = next; } else { const page = renderPage(sol, await fetchTopicTags(sol)); await Bun.write(join(DOCS, name), page); report.push([workRef, name, "created"]); - for (const tag of frontmatterTopics(page)) tags.set(tag.slug, tag); + written = page; + } + for (const tag of frontmatterTopics(written)) { + tags.set(tag.slug, tag); + tagged.set(tag.slug, (tagged.get(tag.slug) ?? 0) + 1); } // 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}`)} },`); @@ -743,6 +772,26 @@ for (const tag of guideTags) { report.push([`tag ${tag.slug}`, rel, "created"]); } +// Every guide's sidebar badge is its backlink count: the problem pages whose +// tag badge links to it, which is every page carrying the tag, since a guide's +// tag has no landing (`tagTarget()`). A guide whose tag a landing has since +// claimed has no backlinks left. Counts move with every solved problem, so the +// badge is rewritten whenever it differs; the rest of the guide is untouched. +// The same pass warns when a guide's hand-picked `icon:` is one a topic group +// owns (`TOPIC_ICONS`), so a guide row never reads as a topic row. +const topicIcons = new Set(Object.values(TOPIC_ICONS)); +for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) { + const slug = basename(rel, ".mdx"); + const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0); + const page = await Bun.file(join(DOCS, rel)).text(); + const icon = page.match(/^---\n[\s\S]*?\n---/)?.[0].match(/^icon: (\S+)$/m)?.[1]; + if (icon && topicIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is a topic's (TOPIC_ICONS); pick another`); + const next = withBacklinkBadge(page, count); + if (next === page) continue; + await Bun.write(join(DOCS, rel), next); + report.push([`${count} backlinks`, rel, "badged"]); +} + // 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) {