From 170b31d459ef5b1ee377ee35850a1f79fdd4ae0d Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 17 Sep 2026 17:36:22 -0700 Subject: [PATCH] fix(code-pages): redirect the URL of every model page --prune deletes A model that leaves the Router catalog loses its generated page, but the URL that page answered on is already in the wild, and Mintlify has no "gone" state: without a docs.json redirect the retired URL is a 404. The repo's redirect check enforces exactly that and fails any PR that deletes a page without one, which the rolling sync PR cannot satisfy by hand because it is rebuilt from main on every upstream push. The generator owns these pages, so it now owns their redirects too. When --prune deletes a page it writes a redirect to the catalog landing page (/development/comfy-router/models); a page that comes back has its redirect dropped again so it does not shadow the live page; redirects the generator did not write pass through untouched, including one someone hand-wrote for a pruned page with a better destination. --- .github/scripts/snippets/README.md | 9 ++- .../scripts/snippets/gen-code-pages.test.ts | 65 ++++++++++++++++++- .github/scripts/snippets/gen-code-pages.ts | 55 ++++++++++++++-- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/.github/scripts/snippets/README.md b/.github/scripts/snippets/README.md index 66c72f077..50299536a 100644 --- a/.github/scripts/snippets/README.md +++ b/.github/scripts/snippets/README.md @@ -41,7 +41,7 @@ tutorials/partner-nodes/black-forest-labs/flux-1-kontext.mdx Overview (hand-wr ```bash pnpm code-pages:gen # regenerate every code.mdx, and the Models nav in docs.json pnpm code-pages:check # CI: fail if any page is stale OR MISSING, and syntax-check the snippets -pnpm code-pages:gen --prune # also delete pages whose model has left the catalog +pnpm code-pages:gen --prune # also delete pages whose model has left the catalog, redirecting their URLs ``` `code-pages-check.yml` runs the check on any PR touching a spec, a generated @@ -60,7 +60,12 @@ The gate can only see models whose schema has been synced. `GET /v2/models` is the full catalog and is ahead of `router-schemas/` (202 vs 162 on 2026-09-04); closing that gap is the sync bot's job upstream, not this generator's. A page whose model leaves the catalog is reported as an orphan and deleted by `--prune` -— a dead page in the sidebar documents a model that now answers 404. +— a dead page in the sidebar documents a model that now answers 404. The URL the +page answered on is already in the wild, so `--prune` also writes a `docs.json` +redirect from it to the catalog landing page (`/development/comfy-router/models`); +that is what the repo's redirect check requires of any PR that deletes a page. A +redirect someone already wrote for that URL is kept as written, and a model that +comes back has its redirect removed again so it does not shadow the live page. The `Models` group in `docs.json` is generated too, one sub-group per provider, so a new page is in the sidebar the moment it is generated. Provider labels come diff --git a/.github/scripts/snippets/gen-code-pages.test.ts b/.github/scripts/snippets/gen-code-pages.test.ts index b35131232..6bf3e97b6 100644 --- a/.github/scripts/snippets/gen-code-pages.test.ts +++ b/.github/scripts/snippets/gen-code-pages.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { isOpaqueBody, loadModelSchema, opaqueOutputExample, outputContent, outputSchemaFields } from "./gen-code-pages.ts"; +import { isOpaqueBody, loadModelSchema, modelPageRedirects, modelsNav, opaqueOutputExample, outputContent, outputSchemaFields, renderDocsJson } from "./gen-code-pages.ts"; const ROOT = join(import.meta.dir, "../../.."); @@ -118,3 +118,66 @@ describe("the generated pages the Router serves as binary", () => { }); } }); + +describe("modelPageRedirects: a pruned page keeps answering on its URL", () => { + const INDEX = "/development/comfy-router/models"; + const retired = "development/comfy-router/models/kling/kling-v1/code"; + const retiredToo = "development/comfy-router/models/byteplus/seedream-3-0-t2i-250415/code"; + const alive = "development/comfy-router/models/kling/kling-v3/code"; + const handWritten = { source: "/comfy-router-quickstart", destination: "/development/comfy-router/quickstart" }; + + test("every pruned page gets a redirect to the catalog landing page, appended in a stable order", () => { + const out = modelPageRedirects([handWritten], [alive], [retired, retiredToo]); + expect(out).toEqual([ + handWritten, + { source: `/${retiredToo}`, destination: INDEX }, + { source: `/${retired}`, destination: INDEX }, + ]); + }); + + test("the source is the form the redirect check compares against: leading slash, no .mdx", () => { + const [r] = modelPageRedirects([], [], [retired]); + expect(r.source).toBe("/development/comfy-router/models/kling/kling-v1/code"); + expect(r.source.replace(/^\//, "")).toBe(retired); + }); + + test("a redirect someone already wrote for the pruned page is kept as written, not duplicated", () => { + const better = { source: `/${retired}`, destination: `/${alive}`, permanent: true }; + const out = modelPageRedirects([better], [alive], [retired]); + expect(out).toEqual([better]); + }); + + test("a model that comes back loses the redirect that would shadow its page", () => { + const stale = { source: `/${alive}`, destination: INDEX }; + expect(modelPageRedirects([handWritten, stale], [alive], [])).toEqual([handWritten]); + }); + + test("a page that is both live and pruned is live: no redirect is written over it", () => { + expect(modelPageRedirects([], [alive], [alive])).toEqual([]); + }); + + test("with nothing pruned and nothing stale, the redirects are returned unchanged", () => { + const existing = [handWritten, { source: `/${retired}`, destination: INDEX }]; + expect(modelPageRedirects(existing, [alive], [])).toEqual(existing); + }); +}); + +describe("renderDocsJson: the redirect lands in the real docs.json", () => { + const retired = "development/comfy-router/models/kling/kling-v1/code"; + const before = JSON.parse(readFileSync(join(ROOT, "docs.json"), "utf8")); + const nav = modelsNav([{ model: "kling/kling-v3", page: "development/comfy-router/models/kling/kling-v3/code" }]); + + test("a pruned page appends exactly one redirect and leaves the others alone", () => { + const after = JSON.parse(renderDocsJson(nav, { live: [], pruned: [retired] })); + expect(after.redirects.length).toBe(before.redirects.length + 1); + expect(after.redirects.slice(0, -1)).toEqual(before.redirects); + expect(after.redirects.at(-1)).toEqual({ source: `/${retired}`, destination: "/development/comfy-router/models" }); + }); + + test("nothing pruned means the redirects are byte-for-byte what was there", () => { + const after = JSON.parse(renderDocsJson(nav, { live: [], pruned: [] })); + expect(after.redirects).toEqual(before.redirects); + // `redirects` stays the last key, so the file's shape does not churn. + expect(Object.keys(after).at(-1)).toBe("redirects"); + }); +}); diff --git a/.github/scripts/snippets/gen-code-pages.ts b/.github/scripts/snippets/gen-code-pages.ts index 0609d230e..949f47a54 100644 --- a/.github/scripts/snippets/gen-code-pages.ts +++ b/.github/scripts/snippets/gen-code-pages.ts @@ -6,6 +6,7 @@ * bun .github/scripts/snippets/gen-code-pages.ts # write every code.mdx * bun .github/scripts/snippets/gen-code-pages.ts --check # exit 1 if any code.mdx is stale * bun .github/scripts/snippets/gen-code-pages.ts --validate # also syntax-check the emitted snippets + * bun .github/scripts/snippets/gen-code-pages.ts --prune # also delete the page of a model that left the catalog, redirecting its URL * * The template below is the only place the page shape lives. Python, TypeScript * and cURL are all emitted from the same `example` object, so the three cannot @@ -21,6 +22,8 @@ const SPEC_GLOB = "development/comfy-router/models/**/code.yaml"; const SCHEMA_GLOB = "router-schemas/*/*.json"; const MODELS_DIR = "development/comfy-router/models"; const DOCS_JSON = "docs.json"; +/** Where a retired model's page URL redirects: the generated catalog landing page. */ +const MODELS_INDEX_URL = `/${MODELS_DIR}`; const PREVIEW_NOTICE = "snippets/comfy-router/preview-notice.mdx"; const QUEUE_NOTICE = "snippets/comfy-router/queue-preview-notice.mdx"; const BASE_URL = "https://api.comfy.org"; @@ -1097,7 +1100,7 @@ ${sections} type NavGroup = { group: string; pages: (string | NavGroup)[] }; -function modelsNav(pages: { model: string; page: string }[]): NavGroup { +export function modelsNav(pages: { model: string; page: string }[]): NavGroup { const byProvider = new Map(); for (const { model, page } of pages) { const label = providerLabel(providerOf(model)); @@ -1116,10 +1119,49 @@ function modelsNav(pages: { model: string; page: string }[]): NavGroup { }; } -/** Replace the `Models` group under `Comfy Router` in the `en` nav. Returns the new file text. */ -function renderDocsJson(nav: NavGroup): string { +// --------------------------------------------------------------------------- +// Redirects for retired pages +// +// A model that leaves the catalog loses its page (`--prune`), but the URL that +// page answered on is already in the wild: search results, chat logs, the +// Router changelog. Mintlify has no "gone" state, so without a `docs.json` +// redirect the retired URL is a 404, and the repo's redirect check fails any PR +// that deletes a page without one. The generator owns these pages, so it owns +// their redirects too: every pruned page gets one, pointing at the catalog +// landing page, and a page that comes BACK loses its redirect again, or Mintlify +// would serve the redirect instead of the page. Redirects the generator did not +// write (hand-maintained ones, or one someone wrote for a pruned page with a +// better destination) pass through untouched. +// --------------------------------------------------------------------------- + +type Redirect = { source: string; destination: string; [key: string]: unknown }; + +/** `development/comfy-router/models/kling/kling-v1/code` -> `/development/comfy-router/models/kling/kling-v1/code`. */ +const pageUrl = (page: string) => `/${page.replace(/^\//, "")}`; + +/** + * Settle the redirects for the model pages: drop any that would shadow a live + * page, add one for every pruned page that lacks one, keep everything else. + */ +export function modelPageRedirects(existing: Redirect[], live: Iterable, pruned: Iterable): Redirect[] { + const liveUrls = new Set([...live].map(pageUrl)); + const kept = existing.filter((r) => !liveUrls.has(r.source)); + const have = new Set(kept.map((r) => r.source)); + const added = [...new Set([...pruned].map(pageUrl))] + .filter((source) => !have.has(source) && !liveUrls.has(source)) + .sort() + .map((source) => ({ source, destination: MODELS_INDEX_URL })); + return [...kept, ...added]; +} + +/** + * Replace the `Models` group under `Comfy Router` in the `en` nav and settle the + * model-page redirects (see `modelPageRedirects`). Returns the new file text. + */ +export function renderDocsJson(nav: NavGroup, pages: { live: Iterable; pruned: Iterable }): string { const raw = readFileSync(join(ROOT, DOCS_JSON), "utf8"); const doc = JSON.parse(raw); + doc.redirects = modelPageRedirects(Array.isArray(doc.redirects) ? doc.redirects : [], pages.live, pages.pruned); const en = doc.navigation?.languages?.find((l: any) => l.language === "en"); if (!en) throw new Error(`${DOCS_JSON}: no \`en\` language in navigation.languages`); const groups: NavGroup[] = []; @@ -1273,13 +1315,16 @@ if (import.meta.main) { // A model that leaves the catalog leaves its schema and, without this, its page: // a dead page still in the sidebar, documenting a model that now answers 404. const wanted = new Set(pages.map((p) => p.out)); + const pruned: string[] = []; const orphans = [...new Bun.Glob(`${MODELS_DIR}/*/*/code.mdx`).scanSync({ cwd: ROOT })] .filter((rel) => !wanted.has(join(ROOT, rel))) .sort(); for (const rel of orphans) { if (prune && !check) { rmSync(join(ROOT, dirname(rel)), { recursive: true, force: true }); - console.log(`pruned ${rel}`); + const page = rel.replace(/\.mdx$/, ""); + pruned.push(page); + console.log(`pruned ${rel} (redirect for ${pageUrl(page)} kept in ${DOCS_JSON})`); } else { problems.push(`${rel}: no code.yaml spec and no router-schemas document (rerun with --prune to delete it)`); } @@ -1288,7 +1333,7 @@ if (import.meta.main) { // ---- sidebar let docsJson: string; try { - docsJson = renderDocsJson(modelsNav(pages.map(({ model, page }) => ({ model, page })))); + docsJson = renderDocsJson(modelsNav(pages.map(({ model, page }) => ({ model, page }))), { live: pages.map((p) => p.page), pruned }); } catch (e) { problems.push((e as Error).message); docsJson = readFileSync(join(ROOT, DOCS_JSON), "utf8");