diff --git a/README.md b/README.md index 7538bf3..f4c9bda 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Hive, hotshot, pluk, rationguard, promptargs, dibs, Crux, and Spektacular docs a `scripts/sync-hive-docs.files.json`. After syncing it reports file-list drift: upstream pages next to synced subdirectory pages, or linked from synced pages, that are not listed (so their links fall back to GitHub URLs). The report is printed and added to `$GITHUB_STEP_SUMMARY` and never fails the build; with `SYNC_HIVE_DOCS_HEAL=1` - (or `--heal`), used by the scheduled docs refresh, it appends the drift to the list and syncs the new pages. + (or `--heal`), used by the scheduled docs refresh, it appends the drift to the list, syncs the new pages and adds + them to `NAV_STRUCTURE_HIVE` in `src/app/docs/page-map.ts`. List intentional omissions in `scripts/sync-hive-docs.unsynced-allowlist.txt`. - `scripts/sync-sibling-docs.ts` pulls the hotshot, pluk, rationguard, promptargs, dibs, Crux, and Spektacular docs from their repos. Spektacular also pulls its tutorials from `hivecommons/spektacular-website` (MDX, converted to Markdown by diff --git a/scripts/sync-hive-docs.drift.test.ts b/scripts/sync-hive-docs.drift.test.ts index c9b50d2..879eace 100644 --- a/scripts/sync-hive-docs.drift.test.ts +++ b/scripts/sync-hive-docs.drift.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + addToHiveNav, detectDrift, formatDriftReport, healFilesList, @@ -220,7 +221,7 @@ describe("formatDriftReport", () => { "Upstream `src/docs` could not be listed; only linked pages were checked." ); expect(report).toContain( - "Healed: appended 1 page(s) to `scripts/sync-hive-docs.files.json` and synced them:\n\n- `integrations/cline.md` — unlisted sibling of synced pages\n" + "Healed: appended 1 page(s) to `scripts/sync-hive-docs.files.json`, synced them and added them to NAV_STRUCTURE_HIVE in `src/app/docs/page-map.ts`:\n\n- `integrations/cline.md` — unlisted sibling of synced pages\n" ); expect(report).toContain("No unlisted upstream pages."); }); @@ -255,3 +256,81 @@ describe("rewriteLinks unsynced-reference collection", () => { ]); }); }); + +describe("addToHiveNav", () => { + const pageMap = [ + "const NAV_STRUCTURE_HIVE = [", + " {", + " title: 'Reference',", + " items: [", + " { 'Environment variable reference': 'env-vars.md' },", + " ]", + " }", + "]", + "", + "// hotshot Navigation Structure", + "const NAV_STRUCTURE_HOTSHOT = [", + " { title: 'Other', items: [", + " ] }", + "]", + "", + ].join("\n"); + + it("appends a new entry to the last section of the hive nav", () => { + const out = addToHiveNav(pageMap, [ + { title: "Forges", target: "integrations/forges.md" }, + ]); + expect(out).toContain( + " { 'Environment variable reference': 'env-vars.md' },\n" + + " { 'Forges': 'integrations/forges.md' },\n ]\n }\n]" + ); + expect(out.split("NAV_STRUCTURE_HOTSHOT")[1]).toBe( + pageMap.split("NAV_STRUCTURE_HOTSHOT")[1] + ); + }); + + it("does not duplicate a target already in the nav", () => { + const out = addToHiveNav(pageMap, [ + { title: "Env", target: "env-vars.md" }, + ]); + expect(out).toBe(pageMap); + const once = addToHiveNav(pageMap, [{ title: "A", target: "a.md" }]); + expect(addToHiveNav(once, [{ title: "A", target: "a.md" }])).toBe(once); + }); + + it("uses the README -> readme target from healFilesList", () => { + const [entry] = healFilesList( + [], + [ + { + source: "adr/README.md", + reason: "unlisted-sibling", + referencedBy: [], + }, + ] + ); + const out = addToHiveNav(pageMap, [ + { title: "ADR index", target: entry.target ?? entry.source }, + ]); + expect(out).toContain("{ 'ADR index': 'adr/readme.md' },"); + }); + + it("inserts after a trailing nested sub-list, not inside it", () => { + const nested = pageMap.replace( + " { 'Environment variable reference': 'env-vars.md' },\n", + " { 'Environment variable reference': 'env-vars.md' },\n" + + " { title: 'ADRs', items: [\n" + + " { '0001 First': 'adr/0001-first.md' },\n" + + " ] },\n" + ); + const out = addToHiveNav(nested, [{ title: "New", target: "new.md" }]); + expect(out).toContain( + " ] },\n { 'New': 'new.md' },\n ]\n }\n]" + ); + }); + + it("escapes quotes in titles", () => { + const out = addToHiveNav(pageMap, [{ title: "Don't", target: "d.md" }]); + expect(out).toContain("{ 'Don\\'t': 'd.md' },"); + }); +}); diff --git a/scripts/sync-hive-docs.main.test.ts b/scripts/sync-hive-docs.main.test.ts index d0d363c..6a52d4c 100644 --- a/scripts/sync-hive-docs.main.test.ts +++ b/scripts/sync-hive-docs.main.test.ts @@ -485,7 +485,7 @@ describe("sync-hive-docs main() file-list drift (#612)", () => { expect(fetched.length).toBe(listed.length + 2); expect(out).toContain( - "Healed: appended 2 page(s) to `scripts/sync-hive-docs.files.json` and synced them:" + "Healed: appended 2 page(s) to `scripts/sync-hive-docs.files.json`, synced them and added them to NAV_STRUCTURE_HIVE in `src/app/docs/page-map.ts`:" ); // A healed page linking to another unlisted page is reported for the // next refresh rather than chased here. diff --git a/scripts/sync-hive-docs.ts b/scripts/sync-hive-docs.ts index 9d2bc89..83345aa 100644 --- a/scripts/sync-hive-docs.ts +++ b/scripts/sync-hive-docs.ts @@ -230,8 +230,9 @@ export function resolveSyncTarget(root: string, target: string): string { // check is report-only: it prints the drift, appends it to // $GITHUB_STEP_SUMMARY when set, and never changes the exit code. Heal mode // (SYNC_HIVE_DOCS_HEAL=1 or --heal; the scheduled refresh) appends the drift -// to scripts/sync-hive-docs.files.json and syncs the new pages, failing only -// if that cannot be applied. +// to scripts/sync-hive-docs.files.json, syncs the new pages and adds them to +// NAV_STRUCTURE_HIVE in src/app/docs/page-map.ts (so the #616 nav guard keeps +// passing), failing only if that cannot be applied. export interface DriftEntry { /** Path under src/docs, as used for `source` in the files list. */ @@ -312,6 +313,41 @@ export function healFilesList( ]; } +export const PAGE_MAP_PATH = "src/app/docs/page-map.ts"; + +/** Nav title: the page's first `# ` heading, falling back to the filename. */ +function navTitle(content: string, source: string): string { + const heading = /^# +(.+?)\s*$/m.exec(content)?.[1]; + return heading ?? path.posix.basename(source, ".md"); +} + +/** + * Appends one nav entry per healed page to the last section of + * NAV_STRUCTURE_HIVE (text edit between the NAV_STRUCTURE_HIVE and + * NAV_STRUCTURE_HOTSHOT markers). Targets already present are skipped. + */ +export function addToHiveNav( + pageMap: string, + pages: Array<{ title: string; target: string }> +): string { + const start = pageMap.indexOf("const NAV_STRUCTURE_HIVE"); + const end = pageMap.indexOf("const NAV_STRUCTURE_HOTSHOT"); + if (start === -1 || end === -1 || end < start) return pageMap; + const region = pageMap.slice(start, end); + const quote = (v: string) => + `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; + const lines = pages + .filter(p => !region.includes(quote(p.target))) + .map(p => ` { ${quote(p.title)}: ${quote(p.target)} },\n`); + // Last line that is exactly the section closer (` ]`); a plain + // substring search would also match a deeper-indented sub-list closer. + let close = -1; + for (const m of region.matchAll(/^ {4}\]$/gm)) close = m.index; + if (!lines.length || close === -1) return pageMap; + const at = start + close; + return pageMap.slice(0, at) + lines.join("") + pageMap.slice(at); +} + function describeDrift(e: DriftEntry): string { const why = [ e.reason === "unlisted-sibling" ? "unlisted sibling of synced pages" : "", @@ -335,7 +371,7 @@ export function formatDriftReport(opts: { } if (opts.healed?.length) { lines.push( - `Healed: appended ${opts.healed.length} page(s) to \`${FILES_LIST_PATH}\` and synced them:`, + `Healed: appended ${opts.healed.length} page(s) to \`${FILES_LIST_PATH}\`, synced them and added them to NAV_STRUCTURE_HIVE in \`${PAGE_MAP_PATH}\`:`, "", ...opts.healed.map(describeDrift), "" @@ -452,6 +488,19 @@ async function main() { const listPath = path.join(process.cwd(), FILES_LIST_PATH); fs.mkdirSync(path.dirname(listPath), { recursive: true }); fs.writeFileSync(listPath, JSON.stringify(files, null, 2) + "\n"); + const pageMapPath = path.join(process.cwd(), PAGE_MAP_PATH); + if (fs.existsSync(pageMapPath)) { + fs.writeFileSync( + pageMapPath, + addToHiveNav( + fs.readFileSync(pageMapPath, "utf8"), + added.map(f => ({ + title: navTitle(cache.get(f.source) ?? "", f.source), + target: f.target ?? f.source, + })) + ) + ); + } drift = detect(); }