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 ( {parts.map((part, index) => part.kind === 'text' ? ( {part.text} ) : ( {part.text} ), )} {/* Le message varie selon un nombre : le signaler sans montrer la mécanique, que l'éditeur de pluriels prend en charge. */} {hasPlural && ( pluriel )} ); } 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; }