🌐 ci: Sync README Translations With AI (#16847)

* ci: Add README Translation Sync Workflow

* ci: Pin create-pull-request Action in README Sync

* ci: Check Translation Content and Attribute Text in README Sync

* ci: Harden README Translation Validation and Switcher

* ci: Validate Protected Tokens in README Translations

* ci: Keep Link Order and Fence Boundaries in README Translation

* ci: Validate README Translations Against the Markdown Tree

* ci: Check Short Prose and Link Metadata in README Translation

* ci: Handle Brand Suffixes, Titles and Table Alignment in README Validation

* ci: Preserve Emoji, Image Alt and Raw Code in README Validation

* ci: Stop Rejecting Faithful Translations That Keep Language Names

* ci: Refresh Switchers in Unselected README Translations

* ci: Compare Comment Contents and Deduplicate Pending README Sections

* ci: Revalidate Cached README Translations and Resolve Reference Links

* ci: Keep Code in Raw HTML Blocks and Accept Full-Form Reference Translations

* ci: Read Alt Text of Reference-Style README Images

* ci: Test the README Translation Helpers on Pull Requests

* ci: Keep Reference Image Alt Text and Translate Definition Titles

* ci: Resolve README Reference Definitions Before Classifying Prose

* ci: Resolve README Footnotes When Validating Chunks

* ci: Point README Fragment Links at Translated Heading Anchors

Same-document links kept the English heading slug, which no longer exists once GitHub derives anchors from the translated heading text. Assembly now rewrites #fragment links in Markdown links, definitions and raw HTML hrefs to the slug of the heading at the same position, using github-slugger.

Closes berry-13/LibreChat#384
This commit is contained in:
Marco Beretta 2026-10-26 16:53:48 +01:00 • committed by GitHub
parent 600825ac3c
commit d0fcdb7832
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1975 additions and 0 deletions

122
.github/workflows/readme-translate.yml vendored Normal file
View file

@ -0,0 +1,122 @@
name: Sync README Translations
on:
push:
branches: [dev]
paths:
- 'README.md'
pull_request:
branches: [dev]
paths:
- 'scripts/readme/**'
- '.github/workflows/readme-translate.yml'
workflow_dispatch:
inputs:
languages:
description: 'Comma-separated language codes'
default: 'zh,ru'
force:
description: 'Retranslate everything, ignoring the cache'
type: boolean
default: false
concurrency:
# Pull request test runs get their own group so they never displace a pending translation run.
group: readme-translate${{ github.event_name == 'pull_request' && format('-pr-{0}', github.event.number) || '' }}
cancel-in-progress: false
permissions: {}
jobs:
test:
name: Test Translation Helpers
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Set Up Node.js
uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Install Translation Dependencies
run: npm ci --prefix scripts/readme --ignore-scripts --no-audit --no-fund --registry=https://registry.npmjs.org/
- name: Test Translation Helpers
run: node --test scripts/readme/lib.test.mjs
translate:
name: Translate README
# Unset on forks and until a maintainer picks a model, so the job skips instead of failing.
if: ${{ github.event_name != 'pull_request' && vars.README_TRANSLATE_MODEL != '' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout Repository
uses: actions/checkout@v5
with:
ref: dev
persist-credentials: false
- name: Set Up Node.js
uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Install Translation Dependencies
run: npm ci --prefix scripts/readme --ignore-scripts --no-audit --no-fund --registry=https://registry.npmjs.org/
- name: Test Translation Helpers
run: node --test scripts/readme/lib.test.mjs
- name: Translate Changed README Sections
env:
README_TRANSLATE_BASE_URL: ${{ secrets.README_TRANSLATE_BASE_URL }}
README_TRANSLATE_API_KEY: ${{ secrets.README_TRANSLATE_API_KEY }}
README_TRANSLATE_MODEL: ${{ vars.README_TRANSLATE_MODEL }}
LANGUAGES: ${{ inputs.languages || 'zh,ru' }}
FORCE: ${{ inputs.force && '--force' || '' }}
run: node scripts/readme/index.mjs "--langs=$LANGUAGES" $FORCE
- name: Create Pull Request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ github.token }}
add-paths: |
README.md
README.*.md
.github/readme-i18n.json
commit-message: 'i18n: Sync README Translations'
base: dev
branch: i18n/readme-translations
title: '🌐 i18n: Sync README Translations'
body: |
## Summary
`README.md` changed on `dev`, so the Chinese and Russian READMEs are regenerated from it. Only sections whose English text changed were retranslated; the rest comes from the cache in `.github/readme-i18n.json`. Each section is checked against its source for identical links, image paths, HTML tags and list structure before it is accepted.
## Type of change
* [x] Translation
## Testing
* `node --test scripts/readme/lib.test.mjs` passed in the workflow run.
* Every regenerated section kept its source links, image paths, HTML tags and list structure.
## Risk / compatibility
None. Only README files and the translation cache change.
## Checklist
* [x] The change does not introduce new warnings or errors
labels: '🌍 i18n'

164
scripts/readme/index.mjs Normal file
View file

@ -0,0 +1,164 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import {
LANGUAGES,
buildMessages,
cleanOutput,
definitions,
hash,
isSwitcher,
localizeAnchors,
needsTranslation,
renderSwitcher,
reusable,
splitChunks,
validate,
} from './lib.mjs';
const SOURCE = 'README.md';
const CACHE = '.github/readme-i18n.json';
const ATTEMPTS = 3;
const CONCURRENCY = 4;
const args = process.argv.slice(2);
const force = args.includes('--force');
const langsArg = args.find((arg) => arg.startsWith('--langs='))?.slice('--langs='.length);
const langs = (langsArg || Object.keys(LANGUAGES).join(','))
.split(',')
.map((code) => code.trim())
.filter(Boolean);
const baseUrl = process.env.README_TRANSLATE_BASE_URL?.replace(/\/+$/, '');
const apiKey = process.env.README_TRANSLATE_API_KEY;
const model = process.env.README_TRANSLATE_MODEL;
const missing = Object.entries({
README_TRANSLATE_BASE_URL: baseUrl,
README_TRANSLATE_API_KEY: apiKey,
README_TRANSLATE_MODEL: model,
})
.filter(([, value]) => !value)
.map(([key]) => key);
if (missing.length > 0) {
console.error(`Missing environment variables: ${missing.join(', ')}`);
process.exit(2);
}
for (const code of langs) {
if (!LANGUAGES[code]) {
console.error(`Unsupported language: ${code}`);
process.exit(2);
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function complete(code, chunk) {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model, messages: buildMessages(LANGUAGES[code].name, chunk) }),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const content = data?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || content.trim() === '') throw new Error('empty completion');
return cleanOutput(content);
}
async function translate(code, chunk, context) {
let reason = '';
for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
try {
const output = await complete(code, chunk);
const problems = validate(chunk, output, code, context);
if (problems.length === 0) return output;
reason = problems.join('; ');
} catch (error) {
reason = error instanceof Error ? error.message : 'request failed';
}
if (attempt < ATTEMPTS) await sleep(1000 * 2 ** (attempt - 1));
}
const preview = chunk.slice(0, 80).replace(/\s+/g, ' ');
throw new Error(`${code} translation failed (${reason}) for: ${preview}`);
}
async function pool(items, worker) {
const queue = [...items];
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
while (queue.length > 0) await worker(queue.shift());
}),
);
}
const readIfExists = async (file) => readFile(file, 'utf8').catch(() => null);
const source = await readFile(SOURCE, 'utf8');
const units = splitChunks(source);
const chunks = units.map((unit) => unit.text);
const context = definitions(source);
const stored = JSON.parse((await readIfExists(CACHE)) ?? '{}');
const cache = { version: 1 };
const outputs = {};
const available = await Promise.all(
Object.entries(LANGUAGES).map(async ([code, { file }]) =>
langs.includes(code) || (await readIfExists(file)) !== null ? code : null,
),
).then((codes) => codes.filter(Boolean));
for (const code of langs) {
const { file } = LANGUAGES[code];
const previous = force ? {} : (stored[code] ?? {});
const next = {};
const pending = [];
for (const chunk of chunks) {
if (isSwitcher(chunk) || !needsTranslation(chunk, context)) continue;
const key = hash(chunk);
const cached = reusable(previous[key], chunk, code, context);
if (cached !== undefined) next[key] = cached;
else if (!pending.some((item) => item.key === key)) pending.push({ key, chunk });
}
console.log(
`${code}: ${pending.length} chunk(s) to translate, ${Object.keys(next).length} cached`,
);
await pool(pending, async ({ key, chunk }) => {
next[key] = await translate(code, chunk, context);
});
const assembled = units
.map(({ text, joiner }, index) => {
const part = isSwitcher(text) ? renderSwitcher(code, available) : text;
const out = !isSwitcher(text) && needsTranslation(text, context) ? next[hash(text)] : part;
return index === 0 ? out : joiner + out;
})
.join('');
const body = localizeAnchors(source.replace(/\r\n/g, '\n'), assembled);
const notice =
'<!-- Generated from README.md by .github/workflows/readme-translate.yml. Do not edit by hand. -->';
outputs[file] = `${notice}\n\n${body}\n`;
cache[code] = Object.fromEntries(
chunks
.filter((chunk) => !isSwitcher(chunk) && needsTranslation(chunk, context))
.map((chunk) => [hash(chunk), next[hash(chunk)]]),
);
}
for (const code of Object.keys(LANGUAGES)) {
if (!langs.includes(code) && stored[code]) cache[code] = stored[code];
}
// Languages not selected keep their body, but their switcher must list every available language.
for (const [code, { file }] of Object.entries(LANGUAGES)) {
const existing = langs.includes(code) ? null : await readIfExists(file);
const current = existing && splitChunks(existing).find((unit) => isSwitcher(unit.text));
if (!current || current.text === renderSwitcher(code, available)) continue;
outputs[file] = existing.replace(current.text, () => renderSwitcher(code, available));
}
const switcher = chunks.find(isSwitcher);
if (switcher && switcher !== renderSwitcher('en', available)) {
outputs[SOURCE] = source.replace(switcher, () => renderSwitcher('en', available));
}
for (const [file, content] of Object.entries(outputs)) await writeFile(file, content);
await writeFile(CACHE, `${JSON.stringify(cache, null, 2)}\n`);

376
scripts/readme/lib.mjs Normal file
View file

@ -0,0 +1,376 @@
import { createHash } from 'node:crypto';
import GithubSlugger from 'github-slugger';
import { fromMarkdown } from 'mdast-util-from-markdown';
import { gfmFromMarkdown } from 'mdast-util-gfm';
import { gfm } from 'micromark-extension-gfm';
export const LANGUAGES = {
zh: {
name: 'Simplified Chinese',
label: '中文',
file: 'README.zh.md',
script: /\p{Script=Han}/u,
},
ru: { name: 'Russian', label: 'Русский', file: 'README.ru.md', script: /\p{Script=Cyrillic}/u },
};
export const GLOSSARY = [
'LibreChat',
'Agents',
'MCP',
'Artifacts',
'Skills',
'Subagents',
'Code Interpreter',
'OpenAI',
'Anthropic',
'Docker',
'Helm',
'Railway',
'Zeabur',
'Sealos',
'API',
'URL',
'SDK',
'OAuth',
'JSON',
'YAML',
'CLI',
'npm',
'GitHub',
'Discord',
'YouTube',
];
export const hash = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16);
const GLOSSARY_PATTERN = new RegExp(
`\\b(?:${GLOSSARY.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?:['’]s|s)?\\b`,
'gi',
);
const MIN_WORDS_FOR_CONTENT_CHECK = 3;
const MIN_LENGTH_RATIO = 0.2;
const MAX_UNTRANSLATED_SHARE = 0.6;
const TEXT_ATTRIBUTE = /(?<![\w-])(alt|title|aria-label)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/g;
const HTML_PIECE = /<!--[\s\S]*?-->|<\/?[a-zA-Z][^>]*>/g;
const parse = (markdown) =>
fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
const unquote = (value) => value.replace(/^(["'])([\s\S]*)\1$/, '$2');
/**
* Splits Markdown into the units that are translated and cached: each top-level block, with list
* items taken one by one. `joiner` is the separator that precedes a unit when they are reassembled.
*/
export function splitChunks(markdown) {
const text = markdown.replace(/\r\n/g, '\n');
const slice = (node) => text.slice(node.position.start.offset, node.position.end.offset);
const chunks = [];
for (const node of parse(text).children) {
if (node.type !== 'list') {
chunks.push({ text: slice(node), joiner: '\n\n' });
continue;
}
node.children.forEach((item, index) => {
const joiner = index === 0 || node.spread ? '\n\n' : '\n';
chunks.push({ text: slice(item), joiner });
});
}
return chunks;
}
/**
* Every link reference and footnote definition in a document. A chunk is validated with these
* appended, so a reference-style link or footnote resolves the same way it does in the whole README.
*/
export function definitions(markdown) {
const text = markdown.replace(/\r\n/g, '\n');
const found = [];
walk(parse(text), (node) => {
if (node.type === 'definition' || node.type === 'footnoteDefinition') {
found.push(text.slice(node.position.start.offset, node.position.end.offset));
}
});
return found.join('\n\n');
}
const SWITCHER_ITEM = '(?:<strong>[^<]+</strong>|<a href="README(?:\\.\\w+)?\\.md">[^<]+</a>)';
const SWITCHER = new RegExp(`^<p align="center">\\s*(?:${SWITCHER_ITEM}\\s*·?\\s*)+</p>$`);
/** True for the language switcher block in any README, whichever language is current. */
export const isSwitcher = (chunk) => SWITCHER.test(chunk.trim());
/** `available` lists the language codes whose README exists or is being generated. */
export function renderSwitcher(current, available = Object.keys(LANGUAGES)) {
const entries = [
{ code: 'en', label: 'English', file: 'README.md' },
...Object.entries(LANGUAGES)
.filter(([code]) => available.includes(code))
.map(([code, { label, file }]) => ({ code, label, file })),
];
const items = entries.map((entry) =>
entry.code === current
? `<strong>${entry.label}</strong>`
: `<a href="${entry.file}">${entry.label}</a>`,
);
return `<p align="center">\n ${items.join(' ·\n ')}\n</p>`;
}
/** Change in `<code>`/`<pre>` nesting that an HTML fragment causes. */
const rawCodeDelta = (html) =>
(html.match(/<(?:code|pre)[\s>]/gi) ?? []).length -
(html.match(/<\/(?:code|pre)>/gi) ?? []).length;
function walk(node, visit) {
visit(node);
for (const child of node.children ?? []) walk(child, visit);
}
/** Nodes whose alt text or title a reader sees. */
const LABELED_NODES = new Set(['image', 'imageReference', 'link', 'definition']);
/**
* Words a reader sees: text, image alt, and alt, title and aria-label values, minus glossary terms.
* `context` resolves reference links without contributing words of its own.
*/
function proseWords(markdown, context = '', withAttributes = true) {
const parts = [];
let rawCode = 0;
walk(parse(context ? `${markdown}\n\n${context}` : markdown), (node) => {
if (node.position && node.position.start.offset >= markdown.length) return;
if (node.type === 'html') rawCode = Math.max(rawCode + rawCodeDelta(node.value), 0);
if (node.type === 'text' && rawCode === 0) parts.push(node.value);
else if (withAttributes && LABELED_NODES.has(node.type)) {
if (node.alt) parts.push(node.alt);
if (node.title) parts.push(node.title);
} else if (node.type === 'html') {
if (withAttributes) {
for (const match of node.value.matchAll(TEXT_ATTRIBUTE)) parts.push(unquote(match[2]));
}
const visible = node.value.replace(/<(code|pre)[\s>][\s\S]*?<\/\1>/gi, ' ');
parts.push(visible.replace(HTML_PIECE, ' '));
}
});
const text = parts
.join(' ')
.replace(/https?:\/\/\S+/g, ' ')
.replace(GLOSSARY_PATTERN, ' ');
return text.match(/\p{L}[\p{L}'’-]*/gu) ?? [];
}
/** True when a chunk holds prose worth sending to the model. */
export const needsTranslation = (markdown, context = '') =>
proseWords(markdown, context).length > 0;
const normalizeTag = (tag) =>
tag.startsWith('<!--')
? tag
: tag
.replace(/=\s*'([^']*)'/g, '="$1"')
.replace(TEXT_ATTRIBUTE, (match, name, value) =>
unquote(value).trim() === '' ? `${name}=""` : `${name}="*"`,
)
.replace(/("[^"]*")|\s+/g, (match, quoted) => quoted ?? ' ');
/** Tags of a raw HTML node with prose masked; text inside its `<code>`/`<pre>` stays verbatim. */
function htmlTokens(raw) {
const tokens = [];
let last = 0;
let code = 0;
const text = (value) => {
if (value.trim()) tokens.push(code > 0 ? `rawcode:${value}` : 'T');
};
for (const match of raw.matchAll(HTML_PIECE)) {
text(raw.slice(last, match.index));
tokens.push(normalizeTag(match[0]));
code = Math.max(code + rawCodeDelta(match[0]), 0);
last = match.index + match[0].length;
}
text(raw.slice(last));
return tokens;
}
/** The structure of a chunk with prose masked: everything a translation must reproduce exactly. */
function skeleton(markdown) {
const tokens = [];
const push = (token) => {
if (token !== 'T' || tokens.at(-1) !== 'T') tokens.push(token);
};
let rawCode = 0;
const visit = (node) => {
if (node.type === 'text') {
if (rawCode > 0) return push(`rawcode:${node.value}`);
push('T');
const emoji = node.value.match(/\p{Extended_Pictographic}/gu);
return emoji && push(`emoji:${emoji.join('')}`);
}
if (node.type === 'inlineCode') return push(`code:${node.value}`);
if (node.type === 'code') return push(`fence:${node.lang ?? ''}:${node.value}`);
if (node.type === 'html') {
rawCode = Math.max(rawCode + rawCodeDelta(node.value), 0);
return htmlTokens(node.value).forEach(push);
}
if (node.type === 'image') {
return push(`image:${node.url}:${node.title ? 'titled' : ''}:${node.alt ? 'alt' : ''}`);
}
if (node.type === 'imageReference') {
return push(`imageReference:${node.identifier}:${node.alt ? 'alt' : ''}`);
}
const detail = [
node.depth,
node.url,
node.ordered,
node.checked,
node.start,
node.identifier,
node.title ? 'titled' : undefined,
node.align?.map((align) => align ?? 'none').join(','),
].filter((value) => value !== undefined && value !== null);
const tag = [node.type, ...detail].join(':');
if (!node.children) return push(tag);
push(`<${tag}>`);
node.children.forEach(visit);
push(`</${node.type}>`);
};
visit(parse(markdown));
return tokens;
}
/** Common lowercase English words; capitalized names such as language names legitimately stay. */
const commonWords = (words) => words.filter((word) => /^[a-z]{4,}$/.test(word));
/**
* Returns a list of problems with a translation: any difference in structure, links, code or
* attributes from its source, and output that is empty, short, or not in the target language.
* `context` holds the document's reference definitions (see `definitions`).
*/
export function validate(source, translated, code, context = '') {
const problems = [];
if (translated.trim() === '') return ['output is empty'];
const withContext = (markdown) => (context ? `${markdown}\n\n${context}` : markdown);
const a = skeleton(withContext(source));
const b = skeleton(withContext(translated));
const at = a.findIndex((token, index) => token !== b[index]);
if (at !== -1 || a.length !== b.length) {
const near = (a[Math.max(at, 0)] ?? b[Math.max(at, 0)] ?? '').slice(0, 60);
problems.push(`structure differs near ${near}`);
}
const script = LANGUAGES[code]?.script;
const words = proseWords(source, context);
const sameWords = (a, b) => a.length === b.length && a.every((word, index) => word === b[index]);
if (
script &&
proseWords(source, context, false).length > 0 &&
sameWords(words, proseWords(translated, context))
) {
problems.push('output is unchanged');
}
if (
script &&
(proseWords(source, context, false).length > 0 ||
words.length >= MIN_WORDS_FOR_CONTENT_CHECK) &&
!script.test(translated)
) {
problems.push(
'output is not in the target language (add intentional English terms to GLOSSARY)',
);
}
if (script && words.length >= MIN_WORDS_FOR_CONTENT_CHECK) {
if (translated.length < source.length * MIN_LENGTH_RATIO) problems.push('output is truncated');
const plain = commonWords(words);
const kept = new Set(proseWords(translated, context).map((word) => word.toLowerCase()));
const share = plain.filter((word) => kept.has(word)).length / (plain.length || 1);
if (plain.length >= MIN_WORDS_FOR_CONTENT_CHECK && share > MAX_UNTRANSLATED_SHARE) {
problems.push('output is mostly untranslated');
}
}
return problems;
}
/** Anchor of every Markdown heading in document order, as GitHub derives it from the heading text. */
function headingSlugs(markdown) {
const slugger = new GithubSlugger();
const slugs = [];
walk(parse(markdown), (node) => {
if (node.type !== 'heading') return;
let text = '';
walk(node, (child) => {
if (child.type === 'text' || child.type === 'inlineCode') text += child.value;
});
slugs.push(slugger.slug(text));
});
return slugs;
}
const HTML_FRAGMENT = /(href\s*=\s*)(["'])#([^"']*)\2/gi;
/**
* Points `#fragment` links of a translated README at the anchors of its translated headings, which
* GitHub derives from the heading text. Headings pair up by position: `validate` keeps them equal.
*/
export function localizeAnchors(source, translated) {
const from = headingSlugs(source);
const to = headingSlugs(translated);
if (from.length !== to.length) return translated;
const anchors = new Map(from.map((slug, index) => [slug, to[index]]));
const edits = [];
walk(parse(translated), (node) => {
if (!node.position) return;
const start = node.position.start.offset;
const end = node.position.end.offset;
if (node.type === 'html') {
const value = translated.slice(start, end);
const next = value.replace(HTML_FRAGMENT, (match, prefix, quote, fragment) =>
anchors.has(fragment) ? `${prefix}${quote}#${anchors.get(fragment)}${quote}` : match,
);
if (next !== value) edits.push({ start, end, value: next });
return;
}
if (node.type !== 'link' && node.type !== 'definition') return;
const fragment = node.url?.startsWith('#') ? node.url.slice(1) : null;
if (fragment === null || !anchors.has(fragment)) return;
const after =
node.type === 'link'
? (node.children.at(-1)?.position.end.offset ?? start)
: translated.indexOf(']:', start);
const at = translated.indexOf(node.url, after);
if (at === -1 || at >= end) return;
edits.push({ start: at, end: at + node.url.length, value: `#${anchors.get(fragment)}` });
});
return edits
.sort((a, b) => b.start - a.start)
.reduce(
(text, edit) => text.slice(0, edit.start) + edit.value + text.slice(edit.end),
translated,
);
}
/** A cached translation that still passes the current `validate`, else undefined to retranslate. */
export const reusable = (cached, source, code, context = '') =>
typeof cached === 'string' && validate(source, cached, code, context).length === 0
? cached
: undefined;
/** Drops a code fence the model sometimes wraps around its answer. */
export function cleanOutput(text) {
const wrapped = /^\s*```(?:markdown|md|html)?\n([\s\S]*)\n```\s*$/.exec(text);
return (wrapped ? wrapped[1] : text).replace(/^(?:[ \t]*\n)+/, '').trimEnd();
}
export function buildMessages(language, chunk) {
const system = [
`You translate fragments of the LibreChat README from English into ${language}.`,
'Reply with the translated fragment only: no commentary, and no code fence around it.',
'Keep Markdown and HTML structure, tags, attribute names, URLs, file paths, inline code, badges and emoji exactly as given.',
'Translate human-readable prose, headings, link text, and alt, title and aria-label values.',
`Keep these terms in English: ${GLOSSARY.join(', ')}, plus product and brand names.`,
'Keep the same number of lines and list items as the input, and keep links in the same order.',
'Write a reference link such as [Label] or [Label][] as [translated label][Label] so it still resolves.',
'Leave a space between bold or italic markers and neighbouring words so the Markdown still renders.',
].join('\n');
return [
{ role: 'system', content: system },
{ role: 'user', content: chunk },
];
}

299
scripts/readme/lib.test.mjs Normal file
View file

@ -0,0 +1,299 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
cleanOutput,
definitions,
isSwitcher,
localizeAnchors,
needsTranslation,
renderSwitcher,
reusable,
splitChunks,
validate,
} from './lib.mjs';
const texts = (markdown) => splitChunks(markdown).map((chunk) => chunk.text);
test('splitChunks keeps fenced code whole, including a different fence marker inside', () => {
assert.deepEqual(texts('# Title\n\nText\n\n```text\n~~~\nb\n\nc\n```\n\n\nEnd\n'), [
'# Title',
'Text',
'```text\n~~~\nb\n\nc\n```',
'End',
]);
});
test('splitChunks separates prose from an adjacent fenced block', () => {
assert.deepEqual(texts('Intro\n```sh\nnpm install\n```\nOutro'), [
'Intro',
'```sh\nnpm install\n```',
'Outro',
]);
});
test('splitChunks takes list items one by one and remembers tight and loose lists', () => {
const tight = splitChunks('- a\n- b\n - c\n');
assert.deepEqual(
tight.map((chunk) => chunk.text),
['- a', '- b\n - c'],
);
assert.deepEqual(
tight.map((chunk) => chunk.joiner),
['\n\n', '\n'],
);
assert.equal(splitChunks('- a\n\n- b\n')[1].joiner, '\n\n');
});
test('renderSwitcher bolds the current language and links the rest', () => {
const en = renderSwitcher('en');
assert.ok(isSwitcher(en));
assert.match(en, /<a href="README\.zh\.md">/);
assert.match(en, /<a href="README\.ru\.md">/);
const ru = renderSwitcher('ru');
assert.match(ru, /<strong>Русский<\/strong>/);
assert.match(ru, /<a href="README\.md">English<\/a>/);
});
test('renderSwitcher links only available languages', () => {
const en = renderSwitcher('en', ['zh']);
assert.match(en, /README\.zh\.md/);
assert.doesNotMatch(en, /README\.ru\.md/);
});
test('needsTranslation keeps alt text in either quote style and skips code and glossary terms', () => {
assert.equal(
needsTranslation('<a aria-label="Sponsors" href="https://x.y"><img src="a.svg"></a>'),
true,
);
assert.equal(needsTranslation("<img alt='Build status' src='x.svg'>"), true);
assert.equal(needsTranslation('<img src="logo.svg" height="256">'), false);
assert.equal(needsTranslation('```sh\nnpm run build\n```'), false);
assert.equal(needsTranslation(' npm run build\n npm test'), false);
assert.equal(needsTranslation('# LibreChat'), false);
assert.equal(needsTranslation('- **Agents:** build [docs](https://x.y)'), true);
});
test('validate accepts a faithful translation', () => {
const source = '- **Agents:** see [docs](https://librechat.ai/docs) and `npm run build`';
const out = '- **Agents:** см. [документацию](https://librechat.ai/docs) и `npm run build`';
assert.deepEqual(validate(source, out, 'ru'), []);
});
test('validate flags structural changes', () => {
const html = '<a href="https://a.b"><img src="x.svg"></a>';
assert.ok(validate(html, '<a href="https://a.b"></a>', 'ru').length > 0);
assert.ok(validate(html, '<a href="https://a.c"><img src="x.svg"></a>', 'ru').length > 0);
assert.ok(validate('- a\n- b', '- a', 'ru').length > 0);
assert.ok(validate('1. One\n2. Two', '1. Один', 'ru').length > 0);
assert.ok(validate('## A\n\n## B', '## А\n\n### Б', 'ru').length > 0);
assert.ok(validate('**Bold** text', 'Жирный текст', 'ru').length > 0);
});
test('validate protects inline code, link order and non-text HTML attributes', () => {
const code = 'Run `npm install` now to begin.';
assert.ok(validate(code, 'Запустите `npm установка` сейчас.', 'ru').length > 0);
assert.ok(
validate('Use `npm` before `node`.', 'Используйте `node` перед `npm`.', 'ru').length > 0,
);
const links = '[API](api.md) then [Setup](setup.md)';
assert.ok(validate(links, '[API](setup.md) затем [Setup](api.md)', 'ru').length > 0);
assert.deepEqual(validate(links, '[API](api.md) затем [Setup](setup.md)', 'ru'), []);
const img = '<img src="logo.svg" width="400" alt="Logo">';
assert.ok(validate(img, '<img src="logo.svg" width="200" alt="Логотип">', 'ru').length > 0);
assert.deepEqual(validate(img, "<img src='logo.svg' width='400' alt='Логотип'>", 'ru'), []);
});
test('validate keeps reference identifiers and link title presence', () => {
const ref = '[Docs][setup]\n\n[setup]: https://x.y';
assert.ok(validate(ref, '[Документы][документация]\n\n[setup]: https://x.y', 'ru').length > 0);
assert.deepEqual(validate(ref, '[Документы][setup]\n\n[setup]: https://x.y', 'ru'), []);
const titled = '[Docs](https://x.y "Read the docs")';
assert.ok(validate(titled, '[Документы](https://x.y)', 'ru').length > 0);
assert.deepEqual(validate(titled, '[Документы](https://x.y "Читать документацию")', 'ru'), []);
});
test('validate rejects echoed, mixed, refused, truncated and empty output', () => {
const source = 'This sentence should become Russian for sure, thanks.';
assert.ok(validate(source, source, 'ru').length > 0);
assert.ok(validate(source, 'This sentence should become Russian for sure, но.', 'ru').length > 0);
assert.ok(validate(source, 'Sorry, I cannot translate that.', 'ru').length > 0);
assert.ok(validate(source, 'Это', 'ru').includes('output is truncated'));
assert.deepEqual(validate('Continue', '', 'ru'), ['output is empty']);
assert.deepEqual(
validate(source, 'Это предложение обязательно станет русским, спасибо.', 'ru'),
[],
);
assert.ok(validate('# Features', '# Features', 'ru').length > 0);
assert.deepEqual(validate('# Features', '# Возможности', 'ru'), []);
assert.deepEqual(
validate('<img alt="Trendshift" src="x.svg">', '<img alt="Trendshift" src="x.svg">', 'ru'),
[],
);
});
test('cleanOutput unwraps a fenced answer and keeps indentation', () => {
assert.equal(cleanOutput('```markdown\n# Hi\n```'), '# Hi');
assert.equal(cleanOutput('\n plain \n'), ' plain');
assert.equal(cleanOutput(' - Child\n - Two'), ' - Child\n - Two');
});
test('validate and needsTranslation handle brand suffixes, titles, tables and data attributes', () => {
assert.equal(needsTranslation("LibreChat's MCPs"), false);
assert.equal(needsTranslation('# API'), false);
assert.equal(needsTranslation('[`x`](https://x.y "Read the docs")'), true);
assert.ok(validate('![Logo](img.svg "Read the docs")', '![Логотип](img.svg)', 'ru').length > 0);
const table = '| A | B |\n| :-- | --: |\n| 1 | 2 |';
assert.ok(validate(table, '| А | Б |\n| --: | :-- |\n| 1 | 2 |', 'ru').length > 0);
const div = '<div data-title="build-id 7">Text</div>';
assert.ok(validate(div, '<div data-title="build-id 7">Текст</div>', 'ru').length > 0);
assert.ok(
validate(
'<img alt="Translation Progress now live" src="x.svg">',
'<img alt="Translation Progress now live" src="x.svg">',
'ru',
).length > 0,
);
});
test('validate keeps emoji, image alt presence and raw HTML code content', () => {
assert.ok(validate('## 🚀 Whats New today', '## Что нового сегодня', 'ru').length > 0);
assert.deepEqual(validate('## 🚀 Whats New today', '## 🚀 Что нового сегодня', 'ru'), []);
assert.ok(validate('![](logo.svg)', '![Логотип](logo.svg)', 'ru').length > 0);
const code = 'Run <code>npm install package</code> first.';
assert.ok(validate(code, 'Сначала <code>npm установить пакет</code>.', 'ru').length > 0);
assert.deepEqual(validate(code, 'Сначала запустите <code>npm install package</code>.', 'ru'), []);
});
test('validate accepts a translated label that keeps language names and rejects the unchanged chunk', () => {
const source = '- **Multilingual UI**:\n - English, 中文 (简体), Deutsch, Español, Русский';
const out = '- **Многоязычный интерфейс**:\n - English, 中文 (简体), Deutsch, Español, Русский';
assert.deepEqual(validate(source, out, 'ru'), []);
assert.ok(validate(source, source, 'ru').includes('output is unchanged'));
});
test('isSwitcher recognizes the switcher of every language and nothing else', () => {
for (const code of ['en', 'zh', 'ru']) assert.ok(isSwitcher(renderSwitcher(code)));
assert.equal(
isSwitcher('<p align="center">\n <a href="https://librechat.ai">Site</a>\n</p>'),
false,
);
assert.equal(isSwitcher('Some text'), false);
});
test('validate keeps translatable attributes nonempty and compares echoes by prose, not formatting', () => {
const sponsors = '<a aria-label="Sponsors" href="https://x.y"></a>';
assert.ok(validate(sponsors, '<a aria-label="" href="https://x.y"></a>', 'ru').length > 0);
assert.deepEqual(
validate(sponsors, '<a aria-label="Спонсоры" href="https://x.y"></a>', 'ru'),
[],
);
const list = '- **Multilingual UI**:\n - English, 中文, Deutsch';
assert.ok(validate(list, '* **Multilingual UI**:\n * English, 中文, Deutsch', 'ru').length > 0);
});
test('validate compares HTML comment contents exactly', () => {
const source = '<!-- omit from toc -->\n\n## Heading text here';
assert.ok(validate(source, '<!-- пропустить -->\n\n## Заголовок здесь', 'ru').length > 0);
assert.deepEqual(validate(source, '<!-- omit from toc -->\n\n## Заголовок здесь', 'ru'), []);
});
test('validate resolves reference links against the document definitions', () => {
const source = 'Read the [setup guide][setup] before you start.';
const context = definitions(`${source}\n\n[setup]: https://x.y`);
assert.equal(context, '[setup]: https://x.y');
const renamed = 'Прочитайте [руководство][документация] перед началом.';
assert.ok(validate(source, renamed, 'ru').length === 0, 'without context the reference is text');
assert.ok(validate(source, renamed, 'ru', context).length > 0);
assert.deepEqual(
validate(source, 'Прочитайте [руководство][setup] перед началом.', 'ru', context),
[],
);
});
test('reusable keeps a valid cached translation and drops a stale or malformed one', () => {
const source = 'Read the setup guide before you start.';
const good = 'Прочитайте руководство по установке перед началом.';
assert.equal(reusable(good, source, 'ru'), good);
assert.equal(reusable(source, source, 'ru'), undefined);
assert.equal(reusable({ text: good }, source, 'ru'), undefined);
assert.equal(reusable(undefined, source, 'ru'), undefined);
});
test('validate keeps code inside a raw HTML block verbatim', () => {
const block = '<div>Install the app here: <code>npm install</code></div>';
assert.ok(
validate(block, '<div>Установите приложение здесь: <code>npm uninstall</code></div>', 'ru')
.length > 0,
);
assert.deepEqual(
validate(block, '<div>Установите приложение здесь: <code>npm install</code></div>', 'ru'),
[],
);
});
test('validate accepts a translated shortcut reference written in full form', () => {
const context = '[Installation]: https://x.y';
const source = 'Read the [Installation] guide first.';
assert.deepEqual(
validate(source, 'Сначала прочитайте руководство [Установка][Installation].', 'ru', context),
[],
);
assert.ok(
validate(source, 'Сначала прочитайте руководство [Установка].', 'ru', context).length > 0,
);
});
test('needsTranslation and validate read the alt text of a reference-style image', () => {
const context = '[badge]: https://x.y/badge.svg';
const source = '![Build status][badge]';
assert.equal(needsTranslation(`${source}\n\n${context}`), true);
assert.deepEqual(validate(source, '![Статус сборки][badge]', 'ru', context), []);
assert.ok(validate(source, '![Статус сборки][значок]', 'ru', context).length > 0);
});
test('reference images keep alt presence and definition titles are translated', () => {
const context = '[badge]: https://x.y/badge.svg';
const source = 'See the ![Build status][badge] here.';
assert.ok(validate(source, 'Смотрите ![][badge] здесь.', 'ru', context).length > 0);
const definition = '[docs]: https://x.y "Read the documentation"';
assert.equal(needsTranslation(definition), true);
assert.deepEqual(validate(definition, '[docs]: https://x.y "Прочитать документацию"', 'ru'), []);
assert.ok(validate(definition, '[docs]: https://x.y', 'ru').length > 0);
});
test('a reference identifier resolved by the document definitions is not prose', () => {
const context = '[docs]: https://x.y';
assert.equal(needsTranslation('[API][docs]'), true);
assert.equal(needsTranslation('[API][docs]', context), false);
const source = 'Read the [API][docs] reference first.';
assert.deepEqual(
validate(source, 'Сначала прочитайте справочник [API][docs].', 'ru', context),
[],
);
assert.ok(validate(source, source, 'ru', context).length > 0);
});
test('validate resolves footnote references against the document footnotes', () => {
const context = definitions('Read this[^note] first.\n\n[^note]: An explanation.');
assert.equal(context, '[^note]: An explanation.');
const source = 'Read this[^note] before you start.';
assert.deepEqual(validate(source, 'Прочитайте это[^note] перед началом.', 'ru', context), []);
assert.ok(validate(source, 'Прочитайте это[^другое] перед началом.', 'ru', context).length > 0);
});
test('localizeAnchors points fragment links at the translated heading anchors', () => {
const source =
'# ✨ Features\n\nSee [Features](#-features), [setup][s] and <a href="#setup">setup</a>.\n\n## Setup\n\n## Setup\n\n[s]: #setup-1';
const translated =
'# ✨ 功能\n\n参见 [功能](#-features)、[设置][s] 和 <a href="#setup">设置</a>。\n\n## 设置\n\n## 设置\n\n[s]: #setup-1';
assert.equal(
localizeAnchors(source, translated),
'# ✨ 功能\n\n参见 [功能](#-功能)、[设置][s] 和 <a href="#设置">设置</a>。\n\n## 设置\n\n## 设置\n\n[s]: #设置-1',
);
});
test('localizeAnchors leaves external links and unknown fragments alone', () => {
const source = '## Setup\n\n[Docs](https://example.com/#setup) and [Top](#top)';
const translated = '## 设置\n\n[文档](https://example.com/#setup) 和 [顶部](#top)';
assert.equal(localizeAnchors(source, translated), translated);
});

1002
scripts/readme/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
{
"name": "readme-translate",
"private": true,
"type": "module",
"description": "Dependencies for the README translation sync workflow; installed with npm ci in CI.",
"dependencies": {
"github-slugger": "2.0.0",
"mdast-util-from-markdown": "2.0.1",
"mdast-util-gfm": "3.0.0",
"micromark-extension-gfm": "3.0.0"
}
}