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
@@ -39,12 +39,15 @@ export function fichiersDesInteractions(codes, plateforme = 'web') {
39
39
  fichiers.set('voir-plus.ts', plateforme === 'web' ? VOIR_PLUS_TS : VOIR_PLUS_MOBILE);
40
40
  if (emploie('deplacer'))
41
41
  fichiers.set('deplacer.ts', plateforme === 'web' ? DEPLACER_TS : DEPLACER_MOBILE);
42
+ if (emploie('menu'))
43
+ fichiers.set('menu.ts', MENU_TS);
42
44
  return fichiers;
43
45
  }
44
46
  /** Les outils de chaque fichier, pour écrire les imports d'une page. */
45
47
  export const OUTILS_INTERACTIONS = {
46
- temps: ['useActuel', 'useMinuterie', 'usePlanifier', 'useMouvementReduit', 'useQuandChange', 'useQuandPause', 'useDerniere'],
48
+ temps: ['useActuel', 'useMinuterie', 'usePlanifier', 'useMouvementReduit', 'useThemeSombre', 'useQuandChange', 'useQuandPause', 'useDerniere'],
47
49
  'presse-papier': ['copier', 'partager', 'adresseAbsolue'],
50
+ menu: ['menuRepliable'],
48
51
  clavier: ['useRaccourcis', 'correspond', 'cliquer', 'focaliser'],
49
52
  confirmation: ['confirmer', 'useConfirmer'],
50
53
  fenetre: ['Fenetre'],
@@ -57,7 +60,9 @@ export function importsDesInteractions(code, dossier, outils = OUTILS_INTERACTIO
57
60
  for (const [fichier, noms] of Object.entries(outils)) {
58
61
  // « confirmer » vient de useConfirmer() quand la page le déclare (le téléphone).
59
62
  const declares = new Set([...code.matchAll(/\bconst (\w+) = use\w+\(/g)].map((m) => m[1]));
60
- const employes = noms.filter((n) => !declares.has(n) && new RegExp(n === 'Fenetre' ? '<Fenetre\\b' : `\\b${n}\\(`).test(code));
63
+ // Fenetre est un composant ; menuRepliable est passé tel quel à onClick.
64
+ const motif = (n) => (n === 'Fenetre' ? '<Fenetre\\b' : n === 'menuRepliable' ? '\\{menuRepliable\\}' : `\\b${n}\\(`);
65
+ const employes = noms.filter((n) => !declares.has(n) && new RegExp(motif(n)).test(code));
61
66
  if (employes.length)
62
67
  lignes.push(`import { ${employes.join(', ')} } from '${dossier}/${fichier}';`);
63
68
  }
@@ -71,7 +76,7 @@ export function importsDesInteractions(code, dossier, outils = OUTILS_INTERACTIO
71
76
  function tempsTs(plateforme) {
72
77
  const web = plateforme === 'web';
73
78
  return `import { useCallback, useEffect, useLayoutEffect, useRef, useState${web ? ', useSyncExternalStore' : ''} } from 'react';
74
- ${web ? '' : "import { AccessibilityInfo, AppState } from 'react-native';\n"}
79
+ ${web ? '' : "import { AccessibilityInfo, AppState, useColorScheme } from 'react-native';\n"}
75
80
  /**
76
81
  * Les dernières valeurs de la page, pour ce qui s'exécute plus tard (une
77
82
  * minuterie lancée par un clic, le bouton d'un message) : sans cela, il
@@ -195,6 +200,28 @@ export function useMouvementReduit(): boolean {
195
200
  // Le prérendu n'écrit aucun mouvement.
196
201
  () => false,
197
202
  );
203
+ }
204
+
205
+ /** « theme est sombre » : le thème affiché (choix du visiteur ou du projet, sinon le système), suivi en direct. */
206
+ export function useThemeSombre(): boolean {
207
+ return useSyncExternalStore(
208
+ (changement) => {
209
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
210
+ media.addEventListener('change', changement);
211
+ const observateur = new MutationObserver(changement);
212
+ observateur.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
213
+ return () => {
214
+ media.removeEventListener('change', changement);
215
+ observateur.disconnect();
216
+ };
217
+ },
218
+ () => {
219
+ const choisi = document.documentElement.dataset.theme;
220
+ return choisi ? choisi === 'sombre' : window.matchMedia('(prefers-color-scheme: dark)').matches;
221
+ },
222
+ // Le prérendu ne connaît pas le thème du visiteur.
223
+ () => false,
224
+ );
198
225
  }`
199
226
  : `/** « mouvement est reduit » : la personne a demandé à son téléphone de réduire les animations, suivi en direct. */
200
227
  export function useMouvementReduit(): boolean {
@@ -205,6 +232,11 @@ export function useMouvementReduit(): boolean {
205
232
  return () => abonnement.remove();
206
233
  }, []);
207
234
  return reduit;
235
+ }
236
+
237
+ /** « theme est sombre » : le thème affiché par le téléphone, suivi en direct. */
238
+ export function useThemeSombre(): boolean {
239
+ return useColorScheme() === 'dark';
208
240
  }`}
209
241
 
210
242
  /**
@@ -1041,3 +1073,18 @@ const CSS_DEPLACER = `.humalis-liste-deplacable { list-style: none; margin: 0; p
1041
1073
  .humalis-insertion-avant { box-shadow: 0 -3px 0 var(--humalis-bleu); }
1042
1074
  .humalis-insertion-apres { box-shadow: 0 3px 0 var(--humalis-bleu); }
1043
1075
  ${CSS_CACHE}`;
1076
+ /** `src/humalis/menu.ts` : « menu repliable », le bouton « Menu » d'un petit écran. */
1077
+ const MENU_TS = `/**
1078
+ * « menu repliable » : le bouton « Menu » ouvre et ferme les liens (visible
1079
+ * seulement sur un petit écran, par le CSS) ; un lien choisi referme le menu.
1080
+ */
1081
+ export function menuRepliable(evenement: { target: EventTarget; currentTarget: EventTarget }): void {
1082
+ const menu = evenement.currentTarget as HTMLElement;
1083
+ const cible = evenement.target as Element;
1084
+ const bouton = menu.querySelector(':scope > .humalis-menu-bouton');
1085
+ const ouvrir = cible.closest('.humalis-menu-bouton') !== null && !menu.hasAttribute('data-ouvert');
1086
+ if (!ouvrir && cible.closest('.humalis-menu-bouton, a') === null) return;
1087
+ menu.toggleAttribute('data-ouvert', ouvrir);
1088
+ bouton?.setAttribute('aria-expanded', String(ouvrir));
1089
+ }
1090
+ `;
@@ -98,6 +98,9 @@ export function genererProjetWeb(application, options) {
98
98
  }
99
99
  if (dispositions.entete || dispositions.pied)
100
100
  fichiers.set('src/Disposition.tsx', dispositionTsx(dispositions, application));
101
+ // Avec un en-tête, le clavier peut sauter directement au contenu de la page.
102
+ if (dispositions.entete)
103
+ fichiers.set('src/humalis/aller-au-contenu.tsx', ALLER_AU_CONTENU);
101
104
  const pages = application.ecrans.map((ecran) => ({ ecran, page: genererEcran(ecran, chemins, contexte) }));
102
105
  session ||= pages.some(({ page }) => page.session);
103
106
  // Une page réservée demande la session, même si elle ne la lit pas elle-même.
@@ -433,6 +436,24 @@ ${mouvement ? NETTOYAGE_PRERENDU : ''}};
433
436
  `;
434
437
  }
435
438
  /** Disposition.tsx : l'en-tête et le pied communs autour des pages, qui restent montés quand on change de page. */
439
+ /** `src/humalis/aller-au-contenu.tsx` : le premier arrêt du clavier, qui saute l'en-tête. */
440
+ const ALLER_AU_CONTENU = `/** Le lien « Aller au contenu » : caché tant qu'il n'a pas le focus du clavier, il saute l'en-tête. */
441
+ export default function AllerAuContenu() {
442
+ return (
443
+ <a
444
+ className="humalis-aller-au-contenu"
445
+ href="#contenu"
446
+ onClick={(evenement) => {
447
+ const contenu = document.querySelector('main');
448
+ if (!contenu) return;
449
+ evenement.preventDefault();
450
+ contenu.setAttribute('tabindex', '-1');
451
+ contenu.focus();
452
+ }}
453
+ >Aller au contenu</a>
454
+ );
455
+ }
456
+ `;
436
457
  function dispositionTsx(d, application) {
437
458
  const balise = (genre, composant) => {
438
459
  const dispo = application.disposition[genre];
@@ -441,12 +462,12 @@ function dispositionTsx(d, application) {
441
462
  return `{${genre} && (\n <${ouvre} className="${classes}">\n <${composant} />\n </${ferme}>\n )}`;
442
463
  };
443
464
  return `import { Outlet } from 'react-router';
444
- ${d.entete ? `import ${d.entete} from './composants/${d.entete}';\n` : ''}${d.pied ? `import ${d.pied} from './composants/${d.pied}';\n` : ''}
465
+ ${d.entete ? `import AllerAuContenu from './humalis/aller-au-contenu';\nimport ${d.entete} from './composants/${d.entete}';\n` : ''}${d.pied ? `import ${d.pied} from './composants/${d.pied}';\n` : ''}
445
466
  /** L'en-tête et le pied communs à toutes les pages : montés une fois, ils restent là quand on change de page. */
446
467
  export default function Disposition({ entete = true, pied = true }: { entete?: boolean; pied?: boolean }) {
447
468
  return (
448
469
  <>
449
- ${d.entete ? balise('entete', d.entete) : '{entete && null}'}
470
+ ${d.entete ? `{entete && <AllerAuContenu />}\n ${balise('entete', d.entete)}` : '{entete && null}'}
450
471
  <Outlet />
451
472
  ${d.pied ? balise('pied', d.pied) : '{pied && null}'}
452
473
  </>
@@ -512,6 +533,12 @@ ${session ? "import { Session } from './humalis/session';\n" : ''}${partage ? "i
512
533
  ${dispositions.entete || dispositions.pied ? "import Disposition from './Disposition';\n" : ''}${imports}
513
534
  import './humalis/styles.css';
514
535
  ${mouvement ? "import { observerLeDefilement } from './humalis/mouvement';\n" : ''}${feuilles.map((f) => `import './styles/${f}';\n`).join('')}
536
+ // Un hébergeur statique sert /documentation/index.html pour /documentation/ :
537
+ // le routeur lit l'adresse sans « index.html », comme le dossier.
538
+ if (/\\/index\\.html$/.test(location.pathname)) {
539
+ history.replaceState(history.state, '', location.pathname.replace(/index\\.html$/, '') + location.search + location.hash);
540
+ }
541
+
515
542
  createRoot(document.getElementById('racine')!).render(
516
543
  <StrictMode>
517
544
  <BrowserRouter>
@@ -102,14 +102,23 @@ export const SCRIPT_THEME = `try{var t=localStorage.getItem('${CLE_THEME}');if(t
102
102
  /** `src/humalis/theme.ts` du projet web : « changer theme par … ». */
103
103
  export function themeTs(defaut) {
104
104
  return `/** Le thème choisi par le visiteur (« changer theme par sombre »), gardé dans le navigateur. */
105
- export type Theme = 'clair' | 'sombre' | 'automatique';
105
+ export type Theme = 'clair' | 'sombre' | 'automatique' | 'bascule';
106
106
 
107
107
  const CLE = '${CLE_THEME}';
108
108
  /** Le mode du projet quand le visiteur n'a rien choisi (bloc « theme : »). */
109
109
  const PAR_DEFAUT: 'clair' | 'sombre' | undefined = ${defaut ? `'${defaut}'` : 'undefined'};
110
110
 
111
- export function definirTheme(theme: Theme): void {
111
+ /** Le thème affiché : le choix du visiteur ou du projet, sinon celui du système. */
112
+ function themeAffiche(): 'clair' | 'sombre' {
113
+ const choisi = document.documentElement.dataset.theme;
114
+ if (choisi === 'clair' || choisi === 'sombre') return choisi;
115
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'sombre' : 'clair';
116
+ }
117
+
118
+ export function definirTheme(demande: Theme): void {
112
119
  const racine = document.documentElement;
120
+ // « basculer theme » : l'inverse de ce qu'on voit.
121
+ const theme = demande === 'bascule' ? (themeAffiche() === 'sombre' ? 'clair' : 'sombre') : demande;
113
122
  try {
114
123
  if (theme === 'automatique') localStorage.removeItem(CLE);
115
124
  else localStorage.setItem(CLE, theme);
@@ -38,6 +38,7 @@ export function typeDe(valeur, contexte) {
38
38
  case 'utilisateur':
39
39
  case 'etatRequete':
40
40
  case 'mouvementReduit':
41
+ case 'themeSombre':
41
42
  return { genre: 'booleen' };
42
43
  case 'erreurRequete':
43
44
  return { genre: 'texte' };
@@ -150,6 +151,9 @@ export function expression(valeur, contexte) {
150
151
  case 'mouvementReduit':
151
152
  // « réduire les animations » du système, suivi en direct (src/humalis/temps.ts).
152
153
  return 'mouvementReduit';
154
+ case 'themeSombre':
155
+ // Le thème affiché, suivi en direct (src/humalis/temps.ts).
156
+ return 'themeSombre';
153
157
  case 'etatRequete':
154
158
  // L'état de la requête de la variable (src/humalis/serveur.ts) : chargement, erreur ou introuvable.
155
159
  return `${camel(valeur.variable)}Req.statut === '${valeur.etat}'`;
@@ -187,6 +191,19 @@ function texteTS(parties, contexte, langue) {
187
191
  }
188
192
  /** Le contenu d'un élément dans du JSX : `Bienvenue`, `{nom}`, `{panier.join(', ')}`. */
189
193
  export function contenuJSX(valeur, contexte) {
194
+ if (valeur.type === 'texte' && (valeur.parties.some((p) => 'texte' in p && (p.accent || p.code)) || valeur.versions?.some((v) => v.parties.some((p) => 'texte' in p && (p.accent || p.code))))) {
195
+ const enfants = (parties) => parties.map((p) => {
196
+ if ('texte' in p)
197
+ return p.accent
198
+ ? `<span className="humalis-accent">{${chaine(p.texte)}}</span>`
199
+ : p.code ? `<code className="humalis-code-ligne">{${chaine(p.texte)}}</code>`
200
+ : `{${chaine(p.texte)}}`;
201
+ return contenuJSX({ type: 'texte', parties: [p] }, contexte);
202
+ }).join('');
203
+ if (valeur.versions)
204
+ return valeur.versions.map((v) => `{langue === ${chaine(v.code)} && <>${enfants(v.parties)}</>}`).join('');
205
+ return enfants(valeur.parties);
206
+ }
190
207
  if (valeur.type === 'texte' && !valeur.versions && valeur.parties.every((p) => 'texte' in p)) {
191
208
  const texte = valeur.parties.map((p) => ('texte' in p ? p.texte : '')).join('');
192
209
  // Les caractères qui ont un sens en JSX passent par une chaîne.
@@ -185,7 +185,7 @@ export function dependancesDeDeveloppement(codes) {
185
185
  }
186
186
  const AVATAR_TSX = `import { useState } from 'react';
187
187
 
188
- /** Les initiales d'un nom : « Emch Kongo » donne « EK ». */
188
+ /** Les initiales d'un nom : « Camille Martin » donne « CM ». */
189
189
  function initiales(nom: string): string {
190
190
  const mots = nom.trim().split(/\\s+/).filter(Boolean);
191
191
  return ((mots[0]?.[0] ?? '') + (mots.length > 1 ? (mots[mots.length - 1][0] ?? '') : '')).toUpperCase();
@@ -65,8 +65,10 @@ export const MESSAGES = [
65
65
  { fr: "Choisissez d'abord un fichier.", en: 'Choose a file first.' },
66
66
  { fr: 'AAAA-MM-JJ', en: 'YYYY-MM-DD' },
67
67
  { fr: 'Fermer le message', en: 'Close the message' },
68
+ { fr: 'Aller au contenu', en: 'Skip to content' },
68
69
  { fr: 'Fermer', en: 'Close' },
69
70
  { fr: 'Copié', en: 'Copied' },
71
+ { fr: 'Copier', en: 'Copy' },
70
72
  { fr: 'Copie impossible.', en: 'Could not copy.' },
71
73
  { fr: 'Lien copié : il ne reste qu’à le coller.', en: 'Link copied: paste it wherever you like.' },
72
74
  { fr: 'Texte copié : il ne reste qu’à le coller.', en: 'Text copied: paste it wherever you like.' },
@@ -30,6 +30,23 @@ function motColle(ligne, debut, premier) {
30
30
  }
31
31
  return null;
32
32
  }
33
+ /**
34
+ * Le guillemet qui ferme un {code "…"} ou un {accent "…"} commencé à `debut` : le premier « "} »
35
+ * où les accolades du contenu sont équilibrées ({code "{accent "…"}"}), sinon le premier « "} »
36
+ * ({code "{"}). -1 s'il n'y en a pas.
37
+ */
38
+ function finDuMorceau(texte, debut) {
39
+ let profondeur = 0;
40
+ for (let j = debut; j < texte.length; j++) {
41
+ if (texte.startsWith('"}', j) && profondeur <= 0)
42
+ return j;
43
+ if (texte[j] === '{')
44
+ profondeur++;
45
+ if (texte[j] === '}')
46
+ profondeur--;
47
+ }
48
+ return texte.indexOf('"}', debut);
49
+ }
33
50
  function decouperChaine(valeur, ligne, colonne) {
34
51
  const parties = [];
35
52
  let tampon = '';
@@ -42,6 +59,13 @@ function decouperChaine(valeur, ligne, colonne) {
42
59
  }
43
60
  i++;
44
61
  let nomVariable = '';
62
+ // {code "…"} et {accent "…"} : le morceau va jusqu'au premier « "} ».
63
+ const morceau = /^(code|accent)\s*"/.exec(valeur.slice(i));
64
+ const finMorceau = morceau ? finDuMorceau(valeur, i + morceau[0].length) : -1;
65
+ if (finMorceau >= 0) {
66
+ nomVariable = valeur.slice(i, finMorceau + 1);
67
+ i = finMorceau + 1;
68
+ }
45
69
  while (i < valeur.length && valeur[i] !== '}') {
46
70
  nomVariable += valeur[i];
47
71
  i++;
@@ -50,7 +74,21 @@ function decouperChaine(valeur, ligne, colonne) {
50
74
  throw new ErreurLexicale('accolade non fermée dans la chaîne : il manque }', ligne, colonne);
51
75
  }
52
76
  i++;
53
- parties.push({ type: 'variable', valeur: nomVariable.trim() });
77
+ const accent = /^accent\b(.*)$/s.exec(nomVariable.trim());
78
+ if (accent) {
79
+ const contenu = /^\s*"([\s\S]*)"\s*$/.exec(accent[1]);
80
+ if (!contenu)
81
+ throw new ErreurLexicale('accent attend un texte entre guillemets : {accent "mot"}.', ligne, colonne);
82
+ parties.push({ type: 'accent', valeur: contenu[1] });
83
+ }
84
+ else if (/^code\s+\S/s.test(nomVariable.trim())) {
85
+ const contenu = /^code\s+"([\s\S]*)"\s*$/.exec(nomVariable.trim());
86
+ if (!contenu)
87
+ throw new ErreurLexicale('code attend un texte entre guillemets : {code "titre"}.', ligne, colonne);
88
+ parties.push({ type: 'code', valeur: contenu[1] });
89
+ }
90
+ else
91
+ parties.push({ type: 'variable', valeur: nomVariable.trim() });
54
92
  continue;
55
93
  }
56
94
  tampon += valeur[i];
@@ -68,10 +106,52 @@ export function analyser(source) {
68
106
  for (let numLigne = 0; numLigne < lignes.length; numLigne++) {
69
107
  const ligne = lignes[numLigne];
70
108
  let i = 0;
109
+ // « code humalis : », « code : », et l'animation : « code humalis avec animation frappe au defilement : ».
110
+ const codeBrut = /^( *)(code)(?: +(?!avec\b)([\p{L}][\p{L}0-9_]*))?( +avec +animation +frappe(?: +au +d[eé]filement)?)? *: *$/u.exec(ligne);
111
+ if (codeBrut) {
112
+ const contenu = [];
113
+ const indentation = codeBrut[1].length;
114
+ if (indentation % 4 !== 0)
115
+ throw new ErreurLexicale('indentation incohérente : un multiple de 4 est attendu', numLigne + 1, 1);
116
+ const niveau = indentation / 4;
117
+ const courant = pile[pile.length - 1];
118
+ if (niveau > courant + 1)
119
+ throw new ErreurLexicale('indentation incohérente : un seul niveau à la fois', numLigne + 1, 1);
120
+ if (niveau === courant + 1) {
121
+ pile.push(niveau);
122
+ tokens.push({ type: 'INDENT', valeur: '', ligne: numLigne + 1, colonne: 1 });
123
+ }
124
+ else
125
+ while (pile.length > 1 && pile[pile.length - 1] > niveau) {
126
+ pile.pop();
127
+ tokens.push({ type: 'DEDENT', valeur: '', ligne: numLigne + 1, colonne: 1 });
128
+ }
129
+ if (pile[pile.length - 1] !== niveau)
130
+ throw new ErreurLexicale('indentation incohérente : ce niveau ne correspond à aucun bloc ouvert', numLigne + 1, 1);
131
+ while (numLigne + 1 < lignes.length) {
132
+ const suivante = lignes[numLigne + 1];
133
+ const marge = suivante.length - suivante.trimStart().length;
134
+ if (suivante.trim() !== '' && marge <= indentation)
135
+ break;
136
+ contenu.push(lignes[++numLigne]);
137
+ }
138
+ while (contenu.length && contenu[contenu.length - 1].trim() === '')
139
+ contenu.pop();
140
+ const marge = Math.min(...contenu.filter((l) => l.trim() !== '').map((l) => l.length - l.trimStart().length));
141
+ const brut = contenu.map((l) => l.slice(Math.min(marge, l.length - l.trimStart().length))).join('\n');
142
+ const ligneDebut = numLigne - contenu.length + 1;
143
+ tokens.push({ type: 'MOT_CLE', valeur: 'code', ligne: ligneDebut, colonne: indentation + 1, reserve: false, original: 'code' }, ...(codeBrut[3] ? [{ type: 'IDENTIFIANT', valeur: codeBrut[3], ligne: ligneDebut, colonne: indentation + 6 }] : []), ...(codeBrut[4] ? codeBrut[4].trim().split(/ +/).map((mot) => ({ type: 'IDENTIFIANT', valeur: mot, ligne: ligneDebut, colonne: ligne.indexOf(codeBrut[4]) + 1 })) : []), { type: 'PONCTUATION', valeur: ':', ligne: ligneDebut, colonne: ligne.indexOf(':') + 1 }, { type: 'FIN_LIGNE', valeur: '', ligne: ligneDebut, colonne: ligne.length + 1 }, { type: 'CODE_BRUT', valeur: brut, ligne: ligneDebut + 1, colonne: 1 }, { type: 'FIN_LIGNE', valeur: '', ligne: numLigne + 1, colonne: 1 });
144
+ continue;
145
+ }
71
146
  // « style : » au début d'une ligne, hors de tout bloc : les lignes
72
147
  // indentées qui suivent sont du CSS, recopié tel quel. Il contient des
73
148
  // {, des : et des # qui ne doivent pas être lus comme du Humalis.
74
- if (pile.length === 1 && /^style\s*:\s*$/i.test(ligne)) {
149
+ if (/^style\s*:\s*$/i.test(ligne)) {
150
+ // Juste après un bloc de haut de fichier (« theme : »), ce bloc se ferme ici.
151
+ while (pile.length > 1) {
152
+ pile.pop();
153
+ tokens.push({ type: 'DEDENT', valeur: '', ligne: numLigne + 1, colonne: 1 });
154
+ }
75
155
  const debut = numLigne;
76
156
  const contenu = [];
77
157
  while (numLigne + 1 < lignes.length && (lignes[numLigne + 1].trim() === '' || /^\s/.test(lignes[numLigne + 1]))) {
@@ -141,7 +221,30 @@ export function analyser(source) {
141
221
  const debut = i;
142
222
  i++;
143
223
  let valeur = '';
144
- while (i < ligne.length && ligne[i] !== '"') {
224
+ let profondeur = 0;
225
+ while (i < ligne.length && (ligne[i] !== '"' || (profondeur > 0 && ligne.indexOf('}', i + 1) >= 0))) {
226
+ // {code "…"} et {accent "…"} : recopiés tels quels jusqu'au premier « "} »
227
+ // (accolades, guillemets et antislashs compris).
228
+ const morceau = /^\{(code|accent)\s*"/.exec(ligne.slice(i));
229
+ const fin = morceau ? finDuMorceau(ligne, i + morceau[0].length) : -1;
230
+ if (fin >= 0) {
231
+ valeur += ligne.slice(i, fin + 2);
232
+ i = fin + 2;
233
+ continue;
234
+ }
235
+ // \" écrit un guillemet dans le texte ; tout autre antislash reste tel quel
236
+ // (« \\ » aussi, en entier : un texte peut finir par lui, comme avant).
237
+ if (ligne[i] === '\\' && ligne[i + 1] === '\\') {
238
+ valeur += '\\\\';
239
+ i += 2;
240
+ continue;
241
+ }
242
+ if (ligne[i] === '\\' && ligne[i + 1] === '"')
243
+ i++;
244
+ if (ligne[i] === '{')
245
+ profondeur++;
246
+ if (ligne[i] === '}' && profondeur > 0)
247
+ profondeur--;
145
248
  valeur += ligne[i];
146
249
  i++;
147
250
  }