Livret PDF (export campagne) :
- refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs
en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat,
ligne de contexte des scènes, en-tête courant, tables zébrées
- reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub
fusionnées dans leur chapitre, partie « Quêtes » sans doublon
- contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces
explorables, partie « Tables aléatoires »
- polices DejaVu embarquées (couverture Unicode, licence incluse) et harness
visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres)
Graphe des liens :
- déplacé du Lore vers la campagne (les PNJ sont des entités de campagne) ;
endpoint /api/npcs/lore/{id} supprimé
- layout retravaillé (anti-chevauchement des libellés, éventail des feuilles
autour des hubs), scènes et quêtes ajoutées, libellés sur 2 lignes,
filtres de légende mémorisés, focus au survol
- positions drag & drop persistées sur la campagne (migration V23,
PUT /api/campaigns/{id}/graph-positions, bouton « Disposition auto »)
Divers :
- ImportService découpé en classes dédiées (parser d'archive, remapper d'ids,
inserters campagne/lore/état de jeu, conversion legacy des quêtes)
- fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans
- garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs),
budget SCSS anyComponentStyle relevé à 12 ko
128 lines
5.8 KiB
JavaScript
128 lines
5.8 KiB
JavaScript
// Vérificateur de cohérence i18n.
|
|
//
|
|
// Les traductions vivent en DOUBLE : les fichiers consolidés (src/assets/i18n/fr.json,
|
|
// en.json — ceux que charge ngx-translate) et les fragments de maintenance
|
|
// (src/assets/i18n/fragments/*.fr.json / *.en.json). Une section peut être alimentée
|
|
// par PLUSIEURS fragments (ex. playthroughDetail) : un fragment est donc un
|
|
// SOUS-ENSEMBLE du consolidé, pas une copie exacte.
|
|
//
|
|
// ERREURS (code retour 1) :
|
|
// 1. clé de fragment absente du consolidé, ou valeur différente (le consolidé est
|
|
// ce que voit l'utilisateur : un fragment en avance = trad perdue en prod) ;
|
|
// 2. deux fragments qui définissent la même clé avec des valeurs contradictoires ;
|
|
// 3. parité structurelle fr/en rompue (fr.json vs en.json, et chaque paire de
|
|
// fragments x.fr.json / x.en.json) ;
|
|
// 4. JSON invalide ou paire de fragments incomplète.
|
|
//
|
|
// AVERTISSEMENTS (non bloquants) : clés du consolidé couvertes par aucun fragment
|
|
// (dette de maintenance, pas un bug utilisateur).
|
|
//
|
|
// Usage : node scripts/check-i18n.mjs
|
|
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
|
|
const webDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
const i18nDir = path.join(webDir, 'src', 'assets', 'i18n');
|
|
const fragmentsDir = path.join(i18nDir, 'fragments');
|
|
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
function loadJson(file) {
|
|
try {
|
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
} catch (e) {
|
|
errors.push(`${path.relative(webDir, file)} : JSON invalide (${e.message})`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
|
|
/** Feuilles d'un objet : Map "a.b.c" -> valeur (les tableaux sont des feuilles). */
|
|
function leaves(obj, prefix = '', out = new Map()) {
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const p = prefix ? `${prefix}.${key}` : key;
|
|
if (isObject(value)) leaves(value, p, out);
|
|
else out.set(p, value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
|
|
/** Parité structurelle : mêmes chemins de feuilles des deux côtés. */
|
|
function structuralDiff(labelA, a, labelB, b) {
|
|
const pathsA = leaves(a);
|
|
const pathsB = leaves(b);
|
|
for (const p of pathsA.keys()) if (!pathsB.has(p)) errors.push(`${labelB} : clé manquante « ${p} » (présente dans ${labelA})`);
|
|
for (const p of pathsB.keys()) if (!pathsA.has(p)) errors.push(`${labelA} : clé manquante « ${p} » (présente dans ${labelB})`);
|
|
}
|
|
|
|
// --- Chargement -------------------------------------------------------------
|
|
const consolidated = {
|
|
fr: loadJson(path.join(i18nDir, 'fr.json')),
|
|
en: loadJson(path.join(i18nDir, 'en.json'))
|
|
};
|
|
const consolidatedLeaves = {
|
|
fr: consolidated.fr ? leaves(consolidated.fr) : new Map(),
|
|
en: consolidated.en ? leaves(consolidated.en) : new Map()
|
|
};
|
|
const fragmentFiles = readdirSync(fragmentsDir).filter(f => f.endsWith('.json')).sort();
|
|
|
|
// --- 1 + 2 : chaque clé de fragment doit exister À L'IDENTIQUE au consolidé ---
|
|
const fragmentLeafOwner = { fr: new Map(), en: new Map() };
|
|
for (const file of fragmentFiles) {
|
|
const match = file.match(/^(.+)\.(fr|en)\.json$/);
|
|
if (!match) { errors.push(`fragments/${file} : nom inattendu (attendu <nom>.fr.json / <nom>.en.json)`); continue; }
|
|
const lang = match[2];
|
|
const fragment = loadJson(path.join(fragmentsDir, file));
|
|
if (!fragment || !consolidated[lang]) continue;
|
|
for (const [leafPath, value] of leaves(fragment)) {
|
|
const owner = fragmentLeafOwner[lang].get(leafPath);
|
|
if (owner && !sameValue(owner.value, value)) {
|
|
errors.push(`Clé « ${leafPath} » (${lang}) contradictoire entre fragments/${owner.file} et fragments/${file}`);
|
|
}
|
|
fragmentLeafOwner[lang].set(leafPath, { file, value });
|
|
if (!consolidatedLeaves[lang].has(leafPath)) {
|
|
errors.push(`${lang}.json : clé « ${leafPath} » manquante (définie par fragments/${file})`);
|
|
} else if (!sameValue(consolidatedLeaves[lang].get(leafPath), value)) {
|
|
errors.push(`${lang}.json ≠ fragments/${file} : valeur différente pour « ${leafPath} »`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 3 : parité structurelle fr/en -------------------------------------------
|
|
if (consolidated.fr && consolidated.en) structuralDiff('fr.json', consolidated.fr, 'en.json', consolidated.en);
|
|
const fragmentNames = new Set(fragmentFiles.map(f => f.replace(/\.(fr|en)\.json$/, '')));
|
|
for (const name of fragmentNames) {
|
|
const frFile = `${name}.fr.json`;
|
|
const enFile = `${name}.en.json`;
|
|
if (!fragmentFiles.includes(frFile) || !fragmentFiles.includes(enFile)) {
|
|
errors.push(`fragments/${name} : paire fr/en incomplète`);
|
|
continue;
|
|
}
|
|
const fr = loadJson(path.join(fragmentsDir, frFile));
|
|
const en = loadJson(path.join(fragmentsDir, enFile));
|
|
if (fr && en) structuralDiff(`fragments/${frFile}`, fr, `fragments/${enFile}`, en);
|
|
}
|
|
|
|
// --- Avertissements : clés consolidées sans fragment (dette, non bloquant) ----
|
|
for (const lang of ['fr']) { // fr suffit : la parité fr/en est déjà vérifiée
|
|
const uncovered = [...consolidatedLeaves[lang].keys()].filter(p => !fragmentLeafOwner[lang].has(p));
|
|
if (uncovered.length > 0) {
|
|
warnings.push(`${uncovered.length} clé(s) de ${lang}.json sans fragment (dette de maintenance), ex. : ${uncovered.slice(0, 5).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// --- Rapport ------------------------------------------------------------------
|
|
for (const w of warnings) console.warn(`i18n (avertissement) : ${w}`);
|
|
if (errors.length > 0) {
|
|
console.error(`\ni18n : ${errors.length} incohérence(s) bloquante(s) :\n`);
|
|
for (const e of errors) console.error(` - ${e}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`i18n OK — ${Object.keys(consolidated.fr ?? {}).length} sections, ${consolidatedLeaves.fr.size} clés, ${fragmentFiles.length} fragments.`);
|