diff --git a/apps/cli/release-topics.ts b/apps/cli/release-topics.ts index 7de4e3b..dc76800 100755 --- a/apps/cli/release-topics.ts +++ b/apps/cli/release-topics.ts @@ -14,17 +14,21 @@ * the same day. * * Each release is a changelog snapshot at that instant: the issue's problems - * (a topic's sub-issues plus those since re-parented into Review issues, or a - * review's sub-issues in solve order — see topics.ts) grouped as closed on the - * finish day (ET), closed earlier, and open at release, each with its set, - * difficulty, and the solution files as they stood in the tagged commit — the - * last main commit before the finish. + * that had closed by then (a topic's sub-issues plus those since re-parented + * into Review issues, or a review's sub-issues in solve order — see + * topics.ts), grouped as closed on the finish day (ET) and closed earlier, + * each with its set, difficulty, and the solution files as they stood in the + * 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 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 (`releasesDatedByTag` in apps/docs/blume.config.ts) + * this timever tag instead (apps/docs/lib/releases.ts) * — the tag format is that contract. * * Reconciles instead of reacting: every finished topic or review without a @@ -201,11 +205,11 @@ function bodySection(body: string, heading: string): string | undefined { function render(f: Finished, tag: string, sha: string, files: Map): string { const { issue, problems } = f; const finishDay = etDate(new Date(f.finishedAt)); - // Snapshot at the finish instant: a problem closed later was open then. + // Snapshot at the finish instant: a problem closed later was open then, and + // open problems are not part of the release. const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt; const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay); const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay); - const open = problems.filter((p) => !closedBy(p)); const row = (p: Problem): string[] => { const lc = Number(p.title.match(/^LC (\d+)/)?.[1]); @@ -248,7 +252,6 @@ function render(f: Finished, tag: string, sha: string, files: Map\n${summary}`, fields.join(" · ")]; sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`); if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`); - if (open.length) sections.push(`### Open at release\n\n${table(open)}`); const notes = NOTES.flatMap((h) => { const text = bodySection(issue.body, h); return text ? [`**${h}.** ${text}`] : []; diff --git a/apps/cli/sync.ts b/apps/cli/sync.ts index 31a0878..99f01be 100755 --- a/apps/cli/sync.ts +++ b/apps/cli/sync.ts @@ -18,18 +18,25 @@ * 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. + * - Every page carries its GitHub problem issue as frontmatter `issue:` (the + * issue's URL; the `problem`-labelled issue titled `LC · …`), rendered + * by the same wrapper as a GitHub badge after the others. Like `topics:` it + * is filled once — on create, or on any run that finds it missing — and the + * issue list is fetched only on such a run (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`) 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 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 (and give an `icon:`, - * which every badge linking to the guide shows; sync warns while one is - * missing or shared). + * `apps/docs/topic-landings.ts`) gets a placeholder guide page in the + * Appendix, `appendix/.mdx`, with `list:` pointing at LeetCode's + * problem list for 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. `appendix/(2-algorithms)/`) — no route + * segment, so `/appendix/` holds — and is found there by slug; a new + * placeholder lands at the `appendix/` root for a human to file (and give + * an `icon:`, which every badge linking to the guide shows; sync warns + * while one is missing or shared). * * 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 @@ -60,6 +67,7 @@ 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"; +import { github } from "./github.ts"; const ROOT = join(import.meta.dir, "..", ".."); const WORK = join(ROOT, "work", "1"); @@ -397,10 +405,49 @@ function frontmatterTopics(page: string): TopicTag[] { return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string"); } +// ── GitHub problem issues ──────────────────────────────────────── + +let issueUrls: Promise | undefined> | undefined; + +/** + * LC number → the URL of its `problem` issue (`LC · `, open or + * closed), listed once per run and only when a page first needs it — or + * undefined, warned, when GitHub cannot answer (offline commit, no `gh` + * auth): pages go without `issue:` and the next sync fills it in. + */ +function problemIssues(): Promise | undefined> { + issueUrls ??= (async () => { + try { + const gh = await github(); + const urls = new Map(); + for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=all`)) { + if (!raw || typeof raw !== "object" || "pull_request" in raw) continue; + if (!("title" in raw) || typeof raw.title !== "string") continue; + if (!("html_url" in raw) || typeof raw.html_url !== "string") continue; + const lc = raw.title.match(/^LC (\d+) · /)?.[1]; + if (lc && !urls.has(Number(lc))) urls.set(Number(lc), raw.html_url); + } + return urls; + } catch (err) { + console.warn(`no problem issues: ${err instanceof Error ? err.message : err}`); + return undefined; + } + })(); + return issueUrls; +} + +/** The problem's issue URL, or undefined (warned when GitHub answered but has no such issue). */ +async function issueUrl(sol: Solution): Promise { + const urls = await problemIssues(); + const url = urls?.get(sol.num); + if (urls && !url) console.warn(`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`); + return url; +} + // ── guides ─────────────────────────────────────────────────────── /** - * Placeholder for a tag no campaign topic teaches: the `/guides/` page a + * Placeholder for a tag no campaign topic teaches: the `/appendix/` page a * problem's topic badge links to. `list:` is the tag's LeetCode problem list, * rendered as a badge by the docs MDX `wrapper`. Written once — the page * is hand-owned from then on. @@ -419,8 +466,12 @@ function guidePage(tag: TopicTag): string { ].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`; +/** + * Folder meta for `appendix/`: a drill-in (`display: "page"`) sidebar row + * sorted ahead of the phase groups, so it sits right under the root pages + * (Introduction, Keywords, Progress). + */ +const APPENDIX_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Appendix", icon: "library", order: 0, display: "page" });\n`; /** * A guide's frontmatter with `sidebar.badge` set to its backlink count — the @@ -547,7 +598,7 @@ function warningText(h: Header): string { /** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */ const PAGE_ICON = "dot"; -function renderPage(sol: Solution, tags: TopicTag[] | undefined): string { +function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): string { const h = sol.header; const ids = identifiers(sol); const description = h.statement[0]?.replace(/\.\s*$/, "") ?? ""; @@ -559,6 +610,7 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined): string { /[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`, `icon: ${PAGE_ICON}`, ...(tags ? [topicsYaml(tags)] : []), + ...(issue ? [`issue: ${JSON.stringify(issue)}`] : []), "sidebar:", ` label: '${h.title.replaceAll("'", "''")}'`, ` badge: '${h.difficulty}'`, @@ -654,6 +706,15 @@ async function ensureTopics(page: string, sol: Solution): Promise { return fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) + page.slice(fm[0].length); } +/** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */ +async function ensureIssue(page: string, sol: Solution): Promise { + const fm = page.match(/^---\n[\s\S]*?\n---/); + if (!fm || /^issue:/m.test(fm[0])) return page; + const url = await issueUrl(sol); + if (!url) return page; + return fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\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 @@ -713,7 +774,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 = await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol); + const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), sol); const actions: string[] = []; if (existing !== name) { await rename(join(DOCS, existing), join(DOCS, name)); @@ -727,7 +788,7 @@ for (const sol of ordered) { report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]); written = next; } else { - const page = renderPage(sol, await fetchTopicTags(sol)); + const page = renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol)); await Bun.write(join(DOCS, name), page); report.push([workRef, name, "created"]); written = page; @@ -758,16 +819,16 @@ for (const sol of ordered) { // One guide page per tag that no campaign topic teaches, created when missing // (an empty file counts as missing) and never rewritten. A guide is found by -// slug anywhere under guides/ — a human files it into a category `(group)`, -// which leaves its route alone — and a new one lands at the guides/ root. A +// slug anywhere under appendix/ — a human files it into a category `(group)`, +// which leaves its route alone — and a new one lands at the appendix/ root. 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)); const guideFiles = new Map(); // slug -> path relative to DOCS -for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel); +for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel); for (const tag of guideTags) { - const rel = guideFiles.get(tag.slug) ?? `guides/${tag.slug}.mdx`; + const rel = guideFiles.get(tag.slug) ?? `appendix/${tag.slug}.mdx`; const file = Bun.file(join(DOCS, rel)); if ((await file.exists()) && file.size > 0) continue; await Bun.write(file, guidePage(tag)); @@ -785,7 +846,7 @@ for (const tag of guideTags) { // and must not be another guide's. const topicIcons = new Set(Object.values(TOPIC_ICONS)); const guideIcons = new Map(); // icon -> the guide that took it first -for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) { +for await (const rel of new Bun.Glob("appendix/**/*.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(); @@ -809,11 +870,11 @@ for (const [dir, phase] of phases) { report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]); } -// Script-owned folder metas — one per live topic folder, plus guides/ — +// Script-owned folder metas — one per live topic folder, plus appendix/ — // rewritten only when they differ. const metas: [label: string, dir: string, meta: string][] = [ ...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]), - ...(guideTags.length ? [["guides", "guides", GUIDES_META] as [string, string, string]] : []), + ...(guideTags.length ? [["appendix", "appendix", APPENDIX_META] as [string, string, string]] : []), ]; for (const [label, dir, meta] of metas) { const path = join(DOCS, dir, "meta.ts");