mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 17:56:50 +00:00
🌐 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:
parent
600825ac3c
commit
d0fcdb7832
6 changed files with 1975 additions and 0 deletions
122
.github/workflows/readme-translate.yml
vendored
Normal file
122
.github/workflows/readme-translate.yml
vendored
Normal 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
164
scripts/readme/index.mjs
Normal 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
376
scripts/readme/lib.mjs
Normal 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
299
scripts/readme/lib.test.mjs
Normal 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('', '', '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('', '', '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
1002
scripts/readme/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
12
scripts/readme/package.json
Normal file
12
scripts/readme/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue