gestion_table_tournante_libre/scripts/documentation.js

805 lines
34 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les régions engendrées de la documentation (§ 19.8) et le contrôle de
// version des captures (§ 19.7). Un document porteur délimite chaque région
// par deux marqueurs nommés, seuls à se lire comme tels :
//
// <!-- engendre:debut <nom> -->
// <!-- engendre:fin <nom> -->
//
// Le nom, en minuscules, chiffres et traits d'union, désigne un producteur
// de PRODUCTEURS, qui rend le texte de la région depuis sa source unique : la
// définition des réglages, la table des colonnes du CSV, le registre des
// commandes et leurs libellés figés, la table des raccourcis, les
// démonstrations livrées passées au diagnostic, les styles relevés au
// manifeste des captures, le registre des constantes. Aucune valeur de ces
// tableaux ne s'écrit ici : elle se lit, ou se compose par la traduction.
// Seul s'écrit ici ce qui appartient au document : les en-têtes de colonnes,
// le nom d'un contexte, d'une touche, d'un statut ou de l'état d'une
// bascule.
//
// engendrer remplace le contenu de chaque région — ce qui sépare la fin du
// marqueur de début du début du marqueur de fin — par deux sauts de ligne,
// le texte du producteur, deux sauts de ligne ; aucun octet hors de ces
// bornes ne change. verifier compare chaque région à ce qu'engendrer y
// écrirait et nomme celle qui diffère : une correction faite à la main dans
// une région fait échouer la vérification au lieu de se perdre au passage
// suivant (§ 14.3). Un marqueur illisible, sans fin, sans début ou qui
// ferme une autre région, une région ouverte dans une autre, un nom en
// double ou inconnu sont des fautes ; un porteur sans région aussi, et une
// liste de porteurs vide : un balayage vide ne passe pas (§ 14.2). Seuls les
// fichiers de PORTEURS se lisent.
//
// verifier mesure aussi l'écart entre la version réelle inscrite au
// manifeste des captures et celle de la construction (ecartDeVersion,
// documentation/manifeste.js) : affiché et non bloquant au cycle court,
// bloquant sous --livraison (§ 19.7). Un manifeste absent ne prouve aucune
// version, et compte pour un écart.
//
// Ligne de commande, depuis la racine du projet ou d'ailleurs :
// node scripts/documentation.js engendrer | verifier [--livraison]
// [--racine <dossier>] [--porteur <chemin>]…
// --racine remplace la racine du projet ; chaque --porteur, relatif à la
// racine, remplace la liste PORTEURS. Code de sortie : 0 sans faute, 1 sur
// une faute ou, sous --livraison, un écart de version, 2 sur des arguments
// illisibles.
import { readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { COMMANDES } from '../src/application/commandes.js';
import { creerFormat } from '../src/application/format.js';
import { TABLES_APPLICATION } from '../src/application/libelles.js';
import { creerTraduction } from '../src/application/traduction.js';
import { ENTETES_DES_CHAMPS } from '../src/csv/colonnes.js';
import { CATALOGUE } from '../src/demo/catalogue.js';
import { LIVREES } from '../src/demo/livrees.js';
import { CONSTANTES } from '../src/geometrie/constantes.js';
import { REGLAGES_GENERATION } from '../src/interface/generation/reglages.js';
import { TABLES_INTERFACE } from '../src/interface/libelles.js';
import { RACCOURCIS } from '../src/interface/raccourcis.js';
import { diagnostiquer } from '../src/moteur/diagnostic.js';
import { analyser, configurationDepuisCharge } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.js';
import { CHEMIN_MANIFESTE, ecartDeVersion } from './documentation/manifeste.js';
/** @typedef {{nom: string, debut: number, fin: number, contenu: string}} Region
* debut et fin bornent le contenu dans le texte : texte.slice(debut, fin). */
/** @typedef {{region: string|null, faute: string}} FauteRegion */
/** @typedef {{chemin: string, faute: string}} Faute chemin relatif à la racine */
/** La racine du projet. */
export const RACINE = fileURLToPath(new URL('..', import.meta.url));
/**
* Les fichiers porteurs de régions, relatifs à la racine : la liste fermée
* des documents que verifier et engendrer lisent. Chacun porte au moins une
* région.
* @type {ReadonlyArray<string>}
*/
export const PORTEURS = Object.freeze([]);
/** Une faute de structure des régions : la liste de ses fautes, par région. */
export class ErreurRegions extends Error {
/** @param {FauteRegion[]} fautes */
constructor(fautes) {
super(fautes.map(({ region, faute }) => (region === null ? faute : `région « ${region} » : ${faute}`)).join(' ; '));
this.name = 'ErreurRegions';
this.fautes = fautes;
}
}
// --- Les marqueurs ---------------------------------------------------------------
// Un commentaire HTML, fermé ou courant jusqu'à la fin du texte.
const COMMENTAIRE = /<!--([\s\S]*?)(-->|$)/g;
// Le corps d'un commentaire qui se veut un marqueur.
const SE_VEUT_MARQUEUR = /^\s*engendre\b/;
// Le corps d'un marqueur lisible : sa sorte et son nom.
const MARQUEUR = /^\s*engendre:(debut|fin)\s+([a-z0-9]+(?:-[a-z0-9]+)*)\s*$/;
// Un extrait d'un marqueur pour un message, sur une ligne.
const extrait = (texte) => JSON.stringify(texte.length > 60 ? `${texte.slice(0, 60)}…` : texte);
/**
* Les régions d'un texte, dans leur ordre. Un commentaire dont le corps
* s'ouvre sur « engendre » est un marqueur : lisible, ou une faute ; tout
* autre commentaire est du texte.
*
* @param {string} texte
* @returns {Region[]}
* @throws {ErreurRegions} chaque faute de structure : marqueur illisible,
* début dans une région ouverte, fin sans début, fin qui ferme une autre
* région, région en double, marqueur sans fin
*/
export function lireRegions(texte) {
if (typeof texte !== 'string') throw new TypeError('les régions se lisent dans une chaîne');
const regions = [];
const fautes = [];
const vues = new Set();
let ouverte = null;
for (const trouve of texte.matchAll(COMMENTAIRE)) {
const [entier, corps, fermeture] = trouve;
if (!SE_VEUT_MARQUEUR.test(corps)) continue;
const lu = fermeture === '-->' ? MARQUEUR.exec(corps) : null;
if (lu === null) {
fautes.push({ region: null, faute: `marqueur illisible : ${extrait(entier)}` });
continue;
}
const [, sorte, nom] = lu;
if (sorte === 'debut') {
if (ouverte !== null) fautes.push({ region: nom, faute: `ouverte dans la région « ${ouverte.nom} »` });
else ouverte = { nom, debut: trouve.index + entier.length };
} else if (ouverte === null) {
fautes.push({ region: nom, faute: 'fin sans début' });
} else if (ouverte.nom !== nom) {
fautes.push({ region: nom, faute: `sa fin ferme la région « ${ouverte.nom} »` });
} else {
if (vues.has(nom)) fautes.push({ region: nom, faute: 'région en double' });
vues.add(nom);
regions.push({ nom, debut: ouverte.debut, fin: trouve.index, contenu: texte.slice(ouverte.debut, trouve.index) });
ouverte = null;
}
}
if (ouverte !== null) fautes.push({ region: ouverte.nom, faute: 'marqueur sans fin' });
if (fautes.length > 0) throw new ErreurRegions(fautes);
return regions;
}
/**
* Le contenu d'une région, tel qu'engendrer l'écrit : deux sauts de ligne,
* le texte produit sans ses sauts de ligne finaux, deux sauts de ligne.
*
* @param {string} produit
* @returns {string}
*/
export function contenuDeRegion(produit) {
if (typeof produit !== 'string') throw new TypeError('un producteur rend une chaîne');
return `\n\n${produit.replace(/\n+$/, '')}\n\n`;
}
// Les noms des régions qu'aucun producteur ne porte, en fautes.
function nomsInconnus(regions, producteurs) {
return regions
.filter(({ nom }) => !producteurs.has(nom))
.map(({ nom }) => ({ region: nom, faute: 'nom inconnu : aucun producteur ne le porte' }));
}
/**
* Le texte dont chaque région porte ce que son producteur rend ; le reste,
* marqueurs compris, inchangé à l'unité près. Un texte sans région se rend
* tel quel.
*
* @param {string} texte
* @param {Map<string, () => string>} producteurs par nom de région
* @returns {string}
* @throws {ErreurRegions} une faute de structure, ou un nom inconnu, avant
* tout appel d'un producteur
*/
export function remplacerRegions(texte, producteurs) {
const regions = lireRegions(texte);
const inconnus = nomsInconnus(regions, producteurs);
if (inconnus.length > 0) throw new ErreurRegions(inconnus);
let rendu = '';
let curseur = 0;
for (const { nom, debut, fin } of regions) {
rendu += texte.slice(curseur, debut) + contenuDeRegion(producteurs.get(nom)());
curseur = fin;
}
return rendu + texte.slice(curseur);
}
// La première ligne où deux contenus diffèrent : son rang dans le contenu,
// et la ligne de chacun, null au-delà de sa fin.
function premiereDifference(lu, attendu) {
const a = lu.split('\n');
const b = attendu.split('\n');
let rang = 0;
while (rang < a.length && rang < b.length && a[rang] === b[rang]) rang += 1;
return { rang, lue: a[rang] ?? null, attendue: b[rang] ?? null };
}
// Le numéro, à partir de 1, de la ligne du texte où tombe l'indice.
const ligneDe = (texte, indice) => texte.slice(0, indice).split('\n').length;
/**
* Les fautes des régions d'un texte : de structure, de nom, de producteur,
* ou de contenu — une région qui diffère de ce qu'engendrer y écrirait,
* nommée avec la première ligne qui s'écarte. Un texte sans région est une
* faute.
*
* @param {string} texte
* @param {Map<string, () => string>} producteurs
* @returns {FauteRegion[]} vide quand toutes les régions sont conformes
*/
export function verifierRegions(texte, producteurs) {
let regions;
try {
regions = lireRegions(texte);
} catch (erreur) {
if (erreur instanceof ErreurRegions) return erreur.fautes;
throw erreur;
}
if (regions.length === 0) return [{ region: null, faute: 'aucune région engendrée : un porteur en a au moins une' }];
const fautes = nomsInconnus(regions, producteurs);
for (const { nom, debut, contenu } of regions) {
if (!producteurs.has(nom)) continue;
let attendu;
try {
attendu = contenuDeRegion(producteurs.get(nom)());
} catch (erreur) {
fautes.push({ region: nom, faute: `le producteur échoue : ${erreur.message}` });
continue;
}
if (contenu === attendu) continue;
const { rang, lue, attendue } = premiereDifference(contenu, attendu);
const ligne = ligneDe(texte, debut) + rang;
const cite = (texteLigne) => (texteLigne === null ? 'rien' : `« ${texteLigne} »`);
fautes.push({
region: nom,
faute:
`diffère de ce que le producteur engendre, ligne ${ligne} : ${cite(lue)} au lieu de ${cite(attendue)} ; ` +
'une correction à la main se perd au prochain engendrer',
});
}
return fautes;
}
// --- Les tableaux ---------------------------------------------------------------
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const { nombre } = creerFormat(t);
const VIDE = t('format.vide');
// Les clés de l'application : un libellé d'historique y vit.
const CLES_APPLICATION = new Set(creerTraduction(TABLES_APPLICATION).cles());
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// Un identifiant, en code.
const code = (texte) => `\`${texte}\``;
// Le nombre de décimales qu'un nombre porte dans son écriture la plus
// courte, celle de String : 0,3 en porte une, 200 000 aucune.
function decimalesDe(valeur) {
const ecrit = String(valeur);
if (/e/i.test(ecrit)) throw new RangeError(`documentation : ${ecrit} ne s'écrit pas en chiffres`);
return (ecrit.split('.')[1] ?? '').length;
}
// Une cellule : un saut de ligne, et les blancs qui l'entourent, deviennent
// une espace ; une barre s'échappe, qui fermerait la cellule.
const cellule = (texte) => String(texte).replace(/\s*(?:\r\n|\r|\n)\s*/g, ' ').replace(/\|/g, '\\|');
/**
* Un tableau Markdown : la ligne d'en-tête, le séparateur, une ligne par
* entrée, sans saut de ligne final.
*
* @param {string[]} entetes
* @param {Array<Array<string>>} lignes
* @returns {string}
*/
export function tableau(entetes, lignes) {
const ligne = (cellules) => `| ${cellules.map(cellule).join(' | ')} |`;
return [ligne(entetes), `|${entetes.map(() => '---').join('|')}|`, ...lignes.map(ligne)].join('\n');
}
/**
* Le tableau des réglages (§ 19.8) : chaque entrée de la définition, dans
* son ordre — son libellé, qui porte l'unité d'un champ, l'écran qui la
* montre, sa valeur par défaut.
*
* @param {ReadonlyArray<import('../src/interface/generation/reglages.js').EntreeReglage>} [definition]
* @returns {string}
*/
export function tableauReglages(definition = REGLAGES_GENERATION) {
const defaut = ({ genre, defaut: valeur, cle }) => {
if (genre === 'champ') return nombre(valeur);
if (genre === 'bascule') return valeur ? 'cochée' : 'décochée';
throw new RangeError(`reglages : genre inconnu ${JSON.stringify(genre)} pour ${cle}`);
};
return tableau(
['Réglage', 'Écran', 'Valeur par défaut'],
definition.map((entree) => [t(entree.etiquette), t(`coquille.section.${entree.section}`), defaut(entree)]),
);
}
/**
* Le tableau des colonnes du CSV (§ 10.1, § 19.8) : chaque champ, dans
* l'ordre de l'export — son en-tête, puis ses synonymes.
*
* @param {ReadonlyArray<readonly [string, ReadonlyArray<string>]>} [entetes]
* @returns {string}
*/
export function tableauColonnesCsv(entetes = ENTETES_DES_CHAMPS) {
return tableau(
['Colonne', 'Synonymes reconnus'],
entetes.map(([, [entete, ...synonymes]]) => [code(entete), synonymes.length === 0 ? VIDE : synonymes.map(code).join(', ')]),
);
}
/**
* Les détails d'exemple du libellé d'historique de chaque commande du
* registre : les noms sont inventés. Le libellé lui-même se compose par la
* traduction, de la clé ENTREE_<NOM> de la commande.
*/
export const EXEMPLES_COMMANDES = Object.freeze({
ajouterParticipant: { personne: 'Iris Ombrelle' },
modifierParticipant: { personne: 'Ondine Lacasse', champs: ['courriel'], devenu: null },
exclureParticipant: { personne: 'Théo Pervenche' },
reintegrerParticipant: { personne: 'Théo Pervenche' },
supprimerParticipant: { personne: 'Ondine Lacasse' },
importerParticipants: { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 },
changerEtat: { etat: 'bloque' },
debloquer: { etat: 'retenu' },
enregistrerGeneration: { n: 5, premier: 3, dernier: 7 },
retenirProposition: { id: 3 },
effacerPropositions: { n: 4, gardee: 2 },
creerTable: { numero: 3 },
supprimerTable: { numero: 2, reservations: 2, titres: 1 },
deplacerTable: { numero: 3 },
changerPlacesTable: { numero: 5, sieges: 10 },
realignerTable: { numero: 5, sieges: 8 },
changerDefaut: { sieges: 10, realignees: 4 },
changerTours: { tours: 3, retirees: 2 },
modifierReglage: { cle: 'separerAppartenances', valeur: false },
changerCibleRedondance: { cible: 1200 },
reserver: { personne: 'Théo Pervenche', numero: 4, siege: 2, portee: 'tour', tour: 2 },
retirerReservation: { personne: 'Théo Pervenche', numero: 4, portee: 'tous', tour: null },
deplacerReservation: { personne: 'Théo Pervenche', de: 4, vers: 7, siege: null, portee: 'tour', tour: 2 },
poserTitre: { libelle: 'accueil', numero: 3, siege: 1 },
retirerTitre: { libelle: 'accueil', numero: 3, siege: 1 },
designer: { personne: 'Iris Ombrelle', numero: 3, siege: 1, libelle: 'accueil' },
convertirEnTous: { personne: 'Iris Ombrelle', numero: 3 },
deplacerDansRetenu: { personne: 'Iris Ombrelle', de: 4, vers: 7, siege: null, tour: 2 },
echangerDansRetenu: { a: 'Iris Ombrelle', deA: 4, b: 'Théo Pervenche', deB: null, tour: 2 },
retirerDuTour: { personne: 'Iris Ombrelle', numero: 4, tour: 2 },
});
// La clé du libellé d'historique d'une commande : ENTREE_ puis son nom, un
// souligné devant chaque capitale, en capitales.
const cleDHistorique = (nom) => `ENTREE_${nom.replace(/[A-Z]/g, (lettre) => `_${lettre}`).toUpperCase()}`;
/**
* Le tableau des commandes (§ 8.2, § 19.8) : chaque commande du registre,
* dans son ordre, et un exemple de son libellé d'historique.
*
* @param {Map<string, Function>} [commandes]
* @param {Record<string, Object>} [exemples]
* @returns {string}
* @throws {RangeError} une commande sans exemple ou sans libellé
* d'historique, un exemple sans commande, chacun nommé
*/
export function tableauCommandes(commandes = COMMANDES, exemples = EXEMPLES_COMMANDES) {
const noms = [...commandes.keys()];
const sansCommande = Object.keys(exemples).filter((nom) => !commandes.has(nom)).sort(comparerTextes);
if (sansCommande.length > 0) throw new RangeError(`commandes : exemple sans commande au registre : ${sansCommande.join(', ')}`);
return tableau(
['Commande', "Libellé d'historique (exemple)"],
noms.map((nom) => {
if (!Object.hasOwn(exemples, nom)) throw new RangeError(`commandes : « ${nom} » n'a pas d'exemple (EXEMPLES_COMMANDES)`);
const cle = cleDHistorique(nom);
if (!CLES_APPLICATION.has(cle)) throw new RangeError(`commandes : « ${nom} » n'a pas de libellé d'historique ${cle}`);
return [code(nom), t(cle, exemples[nom])];
}),
);
}
// Le nom de chaque contexte de la table des raccourcis, tel que le
// tableau le montre.
const NOMS_CONTEXTES = new Map([
['plan', 'Plan'],
['grille', 'Grille des participants'],
['champ', 'Champ de saisie'],
['commande', 'Bouton de commande'],
['geste', 'Geste en cours'],
['fenetre', 'Panneau ou infobulle'],
]);
// Le nom de chaque touche, par sa valeur de KeyboardEvent.key.
const NOMS_TOUCHES = new Map([
['ArrowRight', '\u{2192}'],
['ArrowDown', '\u{2193}'],
['ArrowLeft', '\u{2190}'],
['ArrowUp', '\u{2191}'],
['Enter', 'Entrée'],
[' ', 'Espace'],
['Escape', 'Échap'],
['Tab', 'Tab'],
['F2', 'F2'],
]);
// Le nom d'une touche, « Maj+ » gardé devant celui de sa touche.
function nomDeTouche(touche) {
if (touche.startsWith('Maj+')) return `Maj+${nomDeTouche(touche.slice('Maj+'.length))}`;
const nom = NOMS_TOUCHES.get(touche);
if (nom === undefined) throw new RangeError(`raccourcis : touche sans nom ${JSON.stringify(touche)}`);
return nom;
}
/**
* Le tableau des raccourcis (§ 7.3, § 19.8) : une ligne par contexte et par
* effet, dans l'ordre de la table — le contexte, ses touches, le libellé de
* l'effet.
*
* @param {ReadonlyArray<{touche: string, contexte: string, effet: string, cle: string}>} [raccourcis]
* @returns {string}
* @throws {RangeError} une touche ou un contexte sans nom
*/
export function tableauRaccourcis(raccourcis = RACCOURCIS) {
const effets = new Map();
for (const { touche, contexte, effet, cle } of raccourcis) {
const groupe = `${contexte}.${effet}`;
if (!effets.has(groupe)) effets.set(groupe, { contexte, cle, touches: [] });
effets.get(groupe).touches.push(touche);
}
return tableau(
['Où', 'Touches', 'Effet'],
[...effets.values()].map(({ contexte, cle, touches }) => {
const nom = NOMS_CONTEXTES.get(contexte);
if (nom === undefined) throw new RangeError(`raccourcis : contexte sans nom ${JSON.stringify(contexte)}`);
return [nom, touches.map(nomDeTouche).join(' ou '), t(cle)];
}),
);
}
/**
* Le tableau des démonstrations (§ 15, § 19.8) : chaque fichier livré, dans
* l'ordre de LIVREES, lu puis passé au diagnostic — son nom au catalogue,
* son effectif, ses tables, ses tours, l'étendue de son plafond a priori.
*
* @param {ReadonlyArray<{cle: string, texte: string}>} [livrees]
* @returns {string}
*/
export function tableauDemonstrations(livrees = LIVREES) {
return tableau(
['Démonstration', 'Effectif', 'Tables', 'Tours', 'Plafond a priori, minimum', 'Plafond a priori, maximum'],
livrees.map(({ cle, texte }) => {
const entree = CATALOGUE.find((candidate) => candidate.cle === cle);
if (entree === undefined) throw new RangeError(`demonstrations : « ${cle} » n'est pas au catalogue`);
const configuration = configurationDepuisCharge(analyser(texte).charge);
const { plafondAPriori } = diagnostiquer(configuration);
return [
entree.nom,
nombre(configuration.participants.length),
nombre(configuration.tables.length),
nombre(configuration.tours),
nombre(plafondAPriori.min),
nombre(plafondAPriori.max),
];
}),
);
}
const THEMES = Object.freeze(['clair', 'sombre']);
// Vrai pour un style relevé lisible : jeton et rôle nommés, ratio et seuil
// finis.
const styleLisible = (style) =>
style !== null &&
typeof style === 'object' &&
typeof style.jeton === 'string' &&
style.jeton !== '' &&
typeof style.role === 'string' &&
style.role !== '' &&
Number.isFinite(style.ratio) &&
Number.isFinite(style.seuil);
/**
* Le tableau des ratios de contraste (§ 14.5, § 19.8), engendré des styles
* relevés au pilotage : une ligne par jeton et par rôle, rangée par jeton
* puis par rôle, unité UTF-16 par unité ; pour chaque thème, le plus petit
* ratio relevé et la première capture, dans l'ordre du manifeste, où il
* l'est — rien quand le thème ne relève pas ce jeton dans ce rôle.
*
* Un style relevé est { jeton, role, ratio, seuil, … } sous
* captures[].styles.clair et .sombre ; ses autres champs ne se lisent pas.
*
* @param {object|null} manifeste
* @returns {string}
* @throws {Error} un manifeste absent, un style illisible, deux seuils pour
* un même jeton dans un même rôle, aucun style relevé
*/
export function tableauContrastes(manifeste) {
if (manifeste === null || typeof manifeste !== 'object' || !Array.isArray(manifeste.captures)) {
throw new Error(`contrastes : manifeste absent ou sans liste de captures (${CHEMIN_MANIFESTE})`);
}
const groupes = new Map();
for (const capture of manifeste.captures) {
for (const theme of THEMES) {
const styles = capture?.styles?.[theme];
if (!Array.isArray(styles)) throw new Error(`contrastes : ${capture?.fichier} : styles.${theme} n'est pas une liste`);
for (const style of styles) {
if (!styleLisible(style)) {
throw new Error(`contrastes : ${capture.fichier}, thème ${theme} : un style sans jeton, rôle, ratio ou seuil`);
}
const { jeton, role, ratio, seuil } = style;
const cle = JSON.stringify([jeton, role]);
if (!groupes.has(cle)) groupes.set(cle, { jeton, role, seuil, clair: null, sombre: null });
const groupe = groupes.get(cle);
if (groupe.seuil !== seuil) {
throw new Error(`contrastes : « ${jeton} », rôle ${role} : deux seuils, ${groupe.seuil} et ${seuil} (${capture.fichier})`);
}
if (groupe[theme] === null || ratio < groupe[theme].ratio) groupe[theme] = { ratio, capture: capture.fichier };
}
}
}
if (groupes.size === 0) throw new Error('contrastes : aucun style relevé dans le manifeste');
const ranges = [...groupes.values()].sort((a, b) => comparerTextes(a.jeton, b.jeton) || comparerTextes(a.role, b.role));
const releve = (mesure) => (mesure === null ? [VIDE, VIDE] : [nombre(mesure.ratio, { decimales: 2 }), mesure.capture]);
return tableau(
['Jeton', 'Rôle', 'Seuil', 'Thème clair', 'Capture', 'Thème sombre', 'Capture'],
ranges.map(({ jeton, role, seuil, clair, sombre }) => [
code(jeton),
role,
nombre(seuil, { decimales: decimalesDe(seuil) }),
...releve(clair),
...releve(sombre),
]),
);
}
const STATUTS = new Map([
['mesuree', 'mesurée'],
['defautDeTravail', 'défaut de travail'],
]);
/**
* Le tableau du registre des constantes (§ 14.1, § 19.8), lu seul : chaque
* constante, dans l'ordre du registre — son nom, sa valeur, son unité, son
* propriétaire, son statut en toutes lettres, son protocole. Une valeur
* relevée à l'exécution (null) s'écrit vide.
*
* @param {ReadonlyArray<import('../src/geometrie/constantes.js').Constante>} [constantes]
* @returns {string}
* @throws {RangeError} un statut inconnu
*/
export function tableauRegistre(constantes = CONSTANTES) {
return tableau(
['Constante', 'Valeur', 'Unité', 'Propriétaire', 'Statut', 'Protocole'],
constantes.map(({ nom, valeur, unite, proprietaire, statut, protocole }) => {
const enLettres = STATUTS.get(statut);
if (enLettres === undefined) throw new RangeError(`constantes : statut inconnu ${JSON.stringify(statut)} pour ${nom}`);
const ecrite = valeur === null ? VIDE : nombre(valeur, { decimales: decimalesDe(valeur) });
return [code(nom), ecrite, unite, proprietaire, enLettres, protocole];
}),
);
}
// --- Les producteurs --------------------------------------------------------------
/**
* @typedef {{manifeste: () => object|null}} Contexte
* manifeste : le manifeste des captures lu, null quand il manque
*/
const producteur = (nom, produire) => Object.freeze({ nom, produire });
/**
* Les producteurs, par nom de région, dans l'ordre du § 19.8 : chacun
* (contexte) → le texte de sa région.
* @type {ReadonlyArray<Readonly<{nom: string, produire: (contexte: Contexte) => string}>>}
*/
export const PRODUCTEURS = Object.freeze([
producteur('reglages', () => tableauReglages()),
producteur('colonnes-csv', () => tableauColonnesCsv()),
producteur('commandes', () => tableauCommandes()),
producteur('raccourcis', () => tableauRaccourcis()),
producteur('demonstrations', () => tableauDemonstrations()),
producteur('contrastes', (contexte) => tableauContrastes(contexte.manifeste())),
producteur('constantes', () => tableauRegistre()),
]);
/**
* Les producteurs liés à un contexte, par nom, dans l'ordre de PRODUCTEURS :
* chacun produit une fois, puis rend le même texte.
*
* @param {Contexte} contexte
* @returns {Map<string, () => string>}
*/
export function producteursDe(contexte) {
return new Map(
PRODUCTEURS.map(({ nom, produire }) => {
let texte = null;
return [nom, () => (texte ??= produire(contexte))];
}),
);
}
// --- L'arbre ---------------------------------------------------------------------
// Vrai pour l'erreur d'un fichier absent.
const estAbsent = (erreur) => erreur?.code === 'ENOENT';
// Le lecteur par défaut d'une racine : le texte UTF-8 d'un chemin relatif.
const lecteurDe = (racine) => (chemin) => readFileSync(join(racine, chemin), 'utf8');
// Le contexte d'un arbre : le manifeste, lu au premier besoin, une fois ;
// null quand il manque. Un manifeste qui n'est pas du JSON lève.
function contexteDe(lire) {
let lu;
return {
manifeste() {
if (lu !== undefined) return lu;
let texte;
try {
texte = lire(CHEMIN_MANIFESTE);
} catch (erreur) {
if (!estAbsent(erreur)) throw erreur;
lu = null;
return lu;
}
try {
lu = JSON.parse(texte);
} catch {
throw new Error(`${CHEMIN_MANIFESTE} : JSON illisible`);
}
return lu;
},
};
}
// Une faute de région, au chemin de son porteur.
const auChemin = (chemin) => ({ region, faute }) => ({
chemin,
faute: region === null ? faute : `région « ${region} » : ${faute}`,
});
// La faute d'une liste de porteurs vide.
const SANS_PORTEUR = Object.freeze({ chemin: '.', faute: 'aucun fichier porteur : un balayage vide ne passe pas' });
// Le texte d'un porteur, ou la faute qui dit qu'il manque.
function lirePorteur(lire, chemin) {
try {
return { texte: lire(chemin), faute: null };
} catch (erreur) {
if (!estAbsent(erreur)) throw erreur;
return { texte: null, faute: { chemin, faute: 'porteur absent' } };
}
}
/**
* La vérification d'un arbre : les régions de chaque porteur, puis l'écart
* de version du manifeste. Seuls les porteurs et le manifeste se lisent.
*
* @param {{racine?: string, porteurs?: ReadonlyArray<string>, livraison?: boolean,
* version?: string, lire?: (chemin: string) => string}} options
* @returns {{fautes: Faute[], ecart: null|{manifeste: string|null, construction: string},
* regions: number, code: 0|1}}
* code 1 sur une faute, ou sur un écart sous livraison
*/
export function verifierArbre({ racine = RACINE, porteurs = PORTEURS, livraison = false, version = VERSION.affichee, lire = lecteurDe(racine) } = {}) {
const contexte = contexteDe(lire);
const producteurs = producteursDe(contexte);
const fautes = porteurs.length === 0 ? [SANS_PORTEUR] : [];
let regions = 0;
for (const chemin of porteurs) {
const { texte, faute } = lirePorteur(lire, chemin);
if (faute !== null) {
fautes.push(faute);
continue;
}
fautes.push(...verifierRegions(texte, producteurs).map(auChemin(chemin)));
try {
regions += lireRegions(texte).length;
} catch (erreur) {
if (!(erreur instanceof ErreurRegions)) throw erreur;
}
}
let ecart = null;
try {
const manifeste = contexte.manifeste();
ecart = manifeste === null ? { manifeste: null, construction: version } : ecartDeVersion(manifeste, version);
} catch (erreur) {
fautes.push({ chemin: CHEMIN_MANIFESTE, faute: erreur.message });
}
const code = fautes.length > 0 || (livraison && ecart !== null) ? 1 : 0;
return { fautes, ecart, regions, code };
}
/**
* L'engendrement d'un arbre : chaque porteur reçoit ses régions produites ;
* seuls ceux qui changent s'écrivent, et rien ne s'écrit quand un porteur
* est en faute.
*
* @param {{racine?: string, porteurs?: ReadonlyArray<string>, lire?: (chemin: string) => string,
* ecrire?: (chemin: string, texte: string) => void}} options
* @returns {{fautes: Faute[], ecrits: string[], code: 0|1}}
*/
export function engendrerArbre({
racine = RACINE,
porteurs = PORTEURS,
lire = lecteurDe(racine),
ecrire = (chemin, texte) => writeFileSync(join(racine, chemin), texte),
} = {}) {
const producteurs = producteursDe(contexteDe(lire));
const fautes = porteurs.length === 0 ? [SANS_PORTEUR] : [];
const aEcrire = [];
for (const chemin of porteurs) {
const { texte, faute } = lirePorteur(lire, chemin);
if (faute !== null) {
fautes.push(faute);
continue;
}
try {
if (lireRegions(texte).length === 0) {
fautes.push({ chemin, faute: 'aucune région engendrée : un porteur en a au moins une' });
continue;
}
const rendu = remplacerRegions(texte, producteurs);
if (rendu !== texte) aEcrire.push([chemin, rendu]);
} catch (erreur) {
if (erreur instanceof ErreurRegions) fautes.push(...erreur.fautes.map(auChemin(chemin)));
else fautes.push({ chemin, faute: `le producteur échoue : ${erreur.message}` });
}
}
if (fautes.length > 0) return { fautes, ecrits: [], code: 1 };
for (const [chemin, rendu] of aEcrire) ecrire(chemin, rendu);
return { fautes, ecrits: aEcrire.map(([chemin]) => chemin), code: 0 };
}
const USAGE =
'usage : node scripts/documentation.js engendrer | verifier [--livraison] [--racine <dossier>] [--porteur <chemin>]…';
// Les arguments lus : { mode, livraison, racine, porteurs }, ou null quand
// ils ne se lisent pas.
function lireArguments(argv) {
const [mode, ...reste] = argv;
if (mode !== 'engendrer' && mode !== 'verifier') return null;
const lus = { mode, livraison: false, racine: null, porteurs: [] };
for (let rang = 0; rang < reste.length; rang += 1) {
const option = reste[rang];
if (option === '--livraison' && mode === 'verifier') lus.livraison = true;
else if ((option === '--racine' || option === '--porteur') && rang + 1 < reste.length) {
rang += 1;
if (option === '--racine') lus.racine = resolve(reste[rang]);
else lus.porteurs.push(reste[rang]);
} else return null;
}
return lus;
}
/**
* La ligne de commande : lit ses arguments, lance le mode, écrit son bilan
* ligne à ligne sur la sortie.
*
* @param {string[]} argv sans l'exécutable ni le script
* @param {{racine?: string, porteurs?: ReadonlyArray<string>, version?: string,
* lire?: (chemin: string) => string, ecrire?: (chemin: string, texte: string) => void,
* sortie?: (ligne: string) => void}} [options]
* @returns {0|1|2} 2 pour des arguments illisibles, sans rien lire
*/
export function executer(argv, { racine = RACINE, porteurs = PORTEURS, version, lire, ecrire, sortie = (ligne) => process.stdout.write(`${ligne}\n`) } = {}) {
const lus = lireArguments(argv);
if (lus === null) {
sortie(USAGE);
return 2;
}
const options = {
racine: lus.racine ?? racine,
porteurs: lus.porteurs.length > 0 ? lus.porteurs : porteurs,
...(lire === undefined ? {} : { lire }),
};
const ecrireFautes = (fautes) => fautes.forEach(({ chemin, faute }) => sortie(`${chemin} : ${faute}`));
if (lus.mode === 'engendrer') {
const bilan = engendrerArbre({ ...options, ...(ecrire === undefined ? {} : { ecrire }) });
ecrireFautes(bilan.fautes);
bilan.ecrits.forEach((chemin) => sortie(`engendré : ${chemin}`));
sortie(`engendrer : ${options.porteurs.length} porteur(s), ${bilan.ecrits.length} réécrit(s), ${bilan.fautes.length} faute(s)`);
return bilan.code;
}
const bilan = verifierArbre({ ...options, livraison: lus.livraison, ...(version === undefined ? {} : { version }) });
ecrireFautes(bilan.fautes);
if (bilan.ecart !== null) {
const portee = lus.livraison ? 'bloquant' : 'non bloquant';
const manifeste = bilan.ecart.manifeste ?? 'aucune (manifeste absent ou sans capture)';
sortie(`écart de version (${portee}) : manifeste ${manifeste}, construction ${bilan.ecart.construction}`);
}
sortie(`verifier : ${options.porteurs.length} porteur(s), ${bilan.regions} région(s), ${bilan.fautes.length} faute(s)`);
return bilan.code;
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exitCode = executer(process.argv.slice(2));
}