/** * 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; /** 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(); 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 | null { const branches: Record = {}; 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; }