TQ-Slator/assets/components/SourceText.tsx
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

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