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");