humalis 1.0.3 → 1.1.0

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.
Files changed (47) hide show
  1. package/LICENSE +85 -201
  2. package/LICENSE.en.md +86 -0
  3. package/NOTICE +6 -3
  4. package/README.en.md +1 -1
  5. package/README.md +1 -1
  6. package/dist/analyse/accessibilite.js +13 -5
  7. package/dist/analyse/analyse.js +43 -3
  8. package/dist/analyse/ecran.js +1 -1
  9. package/dist/analyse/langues.js +30 -10
  10. package/dist/analyse/medias.js +4 -1
  11. package/dist/analyse/navigation.js +35 -0
  12. package/dist/analyse/niveaux.js +33 -0
  13. package/dist/analyse/noms.js +1 -1
  14. package/dist/analyse/site.js +30 -8
  15. package/dist/cibles/commun/colorerCode.js +59 -0
  16. package/dist/cibles/commun/mouvement.js +110 -4
  17. package/dist/cibles/mobile/projet.js +7 -2
  18. package/dist/cibles/mobile/rendu.js +6 -3
  19. package/dist/cibles/statique/elements-js.js +1 -1
  20. package/dist/cibles/statique/page.js +99 -6
  21. package/dist/cibles/statique/projet.js +31 -0
  22. package/dist/cibles/statique/valeurs.js +5 -1
  23. package/dist/cibles/statique/widgets.js +1 -1
  24. package/dist/cibles/web/css-ecran.js +22 -1
  25. package/dist/cibles/web/ecran.js +161 -16
  26. package/dist/cibles/web/interactions.js +50 -3
  27. package/dist/cibles/web/projet.js +29 -2
  28. package/dist/cibles/web/theme.js +11 -2
  29. package/dist/cibles/web/valeurs.js +17 -0
  30. package/dist/cibles/web/widgets.js +1 -1
  31. package/dist/i18n/messages.js +2 -0
  32. package/dist/lexer/lexer.js +106 -3
  33. package/dist/navigateur.js +5274 -0
  34. package/dist/parser/interactions.js +6 -2
  35. package/dist/parser/langues.js +1 -1
  36. package/dist/parser/medias.js +6 -1
  37. package/dist/parser/miseEnPage.js +1 -1
  38. package/dist/parser/parser.js +85 -3
  39. package/dist/parser/theme.js +10 -1
  40. package/dist/ri/construire.js +24 -1
  41. package/dist/vocabulaire/table-ecran.js +3 -3
  42. package/dist/vocabulaire/table-interactions.js +16 -1
  43. package/dist/vocabulaire/table.js +4 -4
  44. package/docs/guide.en.md +170 -2
  45. package/docs/guide.md +171 -1
  46. package/llms.txt +14 -1
  47. package/package.json +7 -4
@@ -1,4 +1,4 @@
1
- import { verificationsDuChamp, messageEnCode, libellesEnLangues } from '../../i18n/messages.js';
1
+ import { verificationsDuChamp, messageEnCode, libellesEnLangues, messageEnLangues } from '../../i18n/messages.js';
2
2
  /**
3
3
  * Un écran de la représentation intermédiaire → une page du site statique :
4
4
  * son contenu de départ, écrit dans le HTML (décision T2), et son script,
@@ -9,6 +9,8 @@ import { verificationsDuChamp, messageEnCode, libellesEnLangues } from '../../i1
9
9
  * script doivent donner exactement la même page.
10
10
  */
11
11
  import { sousActions } from '../../ri/actions.js';
12
+ import { colorerCodeHumalis } from '../commun/colorerCode.js';
13
+ import { attributsFrappe } from '../commun/mouvement.js';
12
14
  import { arbreWidget, arbreComposite, initiales } from './widgets.js';
13
15
  import { iconeHtml, etoilesHtml } from './html-elements.js';
14
16
  import { appelGraphique, htmlGraphique } from './graphique.js';
@@ -320,9 +322,13 @@ class GenerateurPage {
320
322
  this.nomsPris.add(resultat);
321
323
  return resultat;
322
324
  }
325
+ /** « Copier » dans chaque langue du site, la langue de base (celle du HTML de départ) en premier. */
326
+ messageCopier() {
327
+ return (this.site.langues && messageEnLangues('Copier', this.site.langues.map((l) => l.code))) || [{ code: 'fr', texte: 'Copier' }];
328
+ }
323
329
  // ─── La page ────────────────────────────────────────────────────────────
324
330
  generer() {
325
- const { titre, description } = titreEtDescription(this.ecran);
331
+ const { titre, description } = titreEtDescription(this.ecran, this.site.langues);
326
332
  // Les fonctions et le dessin d'abord : on y lit les variables partagées
327
333
  // que la page utilise vraiment.
328
334
  const fonctions = new Ecrivain();
@@ -600,7 +606,7 @@ ${prise}${code}}
600
606
  break;
601
607
  case 'copie':
602
608
  // « copier code » : le résultat est dit à la personne, sauf « sans message ».
603
- e.ligne(`await copier(${this.enTexte(action.valeur)}${action.message === null ? ', null' : action.message === 'Copié' ? '' : `, ${chaine(action.message)}`});`);
609
+ e.ligne(`await copier(${this.enTexte(action.valeur)}${action.message === null ? ', null' : action.texte ? `, ${x(action.texte)}` : action.message === 'Copié' ? '' : `, ${chaine(action.message)}`});`);
604
610
  break;
605
611
  case 'partage': {
606
612
  // La feuille de partage du système ; sans elle, le lien (ou le texte) est copié.
@@ -892,13 +898,40 @@ ${prise}${code}}
892
898
  case 'graphique':
893
899
  e.ligne(`${appelGraphique(noeud, (v) => expressionJs(v, this.contexte))},`);
894
900
  break;
901
+ case 'codeDocumentation':
902
+ const frappe = attributsFrappe(noeud);
903
+ const marques = frappe ? `, 'data-humalis-frappe': '${frappe.frappe}', 'data-humalis-frappe-cle': '${frappe.cle}'` : '';
904
+ e.ligne(`creer('div', { class: 'humalis-bloc-code-conteneur' }, creer('pre', { class: 'humalis-bloc-code${frappe ? ` ${frappe.classe}` : ''}', 'data-langage': ${chaine(noeud.langage ?? '')}${marques} }, (() => { const code = document.createElement('code'); code.innerHTML = ${chaine(colorerCodeHumalis(noeud.code, noeud.langage))}; return code; })()), creer('button', { type: 'button', onclick: () => copier(${chaine(noeud.code)}) }, ${messageEnCode(this.messageCopier(), chaine)})),`);
905
+ break;
906
+ case 'tableauDocumentation': {
907
+ const cellule = (valeur, balise) => `creer('${balise}', {}, ${this.contenuAccentJs(valeur, 'texte')})`;
908
+ const rang = (valeurs, balise) => `creer('tr', {}, ${valeurs.map((v) => cellule(v, balise)).join(', ')})`;
909
+ e.ligne(`creer('div', { class: 'humalis-tableau-conteneur' }, creer('table', {}, creer('caption', {}, ${this.contenuAccentJs(noeud.legende, 'texte')}), creer('thead', {}, ${rang(noeud.colonnes, 'th')}), creer('tbody', {}, ${noeud.rangees.map((r) => rang(r, 'td')).join(', ')}))),`);
910
+ break;
911
+ }
895
912
  case 'bloc': {
913
+ if (noeud.genre === 'liste' || noeud.genre === 'listeNumerotee') {
914
+ const balise = noeud.genre === 'liste' ? 'ul' : 'ol';
915
+ e.ligne(`creer('${balise}', { class: 'humalis-liste' },`);
916
+ e.indenter(() => noeud.corps.forEach((enfant) => {
917
+ if (enfant.type === 'repetition') {
918
+ const debut = `...${expressionJs(enfant.liste, this.contexte)}.flatMap((${nomJs(enfant.variable)}) => `;
919
+ e.bloc(`${debut}[`, () => this.ecrireGroupe(e, enfant.corps, "creer('li', {}, ...", '),'), ']),');
920
+ }
921
+ else
922
+ this.ecrireGroupe(e, [enfant], "creer('li', {}, ...", '),');
923
+ }));
924
+ e.ligne('),');
925
+ break;
926
+ }
896
927
  if (COMPOSITES.has(noeud.genre)) {
897
928
  this.ecrireEnfantJs(e, arbreComposite(this.aideComposite(noeud, this.environnement())));
898
929
  break;
899
930
  }
900
931
  const balise = BALISES_BLOC[noeud.genre] ?? 'div';
901
- this.ecrireGroupe(e, noeud.corps, `creer('${balise}', { class: ${chaine(classesDuBloc(noeud))} }, ...`, '),');
932
+ // « menu repliable » : le bouton « Menu » en premier enfant (l'écouteur est dans humalis.js).
933
+ const bouton = estRepliable(noeud) ? `creer('button', { type: 'button', class: 'humalis-menu-bouton', 'aria-expanded': 'false' }, 'Menu'), ` : '';
934
+ this.ecrireGroupe(e, noeud.corps, `creer('${balise}', { class: ${chaine(classesDuBloc(noeud))} }, ${bouton}...`, '),');
902
935
  // « arrive en bas de » ce bloc : la sentinelle observée et le vrai bouton « Voir plus », tant que la condition est vraie.
903
936
  const bas = noeud.id !== undefined ? this.basDesBlocs.get(noeud.id) : undefined;
904
937
  if (bas) {
@@ -963,7 +996,7 @@ ${prise}${code}}
963
996
  const contenu = (lucideIcone ? `icone(${chaine(lucideIcone)}, 'humalis-icone'), ` : '') +
964
997
  (this.textesChanges.has(noeud.id)
965
998
  ? `textes[${chaine(noeud.id)}] ?? ${contenuJs(noeud.contenu, this.contexte)}`
966
- : contenuJs(noeud.contenu, this.contexte));
999
+ : this.contenuAccentJs(noeud.contenu, noeud.genre));
967
1000
  switch (noeud.genre) {
968
1001
  case 'titre':
969
1002
  case 'soustitre':
@@ -1137,13 +1170,49 @@ ${prise}${code}}
1137
1170
  case 'graphique':
1138
1171
  lignes.push(htmlGraphique(noeud, (v) => this.initial(v, env), (v) => this.texteInitial(v, env)));
1139
1172
  break;
1173
+ case 'codeDocumentation':
1174
+ const frappe = attributsFrappe(noeud);
1175
+ const marques = frappe ? ` data-humalis-frappe="${frappe.frappe}" data-humalis-frappe-cle="${frappe.cle}"` : '';
1176
+ lignes.push(`<div class="humalis-bloc-code-conteneur"><pre class="humalis-bloc-code${frappe ? ` ${frappe.classe}` : ''}" data-langage="${echapperTexte(noeud.langage ?? '')}"${marques}><code>${colorerCodeHumalis(noeud.code, noeud.langage)}</code></pre><button type="button">${echapperTexte(this.messageCopier()[0].texte)}</button></div>`);
1177
+ break;
1178
+ case 'tableauDocumentation': {
1179
+ const cellule = (v, balise) => `<${balise}>${this.contenuAccentHtml(v, 'texte', env)}</${balise}>`;
1180
+ lignes.push('<div class="humalis-tableau-conteneur"><table>');
1181
+ lignes.push(`<caption>${this.contenuAccentHtml(noeud.legende, 'texte', env)}</caption><thead><tr>${noeud.colonnes.map((v) => cellule(v, 'th')).join('')}</tr></thead><tbody>`);
1182
+ for (const rangee of noeud.rangees)
1183
+ lignes.push(`<tr>${rangee.map((v) => cellule(v, 'td')).join('')}</tr>`);
1184
+ lignes.push('</tbody></table></div>');
1185
+ break;
1186
+ }
1140
1187
  case 'bloc': {
1188
+ if (noeud.genre === 'liste' || noeud.genre === 'listeNumerotee') {
1189
+ const balise = noeud.genre === 'liste' ? 'ul' : 'ol';
1190
+ lignes.push(`<${balise} class="humalis-liste">`);
1191
+ for (const enfant of noeud.corps) {
1192
+ if (enfant.type === 'repetition') {
1193
+ const valeurs = evaluer(enfant.liste, env);
1194
+ for (const valeur of Array.isArray(valeurs) ? valeurs : []) {
1195
+ lignes.push('<li>');
1196
+ this.ecrireGroupeHtml(enfant.corps, env.enfant().definir(enfant.variable, valeur), lignes);
1197
+ lignes.push('</li>');
1198
+ }
1199
+ continue;
1200
+ }
1201
+ lignes.push('<li>');
1202
+ this.ecrireGroupeHtml([enfant], env, lignes);
1203
+ lignes.push('</li>');
1204
+ }
1205
+ lignes.push(`</${balise}>`);
1206
+ break;
1207
+ }
1141
1208
  if (COMPOSITES.has(noeud.genre)) {
1142
1209
  this.ecrireEnfantHtml(arbreComposite(this.aideComposite(noeud, env)), env, lignes);
1143
1210
  break;
1144
1211
  }
1145
1212
  const balise = BALISES_BLOC[noeud.genre] ?? 'div';
1146
1213
  lignes.push(`<${balise}${attributs([['class', classesDuBloc(noeud)]])}>`);
1214
+ if (estRepliable(noeud))
1215
+ lignes.push('<button type="button" class="humalis-menu-bouton" aria-expanded="false">Menu</button>');
1147
1216
  this.ecrireGroupeHtml(noeud.corps, env, lignes);
1148
1217
  lignes.push(`</${balise}>`);
1149
1218
  const bas = noeud.id !== undefined ? this.basDesBlocs.get(noeud.id) : undefined;
@@ -1192,6 +1261,27 @@ ${prise}${code}}
1192
1261
  return undefined;
1193
1262
  }
1194
1263
  }
1264
+ contenuAccentJs(valeur, genre) {
1265
+ if (valeur.type !== 'texte' || !['titre', 'soustitre', 'texte'].includes(genre))
1266
+ return contenuJs(valeur, this.contexte);
1267
+ const parties = (liste) => liste.map((p) => 'texte' in p
1268
+ ? p.accent ? `creer('span', { class: 'humalis-accent' }, ${chaine(p.texte)})` : p.code ? `creer('code', { class: 'humalis-code-ligne' }, ${chaine(p.texte)})` : chaine(p.texte)
1269
+ : contenuJs({ type: 'texte', parties: [p] }, this.contexte)).join(', ');
1270
+ const accent = (liste) => liste.some((p) => 'texte' in p && (p.accent || p.code));
1271
+ if (!accent(valeur.parties) && !valeur.versions?.some((v) => accent(v.parties)))
1272
+ return contenuJs(valeur, this.contexte);
1273
+ if (valeur.versions)
1274
+ return `...(${valeur.versions.slice(1).map((v) => `langue() === ${chaine(v.code)} ? [${parties(v.parties)}] : `).join('')}[${parties(valeur.versions[0].parties)}])`;
1275
+ return parties(valeur.parties);
1276
+ }
1277
+ contenuAccentHtml(valeur, genre, env) {
1278
+ if (valeur.type !== 'texte' || !['titre', 'soustitre', 'texte'].includes(genre) || !valeur.parties.some((p) => 'texte' in p && (p.accent || p.code)))
1279
+ return echapperTexte(texteDe(valeur, env, this.contexte.champs));
1280
+ return valeur.parties.map((p) => {
1281
+ const texte = echapperTexte('texte' in p ? p.texte : texteDe({ type: 'texte', parties: [p] }, env, this.contexte.champs));
1282
+ return 'texte' in p && p.accent ? `<span class="humalis-accent">${texte}</span>` : 'texte' in p && p.code ? `<code class="humalis-code-ligne">${texte}</code>` : texte;
1283
+ }).join('');
1284
+ }
1195
1285
  texteInitial(valeur, env) {
1196
1286
  try {
1197
1287
  return texteDe(valeur, env, this.contexte.champs);
@@ -1456,7 +1546,7 @@ ${prise}${code}}
1456
1546
  const classes = this.classes(noeud) || undefined;
1457
1547
  const commun = (avant = []) => attributs([...avant, ['data-humalis', noeud.id], ['class', classes], ...(noeud.annonce ? [['aria-live', 'polite'], ['aria-atomic', 'true']] : []), ...attributsRaccourci(noeud)]);
1458
1548
  const lucideHtml = noeud.reglages?.icone ? chercherIcone(noeud.reglages.icone)?.lucide : undefined;
1459
- const contenu = (lucideHtml ? iconeHtml(lucideHtml, 'humalis-icone') : '') + echapperTexte(texteDe(noeud.contenu, env, this.contexte.champs));
1549
+ const contenu = (lucideHtml ? iconeHtml(lucideHtml, 'humalis-icone') : '') + this.contenuAccentHtml(noeud.contenu, noeud.genre, env);
1460
1550
  switch (noeud.genre) {
1461
1551
  case 'titre':
1462
1552
  case 'soustitre': {
@@ -1485,6 +1575,8 @@ ${prise}${code}}
1485
1575
  }
1486
1576
  }
1487
1577
  }
1578
+ /** « menu repliable » : un bouton « Menu » le replie sur un petit écran. */
1579
+ const estRepliable = (noeud) => noeud.options.some((o) => o.nom === 'menu' && o.valeur === 'repliable');
1488
1580
  /** Les blocs d'éléments d'écran, décrits par widgets.ts. */
1489
1581
  const COMPOSITES = new Set(['onglets', 'deroulant', 'accordeon', 'carrousel', 'filAriane']);
1490
1582
  /** La balise de chaque bloc : une section, l'en-tête et le pied de la page, sinon une boîte. */
@@ -1512,6 +1604,7 @@ const OUTILS_PAGE = [
1512
1604
  'afficherMessage',
1513
1605
  'planifier',
1514
1606
  'mouvementReduit',
1607
+ 'themeSombre',
1515
1608
  'copier',
1516
1609
  'partager',
1517
1610
  'derniereRequete',
@@ -133,6 +133,10 @@ ${fonctions.join('\n')}`);
133
133
  const emploie = (motif) => scriptsDesPages.some((contenu) => motif.test(contenu));
134
134
  const interactions = { pressePapier: emploie(/\b(copier|partager)\(/), fenetres: emploie(/\bfenetre\(/), questions: emploie(/\bconfirmer\(/), bas: emploie(/\bonbas:/) };
135
135
  fichiers.set('humalis/humalis.js', humalisJs(options.source, pagesFichiers, serveur, session, bascule ? modeTheme ?? '' : undefined, usage, application.langues, interactions));
136
+ // « menu repliable » : un seul écouteur pour tous les menus, ceux du HTML de départ comme ceux redessinés.
137
+ if ([...fichiers.values()].some((contenu) => contenu.includes('humalis-menu-bouton'))) {
138
+ fichiers.set('humalis/humalis.js', `${fichiers.get('humalis/humalis.js')}\n${MENU_REPLIABLE_JS}`);
139
+ }
136
140
  // L'observateur des animations « au defilement », seulement si une page en a : il pose humalis-js au chargement.
137
141
  if (animeAuDefilement([...fichiers.values()])) {
138
142
  fichiers.set('humalis/humalis.js', `${fichiers.get('humalis/humalis.js')}\n// Les animations « au defilement » attendent que l'élément entre à l'écran.\n${OBSERVATEUR_DEFILEMENT}\nobserverLeDefilement();\n`);
@@ -291,6 +295,7 @@ function humalisJs(source, pages, serveur, session, theme, elements, langues, in
291
295
  'afficherMessage',
292
296
  'planifier',
293
297
  'mouvementReduit',
298
+ 'themeSombre',
294
299
  'cliquer',
295
300
  'focaliser',
296
301
  'retirerPremier',
@@ -544,6 +549,14 @@ ${interactions.fenetres ? ' // Une fenêtre fermée rend le focus à ce qui l
544
549
  // Le réglage change pendant la visite : la page est redessinée.
545
550
  animationsReduites?.addEventListener('change', () => actualiser());
546
551
 
552
+ /** « theme est sombre » : le thème affiché (choix du visiteur ou du projet, sinon le système). */
553
+ const schemaSombre = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
554
+ const themeSombre = () => {
555
+ const choisi = document.documentElement.dataset.theme;
556
+ return choisi ? choisi === 'sombre' : (schemaSombre?.matches ?? false);
557
+ };
558
+ schemaSombre?.addEventListener('change', () => actualiser());
559
+
547
560
  /** Les minuteries de la page existent tant que leur condition est vraie ; « arreter » les arrête pour de bon. */
548
561
  function reglerMinuteries() {
549
562
  for (const m of page.minuteries ?? []) {
@@ -1112,6 +1125,11 @@ const themeJs = (defaut) => `
1112
1125
  const THEME_PAR_DEFAUT = ${defaut ? `'${defaut}'` : "''"};
1113
1126
 
1114
1127
  function definirTheme(theme) {
1128
+ // « basculer theme » : l'inverse du thème affiché (choix du visiteur ou du projet, sinon le système).
1129
+ if (theme === 'bascule') {
1130
+ const affiche = document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'sombre' : 'clair');
1131
+ theme = affiche === 'sombre' ? 'clair' : 'sombre';
1132
+ }
1115
1133
  try {
1116
1134
  if (theme === 'automatique') localStorage.removeItem('${CLE_THEME}');
1117
1135
  else localStorage.setItem('${CLE_THEME}', theme);
@@ -1121,5 +1139,18 @@ const themeJs = (defaut) => `
1121
1139
  const choisi = theme === 'automatique' ? THEME_PAR_DEFAUT : theme;
1122
1140
  if (choisi) document.documentElement.dataset.theme = choisi;
1123
1141
  else delete document.documentElement.dataset.theme;
1142
+ // « theme est sombre » est relu : la page est redessinée.
1143
+ actualiser();
1124
1144
  }
1125
1145
  `;
1146
+ /** « menu repliable » : le bouton « Menu » ouvre et ferme les liens ; un lien choisi referme le menu. */
1147
+ const MENU_REPLIABLE_JS = `// Les menus repliables : le bouton « Menu » d'un petit écran.
1148
+ document.addEventListener('click', (evenement) => {
1149
+ const cible = evenement.target instanceof Element ? evenement.target : null;
1150
+ const menu = cible?.closest('.humalis-menu-repliable');
1151
+ if (!menu || !cible.closest('.humalis-menu-bouton, a')) return;
1152
+ const ouvrir = cible.closest('.humalis-menu-bouton') !== null && !menu.hasAttribute('data-ouvert');
1153
+ menu.toggleAttribute('data-ouvert', ouvrir);
1154
+ menu.querySelector(':scope > .humalis-menu-bouton')?.setAttribute('aria-expanded', String(ouvrir));
1155
+ });
1156
+ `;
@@ -133,6 +133,9 @@ export function expressionJs(valeur, contexte) {
133
133
  case 'mouvementReduit':
134
134
  // « réduire les animations » du système (humalis.js), relu à chaque dessin.
135
135
  return 'mouvementReduit()';
136
+ case 'themeSombre':
137
+ // Le thème affiché (humalis.js), relu à chaque dessin.
138
+ return 'themeSombre()';
136
139
  case 'etatRequete':
137
140
  case 'erreurRequete':
138
141
  // Les sources ne sont pas encore produites pour le site statique : l'analyse de la cible les refuse avant.
@@ -371,8 +374,9 @@ export function evaluer(valeur, env) {
371
374
  }
372
375
  case 'utilisateur':
373
376
  case 'etatRequete':
374
- // Le HTML de départ n'écrit aucun mouvement.
377
+ // Le HTML de départ n'écrit aucun mouvement, et ne connaît pas le thème du visiteur.
375
378
  case 'mouvementReduit':
379
+ case 'themeSombre':
376
380
  return false;
377
381
  case 'erreurRequete':
378
382
  return '';
@@ -81,7 +81,7 @@ export function arbreWidget(genre, a) {
81
81
  throw new Error('le choix d’un fichier n’est pas encore produit pour le site statique');
82
82
  }
83
83
  }
84
- /** Les initiales d'un nom : « Emch Kongo » donne « EK » (le même calcul que humalis.initiales). */
84
+ /** Les initiales d'un nom : « Camille Martin » donne « CM » (le même calcul que humalis.initiales). */
85
85
  export function initiales(nom) {
86
86
  const mots = nom.trim().split(/\s+/).filter(Boolean);
87
87
  return ((mots[0]?.[0] ?? '') + (mots.length > 1 ? (mots[mots.length - 1][0] ?? '') : '')).toUpperCase();
@@ -34,9 +34,30 @@ export function reglagesUtilises(codes) {
34
34
  export function cssEcran(codes) {
35
35
  const utilises = reglagesUtilises(codes);
36
36
  const regles = [];
37
+ if (codes.some((c) => c.includes('humalis-accent')))
38
+ regles.push('.humalis-accent { color: var(--humalis-marque, currentColor); }');
39
+ if (codes.some((c) => c.includes('humalis-code-ligne')))
40
+ regles.push('.humalis-code-ligne { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; padding: 0.1em 0.25em; border-radius: 0.2em; background: rgb(127 127 127 / 0.12); }');
41
+ if (codes.some((c) => c.includes('humalis-bloc-code')))
42
+ regles.push('.humalis-bloc-code-conteneur { position: relative; max-width: 100%; overflow-x: auto; } .humalis-bloc-code { overflow-x: auto; padding: 1rem; border-radius: 0.5rem; background: rgb(127 127 127 / 0.12); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; } .humalis-bloc-code-conteneur button { position: absolute; top: 0.5rem; right: 0.5rem; }');
43
+ if (codes.some((c) => c.includes('humalis-code-mot')))
44
+ regles.push('.humalis-code-mot { color: #7348c3; font-weight: 600; } .humalis-code-texte { color: #147d50; } .humalis-code-commentaire { color: #68717d; } .humalis-code-nombre { color: #b25215; }');
45
+ if (codes.some((c) => c.includes('humalis-tableau-conteneur')))
46
+ regles.push('.humalis-tableau-conteneur { max-width: 100%; overflow-x: auto; } .humalis-tableau-conteneur table { border-collapse: collapse; width: 100%; } .humalis-tableau-conteneur th, .humalis-tableau-conteneur td { border: 1px solid rgb(127 127 127 / 0.3); padding: 0.5rem; text-align: left; } .humalis-tableau-conteneur caption { text-align: left; font-weight: 700; margin-bottom: 0.5rem; }');
47
+ if (codes.some((c) => c.includes('humalis-liste')))
48
+ regles.push('.humalis-liste { padding-inline-start: 1.5rem; } .humalis-liste > li { margin-block: 0.35rem; }');
37
49
  // L'en-tête et le pied communs : hors de la page, sur toute la largeur.
38
- if (codes.some((c) => /humalis-entete\b/.test(c)))
50
+ if (codes.some((c) => /humalis-entete\b/.test(c))) {
39
51
  regles.push('.humalis-entete { padding: 0.75rem 1rem; border-bottom: 1px solid rgb(138 138 144 / 0.4); }');
52
+ // « Aller au contenu » : hors de l'écran, il apparaît quand le clavier l'atteint.
53
+ regles.push('.humalis-aller-au-contenu { position: absolute; left: 0.5rem; top: -4rem; z-index: 1000; padding: 0.5rem 1rem; border-radius: 0.5rem; background: Canvas; color: CanvasText; }');
54
+ regles.push('.humalis-aller-au-contenu:focus { top: 0.5rem; }');
55
+ }
56
+ // « menu repliable » : sur un petit écran, les liens attendent le bouton « Menu ».
57
+ if (codes.some((c) => c.includes('humalis-menu-repliable'))) {
58
+ regles.push('.humalis-menu-bouton { display: none; }');
59
+ regles.push('@media (max-width: 720px) { .humalis-menu-repliable { flex-direction: column; align-items: stretch; } .humalis-menu-bouton { display: inline-flex; align-self: flex-start; } .humalis-menu-repliable:not([data-ouvert]) > :not(.humalis-menu-bouton) { display: none; } }');
60
+ }
40
61
  if (codes.some((c) => /humalis-pied\b/.test(c)))
41
62
  regles.push('.humalis-pied { padding: 1.5rem 1rem; margin-top: 2rem; border-top: 1px solid rgb(138 138 144 / 0.4); }');
42
63
  if (utilises.size === 0 && regles.length === 0)
@@ -2,6 +2,7 @@ import { verificationsDuChamp, messageEnCode, libellesEnLangues } from '../../i1
2
2
  import { titreComplet, adresseImage, sansBarreFinale } from '../publication.js';
3
3
  import { sousActions } from '../../ri/actions.js';
4
4
  import { nomDeLaCopie, estFichierLocal } from '../../ri/medias.js';
5
+ import { estCombine } from '../../ri/types.js';
5
6
  import { simplifier } from '../../analyse/elements.js';
6
7
  import { lireCible } from '../../analyse/navigation.js';
7
8
  import { Ecrivain } from './ecrivain.js';
@@ -10,6 +11,8 @@ import { WIDGETS_WEB } from './widgets.js';
10
11
  import { baliseGraphique } from './graphique.js';
11
12
  import { typeTS, typeDe, expression, contenuJSX, condition, ficheAvec } from './valeurs.js';
12
13
  import { importsDesInteractions, decrireTouches } from './interactions.js';
14
+ import { colorerCodeHumalis } from '../commun/colorerCode.js';
15
+ import { attributsFrappe } from '../commun/mouvement.js';
13
16
  /** Les sortes de champ où l'on écrit une seule ligne : la touche Entrée y envoie le formulaire. */
14
17
  const UNE_LIGNE = new Set(['texte', 'email', 'telephone', 'nombre', 'date', 'heure', 'motDePasse', 'adresseWeb']);
15
18
  /** Un texte écrit en clair dans du JSX : tel quel, ou entre accolades s'il contient un caractère du JSX. */
@@ -152,7 +155,9 @@ export const RENDU_WEB = {
152
155
  ouvrir: (genre, options, classes, ancre) => {
153
156
  const toutes = [`humalis-${genre}`, ...options.map((o) => `humalis-${o.nom}-${o.valeur}`), ...classes];
154
157
  const balise = genre === 'section' ? 'section' : genre === 'menu' ? 'nav' : 'div';
155
- return `<${balise} className="${toutes.join(' ')}"${ancre !== undefined ? ` id=${JSON.stringify(ancre)}` : ''}>`;
158
+ // « menu repliable » : le bouton « Menu » du petit écran (src/humalis/menu.ts).
159
+ const repliable = options.some((o) => o.nom === 'menu' && o.valeur === 'repliable');
160
+ return `<${balise} className="${toutes.join(' ')}"${ancre !== undefined ? ` id=${JSON.stringify(ancre)}` : ''}${repliable ? ' onClick={menuRepliable}' : ''}>${repliable ? '<button type="button" className="humalis-menu-bouton" aria-expanded="false">Menu</button>' : ''}`;
156
161
  },
157
162
  fermer: (genre) => (genre === 'section' ? '</section>' : genre === 'menu' ? '</nav>' : '</div>'),
158
163
  },
@@ -204,6 +209,7 @@ class GenerateurEcran {
204
209
  chemins;
205
210
  application;
206
211
  parametres;
212
+ listeEnCours;
207
213
  contexte = { types: new Map(), champs: new Map(), sortes: new Map() };
208
214
  /** Les champs qui ont une sorte ou des options, et le libellé de chaque champ (pour les messages). */
209
215
  saisies = new Map();
@@ -325,7 +331,7 @@ class GenerateurEcran {
325
331
  for (const g of this.ecran.gestionnaires)
326
332
  this.enregistrer(g, []);
327
333
  const composant = pascal(this.ecran.nom);
328
- const { titre, description } = titreEtDescription(this.ecran);
334
+ const { titre, description } = titreEtDescription(this.ecran, this.application.langues);
329
335
  // Un composant reçoit ses paramètres comme des propriétés React, et ne
330
336
  // change pas le titre de la page qui l'affiche.
331
337
  const signature = this.parametres
@@ -392,7 +398,9 @@ class GenerateurEcran {
392
398
  const lues = [...new Set([...code.matchAll(/\bactuel\.current\.(\w+)/g)].map((m) => m[1]))];
393
399
  code = code.replace(' /*ACTUEL*/\n', lues.length > 0 ? ` const actuel = useActuel({ ${lues.join(', ')} });\n` : '');
394
400
  // « mouvement est reduit » : suivi en direct (src/humalis/temps.ts).
395
- code = code.replace(' /*MOUVEMENT*/\n', /\bmouvementReduit\b/.test(code) ? ' const mouvementReduit = useMouvementReduit();\n' : '');
401
+ code = code.replace(' /*MOUVEMENT*/\n', (/\bmouvementReduit\b/.test(code) ? ' const mouvementReduit = useMouvementReduit();\n' : '') +
402
+ // « theme est sombre » : le thème affiché, suivi en direct.
403
+ (/\bthemeSombre\b/.test(code) ? ' const themeSombre = useThemeSombre();\n' : ''));
396
404
  // Sur le téléphone, « confirmer » vient d'un crochet : la question est une fenêtre de l'application.
397
405
  code = code.replace(' /*CONFIRMER*/\n', this.rendu.confirmationParCrochet && /\bconfirmer\(/.test(code) ? ' const confirmer = useConfirmer();\n' : '');
398
406
  code = code.replace(' /*PLANIFIER*/\n', (/\bplanifier\(/.test(code) ? ' const planifier = usePlanifier();\n' : '') + (/\bderniereRequete\(/.test(code) ? ' const derniereRequete = useDerniere();\n' : ''));
@@ -407,13 +415,16 @@ class GenerateurEcran {
407
415
  }
408
416
  /** Le premier titre (ou texte) de la page, s'il a des versions : il suit la langue (« t({ fr, en }) »). */
409
417
  traduit(genre) {
410
- const valeur = this.application.langues ? premiereValeur(genre, this.ecran.vue) : undefined;
418
+ const valeur = this.application.langues ? premiereValeur(genre, this.ecran.vue, this.application.langues) : undefined;
411
419
  return valeur?.type === 'texte' && valeur.versions ? expression(valeur, this.contexte) : undefined;
412
420
  }
413
421
  // ─── Recensement : champs, cibles des événements, besoins ───────────────
414
422
  recenser(noeuds, boucles, formulaire) {
415
423
  for (const noeud of noeuds) {
416
424
  switch (noeud.type) {
425
+ case 'codeDocumentation':
426
+ this.besoins.messages = true;
427
+ break;
417
428
  case 'element':
418
429
  if (noeud.genre === 'champ') {
419
430
  const libelle = texteDeBase(noeud.contenu) ?? noeud.id;
@@ -834,8 +845,8 @@ class GenerateurEcran {
834
845
  const code = expression(valeur, this.contexte);
835
846
  return complet && site?.nom ? `${code} + ${suffixe()}` : code;
836
847
  };
837
- const titreCode = enCode(apercu?.titre ?? premiereValeur('titre', this.ecran.vue), true) ?? titreLitteral(titre);
838
- const descriptionCode = enCode(apercu?.description ?? premiereValeur('texte', this.ecran.vue)) ??
848
+ const titreCode = enCode(apercu?.titre ?? premiereValeur('titre', this.ecran.vue, this.application.langues), true) ?? titreLitteral(titre);
849
+ const descriptionCode = enCode(apercu?.description ?? premiereValeur('texte', this.ecran.vue, this.application.langues)) ??
839
850
  (site?.descriptionLangues ? messageEnCode(site.descriptionLangues, chaine) : chaine(site?.description ?? description));
840
851
  const chemin = this.chemins.get(this.ecran.nom) ?? `/${simplifier(this.ecran.nom)}`;
841
852
  const image = apercu?.image ?? (site?.image !== undefined ? { type: 'texte', parties: [{ texte: site.image }] } : undefined);
@@ -1017,7 +1028,7 @@ class GenerateurEcran {
1017
1028
  break;
1018
1029
  case 'copie':
1019
1030
  // « copier code » : le résultat est dit à la personne, sauf « sans message ».
1020
- e.ligne(`await copier(${this.enTexte(action.valeur)}${action.message === null ? '' : `, afficherMessage${action.message === 'Copié' ? '' : `, ${chaine(action.message)}`}`});`);
1031
+ e.ligne(`await copier(${this.enTexte(action.valeur)}${action.message === null ? '' : `, afficherMessage${action.texte ? `, ${x(action.texte)}` : action.message === 'Copié' ? '' : `, ${chaine(action.message)}`}`});`);
1021
1032
  break;
1022
1033
  case 'partage': {
1023
1034
  // La feuille de partage du système ; sans elle, le lien (ou le texte) est copié.
@@ -1437,6 +1448,50 @@ class GenerateurEcran {
1437
1448
  ecrireVue(e, noeuds) {
1438
1449
  for (const noeud of noeuds) {
1439
1450
  switch (noeud.type) {
1451
+ case 'tableauDocumentation':
1452
+ if (this.application.rendu) {
1453
+ const mobile = (valeur) => contenuJSX(valeur, this.contexte)
1454
+ .replace(/<code className="humalis-code-ligne">/g, '<Text style={{ fontFamily: "monospace" }}>')
1455
+ .replace(/<span className="humalis-accent">/g, '<Text style={s.accent}>')
1456
+ .replace(/<\/(code|span)>/g, '</Text>');
1457
+ e.ligne('<ScrollView horizontal><View>');
1458
+ e.ligne(`<Text>${mobile(noeud.legende)}</Text>`);
1459
+ for (const ligne of [noeud.colonnes, ...noeud.rangees]) {
1460
+ e.ligne('<View style={{ flexDirection: "row" }}>');
1461
+ for (const cellule of ligne)
1462
+ e.ligne(`<Text style={{ minWidth: 120 }}>${mobile(cellule)}</Text>`);
1463
+ e.ligne('</View>');
1464
+ }
1465
+ e.ligne('</View></ScrollView>');
1466
+ }
1467
+ else {
1468
+ e.ligne('<div className="humalis-tableau-conteneur"><table><caption>');
1469
+ e.ligne(contenuJSX(noeud.legende, this.contexte));
1470
+ e.ligne('</caption><thead><tr>');
1471
+ for (const cellule of noeud.colonnes)
1472
+ e.ligne(`<th scope="col">${contenuJSX(cellule, this.contexte)}</th>`);
1473
+ e.ligne('</tr></thead><tbody>');
1474
+ for (const ligne of noeud.rangees) {
1475
+ e.ligne('<tr>');
1476
+ for (const cellule of ligne)
1477
+ e.ligne(`<td>${contenuJSX(cellule, this.contexte)}</td>`);
1478
+ e.ligne('</tr>');
1479
+ }
1480
+ e.ligne('</tbody></table></div>');
1481
+ }
1482
+ break;
1483
+ case 'codeDocumentation':
1484
+ if (this.application.rendu)
1485
+ e.ligne(`<ScrollView horizontal><Text style={{ fontFamily: 'monospace' }}>{${chaine(noeud.code)}}</Text></ScrollView>`);
1486
+ else {
1487
+ const copie = this.application.langues ? `t({ fr: 'Copié', en: 'Copied' })` : `'Copié'`;
1488
+ const libelle = this.application.langues ? `{t({ fr: 'Copier', en: 'Copy' })}` : 'Copier';
1489
+ const frappe = attributsFrappe(noeud);
1490
+ const animation = frappe ? ` ${frappe.classe}` : '';
1491
+ const marques = frappe ? ` data-humalis-frappe="${frappe.frappe}" data-humalis-frappe-cle="${frappe.cle}"` : '';
1492
+ e.ligne(`<div className="humalis-bloc-code-conteneur"><pre className="humalis-bloc-code${animation}" data-langage=${chaine(noeud.langage ?? '')}${marques}><code dangerouslySetInnerHTML={{ __html: ${chaine(colorerCodeHumalis(noeud.code, noeud.langage))} }} /></pre><button type="button" onClick={() => void copier(${chaine(noeud.code)}, afficherMessage, ${copie})}>${libelle}</button></div>`);
1493
+ }
1494
+ break;
1440
1495
  case 'element':
1441
1496
  this.ecrireElement(e, noeud);
1442
1497
  break;
@@ -1500,7 +1555,13 @@ class GenerateurEcran {
1500
1555
  e.ligne(`{${liste}.map((${variable}, ${index}) => (`);
1501
1556
  e.indenter(() => {
1502
1557
  e.ligne(`<Fragment key={${index}}>`);
1503
- e.indenter(() => this.ecrireVue(e, contenu));
1558
+ e.indenter(() => {
1559
+ if (this.listeEnCours)
1560
+ e.ligne(this.application.rendu ? `<View style={{ flexDirection: "row" }}><Text>${this.listeEnCours === 'liste' ? '•' : `{${index} + 1}.`}</Text>` : '<li>');
1561
+ this.ecrireVue(e, contenu);
1562
+ if (this.listeEnCours)
1563
+ e.ligne(this.application.rendu ? '</View>' : '</li>');
1564
+ });
1504
1565
  e.ligne('</Fragment>');
1505
1566
  });
1506
1567
  e.ligne('))}');
@@ -1541,6 +1602,40 @@ class GenerateurEcran {
1541
1602
  break;
1542
1603
  }
1543
1604
  case 'bloc':
1605
+ if (noeud.genre === 'liste' || noeud.genre === 'listeNumerotee') {
1606
+ if (this.application.rendu) {
1607
+ e.ligne('<View>');
1608
+ e.indenter(() => noeud.corps.forEach((enfant, index) => {
1609
+ if (enfant.type === 'repetition') {
1610
+ this.listeEnCours = noeud.genre;
1611
+ this.ecrireVue(e, [enfant]);
1612
+ this.listeEnCours = undefined;
1613
+ return;
1614
+ }
1615
+ e.ligne('<View style={{ flexDirection: "row" }}>');
1616
+ e.ligne(`<Text>${noeud.genre === 'liste' ? '•' : `${index + 1}.`}</Text>`);
1617
+ this.ecrireVue(e, [enfant]);
1618
+ e.ligne('</View>');
1619
+ }));
1620
+ e.ligne('</View>');
1621
+ break;
1622
+ }
1623
+ const balise = noeud.genre === 'liste' ? 'ul' : 'ol';
1624
+ e.ligne(`<${balise} className="humalis-liste">`);
1625
+ e.indenter(() => noeud.corps.forEach((enfant) => {
1626
+ if (enfant.type === 'repetition') {
1627
+ this.listeEnCours = noeud.genre;
1628
+ this.ecrireVue(e, [enfant]);
1629
+ this.listeEnCours = undefined;
1630
+ return;
1631
+ }
1632
+ e.ligne('<li>');
1633
+ e.indenter(() => this.ecrireVue(e, [enfant]));
1634
+ e.ligne('</li>');
1635
+ }));
1636
+ e.ligne(`</${balise}>`);
1637
+ break;
1638
+ }
1544
1639
  if (COMPOSITES.has(noeud.genre)) {
1545
1640
  this.ecrireComposite(e, noeud);
1546
1641
  break;
@@ -1926,24 +2021,72 @@ function modifiees(actions, noms = new Set()) {
1926
2021
  }
1927
2022
  return noms;
1928
2023
  }
1929
- /** La première valeur d'un élément de ce genre, affiché sans condition (même dans un bloc de mise en page). */
1930
- function premiereValeur(genre, noeuds) {
2024
+ /**
2025
+ * « si langue est anglais » : la branche montrée dans la langue `code` ;
2026
+ * rien pour une condition qui ne porte pas sur la langue.
2027
+ */
2028
+ function brancheDeLangue(noeud, code) {
2029
+ if (noeud.type !== 'condition')
2030
+ return undefined;
2031
+ const test = noeud.test;
2032
+ if (estCombine(test))
2033
+ return undefined;
2034
+ if (test.gauche.type !== 'variable' || test.gauche.nom !== 'langue' || (test.operateur !== 'egal' && test.operateur !== 'different'))
2035
+ return undefined;
2036
+ const visee = test.droite && texteDeBase(test.droite);
2037
+ if (visee === undefined)
2038
+ return undefined;
2039
+ const vraie = (visee === code) !== (test.operateur === 'different') !== Boolean(test.negation);
2040
+ return vraie ? noeud.alors : noeud.sinon;
2041
+ }
2042
+ /**
2043
+ * La première valeur d'un élément de ce genre, affiché sans condition (même
2044
+ * dans un bloc de mise en page), dans la langue `code` : avec des langues,
2045
+ * une branche « si langue est … » compte.
2046
+ */
2047
+ function premiereValeurEn(genre, noeuds, code) {
1931
2048
  for (const n of noeuds) {
1932
2049
  if (n.type === 'element' && n.genre === genre)
1933
2050
  return n.contenu;
1934
- if (n.type === 'bloc') {
1935
- const trouvee = premiereValeur(genre, n.corps);
2051
+ const corps = n.type === 'bloc' ? n.corps : code !== undefined ? brancheDeLangue(n, code) : undefined;
2052
+ if (corps) {
2053
+ const trouvee = premiereValeurEn(genre, corps, code);
1936
2054
  if (trouvee)
1937
2055
  return trouvee;
1938
2056
  }
1939
2057
  }
1940
2058
  return undefined;
1941
2059
  }
2060
+ /**
2061
+ * La première valeur d'un élément de ce genre. Avec des langues, celle de la
2062
+ * langue de base ; si les branches « si langue est … » en montrent une autre
2063
+ * dans une autre langue, elle devient la version de cette langue.
2064
+ */
2065
+ function premiereValeur(genre, noeuds, langues) {
2066
+ if (!langues || langues.length === 0)
2067
+ return premiereValeurEn(genre, noeuds);
2068
+ const [base, ...autres] = langues;
2069
+ const valeur = premiereValeurEn(genre, noeuds, base.code);
2070
+ if (valeur?.type !== 'texte')
2071
+ return valeur;
2072
+ const versions = autres.flatMap(({ code }) => {
2073
+ const autre = premiereValeurEn(genre, noeuds, code);
2074
+ if (autre === valeur || autre?.type !== 'texte')
2075
+ return [];
2076
+ return [{ code, parties: autre.versions?.find((v) => v.code === code)?.parties ?? autre.parties }];
2077
+ });
2078
+ if (versions.length === 0)
2079
+ return valeur;
2080
+ // Les versions écrites avec « en anglais » restent, celles des branches les complètent.
2081
+ const ecrites = (valeur.versions ?? []).filter((v) => v.code !== base.code && !versions.some((b) => b.code === v.code));
2082
+ return { ...valeur, versions: [{ code: base.code, parties: valeur.parties }, ...ecrites, ...versions] };
2083
+ }
1942
2084
  /**
1943
2085
  * Le titre et la description de la page (décision W7) : le premier titre
1944
2086
  * et le premier texte écrits en clair, à défaut le nom de la page.
1945
2087
  */
1946
- export function titreEtDescription(ecran) {
2088
+ export function titreEtDescription(ecran, langues) {
2089
+ const base = langues?.[0]?.code;
1947
2090
  const premier = (genre, noeuds) => {
1948
2091
  for (const n of noeuds) {
1949
2092
  if (n.type === 'element' && n.genre === genre) {
@@ -1951,9 +2094,11 @@ export function titreEtDescription(ecran) {
1951
2094
  if (texte)
1952
2095
  return texte;
1953
2096
  }
1954
- // Toujours affiché : le contenu d'un bloc de mise en page compte aussi.
1955
- if (n.type === 'bloc') {
1956
- const texte = premier(genre, n.corps);
2097
+ // Toujours affiché : le contenu d'un bloc de mise en page compte aussi, et
2098
+ // avec des langues, la branche « si langue est … » de la langue de base.
2099
+ const corps = n.type === 'bloc' ? n.corps : base !== undefined ? brancheDeLangue(n, base) : undefined;
2100
+ if (corps) {
2101
+ const texte = premier(genre, corps);
1957
2102
  if (texte)
1958
2103
  return texte;
1959
2104
  }