Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/scripts/snippets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
65 changes: 64 additions & 1 deletion .github/scripts/snippets/gen-code-pages.test.ts
Original file line number Diff line number Diff line change
@@ -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, "../../..");

Expand Down Expand Up @@ -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");
});
});
55 changes: 50 additions & 5 deletions .github/scripts/snippets/gen-code-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -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<string, string[]>();
for (const { model, page } of pages) {
const label = providerLabel(providerOf(model));
Expand All @@ -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<string>, pruned: Iterable<string>): 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<string>; pruned: Iterable<string> }): 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[] = [];
Expand Down Expand Up @@ -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)`);
}
Expand All @@ -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");
Expand Down
Loading