Skip to content
Closed
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
14 changes: 13 additions & 1 deletion .cursor/skills/docs-i18n-translate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ description: >-
Translate ComfyUI Mintlify docs from English MDX to ja/zh/ko using translate-i18n.ts.
Incremental hash sync, chunked long pages, changelog update_blocks, glossary terms.
Use when translating docs, updating zh/ja/ko changelog or pages, running pnpm translate,
translationSourceHash, glossary sync, docs.json i18n, or fixing truncated translations.
translationSourceHash, glossary sync, docs.json i18n, OpenAPI spec localization,
or fixing truncated translations.
---

# Docs i18n Translation
Expand All @@ -21,10 +22,18 @@ index.mdx, changelog/index.mdx, … ← English (edit here)
▼ pnpm translate ← MDX only (does NOT touch docs.json)
{ja,zh,ko}/… ← translated MDX (commit to git)
snippets/{ja,zh,ko}/…
openapi/cloud.{lang}.yaml ← translated OpenAPI (API Reference pages)
openapi/registry.{lang}.yaml
openapi/.i18n/*.json ← translation sidecar metadata
▼ only if EN nav changed
pnpm translate:sync-docs-json ← mirror nav paths in docs.json (opt-in)
```

OpenAPI endpoint pages (`api-reference/**`) are Mintlify-generated from specs in
`translation-config.json` → `openapi_specs`. `pnpm translate` copies each English
spec to `*.{lang}.yaml` and translates `summary` / `description` incrementally.

Incremental: each file stores `translationSourceHash` in frontmatter. Unchanged English → skip.

## Environment (`.env.local`)
Expand Down Expand Up @@ -57,6 +66,9 @@ Requires **Bun**.
| `pnpm translate -- --with-docs-json` | Translate then sync `docs.json` nav (opt-in) |
| `pnpm translate:sync-docs-json` | Sync `docs.json` nav paths only (labels preserved) |
| `pnpm translate:sync-docs-json -- --translate-nav-labels` | Also translate new EN nav labels |
| `pnpm translate:openapi` | OpenAPI specs only |
| `pnpm translate -- --no-openapi` | Skip OpenAPI during a normal run |
| `pnpm translate -- --fetch-openapi` | Refresh `openapi/registry.en.yaml` from API before translating |
| `pnpm glossary:sync` | Rebuild glossary from ComfyUI frontend |
| `pnpm glossary:sync:dry-run` | Preview glossary sync |

Expand Down
36 changes: 34 additions & 2 deletions .github/scripts/i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,38 @@ pnpm translate:check-truncation # scan for truncated output
pnpm translate:repair-fences # append missing closing ```
pnpm translate:repair-truncated -- --lang ko
pnpm translate:sync-hash # update hashes after manual translation edits
pnpm translate:sync-docs-json # sync docs.json navigation paths (standalone)
pnpm translate:sync-docs-json # sync docs.json navigation paths
pnpm translate -- --openapi-only # OpenAPI specs only
pnpm translate -- --no-openapi # skip OpenAPI specs
pnpm translate -- --fetch-openapi # refresh vendored specs from fetch_url
```

### OpenAPI API Reference (Mintlify auto-generated pages)

Mintlify endpoint pages under `api-reference/**` are generated from OpenAPI
specs, not MDX. This pipeline copies each English spec to locale-specific files
(for example `openapi/cloud.zh.yaml`) and translates `summary` / `description`
fields incrementally.

Configure sources in `translation-config.json` → `openapi_specs`:

| Source | Output | Notes |
|--------|--------|-------|
| `openapi/cloud.en.yaml` | `openapi/cloud.{lang}.yaml` | Cloud API Reference tab |
| `openapi/registry.en.yaml` | `openapi/registry.{lang}.yaml` | Registry + Admin tabs; refresh with `--fetch-openapi` |

`docs.json` keeps English sources under `openapi/`. Sidecar hashes live in `openapi/.i18n/`.
`pnpm translate:sync-docs-json` localizes tab `openapi.source` values to the
matching `{lang}` file for zh / ja / ko.

Sidecar hashes per locale live in `openapi/.i18n/` as `{basename}.json`
(e.g. `openapi/.i18n/cloud.zh.json`). Commit translated specs and sidecars
with MDX translations.

```bash
pnpm translate:dry-run -- --openapi-only --lang zh # preview pending OpenAPI work
pnpm translate -- --openapi-only --lang zh # translate Cloud + Registry specs for zh
pnpm translate -- --fetch-openapi --openapi-only # pull latest Registry spec, then translate
```

**`docs.json` is not rewritten by `pnpm translate` by default.** Nav sync used to run
Expand Down Expand Up @@ -264,6 +295,7 @@ env → `frontend_locales_path` in `translation-config.json` →
| File | Role |
|------|------|
| `translate-i18n.ts` | translation entry point |
| `openapi-translate.ts` | OpenAPI summary/description translation |
| `chunked-translate.ts` | split/reassemble long MDX (`heading_sections`, `update_blocks`) |
| `sync-hash-i18n.ts` | Refresh translation hashes after manual edits (no API) |
| `repair-fences-i18n.ts` | Append missing closing ``` in translations (no API) |
Expand All @@ -273,4 +305,4 @@ env → `frontend_locales_path` in `translation-config.json` →
| `sync-docs-json.mjs` / `nav-label-translate.mjs` | docs.json navigation sync |
| `check-translation-truncation.ts` | detect truncated output |
| `check-i18n-sync.mjs` | PR check: English changes have matching translations |
| `translation-config.json` | languages, skip paths, `preserve_terms`, frontend path |
| `translation-config.json` | languages, skip paths, `openapi_specs`, `preserve_terms`, frontend path |
25 changes: 23 additions & 2 deletions .github/scripts/i18n/check-i18n-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ const addedRaw = gitLines(
const changedFiles = filterEnglish(changedRaw.filter((f) => f.endsWith(".mdx")));
const deletedFiles = filterEnglish(deletedRaw.filter((f) => f.endsWith(".mdx")));
const addedFiles = filterEnglish(addedRaw.filter((f) => f.endsWith(".mdx")));
const openapiSources = (CONFIG.openapi_specs ?? []).map((spec) => spec.source);
const changedOpenApiSources = changedRaw.filter((file) => openapiSources.includes(file));

function localizedOpenApiSource(source, langCode) {
if (!source || langCode === "en") return source;
if (/\.en\.(ya?ml|json)$/i.test(source)) {
return source.replace(/\.en\.(ya?ml|json)$/i, (_, ext) => `.${langCode}.${ext}`);
}
return source.replace(/\.(ya?ml|json)$/i, (_, ext) => `.${langCode}.${ext}`);
}

const allDiffNames = gitLines(`git diff --name-only ${baseSha} ${headSha}`);
const acmrtNames = gitLines(
Expand All @@ -87,9 +97,10 @@ const renamedDestinations = renameLines
if (
changedFiles.length === 0 &&
deletedFiles.length === 0 &&
addedFiles.length === 0
addedFiles.length === 0 &&
changedOpenApiSources.length === 0
) {
console.log("No English MDX files changed outside translation directories. Skipping check.");
console.log("No English MDX or OpenAPI source files changed outside translation directories. Skipping check.");
writeOutput({ skipped: true, missingByLang: {}, movedFiles: [] });
process.exit(0);
}
Expand Down Expand Up @@ -154,6 +165,16 @@ for (const lang of languages) {
missing.push(langFile);
}

for (const source of changedOpenApiSources) {
const langFile = localizedOpenApiSource(source, lang.code);
if (acmrtNames.includes(langFile)) {
console.log(`✅ [${lang.code}] Found OpenAPI translation update: ${langFile}`);
continue;
}
console.log(`❌ [${lang.code}] Missing OpenAPI translation update: ${langFile}`);
missing.push(langFile);
}

if (missing.length > 0) {
missingByLang[lang.code] = { name: lang.name, files: missing };
}
Expand Down
206 changes: 206 additions & 0 deletions .github/scripts/i18n/openapi-translate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { describe, expect, test } from "bun:test";
import {
applyTranslations,
extractTranslatableStrings,
localizedOpenApiSource,
sidecarPathForOutput,
stringifyYamlSpec,
stripYamlHeader,
escapeJpSegment,
unescapeJpSegment,
parseJpPath,
} from "./openapi-translate.ts";

describe("localizedOpenApiSource", () => {
test("inserts language code before extension", () => {
expect(localizedOpenApiSource("openapi/cloud.en.yaml", "zh")).toBe(
"openapi/cloud.zh.yaml"
);
expect(localizedOpenApiSource("openapi/registry.en.yaml", "ja")).toBe(
"openapi/registry.ja.yaml"
);
expect(localizedOpenApiSource("openapi/cloud.en.yaml", "en")).toBe(
"openapi/cloud.en.yaml"
);
});
});

describe("sidecarPathForOutput", () => {
test("uses openapi/.i18n/ directory", () => {
expect(sidecarPathForOutput("openapi/cloud.zh.yaml")).toBe(
"openapi/.i18n/cloud.zh.json"
);
expect(sidecarPathForOutput("openapi/registry.ko.yaml")).toBe(
"openapi/.i18n/registry.ko.json"
);
});
});

describe("JSON Pointer helpers", () => {
test("escapeJpSegment escapes ~ and /", () => {
expect(escapeJpSegment("simple")).toBe("simple");
expect(escapeJpSegment("/proxy/bfl/flux-pro-1.1/generate")).toBe(
"~1proxy~1bfl~1flux-pro-1.1~1generate"
);
expect(escapeJpSegment("a~tilde/key")).toBe("a~0tilde~1key");
});

test("unescapeJpSegment reverses escape", () => {
expect(unescapeJpSegment("simple")).toBe("simple");
expect(unescapeJpSegment("~1proxy~1bfl~1flux-pro-1.1~1generate")).toBe(
"/proxy/bfl/flux-pro-1.1/generate"
);
expect(unescapeJpSegment("a~0tilde~1key")).toBe("a~tilde/key");
});

test("parseJpPath handles JSON Pointer format", () => {
expect(parseJpPath("/info/description")).toEqual(["info", "description"]);
expect(parseJpPath("/paths/~1proxy~1bfl~1flux-pro-1.1~1generate/post/summary")).toEqual(
["paths", "/proxy/bfl/flux-pro-1.1/generate", "post", "summary"]
);
});

test("parseJpPath converts numeric segments to numbers", () => {
expect(parseJpPath("/servers/0/url")).toEqual(["servers", 0, "url"]);
});

test("parseJpPath handles legacy dot format for backward compat", () => {
expect(parseJpPath("info.description")).toEqual(["info", "description"]);
expect(parseJpPath("paths./users.get.summary")).toEqual(["paths", "/users", "get", "summary"]);
});
});

describe("extractTranslatableStrings", () => {
test("collects summary and description using JSON Pointer paths", () => {
const spec = {
info: { title: "API", description: "Root description" },
paths: {
"/users": {
get: {
summary: "List users",
description: "Returns all users",
operationId: "listUsers",
},
},
},
components: {
schemas: {
User: {
type: "object",
properties: {
id: { type: "string", description: "User id" },
},
},
},
},
};

expect(extractTranslatableStrings(spec)).toEqual({
"info/description": "Root description",
"paths/~1users/get/summary": "List users",
"paths/~1users/get/description": "Returns all users",
"components/schemas/User/properties/id/description": "User id",
});
});

test("handles OpenAPI path keys containing dots", () => {
const spec = {
paths: {
"/proxy/bfl/flux-pro-1.1/generate": {
post: {
summary: "Generate with Flux Pro 1.1",
},
},
"/proxy/pika/generate/2.2/t2v": {
post: {
summary: "Pika text-to-video",
},
},
"/proxy/kling/v1/kling-3.0-turbo/generate": {
post: {
summary: "Kling 3.0 turbo",
},
},
},
};

const strings = extractTranslatableStrings(spec);
// All 3 dot-containing paths should be present and reachable
expect(strings["paths/~1proxy~1bfl~1flux-pro-1.1~1generate/post/summary"]).toBe(
"Generate with Flux Pro 1.1"
);
expect(strings["paths/~1proxy~1pika~1generate~12.2~1t2v/post/summary"]).toBe(
"Pika text-to-video"
);
expect(strings["paths/~1proxy~1kling~1v1~1kling-3.0-turbo~1generate/post/summary"]).toBe(
"Kling 3.0 turbo"
);
expect(Object.keys(strings).length).toBe(3);
});
});

describe("stringifyYamlSpec", () => {
test("produces indented multi-line YAML", () => {
const yaml = stringifyYamlSpec({
openapi: "3.0.3",
info: { title: "API", description: "Hello" },
});
expect(yaml.split("\n").length).toBeGreaterThan(3);
expect(yaml).toContain("openapi: 3.0.3");
expect(yaml).toContain(" title: API");
});
});

describe("stripYamlHeader", () => {
test("preserves translation metadata comments", () => {
const { header, body } = stripYamlHeader(
"# translationSourceHash: abc\n# translationFrom: x.yaml\n\nopenapi: 3.0.3\n"
);
expect(header).toContain("translationSourceHash");
expect(body).toContain("openapi: 3.0.3");
});
});

describe("applyTranslations", () => {
test("replaces targeted strings using JSON Pointer paths", () => {
const english = {
info: { description: "Hello" },
paths: { "/x": { get: { summary: "Get X" } } },
};
const localized = applyTranslations(english, {
"/info/description": "你好",
"/paths/~1x/get/summary": "获取 X",
});
expect(localized).toEqual({
info: { description: "你好" },
paths: { "/x": { get: { summary: "获取 X" } } },
});
});

test("handles dot-containing path keys", () => {
const english = {
paths: {
"/proxy/bfl/flux-pro-1.1/generate": {
post: { summary: "Generate" },
},
},
};
const localized = applyTranslations(english, {
"/paths/~1proxy~1bfl~1flux-pro-1.1~1generate/post/summary": "生成",
});
expect(
(localized.paths as Record<string, unknown>)[
"/proxy/bfl/flux-pro-1.1/generate"
] as Record<string, unknown>
).toBeDefined();
expect(
(
(
(localized.paths as Record<string, unknown>)[
"/proxy/bfl/flux-pro-1.1/generate"
] as Record<string, unknown>
).post as Record<string, unknown>
).summary
).toBe("生成");
});
});
Loading
Loading