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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,10 @@ jobs:
# time. `mintlify validate` does NOT parse page content, so a syntax
# error (e.g. a translation that breaks a JSX tag or injects a `{#id}`
# heading anchor) passes that step but fails the post-merge deploy.
# This catches it on the PR instead.
- name: Validate MDX pages parse
# This catches it on the PR instead. It also verifies every image
# reference resolves on disk — that class breaks nothing at build time,
# it just renders as a broken image, so nothing else in CI watches it.
- name: Validate MDX pages parse and image references resolve
run: bun run validate:mdx

test-e2e:
Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/translate-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ jobs:
- name: Translate ${{ matrix.lang }}
run: bun run translate --languages ${{ matrix.lang }} ${{ inputs.force == true && '--force' || '' }}

# Fail the language that actually broke, before its artifact is uploaded.
# `consolidate` re-runs this on the merged tree, but only after every
# language finished — attributing a failure there means reading 14 logs.
# This also covers broken image paths (see findBrokenAssetRefs), the class
# that shipped every logo broken in all 14 translated READMEs: valid MDX,
# valid YAML, so neither `mintlify validate` nor the MDX parse saw it.
- name: Validate translated pages parse and images resolve
run: bun run validate:mdx

- name: Upload translated files
uses: actions/upload-artifact@v7
with:
Expand Down Expand Up @@ -161,7 +170,10 @@ jobs:
working-directory: docs
run: mintlify validate

- name: Validate translated MDX pages
# Parses every page AND checks that every image reference resolves on
# disk — a broken image path is valid MDX, so `mintlify validate` above
# passes it straight through to a reader's browser.
- name: Validate translated MDX pages and image references
run: bun run validate:mdx

- name: Download cache fragments
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
- Restrict stable releases to a maintainer allowlist while leaving prereleases open. `publish.yml`'s preflight now refuses any publish at dist-tag `latest`, or of a non-prerelease version at any dist-tag, unless both `github.actor` and `github.triggering_actor` are on the allowlist (`NiveditJain`) — the second identity matters because a re-run keeps `actor` as the original triggerer, so checking only it would make a maintainer's stable run a re-run button for everyone with write access. A stable version published under `next` is gated too: it claims that number on npm permanently and is one `npm dist-tag add` away from being the stable release. `beta` and `next` builds are untouched, so the branch-dispatch path stays open to anyone GitHub already trusts with write access. The check runs in preflight, which every other job depends on, so a refusal costs seconds rather than a 4-way cross-compile. (#651)

### Fixes
- Repair every image in the 14 translated READMEs, which had been broken since they were first generated. The root `README.md` sits at the repo root, so it writes repo-root-relative paths (`assets/logos/claude.svg`, `readme-arch-hq.gif`); the translator is prompt-forbidden from rewriting paths, so each copy inherited them verbatim into `docs/i18n/`, two directories down, where they resolved to nothing — GitHub 404'd on `docs/i18n/assets/...` and Mintlify, which also serves these pages at `/i18n/README.<lang>`, 403'd from S3. Every CLI logo and the architecture GIF were missing in all 14 languages. `rebaseReadmePaths` now re-points them at generation time: images become absolute `raw.githubusercontent.com` URLs (the only form that renders on both surfaces — a `../../` path fixes GitHub but leaves Mintlify with no `assets/` tree to walk into), while document links (`./LICENSE`, `./CONTRIBUTING.md`) get `../../`, since GitHub is the only place a link to a repo file resolves and a raw URL there would serve plaintext. `srcset` is rewritten alongside `src`, descriptors preserved — each logo cell is a `<picture>` whose dark-mode `<source>` would otherwise have stayed broken for dark-theme readers only. Paths inside fenced code blocks stay literal. (#654)
- Add a broken-image check to `validate:mdx` so that class cannot ship again. A bad image path is valid MDX and valid YAML, so `mintlify validate` and the existing MDX parse both passed it straight through to a reader's browser — nothing in CI was watching. `findBrokenAssetRefs` now resolves every local image reference on every docs page — `src`, `href`, Markdown `![…](…)`, and each `srcset` candidate — against `docs/` for a site-absolute `/…` and against the page's own directory otherwise, failing with the path it resolved to. It runs in the CI `docs` job, in each per-language auto-translation job before its artifact is uploaded, and twice more in `consolidate`. The root `README.md` is checked too, since a bad path there propagates into 14 files as an absolute URL the check would no longer follow. (#654)
- Harden the release workflow against shell injection from ref names and generated outputs, align every Bun cache key with the tracked `bun.lock`, and discard the temporary publish-version edit before switching to `main` for the development-version bump. (#634)

### Dependencies
Expand Down
239 changes: 237 additions & 2 deletions __tests__/scripts/translate-docs/readme-translator.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,200 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { buildMainReadmeLanguageLinks } from "@/scripts/translate-docs/readme-translator";
import { describe, it, expect, vi } from "vitest";

// Stub the model call so `translateReadme` runs its real render pipeline over a
// fixed "translation", and stub the writes so the test never touches the repo's
// own docs/i18n/ files. Everything between — the sanitizers, the rebase, the
// wrapper assembly, and the real validator — runs unmocked.
vi.mock("@/scripts/translate-docs/translator", () => ({
translateValidated: vi.fn(
async (opts: {
render: (raw: string) => string;
validate: (rendered: string) => Promise<string | null>;
}) => {
const rendered = opts.render(RAW_TRANSLATION);
const error = await opts.validate(rendered);
if (error) throw new Error(`fixture failed validation: ${error}`);
return { rendered, inputTokens: 0, outputTokens: 0, attempts: 1 };
},
),
}));

vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return { ...actual, writeFileSync: vi.fn(), mkdirSync: vi.fn() };
});

/** Stands in for the model's output: the shapes the real README emits. */
const RAW_TRANSLATION = [
"# 失败保护 AI",
"",
"<picture>",
' <source media="(prefers-color-scheme: dark)" srcset="assets/logos/openai-dark.svg" />',
' <img src="assets/logos/openai-light.svg" alt="OpenAI Codex" width="56" />',
"</picture>",
"",
"详情见 [LICENSE](./LICENSE)。",
].join("\n");

import { writeFileSync } from "node:fs";
import { translateReadme } from "@/scripts/translate-docs/readme-translator";
import {
buildMainReadmeLanguageLinks,
rebaseReadmePaths,
} from "@/scripts/translate-docs/readme-translator";
import { LANGUAGES } from "@/scripts/translate-docs/config";

const RAW = "https://raw.githubusercontent.com/FailproofAI/failproofai/main";

describe("rebaseReadmePaths", () => {
// The root README sits AT the repo root, so it writes repo-root-relative
// paths. Its translations land in docs/i18n/, two levels down, where those
// paths resolve to nothing — GitHub 404s and Mintlify (which also serves
// these pages at /i18n/README.<lang>) 403s from S3. Every logo and the
// architecture GIF were broken in all 14 languages until this rewrite.
it("rewrites image paths to absolute raw URLs so both surfaces resolve", () => {
expect(
rebaseReadmePaths('<img src="assets/logos/claude.svg" width="56" />'),
).toBe(`<img src="${RAW}/assets/logos/claude.svg" width="56" />`);
expect(rebaseReadmePaths("![demo](readme-arch-hq.gif)")).toBe(
`![demo](${RAW}/readme-arch-hq.gif)`,
);
});

it("rewrites srcset, where the dark-mode logo of every <picture> lives", () => {
// Each logo cell pairs an <img src> with a dark-mode <source srcset>.
// Rewriting only `src` leaves the table half-broken for dark-theme readers
// — the half least likely to be caught by eye in review.
expect(
rebaseReadmePaths(
'<source media="(prefers-color-scheme: dark)" srcset="assets/logos/openai-dark.svg" />',
),
).toBe(
`<source media="(prefers-color-scheme: dark)" srcset="${RAW}/assets/logos/openai-dark.svg" />`,
);
});

it("rebases every srcset candidate, keeping its descriptor and spacing", () => {
expect(
rebaseReadmePaths(
'<source srcset="assets/logos/pi-dark.svg 1x, assets/logos/pi-dark@2x.png 2x" />',
),
).toBe(
`<source srcset="${RAW}/assets/logos/pi-dark.svg 1x, ${RAW}/assets/logos/pi-dark@2x.png 2x" />`,
);
});

it("leaves an already-absolute srcset candidate alone", () => {
const abs = '<source srcset="https://cdn.example.com/logo.svg 2x" />';
expect(rebaseReadmePaths(abs)).toBe(abs);
});

it("rewrites document links to ../../ instead, where GitHub resolves them", () => {
// A raw URL for a .md would serve unrendered plaintext, so links get the
// relative form; GitHub is the only surface they work on either way.
expect(rebaseReadmePaths("[LICENSE](./LICENSE)")).toBe(
"[LICENSE](../../LICENSE)",
);
expect(rebaseReadmePaths("[中文](./docs/i18n/README.zh.md)")).toBe(
"[中文](../../docs/i18n/README.zh.md)",
);
});

it("preserves a fragment on a rewritten link", () => {
expect(rebaseReadmePaths("[build](./CONTRIBUTING.md#build-first)")).toBe(
"[build](../../CONTRIBUTING.md#build-first)",
);
});

it("leaves absolute URLs, anchors, and other schemes alone", () => {
const untouched =
"[npm](https://www.npmjs.com/package/failproofai)\n" +
'<img src="https://d2wq11aau0arks.cloudfront.net/failproof/logo.svg" />\n' +
"[jump](#usage)\n" +
"[mail](mailto:hi@befailproof.ai)\n" +
"![inline](data:image/png;base64,iVBORw0KGgo=)\n" +
"![site](/agenteye/images/alerts.png)";
expect(rebaseReadmePaths(untouched)).toBe(untouched);
});

it("is idempotent — a second pass changes nothing", () => {
const once = rebaseReadmePaths(
'<img src="assets/logos/pi-light.svg" />\n[LICENSE](./LICENSE)',
);
expect(rebaseReadmePaths(once)).toBe(once);
});

it("does not close a fence on a line carrying an info string", () => {
// CommonMark allows an info string on an OPENING fence only. Treating
// ```` ```ts ```` as a close would end the block early and expose the
// sample paths after it to rewriting.
const fenced =
"````md\n" +
"```ts\n" +
'<img src="assets/logos/claude.svg" />\n' +
"```\n" +
"````\n" +
'<img src="assets/logos/devin.svg" />';
expect(rebaseReadmePaths(fenced)).toBe(
"````md\n" +
"```ts\n" +
'<img src="assets/logos/claude.svg" />\n' +
"```\n" +
"````\n" +
`<img src="${RAW}/assets/logos/devin.svg" />`,
);
});

it("keeps a fence intact after an earlier pass lengthened the text", () => {
// The markdown pass rewrites the GIF to a much longer absolute URL, pushing
// the fence forward. A fence map computed once from the input would leave
// the later src/srcset passes reading stale offsets and rewriting the
// literal sample paths inside the block.
const fenced =
"![arch](readme-arch-hq.gif)\n" +
"\n" +
"```html\n" +
'<img src="assets/logos/claude.svg" />\n' +
'<source srcset="assets/logos/claude-dark.svg" />\n' +
"```";
expect(rebaseReadmePaths(fenced)).toBe(
`![arch](${RAW}/readme-arch-hq.gif)\n` +
"\n" +
"```html\n" +
'<img src="assets/logos/claude.svg" />\n' +
'<source srcset="assets/logos/claude-dark.svg" />\n' +
"```",
);
});

it("leaves paths inside fenced code blocks literal", () => {
// There a path is sample text a reader copies, not a reference to resolve.
const fenced =
"```html\n" +
'<img src="assets/logos/claude.svg" />\n' +
"```\n" +
'<img src="assets/logos/devin.svg" />';
expect(rebaseReadmePaths(fenced)).toBe(
"```html\n" +
'<img src="assets/logos/claude.svg" />\n' +
"```\n" +
`<img src="${RAW}/assets/logos/devin.svg" />`,
);
});

it("must run on the model output only, never on the assembled wrapper", () => {
// Documents the call-site contract. The language selector already points at
// docs/i18n/ siblings, so a bare `README.zh.md` there is CORRECT — running
// this over it would rewrite it to a path one directory above the file and
// break every selector link. `translateReadme` therefore rebases `raw`
// before wrapping, not the assembled page.
const selector = "[🇺🇸 English](../../README.md) | [🇨🇳 简体中文](README.zh.md)";
expect(rebaseReadmePaths(selector)).toBe(
"[🇺🇸 English](../../README.md) | [🇨🇳 简体中文](../../README.zh.md)",
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

describe("buildMainReadmeLanguageLinks", () => {
it("returns a string starting with **Translations**:", () => {
const result = buildMainReadmeLanguageLinks();
Expand Down Expand Up @@ -36,3 +228,46 @@ describe("buildMainReadmeLanguageLinks", () => {
expect(result).toContain(" | ");
});
});

describe("translateReadme — rebase call site", () => {
// rebaseReadmePaths is only correct if it runs on the model output BEFORE the
// wrapper is attached. The unit tests above pin the function; these pin the
// ordering, which is what a future refactor would silently get wrong.
const write = vi.mocked(writeFileSync);

const renderOnce = async (): Promise<string> => {
write.mockClear();
// A caller-supplied cache keeps translateReadme off the on-disk one.
await translateReadme("zh", {
force: true,
cache: { sourceHash: "", lastUpdated: "", translations: {} },
});
expect(write).toHaveBeenCalledTimes(1);
return write.mock.calls[0][1] as string;
};

it("rebases the body's src, srcset, and document links", async () => {
const out = await renderOnce();
expect(out).toContain(`src="${RAW}/assets/logos/openai-light.svg"`);
expect(out).toContain(`srcset="${RAW}/assets/logos/openai-dark.svg"`);
expect(out).toContain("[LICENSE](../../LICENSE)");
expect(out).not.toContain('src="assets/logos/');
expect(out).not.toContain('srcset="assets/logos/');
});

it("leaves the language selector's sibling links untouched", async () => {
const out = await renderOnce();
// Written by buildLanguageSelector AFTER the rebase, already relative to
// docs/i18n/. A `../../README.ja.md` here would point one level too high.
expect(out).toContain("](README.ja.md)");
expect(out).toContain("](../../README.md)");
expect(out).not.toContain("](../../README.ja.md)");
});

it("writes to docs/i18n/README.<lang>.md", async () => {
await renderOnce();
expect(String(write.mock.calls[0][0])).toMatch(
/docs[/\\]i18n[/\\]README\.zh\.md$/,
);
});
});
Loading