LibreChat/scripts/readme/lib.test.mjs
Marco Beretta d0fcdb7832
🌐 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
2026-10-26 16:53:48 +01:00

299 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});