snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
import { CORRUPTED_MODEL_OUTPUT_RULE } from '../corrupted-model-output.rule';
const { detect, fix } = CORRUPTED_MODEL_OUTPUT_RULE;
describe('CORRUPTED_MODEL_OUTPUT_RULE', () => {
it('cuts a leaked response envelope off a plain translation', () => {
const sourceText = 'Group name';
const translationText =
'Nome do grupo}]}``` }}} code block? Wait. We messed up.';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe('Nome do grupo');
});
it('cuts a trailing JSON array separator', () => {
expect(detect('レコードテーブルを編集},{', 'Edit Record Table')).toBe(true);
expect(fix('レコードテーブルを編集},{', 'Edit Record Table')).toBe(
'レコードテーブルを編集',
);
});
it('keeps a plural block intact while dropping the envelope after it', () => {
const sourceText =
'{hiddenFieldCount, plural, one {# more populated field available} other {# more populated fields available}}';
const translationText =
'{hiddenFieldCount, plural, other {還有 # 個已填入欄位可用}}}]}દાવાદ്?ฤศจassistant to=developer';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(
'{hiddenFieldCount, plural, other {還有 # 個已填入欄位可用}}',
);
});
it('keeps a closing quote the source also ends with', () => {
const sourceText = 'Set fields created in the future as "visible"';
const translationText =
'Aseta tulevaisuudessa luotavat kentät "näkyviksi"}]}``` }]} } } }';
expect(fix(translationText, sourceText)).toBe(
'Aseta tulevaisuudessa luotavat kentät "näkyviksi"',
);
});
it('drops a trailing apostrophe the source does not carry', () => {
const sourceText = 'An error occurred';
expect(fix("'n Fout het voorgekom'}]}```}``` }```{", sourceText)).toBe(
"'n Fout het voorgekom",
);
});
it('deletes a translation whose salvageable prefix is still envelope', () => {
const sourceText = 'Trigger';
const translationText = 'הפעל","pluralForm":null}]}]}{';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe('');
});
it('deletes a translation with an unclosed brace and no clean cut', () => {
expect(fix('Nome do grupo {broken', 'Group name')).toBe('');
});
it('keeps markdown links when the source carries them too', () => {
const sourceText = 'See [Getting Started](https://twenty.com/docs) first.';
const translationText =
'Consulte [Introdução](https://twenty.com/docs) primeiro.},{';
expect(fix(translationText, sourceText)).toBe(
'Consulte [Introdução](https://twenty.com/docs) primeiro.',
);
});
it('does not flag a healthy translation', () => {
expect(detect('Nome do grupo', 'Group name')).toBe(false);
expect(
detect(
'{count, plural, other {# champs}}',
'{count, plural, one {# field} other {# fields}}',
),
).toBe(false);
});
it('does not flag when the source is unavailable', () => {
expect(detect('Nome do grupo}]}', undefined)).toBe(false);
});
it('does not flag when the source is itself unbalanced', () => {
expect(detect('Algo}', 'Something}')).toBe(false);
});
it('is idempotent', () => {
const sourceText = 'Group name';
const once = fix('Nome do grupo}]}``` }}}', sourceText);
expect(detect(once, sourceText)).toBe(false);
expect(fix(once, sourceText)).toBe(once);
});
});

View File

@@ -0,0 +1,42 @@
import { ESCAPED_EVERY_CHARACTER_RULE } from '../escaped-every-character.rule';
const { detect, fix } = ESCAPED_EVERY_CHARACTER_RULE;
describe('ESCAPED_EVERY_CHARACTER_RULE', () => {
it.each([
['\\ا\\ل\\إ\\ع\\د\\ا\\د\\ا\\ت', 'الإعدادات'],
['\\ダ\\ー\\ク', 'ダーク'],
['\\明\\る\\い', '明るい'],
['\\S\\e\\t\\t\\i\\n\\g\\s', 'Settings'],
])(
'unescapes a message escaped character by character: %s',
(text, expected) => {
expect(detect(text)).toBe(true);
expect(fix(text)).toBe(expected);
},
);
// A path or a regex carries a few backslashes among ordinary text, which is
// the translator writing what the source asked for.
it.each([
'C:\\Users\\name\\file',
'Escape it as \\n to break the line',
'Matches \\d followed by \\w in the pattern',
'Réglages',
'',
'\\',
])('leaves a message whose backslashes are its content: %s', (text) => {
expect(detect(text)).toBe(false);
expect(fix(text)).toBe(text);
});
it('needs more than a couple of escapes before it acts', () => {
expect(detect('\\a\\b')).toBe(false);
expect(fix('\\a\\b')).toBe('\\a\\b');
});
it('runs against both catalog formats and needs no source text', () => {
expect(ESCAPED_EVERY_CHARACTER_RULE.formats).toEqual(['po', 'mdx']);
expect(ESCAPED_EVERY_CHARACTER_RULE.needsSourceText).toBeUndefined();
});
});

View File

@@ -0,0 +1,65 @@
import { ESCAPED_INLINE_CODE_TAGS_RULE } from '../escaped-inline-code-tags.rule';
const { detect, fix, sourceFilter } = ESCAPED_INLINE_CODE_TAGS_RULE;
describe('ESCAPED_INLINE_CODE_TAGS_RULE', () => {
it('repairs an escaped tag inside an inline-code span', () => {
const translationText = 'Remplacez `<path>` par votre chemin';
expect(detect(translationText)).toBe(true);
expect(fix(translationText)).toBe('Remplacez `<path>` par votre chemin');
});
it('repairs numeric and hexadecimal entities', () => {
expect(fix('Utilisez `&#60;img&#x3E;` ici')).toBe('Utilisez `<img>` ici');
});
it('leaves escaped angle brackets outside inline code untouched', () => {
expect(fix('Entourez-le de `&lt;Trans&gt;` et gardez &lt;3 en prose')).toBe(
'Entourez-le de `<Trans>` et gardez &lt;3 en prose',
);
});
it('does not flag a translation whose inline code is already correct', () => {
expect(detect('Remplacez `<path>` par votre chemin')).toBe(false);
});
it('only selects sources that carry a tag inside inline code', () => {
expect(sourceFilter?.('Replace `<path>` with your path')).toBe(true);
expect(sourceFilter?.('Escape a literal &lt; in prose')).toBe(false);
});
it('repairs a backslash-escaped tag inside an inline-code span', () => {
const translationText =
'Ouvrez `https://\\<your-workspace-subdomain>.withtwenty.com`';
expect(detect(translationText)).toBe(true);
expect(fix(translationText)).toBe(
'Ouvrez `https://<your-workspace-subdomain>.withtwenty.com`',
);
});
it('repairs several backslash-escaped tags in one span', () => {
expect(fix('Utilisez `\\<server-url>/s/\\<path>` ici')).toBe(
'Utilisez `<server-url>/s/<path>` ici',
);
});
it('leaves backslash-escaped angle brackets outside inline code untouched', () => {
expect(fix('Gardez `\\<path>` mais laissez \\<literal> en prose')).toBe(
'Gardez `<path>` mais laissez \\<literal> en prose',
);
});
it('is idempotent for backslash escapes', () => {
const once = fix('Utilisez `\\<path>` et `\\<name>`');
expect(fix(once)).toBe(once);
});
it('is idempotent', () => {
const once = fix('Remplacez `&lt;path&gt;` par `&lt;autre&gt;`');
expect(fix(once)).toBe(once);
});
});

View File

@@ -0,0 +1,54 @@
import { ESCAPED_UNICODE_RULE } from '../escaped-unicode.rule';
const { detect, fix } = ESCAPED_UNICODE_RULE;
describe('ESCAPED_UNICODE_RULE', () => {
it('restores characters that leaked as literal \\uXXXX sequences', () => {
const translationText = '\\u62db\\u8058';
expect(detect(translationText, 'Recruiting')).toBe(true);
expect(fix(translationText, 'Recruiting')).toBe('招聘');
});
it('restores surrogate pairs', () => {
expect(fix('ok \\ud83d\\ude00', 'ok 😀')).toBe('ok 😀');
});
it('keeps an escape the source writes itself, such as a code sample', () => {
const sourceText = 'Escape the accent as \\u00e9 in the config';
const translationText =
"Echappez l'accent en \\u00e9 dans la configuration";
expect(detect(translationText, sourceText)).toBe(false);
expect(fix(translationText, sourceText)).toBe(translationText);
});
it('decodes only the escapes the source does not carry', () => {
const sourceText = 'Use \\u00e9 here';
expect(fix('Utilisez \\u00e9 ici \\u62db', sourceText)).toBe(
'Utilisez \\u00e9 ici 招',
);
});
it('stands down when the source is unknown, rather than decoding blind', () => {
expect(detect('\\u62db\\u8058', undefined)).toBe(false);
expect(fix('\\u62db\\u8058', undefined)).toBe('\\u62db\\u8058');
});
it('scans every string rather than filtering by source', () => {
expect(ESCAPED_UNICODE_RULE.sourceFilter).toBeUndefined();
expect(ESCAPED_UNICODE_RULE.needsSourceText).toBe(true);
});
it('leaves text without escaped sequences untouched', () => {
expect(detect('招聘', 'Recruiting')).toBe(false);
});
it('is idempotent', () => {
const once = fix('\\u62db', 'Recruiting');
expect(detect(once, 'Recruiting')).toBe(false);
expect(fix(once, 'Recruiting')).toBe(once);
});
});

View File

@@ -0,0 +1,68 @@
import { INVENTED_ARGUMENT_RULE } from '../invented-argument.rule';
const { detect, fix } = INVENTED_ARGUMENT_RULE;
describe('INVENTED_ARGUMENT_RULE', () => {
it('restores the source spelling of a recased argument', () => {
const sourceText =
'Multiple records found for {conflictingFieldsValues}. Cannot determine which record to update.';
const translationText =
'{ConflictingFieldsValues} のために複数のレコードが見つかりました。';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(
'{conflictingFieldsValues} のために複数のレコードが見つかりました。',
);
});
it('deletes a translation that pluralises an argument-less source', () => {
const sourceText = 'Record(s) selected';
const translationText =
'{count, plural, one {# registro selecionado} other {# registros selecionados}}';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe('');
});
it('deletes a translation that adds an argument nothing will supply', () => {
const sourceText = 'We keep your data for {dataRetentionDays} days.';
const translationText = 'Чувамо податке {dataRetentionDays} {dayOrDays}.';
expect(fix(translationText, sourceText)).toBe('');
});
it('allows a translation to add plural cases its locale needs', () => {
const sourceText = '{count, plural, one {# record} other {# records}}';
const translationText =
'{count, plural, one {# запись} few {# записи} many {# записей} other {# записи}}';
expect(detect(translationText, sourceText)).toBe(false);
});
it('does not read plural case bodies as arguments', () => {
const sourceText = '{unitCount, plural, one {Day} other {Days}}';
const translationText = '{unitCount, plural, one {Dag} other {Dae}}';
expect(detect(translationText, sourceText)).toBe(false);
});
it('does not flag a matching translation or a missing source', () => {
expect(detect('Supprimer {name}', 'Delete {name}')).toBe(false);
expect(detect('Supprimer {objet}', undefined)).toBe(false);
});
it('ignores a dropped argument, which it cannot repair', () => {
expect(detect('Supprimer', 'Delete {name}')).toBe(false);
});
it('is idempotent', () => {
const sourceText = 'Found for {conflictingFieldsValues}.';
const once = fix(
'{ConflictingFieldsValues} で見つかりました。',
sourceText,
);
expect(detect(once, sourceText)).toBe(false);
expect(fix(once, sourceText)).toBe(once);
});
});

View File

@@ -0,0 +1,54 @@
import { INVENTED_MARKUP_RULE } from '../invented-markup.rule';
const { detect, fix } = INVENTED_MARKUP_RULE;
describe('INVENTED_MARKUP_RULE', () => {
it('strips a highlight span the source never had', () => {
const sourceText = 'is not a valid calling code';
const translationText =
'n\'est pas un <span class="highlight">code d\'appel valide</span>';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(
"n'est pas un code d'appel valide",
);
});
it('strips nested direction spans', () => {
const translationText =
'<span dir="rtl"><span dir="ltr">כשל זמני</span></span>';
expect(fix(translationText, 'Temporary Failure')).toBe('כשל זמני');
});
it('strips bold added around numbers and punctuation', () => {
expect(fix('過去<b>12</b>時間', 'Last 12 hours')).toBe('過去12時間');
expect(fix('API名<b>(</b>単数形<b>)</b>', 'API Name (Singular)')).toBe(
'API名(単数形)',
);
});
it('leaves a translation alone when the source carries markup of its own', () => {
const sourceText = 'Your workspace <0>{name}</0> was deleted.';
const translationText = 'Ваш простор <0>{name}</0> је обрисан.';
expect(detect(translationText, sourceText)).toBe(false);
});
it('leaves a Lingui-tagged source alone even when the translation uses html', () => {
expect(detect('Votre <b>espace</b>', 'Your <0>workspace</0>')).toBe(false);
});
it('does not flag a plain translation, a comparison, or a missing source', () => {
expect(detect('Rechercher', 'Search')).toBe(false);
expect(detect('moins de < 5 minutes', 'less than < 5 minutes')).toBe(false);
expect(detect('<b>Recherche</b>', undefined)).toBe(false);
});
it('is idempotent', () => {
const once = fix('過去<b>4</b>時間', 'Last 4 hours');
expect(detect(once, 'Last 4 hours')).toBe(false);
expect(fix(once, 'Last 4 hours')).toBe(once);
});
});

View File

@@ -0,0 +1,126 @@
import { TRANSLATED_IDENTIFIER_RULE } from '../translated-identifier.rule';
const { detect, fix } = TRANSLATED_IDENTIFIER_RULE;
describe('TRANSLATED_IDENTIFIER_RULE', () => {
it('restores a code identifier translated inside backticks', () => {
const sourceText =
'Re-renders are often caused by unnecessary `useEffect`.';
const translationText =
'وكثيرا ما تكون عمليات إعادة التسليم ناجمة عن `فعالية الاستخدام` غير الضرورية.';
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(
'وكثيرا ما تكون عمليات إعادة التسليم ناجمة عن `useEffect` غير الضرورية.',
);
});
// A translation reorders spans to suit its own grammar, so position says
// nothing about which source span belongs in which slot.
it('stands down on more than one span, where order cannot be trusted', () => {
const sourceText =
'The `twenty-app` keyword in your `package.json` `keywords` array';
const translationText =
'Cuvântul cheie `twenty-app` din array-ul `keywords` al fișierului `package.json`';
expect(detect(translationText, sourceText)).toBe(false);
expect(fix(translationText, sourceText)).toBe(translationText);
});
// Backticks in the docs mark UI labels as often as symbols, and the reader
// sees those labels translated in their own app, so restoring them would
// un-translate correct work.
it.each([
[
'Go to `Settings` in the left sidebar.',
'Accesați `Setări` din bara laterală stângă.',
],
[
'Go to `Data Model`, then select the object.',
'Accesați `Model de date`, apoi selectați obiectul.',
],
[
'Select `Deactivate` from the dropdown.',
'Selectați `Dezactivează` din lista derulantă.',
],
])('keeps a translated UI label: %s', (sourceText, translationText) => {
expect(detect(translationText, sourceText)).toBe(false);
expect(fix(translationText, sourceText)).toBe(translationText);
});
it.each([
[
'Caused by `useEffect`.',
'Cauzat de `efectul de utilizare`.',
'Cauzat de `useEffect`.',
],
[
'Fields of type `SELECT`.',
'Câmpuri de tip `SELECTARE`.',
'Câmpuri de tip `SELECT`.',
],
[
'Set `is_auth_required`.',
'Setați `este_necesară_autentificarea`.',
'Setați `is_auth_required`.',
],
[
'Call `client.run()`.',
'Apelați `client.rulează()`.',
'Apelați `client.run()`.',
],
[
'Build the `twenty-emails` package first.',
'Vous devez construire le paquet `vingt-emails`.',
'Vous devez construire le paquet `twenty-emails`.',
],
[
'Send the `x-slack-signature` header.',
'Trimiteți antetul `x-semnătură-slack`.',
'Trimiteți antetul `x-slack-signature`.',
],
])(
'restores an identifier-shaped span: %s',
(sourceText, translationText, expected) => {
expect(detect(translationText, sourceText)).toBe(true);
expect(fix(translationText, sourceText)).toBe(expected);
},
);
// Lining them up positionally is only sound when both sides hold the same
// number; a translation that dropped or added one cannot be matched safely.
it('stands down when the counts do not line up', () => {
const sourceText = 'Use `a` and `b`.';
expect(detect('Utilisez `x`.', sourceText)).toBe(false);
expect(fix('Utilisez `x`.', sourceText)).toBe('Utilisez `x`.');
});
it('leaves a translation whose identifiers already match', () => {
const sourceText = 'Call `useEffect` from [here](/docs/a).';
const translationText = 'Appelez `useEffect` depuis [ici](/docs/a).';
expect(detect(translationText, sourceText)).toBe(false);
});
// Translated pages live under /l/<lang>/ and link to each other there, so a
// locale-prefixed target is the translation being right, not wrong. Paths and
// link targets are left to a human.
it('leaves link targets alone, including locale-prefixed ones', () => {
const sourceText = 'See [the guide](/user-guide/billing).';
const translationText = 'Vezi [ghidul](/l/ro/user-guide/billing).';
expect(detect(translationText, sourceText)).toBe(false);
expect(fix(translationText, sourceText)).toBe(translationText);
});
it('stands down when the source is unknown', () => {
expect(detect('`useEffect`', undefined)).toBe(false);
expect(fix('`useEffect`', undefined)).toBe('`useEffect`');
});
it('runs against MDX catalogs only', () => {
expect(TRANSLATED_IDENTIFIER_RULE.formats).toEqual(['mdx']);
expect(TRANSLATED_IDENTIFIER_RULE.needsSourceText).toBe(true);
});
});

View File

@@ -0,0 +1,72 @@
import { WORDLESS_TRANSLATION_RULE } from '../wordless-translation.rule';
const { detect, fix } = WORDLESS_TRANSLATION_RULE;
describe('WORDLESS_TRANSLATION_RULE', () => {
it('drops a translation that is a lone backslash', () => {
expect(detect('\\', 'The values of this field must be unique')).toBe(true);
expect(fix('\\', 'The values of this field must be unique')).toBe('');
});
it('drops a translation that is an escaped quote pair', () => {
expect(detect("''", 'Count false')).toBe(true);
expect(fix("''", 'Count false')).toBe('');
});
it('drops punctuation and whitespace debris', () => {
expect(detect(' .,; ', 'Regenerate key')).toBe(true);
});
// \w is ASCII-only in JavaScript, so a naive check would delete every
// translation in a non-Latin script.
it.each([
['他們的值必須是唯一的', 'Chinese'],
['הערכים חייבים להיות ייחודיים', 'Hebrew'],
['القيم يجب أن تكون فريدة', 'Arabic'],
['Значения должны быть уникальными', 'Cyrillic'],
['Արժեքներըպետք է եզակի լինեն', 'Armenian'],
['値は一意である必要があります', 'Japanese'],
])('keeps %s (%s)', (translationText) => {
expect(detect(translationText, 'The values must be unique')).toBe(false);
});
it('keeps a translation that is only digits, which are words enough', () => {
expect(detect('2026', 'Year')).toBe(false);
});
it('keeps a wordless translation when the source has no words either', () => {
expect(detect('—', '—')).toBe(false);
expect(detect(':', ':')).toBe(false);
});
it('keeps a translation that is only a placeholder, as its source is', () => {
expect(detect('{count}', '{count}')).toBe(false);
});
// Ignoring placeholder names would catch a translation left as a bare
// {count}, but a dry run over ~200k strings found none of those and two
// Finnish translations that legitimately render a connector word as a
// symbol. Deleting a good translation is worse than keeping a thin one, so
// the rule reads the whole string.
it('keeps a translation that renders a connector word as a symbol', () => {
expect(detect('{0} / {1}', '{0} of {1}')).toBe(false);
expect(detect(' /{intervalLabel}', ' per {intervalLabel}')).toBe(false);
});
it('stands down when the source is unknown, rather than deleting blind', () => {
expect(detect('\\', undefined)).toBe(false);
});
it('leaves a translation already dropped by an earlier rule alone', () => {
expect(detect('', 'Regenerate key')).toBe(false);
});
it('scans every string rather than filtering by source', () => {
expect(WORDLESS_TRANSLATION_RULE.sourceFilter).toBeUndefined();
expect(WORDLESS_TRANSLATION_RULE.needsSourceText).toBe(true);
});
it('runs against PO catalogs only', () => {
expect(WORDLESS_TRANSLATION_RULE.formats).toEqual(['po']);
});
});

View File

@@ -0,0 +1,131 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// Trailing characters the machine translator leaves behind once its response
// envelope bleeds into the string. Only stripped when the source does not end
// the same way, so legitimate quotes and brackets survive.
const DEBRIS_CHARACTERS = new Set([
' ',
'\t',
'\n',
'\r',
' ',
'`',
']',
"'",
'"',
]);
// Fragments of the translator's own JSON/markdown envelope. A salvaged prefix
// carrying one of these was cut inside the envelope rather than after it.
const ENVELOPE_REGEX = /pluralForm|```|\\",\\"|\[/;
const ICU_ARGUMENT_REGEX = /\{\s*([A-Za-z0-9_]+)\s*[,}]/g;
const ICU_TAG_REGEX = /<\/?\d+>/g;
function hasBalancedBraces(text: string): boolean {
let depth = 0;
for (const character of text) {
if (character === '{') {
depth += 1;
continue;
}
if (character === '}') {
depth -= 1;
if (depth < 0) {
return false;
}
}
}
return depth === 0;
}
// The first unmatched '}' is where the translation ends and the envelope begins.
function cutAtUnmatchedBrace(text: string): string | undefined {
let depth = 0;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === '{') {
depth += 1;
continue;
}
if (character === '}') {
if (depth === 0) {
return text.slice(0, index);
}
depth -= 1;
}
}
return undefined;
}
function trimDebris(text: string, sourceText: string): string {
let end = text.length;
while (
end > 0 &&
DEBRIS_CHARACTERS.has(text[end - 1]) &&
!sourceText.endsWith(text[end - 1])
) {
end -= 1;
}
return text.slice(0, end);
}
function icuShape(text: string): string {
const argumentNames = [...text.matchAll(ICU_ARGUMENT_REGEX)]
.map(([, name]) => name)
.sort();
const tags = [...text.matchAll(ICU_TAG_REGEX)].map(([tag]) => tag).sort();
return JSON.stringify([argumentNames, tags]);
}
function isCorrupted(text: string, sourceText?: string): boolean {
return (
sourceText !== undefined &&
hasBalancedBraces(sourceText) &&
!hasBalancedBraces(text)
);
}
function salvage(text: string, sourceText?: string): string {
const source = sourceText ?? '';
const cutText = cutAtUnmatchedBrace(text);
// Balanced text has nothing to cut. Otherwise an unclosed '{' is all that is
// left, with no clean tail to cut after, so the translation is dropped rather
// than guessed at.
if (cutText === undefined) {
return hasBalancedBraces(text) ? text : '';
}
const salvagedText = trimDebris(cutText, source);
const isSalvageable =
salvagedText !== '' &&
hasBalancedBraces(salvagedText) &&
(!ENVELOPE_REGEX.test(salvagedText) || ENVELOPE_REGEX.test(source)) &&
icuShape(salvagedText) === icuShape(source);
// An empty repair tells the runner to delete the translation rather than
// replace it, so the locale falls back to English until Crowdin retranslates.
return isSalvageable ? salvagedText : '';
}
export const CORRUPTED_MODEL_OUTPUT_RULE: NormalizationRule = {
name: 'corrupted-model-output',
formats: ['po'],
needsSourceText: true,
detect: isCorrupted,
fix: salvage,
};

View File

@@ -0,0 +1,46 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// \w is ASCII-only in JavaScript even under the u flag, and every message this
// rule exists for is Arabic, Japanese or Hebrew.
const ESCAPED_CHARACTER_REGEX = /\\([\p{L}\p{N}])/gu;
// Enough of the message to rule out a Windows path or a regex, where a handful
// of backslashes sit among ordinary text. The corruption escapes every
// character it touches, so the ratio separates the two cleanly: `\ا\ل\إ` scores
// 1, `C:\Users\name\file` scores 0.2.
const MINIMUM_ESCAPED_SHARE = 0.8;
const MINIMUM_ESCAPED_CHARACTERS = 3;
function escapedCharacterCount(text: string): number {
return [...text.matchAll(ESCAPED_CHARACTER_REGEX)].length;
}
function unescapedCharacterCount(text: string): number {
return [...text.replaceAll('\\', '')].length;
}
// An import once wrote the Arabic and Japanese UI with a backslash before every
// letter - `\ا\ل\إ\ع\د\ا\د\ا\ت` for Settings, `\ダ\ー\ク` for Dark - which ships
// to the reader verbatim. The letters are intact, so wordless-translation sees
// a real translation and leaves it alone.
function hasEscapedEveryCharacter(text: string): boolean {
const escaped = escapedCharacterCount(text);
const unescaped = unescapedCharacterCount(text);
if (escaped < MINIMUM_ESCAPED_CHARACTERS || unescaped === 0) return false;
return escaped / unescaped >= MINIMUM_ESCAPED_SHARE;
}
function unescapeEveryCharacter(text: string): string {
if (!hasEscapedEveryCharacter(text)) return text;
return text.replace(ESCAPED_CHARACTER_REGEX, '$1');
}
export const ESCAPED_EVERY_CHARACTER_RULE: NormalizationRule = {
name: 'escaped-every-character',
formats: ['po', 'mdx'],
detect: hasEscapedEveryCharacter,
fix: unescapeEveryCharacter,
};

View File

@@ -0,0 +1,40 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
const INLINE_CODE_SPAN_REGEX = /`[^`\n]+`/g;
const ESCAPED_TAG_REGEX =
/&lt;|&gt;|&#0*60;|&#0*62;|&#x0*3c;|&#x0*3e;|\\<|\\>/i;
function inlineCodeSpans(text: string): string[] {
return text.match(INLINE_CODE_SPAN_REGEX) ?? [];
}
function sourceHasTagInInlineCode(sourceText: string): boolean {
return inlineCodeSpans(sourceText).some(
(span) => span.includes('<') || span.includes('>'),
);
}
function hasEscapedTagInInlineCode(text: string): boolean {
return inlineCodeSpans(text).some((span) => ESCAPED_TAG_REGEX.test(span));
}
function unescapeTagsInInlineCode(text: string): string {
return text.replace(INLINE_CODE_SPAN_REGEX, (span) =>
span
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&#0*60;|&#x0*3c;/gi, '<')
.replace(/&#0*62;|&#x0*3e;/gi, '>')
.replace(/\\</g, '<')
.replace(/\\>/g, '>'),
);
}
export const ESCAPED_INLINE_CODE_TAGS_RULE: NormalizationRule = {
name: 'escaped-inline-code-tags',
formats: ['po', 'mdx'],
detect: hasEscapedTagInInlineCode,
fix: unescapeTagsInInlineCode,
sourceFilter: sourceHasTagInInlineCode,
};

View File

@@ -0,0 +1,35 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
const ESCAPED_UNICODE_REGEX = /\\u([0-9a-fA-F]{4})/g;
function escapesIn(text: string): string[] {
return [...text.matchAll(ESCAPED_UNICODE_REGEX)].map(([escape]) => escape);
}
// A source that writes the escape itself - a code sample, a string about
// escaping - means the translation is right to carry it through. Only an escape
// the translator introduced is a decoding failure, and without the source there
// is no way to tell the two apart, so the rule stands down as its peers do.
function hasEscapedUnicode(text: string, sourceText?: string): boolean {
if (sourceText === undefined) return false;
return escapesIn(text).some((escape) => !sourceText.includes(escape));
}
function unescapeUnicode(text: string, sourceText?: string): string {
if (sourceText === undefined) return text;
return text.replace(ESCAPED_UNICODE_REGEX, (escape, hex) =>
sourceText.includes(escape)
? escape
: String.fromCharCode(parseInt(hex, 16)),
);
}
export const ESCAPED_UNICODE_RULE: NormalizationRule = {
name: 'escaped-unicode',
formats: ['po'],
needsSourceText: true,
detect: hasEscapedUnicode,
fix: unescapeUnicode,
};

View File

@@ -0,0 +1,85 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
const ARGUMENT_NAME_REGEX = /^\{\s*([A-Za-z0-9_]+)\s*[,}]/;
// Arguments the message itself takes, ignoring plural and select case bodies:
// a translation may add cases its locale needs, but never a new argument, which
// the caller has no value for.
function topLevelArgumentNames(text: string): Set<string> {
const names = new Set<string>();
let depth = 0;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === '{') {
if (depth === 0) {
const match = ARGUMENT_NAME_REGEX.exec(text.slice(index));
if (match !== null) names.add(match[1]);
}
depth += 1;
continue;
}
if (character === '}') depth -= 1;
}
return names;
}
function inventedArgumentNames(text: string, sourceText: string): string[] {
const sourceNames = topLevelArgumentNames(sourceText);
return [...topLevelArgumentNames(text)].filter(
(name) => !sourceNames.has(name),
);
}
function renameArgument(text: string, from: string, to: string): string {
return text.replace(new RegExp(`\\{\\s*${from}\\s*(?=[,}])`, 'g'), `{${to}`);
}
function hasInventedArgument(text: string, sourceText?: string): boolean {
return (
sourceText !== undefined &&
inventedArgumentNames(text, sourceText).length > 0
);
}
function repairInventedArgument(text: string, sourceText?: string): string {
const source = sourceText ?? '';
const sourceNames = [...topLevelArgumentNames(source)];
const repairedText = inventedArgumentNames(text, source).reduce(
(accumulator, inventedName) => {
// Only a difference in spelling can be repaired: the argument is the same
// one, so restoring the source's casing makes it resolve again.
const intendedName = sourceNames.find(
(name) =>
name.toLowerCase() === inventedName.toLowerCase() &&
!topLevelArgumentNames(accumulator).has(name),
);
return intendedName === undefined
? accumulator
: renameArgument(accumulator, inventedName, intendedName);
},
text,
);
// An argument the source never had has no value to render, so an unrepaired
// translation is dropped rather than shipped with a dangling placeholder.
return inventedArgumentNames(repairedText, source).length > 0
? ''
: repairedText;
}
export const INVENTED_ARGUMENT_RULE: NormalizationRule = {
name: 'invented-argument',
formats: ['po'],
needsSourceText: true,
detect: hasInventedArgument,
fix: repairInventedArgument,
};

View File

@@ -0,0 +1,32 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// Any markup tag, including Lingui's numbered ones (<0>), so a source carrying
// tags of its own is left entirely alone.
const TAG_PATTERN = '<\\/?[A-Za-z0-9][^<>]*>';
const TAG_REGEX = new RegExp(TAG_PATTERN);
const ALL_TAGS_REGEX = new RegExp(TAG_PATTERN, 'g');
// The machine translator sometimes decorates a plain string with markup of its
// own - RTL spans, bold around numbers, highlight spans - which the reader then
// sees as literal tags.
function hasInventedMarkup(text: string, sourceText?: string): boolean {
return (
sourceText !== undefined &&
!TAG_REGEX.test(sourceText) &&
TAG_REGEX.test(text)
);
}
// Stripping everything leaves nothing to say, and the empty result drops the
// translation rather than shipping a blank string.
function removeInventedMarkup(text: string): string {
return text.replace(ALL_TAGS_REGEX, '').trim();
}
export const INVENTED_MARKUP_RULE: NormalizationRule = {
name: 'invented-markup',
formats: ['po'],
needsSourceText: true,
detect: hasInventedMarkup,
fix: removeInventedMarkup,
};

View File

@@ -0,0 +1,70 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// Backticks in the docs carry two different things: symbols the machine
// resolves, and UI labels the reader sees translated in their own app. Only the
// first kind is restored - a span shaped like an identifier, never one shaped
// like prose - so `useEffect` and `SELECT` are put back while `Settings` and
// `Data Model` stay in the reader's language.
const INLINE_CODE_REGEX = /`[^`\n]+`/g;
function codeSpansIn(text: string): string[] {
return [...text.matchAll(new RegExp(INLINE_CODE_REGEX))].map(
([match]) => match,
);
}
// Package, command and header names are hyphenated lowercase, and that is the
// shape translators actually broke: the French docs ship `vingt-emails` and the
// Japanese `20-emails` for `twenty-emails`, each inside an npx command nobody
// can run. Nothing in the English source has this shape without being a symbol.
const KEBAB_CASE_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)+$/;
function looksLikeIdentifier(span: string): boolean {
const code = span.slice(1, -1);
if (/\s/.test(code)) return false;
return (
/[a-z][A-Z]/.test(code) ||
/[_./(]/.test(code) ||
/^[A-Z0-9_]+$/.test(code) ||
KEBAB_CASE_REGEX.test(code)
);
}
// Only a message carrying exactly one span on each side is repaired. Lining
// spans up by position looked obvious and is wrong: a translation reorders them
// to suit its own grammar, so "the `twenty-app` keyword in your `package.json`
// `keywords` array" comes back with keywords and package.json swapped, and
// restoring by position writes package.json over both. With one span there is
// no order to get wrong.
function restoreIdentifiers(text: string, sourceText?: string): string {
if (sourceText === undefined) return text;
const sourceSpans = codeSpansIn(sourceText);
const targetSpans = codeSpansIn(text);
if (
sourceSpans.length !== 1 ||
targetSpans.length !== 1 ||
!looksLikeIdentifier(sourceSpans[0])
) {
return text;
}
return text.replace(INLINE_CODE_REGEX, sourceSpans[0]);
}
function hasTranslatedIdentifier(text: string, sourceText?: string): boolean {
if (sourceText === undefined) return false;
return restoreIdentifiers(text, sourceText) !== text;
}
export const TRANSLATED_IDENTIFIER_RULE: NormalizationRule = {
name: 'translated-identifier',
formats: ['mdx'],
needsSourceText: true,
detect: hasTranslatedIdentifier,
fix: restoreIdentifiers,
};

View File

@@ -0,0 +1,38 @@
import { type NormalizationRule } from '../types/normalization-rule.type';
// \w is ASCII-only in JavaScript, which would read every Chinese, Arabic or
// Hebrew translation as wordless. Only the Unicode letter and number categories
// hold across the scripts we translate into.
const WORD_CHARACTER_REGEX = /[\p{L}\p{N}]/u;
function hasWordCharacter(text: string): boolean {
return WORD_CHARACTER_REGEX.test(text);
}
// A translation carrying no letter or digit at all cannot be a rendering of a
// source that carries them - it is a stray backslash, an escaped quote pair,
// punctuation debris. A source with no words of its own, such as a lone
// placeholder or a separator, is left alone: there a wordless translation is
// the correct one.
function isWordlessTranslation(text: string, sourceText?: string): boolean {
// Rules run in sequence on each other's output, so an empty text means an
// earlier rule already dropped this translation: firing again would only
// report the same deletion twice.
if (sourceText === undefined || text === '') return false;
return hasWordCharacter(sourceText) && !hasWordCharacter(text);
}
// Debris holds nothing to rebuild the missing words from, so the translation is
// dropped and the string falls back to English until Crowdin retranslates it.
function dropTranslation(): string {
return '';
}
export const WORDLESS_TRANSLATION_RULE: NormalizationRule = {
name: 'wordless-translation',
formats: ['po'],
needsSourceText: true,
detect: isWordlessTranslation,
fix: dropTranslation,
};