Système de gestion de traductions pensé en CMS headless : un back-office pour ceux qui traduisent, une API pour ce qui consomme. Architecture - Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en même origine — ce qui rend viable le cookie de session plutôt qu'un jeton en localStorage. - Deux APIs séparées : Management (session ou clé) et Delivery (stateless, clé seule). Les fusionner ferait porter à chaque lecture de bundle le coût de la session. - Stockage canonique en ICU MessageFormat, sérialisation par plateforme. Le format d'une plateforme ne contamine pas la base. - Publication par releases immuables ; le déploiement est un déplacement de pointeur, donc le rollback aussi. - Isolation multi-organisation par filtre Doctrine, avec un test d'architecture qui casse la CI si une entité échappe à l'invariant. Éditeur, deux vues - Par langue : source et cible, jamais douze colonnes. Grille virtualisée, saisie sans bouton « Enregistrer », panneau de contexte permanent. - Par clé : une clé, toutes ses langues empilées et repliées. Répond à « ce libellé est-il prêt partout ? ». - Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner. - Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ par catégorie CLDR de la langue cible. Administration - Deux niveaux : projet (membres, clés API, plateformes) et organisation (annuaire des comptes, création de projets). - Invitations par e-mail, jeton 256 bits stocké haché. - Désactiver un compte coupe les sessions en cours, pas seulement les connexions suivantes. - Les plateformes s'archivent ; ni elles ni les environnements ne se suppriment — la trace explique pourquoi telle clé existe. CLI tqs - PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend ni de Composer ni de la disponibilité de TQ-Slator. - init / push / pull / status ; le sync est non destructif par défaut et son prune est scopé plateforme. 126 tests, PHPStan niveau 8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
6.3 KiB
TypeScript
183 lines
6.3 KiB
TypeScript
/**
|
|
* Composition et décomposition des messages pluriels ICU, côté éditeur.
|
|
*
|
|
* Raison d'être : **le traducteur ne doit jamais voir de syntaxe ICU.**
|
|
* `{count, plural, one {# séance} other {# séances}}` est illisible pour un
|
|
* non-technicien, et lui demander de l'écrire à la main revient à lui confier
|
|
* un éditeur de code déguisé en outil de traduction.
|
|
*
|
|
* L'éditeur affiche donc un champ par catégorie CLDR et recompose la chaîne à
|
|
* l'enregistrement. Le serveur reste l'autorité : il reparse et revalide tout
|
|
* ce qui lui arrive. Ce module n'est qu'une commodité de saisie, pas un
|
|
* contrôle de sécurité.
|
|
*/
|
|
|
|
export interface PluralParts {
|
|
/** Texte précédant la construction plurielle. */
|
|
prefix: string;
|
|
/** Une entrée par sélecteur : catégorie CLDR, ou `=0`. */
|
|
branches: Record<string, string>;
|
|
/** Texte suivant la construction plurielle. */
|
|
suffix: string;
|
|
/** Nom de la variable de comptage. */
|
|
variable: string;
|
|
}
|
|
|
|
const PLURAL_PATTERN = /\{\s*(\w+)\s*,\s*plural\s*,/;
|
|
|
|
export function isPluralMessage(value: string | null): boolean {
|
|
return value !== null && PLURAL_PATTERN.test(value);
|
|
}
|
|
|
|
/**
|
|
* Décompose un message ICU pluriel en champs éditables.
|
|
*
|
|
* Renvoie `null` si le message n'est pas pluriel ou si sa structure dépasse ce
|
|
* que l'éditeur simple sait représenter (imbrication, `select`). Dans ce cas
|
|
* l'interface bascule sur un champ texte brut plutôt que de risquer une
|
|
* recomposition qui perdrait de l'information.
|
|
*/
|
|
export function decomposePlural(value: string | null): PluralParts | null {
|
|
if (value === null) return null;
|
|
|
|
const match = PLURAL_PATTERN.exec(value);
|
|
if (!match || match.index === undefined) return null;
|
|
|
|
const variable = match[1];
|
|
if (!variable) return null;
|
|
|
|
const openIndex = match.index;
|
|
const bodyStart = openIndex + match[0].length;
|
|
const closeIndex = findMatchingBrace(value, openIndex);
|
|
if (closeIndex === -1) return null;
|
|
|
|
const body = value.slice(bodyStart, closeIndex);
|
|
const branches = parseBranches(body);
|
|
if (branches === null) return null;
|
|
|
|
return {
|
|
prefix: value.slice(0, openIndex),
|
|
branches,
|
|
suffix: value.slice(closeIndex + 1),
|
|
variable,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Recompose un message ICU depuis les champs de l'éditeur.
|
|
*
|
|
* Les branches vides sont omises : une catégorie non renseignée doit remonter
|
|
* au serveur comme absente, pour qu'il la refuse si la langue l'exige. La
|
|
* masquer par une chaîne vide produirait une traduction silencieusement fausse.
|
|
*/
|
|
export function composePlural(parts: PluralParts, order: string[]): string {
|
|
const rendered = order
|
|
.filter((selector) => (parts.branches[selector] ?? '').trim() !== '')
|
|
.map((selector) => `${selector} {${parts.branches[selector]}}`)
|
|
.join(' ');
|
|
|
|
if (rendered === '') return '';
|
|
|
|
return `${parts.prefix}{${parts.variable}, plural, ${rendered}}${parts.suffix}`;
|
|
}
|
|
|
|
/**
|
|
* Ordre d'affichage des champs : sélecteurs exacts d'abord, puis catégories
|
|
* CLDR du plus spécifique au repli. `other` ferme la liste — c'est le cas
|
|
* général, il se lit en dernier.
|
|
*/
|
|
export function selectorOrder(pluralCategories: string[], existing: string[]): string[] {
|
|
const canonical = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
const exact = existing.filter((s) => s.startsWith('=')).sort();
|
|
|
|
const categories = canonical.filter(
|
|
(category) => pluralCategories.includes(category) || existing.includes(category),
|
|
);
|
|
|
|
return [...exact, ...categories];
|
|
}
|
|
|
|
/**
|
|
* Variables `{nom}` présentes dans un message, hors constructions plurielles.
|
|
*
|
|
* Sert au retour immédiat de l'éditeur pendant la frappe. Ce n'est PAS la
|
|
* validation : celle-ci s'appuie sur l'AST côté serveur, seul capable de
|
|
* distinguer un argument d'un sélecteur de branche.
|
|
*/
|
|
export function extractVariables(value: string): string[] {
|
|
const found = new Set<string>();
|
|
const pattern = /\{\s*(\w+)\s*(?:,\s*(\w+))?/g;
|
|
|
|
let match: RegExpExecArray | null;
|
|
while ((match = pattern.exec(value)) !== null) {
|
|
const name = match[1];
|
|
const type = match[2];
|
|
|
|
// `{count, plural,` déclare bien la variable count : on la garde.
|
|
// En revanche `one {`, `other {` sont des sélecteurs, jamais des
|
|
// variables — ils sont écartés par la liste ci-dessous.
|
|
if (name && !['zero', 'one', 'two', 'few', 'many', 'other'].includes(name)) {
|
|
found.add(name);
|
|
} else if (name && type) {
|
|
found.add(name);
|
|
}
|
|
}
|
|
|
|
return [...found];
|
|
}
|
|
|
|
function findMatchingBrace(value: string, openIndex: number): number {
|
|
let depth = 0;
|
|
|
|
for (let i = openIndex; i < value.length; i++) {
|
|
const char = value[i];
|
|
|
|
if (char === "'" && (value[i + 1] === '{' || value[i + 1] === '}')) {
|
|
// Accolade échappée : on saute jusqu'à l'apostrophe fermante.
|
|
const end = value.indexOf("'", i + 2);
|
|
i = end === -1 ? value.length : end;
|
|
continue;
|
|
}
|
|
|
|
if (char === '{') depth++;
|
|
else if (char === '}') {
|
|
depth--;
|
|
if (depth === 0) return i;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
function parseBranches(body: string): Record<string, string> | null {
|
|
const branches: Record<string, string> = {};
|
|
let index = 0;
|
|
|
|
while (index < body.length) {
|
|
while (index < body.length && /\s/.test(body[index] ?? '')) index++;
|
|
if (index >= body.length) break;
|
|
|
|
const selectorStart = index;
|
|
while (index < body.length && !/[\s{]/.test(body[index] ?? '')) index++;
|
|
|
|
const selector = body.slice(selectorStart, index);
|
|
if (selector === '') return null;
|
|
|
|
while (index < body.length && /\s/.test(body[index] ?? '')) index++;
|
|
if (body[index] !== '{') return null;
|
|
|
|
const end = findMatchingBrace(body, index);
|
|
if (end === -1) return null;
|
|
|
|
const content = body.slice(index + 1, end);
|
|
|
|
// Sous-message contenant lui-même une construction : hors du périmètre
|
|
// de l'éditeur simple, on renonce plutôt que de mal recomposer.
|
|
if (/\{\s*\w+\s*,\s*(plural|select|selectordinal)\s*,/.test(content)) return null;
|
|
|
|
branches[selector] = content;
|
|
index = end + 1;
|
|
}
|
|
|
|
return Object.keys(branches).length > 0 ? branches : null;
|
|
}
|