TQ-Slator/assets/api/icu.ts
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
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>
2026-08-21 08:16:05 +02:00

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