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>
162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
import { Fragment, useMemo } from 'react';
|
|
|
|
/**
|
|
* Affiche une valeur source en masquant la syntaxe ICU.
|
|
*
|
|
* `Séance avec {praticien}, le {date, date, long}` se lit mal : le `, date, long`
|
|
* est une instruction de formatage destinée à la machine, pas au traducteur.
|
|
* Elle est ici réduite à une pastille portant le seul nom de la variable, ce qui
|
|
* rend la phrase lisible tout en signalant sans ambiguïté ce qui n'est pas du
|
|
* texte — et donc ce qu'il ne faut pas traduire.
|
|
*
|
|
* La chaîne réelle reste intacte : ce composant ne fait que la RENDRE.
|
|
*/
|
|
export function SourceText({ value, className = '' }: { value: string; className?: string }) {
|
|
const parts = useMemo(() => tokenize(value), [value]);
|
|
const hasPlural = /\{\s*[\w.-]+\s*,\s*(?:plural|selectordinal)\s*,/.test(value);
|
|
|
|
return (
|
|
<span className={className}>
|
|
{parts.map((part, index) =>
|
|
part.kind === 'text' ? (
|
|
<Fragment key={index}>{part.text}</Fragment>
|
|
) : (
|
|
<span
|
|
key={index}
|
|
className="mx-0.5 inline-block rounded bg-indigo-50 px-1 font-mono text-[0.85em] text-indigo-700"
|
|
title={
|
|
part.detail === null
|
|
? `Variable « ${part.text} »`
|
|
: `Variable « ${part.text} », formatée en ${part.detail}`
|
|
}
|
|
>
|
|
{part.text}
|
|
</span>
|
|
),
|
|
)}
|
|
|
|
{/* Le message varie selon un nombre : le signaler sans montrer la
|
|
mécanique, que l'éditeur de pluriels prend en charge. */}
|
|
{hasPlural && (
|
|
<span
|
|
className="ml-1.5 inline-block rounded bg-ink-100 px-1 align-middle text-[0.7em] uppercase tracking-wide text-ink-500"
|
|
title="Ce message a plusieurs formes selon le nombre."
|
|
>
|
|
pluriel
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type Token =
|
|
| { kind: 'text'; text: string }
|
|
| { kind: 'var'; text: string; detail: string | null };
|
|
|
|
/**
|
|
* Réduit une construction plurielle à sa forme générale.
|
|
*
|
|
* `{count, plural, one {# séance} other {# séances}} à venir` devient
|
|
* « # séances à venir ». Le traducteur a besoin de comprendre la PHRASE ; la
|
|
* mécanique de sélection des formes, elle, est prise en charge par l'éditeur de
|
|
* pluriels au moment de la saisie. Lui montrer les deux à la fois, c'est lui
|
|
* demander de lire du code pour accéder à du texte.
|
|
*/
|
|
function flattenPlurals(value: string): string {
|
|
const pattern = /\{\s*[\w.-]+\s*,\s*(?:plural|selectordinal|select)\s*,/;
|
|
let result = value;
|
|
|
|
for (let guard = 0; guard < 5; guard++) {
|
|
const match = pattern.exec(result);
|
|
if (!match || match.index === undefined) break;
|
|
|
|
const close = matchingBrace(result, match.index);
|
|
if (close === -1) break;
|
|
|
|
const body = result.slice(match.index + match[0].length, close);
|
|
const fallback = lastBranch(body);
|
|
if (fallback === null) break;
|
|
|
|
result = result.slice(0, match.index) + fallback + result.slice(close + 1);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Contenu de la branche `other`, ou à défaut de la dernière branche. */
|
|
function lastBranch(body: string): string | null {
|
|
const branches: { selector: string; content: string }[] = [];
|
|
let index = 0;
|
|
|
|
while (index < body.length) {
|
|
while (index < body.length && /\s/.test(body[index] ?? '')) index++;
|
|
if (index >= body.length) break;
|
|
|
|
const start = index;
|
|
while (index < body.length && !/[\s{]/.test(body[index] ?? '')) index++;
|
|
const selector = body.slice(start, index);
|
|
|
|
while (index < body.length && /\s/.test(body[index] ?? '')) index++;
|
|
if (body[index] !== '{') return null;
|
|
|
|
const end = matchingBrace(body, index);
|
|
if (end === -1) return null;
|
|
|
|
branches.push({ selector, content: body.slice(index + 1, end) });
|
|
index = end + 1;
|
|
}
|
|
|
|
if (branches.length === 0) return null;
|
|
|
|
return (branches.find((b) => b.selector === 'other') ?? branches[branches.length - 1])!.content;
|
|
}
|
|
|
|
function matchingBrace(value: string, from: number): number {
|
|
let depth = 0;
|
|
|
|
for (let i = value.indexOf('{', from); i >= 0 && i < value.length; i++) {
|
|
if (value[i] === '{') depth++;
|
|
else if (value[i] === '}') {
|
|
depth--;
|
|
if (depth === 0) return i;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Découpe volontairement simple : on ne cherche pas à parser l'ICU, seulement à
|
|
* repérer les arguments de premier niveau, une fois les pluriels aplatis.
|
|
*/
|
|
function tokenize(source: string): Token[] {
|
|
const value = flattenPlurals(source);
|
|
const tokens: Token[] = [];
|
|
const pattern = /\{\s*([\w.-]+)\s*(?:,\s*([^{}]*))?\}/g;
|
|
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = pattern.exec(value)) !== null) {
|
|
const name = match[1];
|
|
if (name === undefined) continue;
|
|
|
|
// Un sélecteur de branche (`one {`, `other {`) n'est pas un argument.
|
|
if (['zero', 'one', 'two', 'few', 'many', 'other'].includes(name) && match[2] === undefined) {
|
|
continue;
|
|
}
|
|
|
|
if (match.index > lastIndex) {
|
|
tokens.push({ kind: 'text', text: value.slice(lastIndex, match.index) });
|
|
}
|
|
|
|
tokens.push({ kind: 'var', text: name, detail: match[2]?.trim() || null });
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
if (lastIndex < value.length) {
|
|
tokens.push({ kind: 'text', text: value.slice(lastIndex) });
|
|
}
|
|
|
|
return tokens;
|
|
}
|