@starterlib/cli 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Etape `dependances` : ce qu'une SPA a le droit d'installer (plan SPA §4, « Dependances »).
3
+ *
4
+ * - En production (`dependencies`), une liste REVUE : Angular, les paquets du socle servis a la page,
5
+ * rxjs, tslib et es-module-shims. Tout autre paquet s'ajoute par `starterWeb.dependancesAdmises`,
6
+ * avec sa raison ECRITE (quarante caracteres au moins) : la revue se lit dans le depot.
7
+ * - Nulle part : zone.js, TanStack, Angular Material, une bibliotheque de traduction concurrente,
8
+ * une bibliotheque d'icones (le sprite du socle et celui du remote suffisent).
9
+ * - En developpement seulement : le harnais de test et ce CLI, jamais servis a une page.
10
+ * - Aucun outil du socle recopie sous `tools/` : une copie est une dependance cachee, que le socle
11
+ * ne peut plus corriger (les huit copies divergeaient deja, audit du 2026-09-20).
12
+ */
13
+ import { existsSync, readdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ const LONGUEUR_MINIMALE_D_UNE_RAISON = 40;
17
+
18
+ /** Ce qu'une page peut charger sans revue. */
19
+ const ADMIS_EN_PRODUCTION = [
20
+ /^@angular\/(?:core|common|compiler|forms|platform-browser|router|cdk|aria)$/,
21
+ /^@starterlib\/(?:core|tokens|ui|util)$/,
22
+ /^rxjs$/,
23
+ /^tslib$/,
24
+ /^es-module-shims$/,
25
+ ];
26
+
27
+ /** Ce qui n'entre nulle part, avec la raison. */
28
+ const INTERDITS = [
29
+ { motif: /^zone\.js$/, raison: 'le socle est zoneless' },
30
+ { motif: /^@tanstack\//, raison: 'la lecture de donnees passe par httpResource()' },
31
+ { motif: /^@angular\/material$/, raison: 'les composants sont ceux du socle (st-*)' },
32
+ {
33
+ motif: /transloco|^@ngx-translate\//,
34
+ raison: 'la langue et les catalogues sont ceux du socle (langue globale, ADR web 0007)',
35
+ },
36
+ {
37
+ motif:
38
+ /^(?:lucide.*|@ng-icons\/.+|@fortawesome\/.+|@material-design-icons\/.+|@material-symbols\/.+|material-symbols|@tabler\/.+|@heroicons\/.+|@phosphor-icons\/.+)$/,
39
+ raison: "aucune bibliotheque d'icones : le sprite du socle et le sprite metier du remote",
40
+ },
41
+ ];
42
+
43
+ /** Jamais en production : du code de test ou d'outillage servi a une page. */
44
+ const DEVELOPPEMENT_SEULEMENT = [/^@starterlib\/(?:testing|cli|eslint-config|eslint-plugin)$/];
45
+
46
+ /** Les outils que le CLI remplace : leur copie ne doit plus exister (lot 174). */
47
+ const COPIES_D_OUTILS = [
48
+ /^verifier-[a-z-]+\.mjs$/,
49
+ /^(?:manifeste|precompresser|sprite|empreinte|cloture|precharger-la-police)\.mjs$/,
50
+ ];
51
+
52
+ export function jugerLesDependances(paquet, outils = []) {
53
+ const defauts = [];
54
+ const admises = paquet.starterWeb?.dependancesAdmises ?? {};
55
+
56
+ for (const [nom, raison] of Object.entries(admises)) {
57
+ if (typeof raison !== 'string' || raison.trim().length < LONGUEUR_MINIMALE_D_UNE_RAISON) {
58
+ defauts.push(
59
+ `starterWeb.dependancesAdmises["${nom}"] : une raison ecrite d'au moins ` +
60
+ `${String(LONGUEUR_MINIMALE_D_UNE_RAISON)} caracteres est exigee.`,
61
+ );
62
+ }
63
+ }
64
+
65
+ const production = Object.keys(paquet.dependencies ?? {});
66
+ const developpement = Object.keys(paquet.devDependencies ?? {});
67
+ for (const nom of [...production, ...developpement]) {
68
+ const interdit = INTERDITS.find(({ motif }) => motif.test(nom));
69
+ if (interdit !== undefined) defauts.push(`${nom} : interdit, ${interdit.raison}.`);
70
+ }
71
+ for (const nom of production) {
72
+ if (DEVELOPPEMENT_SEULEMENT.some((motif) => motif.test(nom))) {
73
+ defauts.push(
74
+ `${nom} : en dependencies, il partirait avec la page ; le mettre en devDependencies.`,
75
+ );
76
+ continue;
77
+ }
78
+ if (INTERDITS.some(({ motif }) => motif.test(nom))) continue;
79
+ if (ADMIS_EN_PRODUCTION.some((motif) => motif.test(nom))) continue;
80
+ if (Object.hasOwn(admises, nom)) continue;
81
+ defauts.push(
82
+ `${nom} : dependance de production hors de la liste revue. L'admettre par ` +
83
+ 'starterWeb.dependancesAdmises, avec sa raison, ou la retirer.',
84
+ );
85
+ }
86
+
87
+ for (const fichier of outils) {
88
+ if (COPIES_D_OUTILS.some((motif) => motif.test(fichier))) {
89
+ defauts.push(
90
+ `tools/${fichier} : copie d'un outil du socle, remplacee par @starterlib/cli. La retirer, ` +
91
+ 'et appeler starter-web dans package.json.',
92
+ );
93
+ }
94
+ }
95
+ return defauts;
96
+ }
97
+
98
+ export default {
99
+ nom: 'dependances',
100
+ roles: ['remote', 'hote'],
101
+ executer(projet) {
102
+ const dossier = join(projet.racine, 'tools');
103
+ const outils = existsSync(dossier) ? readdirSync(dossier) : [];
104
+ const defauts = jugerLesDependances(projet.paquet, outils);
105
+ const nombre = Object.keys(projet.paquet.dependencies ?? {}).length;
106
+ return { defauts, resume: `${String(nombre)} dependance(s) de production, toutes revues.` };
107
+ },
108
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Etape `federation` (lot 177, ADR 0029 regle 3, constat A0898) : ce que la federation partage, lu
3
+ * dans `remoteEntry.json` construit.
4
+ *
5
+ * - chaque paquet partage l'est en SINGLETON, version STRICTE, avec une plage EXPLICITE (ADR 0028,
6
+ * plan SPA §2.4) : `auto` ou `*` ferait de chaque correctif un conflit, ou n'en verrait aucun ;
7
+ * - Angular et le socle SONT partages : un `@starterlib/core` oublie serait duplique dans le
8
+ * remote, avec son propre etat — une seconde session, une seconde langue, des requetes sans jeton
9
+ * anti-falsification — et le formulaire s'afficherait quand meme, sans le moindre message ;
10
+ * - aucun module de la SPA n'importe un specificateur nu que la federation ne partage pas : la carte
11
+ * d'importation ne le resoudrait pas, et la page casserait au chargement de ce module. Le code du
12
+ * cadre n'est pas lu : Angular importe dynamiquement `zone.js/plugins/task-tracking`, que le
13
+ * socle zoneless n'execute jamais.
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { fichiers, lireJson, relatif } from '../commun.mjs';
18
+ import { SPECIFICATEUR_NU, natureDesFichiers, specificateurs } from '../livraison.mjs';
19
+
20
+ /** Ce qui DOIT etre partage : Angular, rxjs et les points d'entree d'etat du socle. */
21
+ export const PARTAGES_EXIGES = [
22
+ '@angular/core',
23
+ '@angular/common',
24
+ '@angular/router',
25
+ '@angular/platform-browser',
26
+ 'rxjs',
27
+ '@starterlib/core',
28
+ '@starterlib/tokens',
29
+ '@starterlib/util',
30
+ ];
31
+
32
+ /** Les defauts du partage `shared` (le tableau de `remoteEntry.json`). */
33
+ export function jugerLePartage(shared) {
34
+ const defauts = [];
35
+ for (const partage of shared) {
36
+ const nom = String(partage.packageName);
37
+ if (partage.singleton !== true) defauts.push(`${nom} : partage sans singleton: true.`);
38
+ if (partage.strictVersion !== true) defauts.push(`${nom} : partage sans strictVersion: true.`);
39
+ const plage = partage.requiredVersion;
40
+ if (typeof plage !== 'string' || plage === '' || plage === 'auto' || plage === '*') {
41
+ defauts.push(`${nom} : requiredVersion "${String(plage)}", une plage explicite est exigee.`);
42
+ }
43
+ }
44
+ const noms = new Set(shared.map((partage) => partage.packageName));
45
+ for (const exige of PARTAGES_EXIGES) {
46
+ if (!noms.has(exige)) {
47
+ defauts.push(
48
+ `${exige} n'est pas partage : il serait duplique dans cette SPA, avec son propre etat ` +
49
+ '(federation.config.mjs : partageDuSocle(train) et le partage d Angular).',
50
+ );
51
+ }
52
+ }
53
+ return defauts;
54
+ }
55
+
56
+ export default {
57
+ nom: 'federation',
58
+ roles: ['remote', 'hote'],
59
+ executer(projet) {
60
+ const entree = lireJson(join(projet.sortie, 'remoteEntry.json'));
61
+ if (entree === null) {
62
+ return {
63
+ defauts: [`${projet.sortieRelative}/remoteEntry.json absent : construire d'abord.`],
64
+ };
65
+ }
66
+ const shared = entree.shared ?? [];
67
+ const defauts = jugerLePartage(shared);
68
+ const noms = new Set(shared.map((partage) => partage.packageName));
69
+ const { partages } = natureDesFichiers(entree);
70
+ let modules = 0;
71
+ for (const chemin of fichiers(projet.sortie).filter((fichier) => fichier.endsWith('.js'))) {
72
+ if (partages.has(relatif(projet.sortie, chemin))) continue;
73
+ modules += 1;
74
+ const nonPartages = new Set(
75
+ specificateurs(readFileSync(chemin, 'utf8')).filter(
76
+ (specificateur) =>
77
+ SPECIFICATEUR_NU.test(specificateur) &&
78
+ !specificateur.startsWith('@nf-internal/') &&
79
+ !noms.has(specificateur),
80
+ ),
81
+ );
82
+ for (const specificateur of nonPartages) {
83
+ defauts.push(
84
+ `${relatif(projet.sortie, chemin)} importe "${specificateur}", que la federation ne partage ` +
85
+ 'pas : la carte d importation ne le resoudra pas.',
86
+ );
87
+ }
88
+ }
89
+ return {
90
+ defauts,
91
+ resume: `${String(shared.length)} paquet(s) partage(s) en singleton strict, ${String(modules)} module(s) sans import nu orphelin.`,
92
+ };
93
+ },
94
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Les deux etapes propres a l'hote (la coquille), venues de ses outils recopies (lot 174).
3
+ *
4
+ * `entree` : le point d'entree ne depend d'AUCUN paquet partage avant `initFederation`. `main.ts`
5
+ * s'execute avant que la carte d'importation existe : c'est lui qui la construit. Tout import
6
+ * STATIQUE d'un specificateur nu dans son graphe est evalue avant cette construction, ne se resout
7
+ * pas, et laisse une page blanche — sans erreur serveur (constate le 2026-09-19 avec
8
+ * `@starterlib/core/federation`). Les imports DYNAMIQUES sont evalues apres, ils sont permis.
9
+ *
10
+ * `amorcage` : aucun PAIR LOURD n'entre dans le premier ecran (ADR 0031). Le defaut qui a coute
11
+ * 67 Ko tenait en une ligne raisonnable a lire : un composant de dialogue importe dans la
12
+ * configuration. Ce qu'aucun lecteur ne voit, un outil le voit.
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join, posix } from 'node:path';
16
+ import { cloture, departDeLaPage } from '../cloture.mjs';
17
+ import { lireJson } from '../commun.mjs';
18
+
19
+ // Imports statiques d'un module ES minifie : `import{a}from"x"`, `import"x"`, `export*from"x"`.
20
+ // `import("x")` (dynamique) ne correspond pas : aucune parenthese n'est admise avant la chaine.
21
+ const SPECIFICATEUR = /^(?:\.{1,2}\/|@?[a-z0-9])[\w@./-]*$/i;
22
+ const STATIQUES = /(?:^|[;\n}])\s*(?:import|export)\s*(?:[^"'()]*?\bfrom\s*)?["']([^"']+)["']/g;
23
+ const SCRIPTS_MODULES =
24
+ /<script[^>]*\bsrc="([^"]+)"[^>]*type="module(?:-shim)?"|<script[^>]*type="module(?:-shim)?"[^>]*\bsrc="([^"]+)"/g;
25
+
26
+ export const entree = {
27
+ nom: 'entree',
28
+ roles: ['hote'],
29
+ executer(projet) {
30
+ const cheminIndex = join(projet.sortie, 'index.html');
31
+ if (!existsSync(cheminIndex)) {
32
+ return { defauts: [`${projet.sortieRelative}/index.html absent : construire d'abord.`] };
33
+ }
34
+ // `module` pour les polyfills, `module-shim` pour l'entree (mode shim d'es-module-shims).
35
+ const entrees = [...readFileSync(cheminIndex, 'utf8').matchAll(SCRIPTS_MODULES)]
36
+ .map((trouve) => trouve[1] ?? trouve[2])
37
+ .filter((src) => src !== undefined && !src.startsWith('http'))
38
+ .map((src) => posix.basename(src));
39
+ if (entrees.length === 0) {
40
+ return { defauts: ["aucun script module dans index.html : la verification n'a rien vu."] };
41
+ }
42
+ const defauts = [];
43
+ const vus = new Set();
44
+ const aVisiter = [...entrees];
45
+ while (aVisiter.length > 0) {
46
+ const fichier = aVisiter.pop();
47
+ if (vus.has(fichier)) continue;
48
+ vus.add(fichier);
49
+ const chemin = join(projet.sortie, fichier);
50
+ if (!existsSync(chemin)) continue;
51
+ for (const [, specificateur] of readFileSync(chemin, 'utf8').matchAll(STATIQUES)) {
52
+ // Une chaine qui n'a pas la forme d'un specificateur (modele `${...}` du shim) n'en est pas un.
53
+ if (!SPECIFICATEUR.test(specificateur)) continue;
54
+ if (specificateur.startsWith('./') || specificateur.startsWith('../')) {
55
+ aVisiter.push(posix.normalize(posix.join(posix.dirname(fichier), specificateur)));
56
+ } else if (!specificateur.startsWith('/') && !specificateur.startsWith('http')) {
57
+ defauts.push(
58
+ `${fichier} importe statiquement "${specificateur}" avant initFederation : l'embarquer ` +
59
+ "(federation.config.mjs, skip) ou l'importer apres initFederation.",
60
+ );
61
+ }
62
+ }
63
+ }
64
+ return {
65
+ defauts,
66
+ resume: `${String(vus.size)} module(s) du graphe statique, aucun paquet partage avant initFederation.`,
67
+ };
68
+ },
69
+ };
70
+
71
+ /** Ce qui n'a rien a faire dans le premier ecran, par prefixe de nom de morceau. */
72
+ const PAIRS_LOURDS = [
73
+ { prefixe: '_angular_cdk', raison: 'la CDK : calque, dialogue, defilement virtuel' },
74
+ { prefixe: '_angular_aria', raison: 'les primitives aria : menu, onglets, arbre, selection' },
75
+ { prefixe: '_angular_forms', raison: 'les formulaires : aucun champ au premier ecran' },
76
+ { prefixe: '_starterlib_ui_surfaces', raison: 'les surfaces : dialogue, porte-messages' },
77
+ ];
78
+
79
+ /**
80
+ * Le seul pair lourd ADMIS au premier ecran : `@angular/cdk/layout`, pair declare de
81
+ * `@starterlib/ui/navigation` (le panneau lateral sait s'il se replie). Un demi-kilo-octet, la ou
82
+ * le reste de la CDK en pese cinquante.
83
+ */
84
+ const ADMIS = ['_angular_cdk_layout'];
85
+
86
+ export const amorcage = {
87
+ nom: 'amorcage',
88
+ roles: ['hote'],
89
+ executer(projet) {
90
+ const cheminIndex = join(projet.sortie, 'index.html');
91
+ if (!existsSync(cheminIndex)) {
92
+ return { defauts: [`${projet.sortieRelative}/index.html absent : construire d'abord.`] };
93
+ }
94
+ const carte = lireJson(join(projet.sortie, 'importmap.json'))?.imports ?? {};
95
+ const premierEcran = cloture(
96
+ projet.sortie,
97
+ departDeLaPage(readFileSync(cheminIndex, 'utf8')),
98
+ carte,
99
+ 2,
100
+ );
101
+ const defauts = [];
102
+ for (const [chemin, details] of premierEcran) {
103
+ if (ADMIS.some((admis) => chemin.startsWith(admis))) continue;
104
+ const pair = PAIRS_LOURDS.find((candidat) => chemin.startsWith(candidat.prefixe));
105
+ if (pair === undefined) continue;
106
+ defauts.push(
107
+ `${chemin} (${pair.raison}) tire par ${details.parent} : une arete STATIQUE l'y traine (ADR 0031, decision 3).`,
108
+ );
109
+ }
110
+ return { defauts, resume: 'aucun pair lourd dans le premier ecran.' };
111
+ },
112
+ };
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Etape `i18n` : les catalogues de la SPA, lus AVANT la construction (plan SPA §3 ; lots 174, 178).
3
+ *
4
+ * Pour chaque perimetre (`manifest.source.json#i18n.perimetres` d'un remote, `socle` pour l'hote) :
5
+ * - `public/i18n/<perimetre>.fr.json` et `.en.json` existent, sont des objets plats de textes non
6
+ * vides, portent le meme jeu de cles, chacune prefixee par `<perimetre>.` ;
7
+ * - chaque message est lisible par le formateur du socle (`@starterlib/core`, `message.ts`) : des
8
+ * accolades equilibrees, `{nom}`, `{n, plural, =0 {..} one {..} other {..}}`,
9
+ * `{g, select, a {..} other {..}}`, chaque choix avec sa branche `other` ;
10
+ * - un nombre de minutes, de secondes, d'heures, de jours, un compte ne s'ecrit pas `{compte}` nu :
11
+ * `{compte, plural, ...}`, sans quoi « 1 minutes » s'affiche (constat A1039) ;
12
+ * - un message porte les MEMES arguments en fr et en en : un `{nom}` traduit `{name}` reste un trou ;
13
+ * - chaque cle que le code appelle (`t('catalog.x')`) et chaque titre de route du manifeste existent
14
+ * au catalogue : une cle absente s'affiche telle quelle, a l'ecran ;
15
+ * - l'hote porte les cles que `@starterlib/core` traduit par son catalogue
16
+ * (`schemas/cles-exigees-du-socle.json`, constat A0837) ;
17
+ * - le type des cles engendre (`src/app/i18n/cles.ts`, `starter-web i18n:types`) est a jour : une cle
18
+ * inconnue dans `t()` est alors une erreur de compilation.
19
+ */
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { fichiers, lireJson, relatif } from '../commun.mjs';
23
+ import { dossierInstalle } from '../socle-installe.mjs';
24
+ import { cheminDesTypesDeCles, typesDeCles } from '../construction/types-i18n.mjs';
25
+
26
+ export const LANGUES = ['fr', 'en'];
27
+
28
+ /** Les arguments qui comptent : ils exigent un pluriel. */
29
+ const ARGUMENTS_NUMERIQUES = new Set([
30
+ 'minutes',
31
+ 'secondes',
32
+ 'heures',
33
+ 'jours',
34
+ 'compte',
35
+ 'nombre',
36
+ ]);
37
+ const CATEGORIES_DE_PLURIEL = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
38
+ const NOM_D_ARGUMENT = /^[A-Za-z_]\w*$/;
39
+
40
+ /** Lit un bloc `{...}` en comptant les accolades imbriquees ; `null` s'il ne se ferme pas. */
41
+ function lireBloc(modele, depuis) {
42
+ let profondeur = 0;
43
+ for (let indice = depuis; indice < modele.length; indice += 1) {
44
+ if (modele[indice] === '{') profondeur += 1;
45
+ else if (modele[indice] === '}') {
46
+ profondeur -= 1;
47
+ if (profondeur === 0) return { contenu: modele.slice(depuis + 1, indice), suite: indice + 1 };
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** Decoupe `cle {contenu} autre {contenu}` ; `null` si un morceau ne se lit pas. */
54
+ function lireOptions(source) {
55
+ const options = new Map();
56
+ let indice = 0;
57
+ while (indice < source.length) {
58
+ while (indice < source.length && /\s/.test(source[indice])) indice += 1;
59
+ if (indice >= source.length) break;
60
+ const debut = indice;
61
+ while (indice < source.length && !/[\s{]/.test(source[indice])) indice += 1;
62
+ const cle = source.slice(debut, indice);
63
+ while (indice < source.length && /\s/.test(source[indice])) indice += 1;
64
+ const bloc = source[indice] === '{' ? lireBloc(source, indice) : null;
65
+ if (cle === '' || bloc === null) return null;
66
+ options.set(cle, bloc.contenu);
67
+ indice = bloc.suite;
68
+ }
69
+ return options;
70
+ }
71
+
72
+ /**
73
+ * Les defauts d'un message, et ses arguments. Rend `{ defauts, arguments }` ; `arguments` : les noms
74
+ * lus, pluriels et choix compris.
75
+ */
76
+ export function lireUnMessage(modele) {
77
+ const defauts = [];
78
+ const noms = new Set();
79
+ const parcourir = (texte) => {
80
+ let indice = 0;
81
+ while (indice < texte.length) {
82
+ const caractere = texte[indice];
83
+ if (caractere === '}') {
84
+ defauts.push('accolade fermante sans ouvrante');
85
+ return;
86
+ }
87
+ if (caractere !== '{') {
88
+ indice += 1;
89
+ continue;
90
+ }
91
+ const bloc = lireBloc(texte, indice);
92
+ if (bloc === null) {
93
+ defauts.push('accolade jamais fermee');
94
+ return;
95
+ }
96
+ lireUnArgument(bloc.contenu);
97
+ indice = bloc.suite;
98
+ }
99
+ };
100
+ const lireUnArgument = (contenu) => {
101
+ const [nom = '', type, ...reste] = contenu.split(',');
102
+ const argument = nom.trim();
103
+ if (!NOM_D_ARGUMENT.test(argument)) {
104
+ defauts.push(`argument "${argument}" illisible`);
105
+ return;
106
+ }
107
+ noms.add(argument);
108
+ if (type === undefined) {
109
+ if (ARGUMENTS_NUMERIQUES.has(argument)) {
110
+ defauts.push(
111
+ `{${argument}} compte : l'ecrire {${argument}, plural, one {...} other {...}}`,
112
+ );
113
+ }
114
+ return;
115
+ }
116
+ const genre = type.trim();
117
+ if (genre !== 'plural' && genre !== 'select') {
118
+ defauts.push(`{${argument}, ${genre}} : plural ou select attendu`);
119
+ return;
120
+ }
121
+ const options = lireOptions(reste.join(','));
122
+ if (options === null || options.size === 0) {
123
+ defauts.push(`{${argument}, ${genre}} : choix illisibles`);
124
+ return;
125
+ }
126
+ if (!options.has('other')) defauts.push(`{${argument}, ${genre}} sans branche other`);
127
+ for (const [cle, texte] of options) {
128
+ if (genre === 'plural' && !CATEGORIES_DE_PLURIEL.has(cle) && !/^=\d+$/.test(cle)) {
129
+ defauts.push(`{${argument}, plural} : categorie "${cle}" inconnue`);
130
+ }
131
+ parcourir(texte);
132
+ }
133
+ };
134
+ parcourir(modele);
135
+ return { defauts, arguments: noms };
136
+ }
137
+
138
+ /** Les defauts d'un perimetre, `catalogues` etant `{ fr: objet | null, en: objet | null }`. */
139
+ export function jugerLesCatalogues(perimetre, catalogues) {
140
+ const defauts = [];
141
+ const cles = {};
142
+ const argumentsParLangue = { fr: new Map(), en: new Map() };
143
+ for (const langue of LANGUES) {
144
+ const catalogue = catalogues[langue];
145
+ const nom = `${perimetre}.${langue}.json`;
146
+ if (catalogue === undefined || catalogue === null) {
147
+ defauts.push(`${nom} : absent ou illisible (public/i18n/).`);
148
+ continue;
149
+ }
150
+ if (typeof catalogue !== 'object' || Array.isArray(catalogue)) {
151
+ defauts.push(`${nom} : un objet de cles est attendu.`);
152
+ continue;
153
+ }
154
+ cles[langue] = new Set(Object.keys(catalogue));
155
+ for (const [cle, texte] of Object.entries(catalogue)) {
156
+ if (!cle.startsWith(`${perimetre}.`)) {
157
+ defauts.push(`${nom} : cle "${cle}" sans le prefixe "${perimetre}.".`);
158
+ }
159
+ if (typeof texte !== 'string' || texte.trim() === '') {
160
+ defauts.push(`${nom} : cle "${cle}", texte vide ou qui n'est pas une chaine.`);
161
+ continue;
162
+ }
163
+ const message = lireUnMessage(texte);
164
+ for (const defaut of message.defauts) defauts.push(`${nom} : cle "${cle}", ${defaut}.`);
165
+ argumentsParLangue[langue].set(cle, message.arguments);
166
+ }
167
+ }
168
+ if (cles.fr !== undefined && cles.en !== undefined) {
169
+ for (const [langue, autre] of [
170
+ ['fr', 'en'],
171
+ ['en', 'fr'],
172
+ ]) {
173
+ for (const cle of cles[langue]) {
174
+ if (!cles[autre].has(cle)) {
175
+ defauts.push(`${perimetre} : cle "${cle}" presente en ${langue}, absente de ${autre}.`);
176
+ }
177
+ }
178
+ }
179
+ for (const [cle, enFrancais] of argumentsParLangue.fr) {
180
+ const enAnglais = argumentsParLangue.en.get(cle);
181
+ if (enAnglais === undefined) continue;
182
+ const fr = [...enFrancais].sort().join(', ');
183
+ const en = [...enAnglais].sort().join(', ');
184
+ if (fr !== en) {
185
+ defauts.push(`${perimetre} : cle "${cle}", arguments {${fr}} en fr et {${en}} en en.`);
186
+ }
187
+ }
188
+ }
189
+ return defauts;
190
+ }
191
+
192
+ /** Les cles appelees par le code : `t('perimetre.cle')` dans les sources (essais exclus). */
193
+ export function clesAppelees(racine) {
194
+ const appelees = new Map();
195
+ for (const chemin of fichiers(join(racine, 'src'))) {
196
+ if (!/\.(?:ts|html)$/.test(chemin) || chemin.endsWith('.spec.ts')) continue;
197
+ const texte = readFileSync(chemin, 'utf8');
198
+ for (const [, cle] of texte.matchAll(/\bt\(\s*['"]([a-z][\w-]*(?:\.[\w-]+)+)['"]/g)) {
199
+ if (!appelees.has(cle)) appelees.set(cle, relatif(racine, chemin));
200
+ }
201
+ }
202
+ return appelees;
203
+ }
204
+
205
+ function lire(chemin) {
206
+ if (!existsSync(chemin)) return null;
207
+ try {
208
+ return JSON.parse(readFileSync(chemin, 'utf8'));
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ export default {
215
+ nom: 'i18n',
216
+ roles: ['remote', 'hote'],
217
+ executer(projet) {
218
+ const perimetres =
219
+ projet.role === 'remote' ? (projet.source?.i18n?.perimetres ?? []) : ['socle'];
220
+ if (perimetres.length === 0) {
221
+ return { defauts: ['manifest.source.json : i18n.perimetres vide, aucun catalogue a lire.'] };
222
+ }
223
+ const defauts = [];
224
+ const catalogues = {};
225
+ let cles = 0;
226
+ for (const perimetre of perimetres) {
227
+ catalogues[perimetre] = Object.fromEntries(
228
+ LANGUES.map((langue) => [
229
+ langue,
230
+ lire(join(projet.racine, 'public', 'i18n', `${perimetre}.${langue}.json`)),
231
+ ]),
232
+ );
233
+ defauts.push(...jugerLesCatalogues(perimetre, catalogues[perimetre]));
234
+ cles += Object.keys(catalogues[perimetre].fr ?? {}).length;
235
+ }
236
+
237
+ // Les cles appelees, et les titres des routes, existent au catalogue de leur perimetre.
238
+ const connue = (cle) => {
239
+ const perimetre = cle.split('.')[0];
240
+ const catalogue = catalogues[perimetre]?.fr;
241
+ return catalogue === undefined || catalogue === null ? null : Object.hasOwn(catalogue, cle);
242
+ };
243
+ for (const [cle, ou] of clesAppelees(projet.racine)) {
244
+ if (connue(cle) === false) defauts.push(`${ou} : t('${cle}'), cle absente du catalogue.`);
245
+ }
246
+ for (const route of projet.source?.routes ?? []) {
247
+ if (typeof route.titre === 'string' && connue(route.titre) === false) {
248
+ defauts.push(`manifest.source.json : titre "${route.titre}" absent du catalogue.`);
249
+ }
250
+ }
251
+
252
+ // L'hote porte les cles que le noyau traduit par son catalogue.
253
+ if (projet.role === 'hote') {
254
+ const dossier = dossierInstalle(projet.racine, '@starterlib/core');
255
+ const exigees =
256
+ dossier === null ? null : lireJson(join(dossier, 'schemas', 'cles-exigees-du-socle.json'));
257
+ for (const cle of exigees?.cles ?? []) {
258
+ for (const langue of LANGUES) {
259
+ if (!Object.hasOwn(catalogues.socle?.[langue] ?? {}, cle)) {
260
+ defauts.push(
261
+ `socle.${langue}.json : cle "${cle}", exigee par @starterlib/core, absente.`,
262
+ );
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ // Le type des cles engendre, a jour.
269
+ const cheminDesTypes = cheminDesTypesDeCles(projet.racine);
270
+ const attendu = typesDeCles(perimetres, catalogues);
271
+ if (!existsSync(cheminDesTypes)) {
272
+ defauts.push(`${relatif(projet.racine, cheminDesTypes)} absent : starter-web i18n:types.`);
273
+ } else if (readFileSync(cheminDesTypes, 'utf8') !== attendu) {
274
+ defauts.push(`${relatif(projet.racine, cheminDesTypes)} perime : starter-web i18n:types.`);
275
+ }
276
+
277
+ return {
278
+ defauts,
279
+ resume: `${String(perimetres.length)} perimetre(s), ${String(cles)} cle(s) en fr et en en, messages lisibles, cles appelees presentes.`,
280
+ };
281
+ },
282
+ };