feat(cli): add backlink badge generation for guides

This commit is contained in:
Prad Nukala committed 2026-09-29 15:37:22 -04:00
1 parent d3f7b1c6f9
commit 436ff3080e
1 file changed
+55 -6
+55 -6
View File
@@ -21,11 +21,13 @@
* - Every tag a page carries that no campaign topic teaches (not a key of * - Every tag a page carries that no campaign topic teaches (not a key of
* `apps/docs/topic-landings.ts`) gets a placeholder guide page, * `apps/docs/topic-landings.ts`) gets a placeholder guide page,
* `guides/<slug>.mdx`, with `list:` pointing at LeetCode's problem list for * `guides/<slug>.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 * the tag. Created once (a missing or empty file), then hand-owned except
* badge on a problem page links to it. A guide may be filed into a category * for `sidebar.badge`, which sync keeps at the guide's backlink count (the
* `(group)` folder (e.g. `guides/(2-algorithms)/`) — no route segment, so * 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/<slug>` holds — and is found there by slug; a new placeholder * `/guides/<slug>` 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/`, * 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 * 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. */ /** 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`; 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 ──────────────────────────── // ── inline-code formatting heuristics ────────────────────────────
/** Identifiers worth backticking in prose, harvested from the code. */ /** Identifiers worth backticking in prose, harvested from the code. */
@@ -662,6 +685,7 @@ const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
const tagged = new Map<string, number>(); // tag slug -> how many problem pages carry it
for (const sol of ordered) { for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+"); const langs = Object.keys(sol.code).sort().join("+");
@@ -677,6 +701,7 @@ for (const sol of ordered) {
const existing = pages.get(sol.num); const existing = pages.get(sol.num);
await mkdir(join(DOCS, dir), { recursive: true }); await mkdir(join(DOCS, dir), { recursive: true });
let written: string; // the page as it stands after this run, for its topics
if (existing) { if (existing) {
const page = await Bun.file(join(DOCS, existing)).text(); const page = await Bun.file(join(DOCS, existing)).text();
const at = page.indexOf("## Solution"); const at = page.indexOf("## Solution");
@@ -698,12 +723,16 @@ for (const sol of ordered) {
actions.push("updated"); actions.push("updated");
} }
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]); report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
for (const tag of frontmatterTopics(next)) tags.set(tag.slug, tag); written = next;
} else { } else {
const page = renderPage(sol, await fetchTopicTags(sol)); const page = renderPage(sol, await fetchTopicTags(sol));
await Bun.write(join(DOCS, name), page); await Bun.write(join(DOCS, name), page);
report.push([workRef, name, "created"]); 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. // 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}`)} },`); 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"]); 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 // Phase metas: created when missing (an empty file counts as missing), never
// rewritten, so a renamed phase title survives every sync. // rewritten, so a renamed phase title survives every sync.
for (const [dir, phase] of phases) { for (const [dir, phase] of phases) {