From 0473a3651f7c63b5e8cf8bc7c08ab0f67053a987 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 28 Sep 2026 11:19:32 -0400 Subject: [PATCH] feat(cli): sync LeetCode topic tags, generate guide placeholders, and write video frontmatter instead of embeds --- apps/cli/sync.ts | 153 +++++++++++++++++++++++++++++++++++++++------ apps/cli/videos.ts | 46 +++++++------- 2 files changed, 155 insertions(+), 44 deletions(-) diff --git a/apps/cli/sync.ts b/apps/cli/sync.ts index 1200517..8d6a534 100755 --- a/apps/cli/sync.ts +++ b/apps/cli/sync.ts @@ -2,20 +2,32 @@ /** * Sync work/1 leetcode solutions into apps/docs/content/ pages. * - * - New problems get a full page (frontmatter, badge, warning, examples, + * - New problems get a full page (frontmatter, warning, examples, * constraints, solution) in the gold-standard format. * - Already-ported pages only get their script-owned parts regenerated — the - * frontmatter `icon:` (`PAGE_ICON`), the `` line and the - * `## Solution` section — so hand-curated prose is never touched. + * frontmatter `icon:` (`PAGE_ICON`) and the `## Solution` section — so + * hand-curated prose is never touched. * - When a problem is solved in both JavaScript and Python the solution * renders as a (Python first); single-language solutions * render as a plain fence. + * - Every page carries LeetCode's own topic tags as frontmatter `topics:` + * (`{ name, slug }` per tag, LeetCode's order), fetched from the GraphQL + * `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 `PageHeader` slot + * (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the + * `video:` key `videos.ts` writes into the badge row above the title. + * - 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. * - * Two classifications, two homes. The sidebar group is the campaign TOPIC - * from the README table (`tree-dfs/`, ordered by topic number via a - * script-owned `meta.ts`); the page badge is the data-structure CATEGORY the - * file sits under in `work/1///`. A problem outside the - * plan (warm-ups) has no topic, so it groups under its category instead. + * 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. * * Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`. * The phase folder is a Blume `(group)` — a sidebar section with its own @@ -38,6 +50,8 @@ import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; +import { TOPIC_LANDINGS } from "../docs/topic-landings.ts"; + const ROOT = join(import.meta.dir, "..", ".."); const WORK = join(ROOT, "work", "1"); const DOCS = join(ROOT, "apps", "docs", "content"); @@ -316,6 +330,83 @@ function groupMeta(topic: Topic): string { return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`; } +// ── LeetCode topic tags ────────────────────────────────────────── + +/** One of LeetCode's topic tags. `slug` is not derivable from `name` (`Graph Theory` → `graph`). */ +interface TopicTag { + name: string; + slug: string; +} + +/** + * The problem's topic tags, in LeetCode's order — or undefined, warned, when + * LeetCode cannot answer (offline commit, unknown slug): the page is written + * without `topics:` and the next sync fills it in. + */ +async function fetchTopicTags(sol: Solution): Promise { + try { + const res = await fetch("https://leetcode.com/graphql", { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": "Mozilla/5.0", + referer: `https://leetcode.com/problems/${sol.slug}/`, + }, + body: JSON.stringify({ + query: "query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }", + variables: { titleSlug: sol.slug }, + }), + }); + if (!res.ok) throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`); + const data = (await res.json()) as { data?: { question?: { topicTags?: TopicTag[] } | null } }; + const tags = data.data?.question?.topicTags; + if (!Array.isArray(tags)) throw new Error("POST /graphql -> no such question"); + return tags.map(({ name, slug }) => ({ name, slug })); + } catch (err) { + console.warn(`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`); + return undefined; + } +} + +/** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */ +function topicsYaml(tags: TopicTag[]): string { + if (tags.length === 0) return "topics: []"; + 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"); +} + +// ── guides ─────────────────────────────────────────────────────── + +/** + * Placeholder for a tag no campaign topic teaches: the `/guides/` page a + * problem's topic badge links to. `list:` is the tag's LeetCode problem list, + * rendered as a badge by the docs `PageHeader` slot. Written once — the page + * is hand-owned from then on. + */ +function guidePage(tag: TopicTag): string { + return [ + "---", + `title: ${JSON.stringify(tag.name)}`, + `list: ${JSON.stringify(`https://leetcode.com/problem-list/${tag.slug}/`)}`, + "---", + "", + ":::note", + `This guide has not been written yet. The LeetCode list above has every problem tagged ${tag.name}.`, + ":::", + "", + ].join("\n"); +} + +/** 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`; + // ── inline-code formatting heuristics ──────────────────────────── /** Identifiers worth backticking in prose, harvested from the code. */ @@ -420,11 +511,7 @@ function warningText(h: Header): string { /** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */ const PAGE_ICON = "dot"; -function badgeLine(sol: Solution): string { - return `${sol.category}`; -} - -function renderPage(sol: Solution): string { +function renderPage(sol: Solution, tags: TopicTag[] | undefined): string { const h = sol.header; const ids = identifiers(sol); const description = h.statement[0]?.replace(/\.\s*$/, "") ?? ""; @@ -435,13 +522,12 @@ function renderPage(sol: Solution): string { `title: '${h.num}. ${h.title.replaceAll("'", "''")}'`, /[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`, `icon: ${PAGE_ICON}`, + ...(tags ? [topicsYaml(tags)] : []), "sidebar:", ` label: '${h.title.replaceAll("'", "''")}'`, ` badge: '${h.difficulty}'`, "---", "", - badgeLine(sol), - "", ); const warning = warningText(h); @@ -523,10 +609,19 @@ function ensureIcon(page: string): string { return next + page.slice(fm[0].length); } +/** Insert `topics:` before `sidebar:` when the frontmatter lacks it; an existing list is kept as is. */ +async function ensureTopics(page: string, sol: Solution): Promise { + const fm = page.match(/^---\n[\s\S]*?\n---/); + if (!fm || /^topics:/m.test(fm[0])) return page; + const tags = await fetchTopicTags(sol); + if (!tags) return page; + return fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) + page.slice(fm[0].length); +} + /** * Offset just past the script-owned `## Solution` section: the next top-level * heading that is not inside a code fence, or end of file. Sections a human - * appends after the solution (`## Explanation`, …) survive regeneration. + * appends after the solution survive regeneration. */ function solutionEnd(page: string, from: number): number { const lines = page.slice(from).split("\n"); @@ -555,6 +650,7 @@ const groups = new Map(); // topic folder -> topic, for meta.ts 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 for (const sol of ordered) { const langs = Object.keys(sol.code).sort().join("+"); @@ -579,7 +675,7 @@ for (const sol of ordered) { } const tail = page.slice(solutionEnd(page, at)); const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : ""); - const next = ensureIcon(ensureLabel(body, sol.header)).replace(/^.*<\/Badge>$/m, badgeLine(sol)); + const next = await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol); const actions: string[] = []; if (existing !== name) { await rename(join(DOCS, existing), join(DOCS, name)); @@ -591,9 +687,12 @@ 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); } else { - await Bun.write(join(DOCS, name), renderPage(sol)); + 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); } // 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}`)} },`); @@ -615,11 +714,25 @@ for (const sol of ordered) { } } -// Script-owned folder metas — one per live phase and topic folder — rewritten -// only when they differ. +// One guide page per tag that no campaign topic teaches, created when missing +// (an empty file counts as missing) and never rewritten. A guide whose tag no +// page carries any more is left alone: it is hand-owned. +const guideTags = [...tags.values()] + .filter((tag) => !TOPIC_LANDINGS[tag.slug]) + .sort((a, b) => a.slug.localeCompare(b.slug)); +for (const tag of guideTags) { + const file = Bun.file(join(DOCS, "guides", `${tag.slug}.mdx`)); + if ((await file.exists()) && file.size > 0) continue; + await Bun.write(file, guidePage(tag)); + report.push([`tag ${tag.slug}`, `guides/${tag.slug}.mdx`, "created"]); +} + +// Script-owned folder metas — one per live phase and topic folder, plus +// guides/ — rewritten only when they differ. const metas: [label: string, dir: string, meta: string][] = [ ...[...phases].map(([dir, phase]): [string, string, string] => [`phase ${phase.order}`, dir, phaseMeta(phase)]), ...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]), + ...(guideTags.length ? [["guides", "guides", GUIDES_META] as [string, string, string]] : []), ]; for (const [label, dir, meta] of metas) { const path = join(DOCS, dir, "meta.ts"); diff --git a/apps/cli/videos.ts b/apps/cli/videos.ts index 39282d9..91a3134 100755 --- a/apps/cli/videos.ts +++ b/apps/cli/videos.ts @@ -2,24 +2,23 @@ /** * Find the NeetCode explanation video for every docs page that has none. * - * A finished solution page ends with `## Explanation` holding one - * `` — NeetCode's walkthrough of the problem. - * `sync.ts` never writes that section (it owns only `## Solution`), so pages - * accumulate without one. This script lists every numbered page lacking a - * `` — + * NeetCode's walkthrough of the problem — which the docs `PageHeader` slot + * (`apps/docs/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 + * up on NeetCode's two channels, main channel first, accepting only a title + * that names the page's exact LC number (`… - Leetcode 226 - …`) and is not a + * #short. No YouTube API key: the channel search page embeds its results as + * `ytInitialData` JSON, which is all we parse. Ranking is the channel's own — + * the first exact-number match wins. * * bun apps/cli/videos.ts # report: page → video (or "no match") - * bun apps/cli/videos.ts --write # also append `## Explanation` + embed + * bun apps/cli/videos.ts --write # also add `video:` to the frontmatter * - * --write appends at end of file (below `## Solution`, where sync.ts leaves - * human sections alone) and adds the `## Explanation` heading only when the - * page lacks it. A page with no match is left untouched, so re-runs are - * no-ops once every page has its embed. + * --write inserts the key before `sidebar:`. A page with no match is left + * untouched, so re-runs are no-ops once every page has its video. */ import { join } from "node:path"; @@ -126,23 +125,21 @@ interface Page { text: string; } -/** Numbered solution pages without a ` { const pages: Page[] = []; for (const rel of await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))) { const text = await Bun.file(join(DOCS, rel)).text(); const t = text.match(/^title: '(\d+)\./m); - if (t && !text.includes(" a.num - b.num); } -function withEmbed(page: Page, video: Video): string { - const embed = ``; - const heading = /^## Explanation\s*$/m.test(page.text) ? "" : "## Explanation\n\n"; - return `${page.text.replace(/\s*$/, "\n")}\n${heading}${embed}\n`; -} - // ── main ───────────────────────────────────────────────────────── const missing = await pagesMissingVideo(); @@ -162,7 +159,8 @@ for (const page of missing) { found++; let status = ""; if (WRITE) { - await Bun.write(join(DOCS, page.rel), withEmbed(page, video)); + const next = page.text.replace(FRONTMATTER, (fm) => fm.replace(/^sidebar:$/m, `video: ${JSON.stringify(video.id)}\nsidebar:`)); + await Bun.write(join(DOCS, page.rel), next); status = "written"; } rows.push([page.rel, `https://youtu.be/${video.id}`, `${video.title} (${video.channel})`, status]);