humalis 1.2.0 → 1.3.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.
package/README.en.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  <p align="center"><a href="README.md">Français</a> · <strong>English</strong></p>
6
6
 
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/humalis"><img src="https://img.shields.io/npm/v/humalis" alt="npm"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-Humalis%201.0-c1e928" alt="Licence"></a>
10
+ <a href="https://github.com/emch04/framework"><img src="https://img.shields.io/badge/server-Astratra-6f42c1" alt="Astratra"></a>
11
+ <a href="https://hub.xerj.org/contributors/emch04"><img src="https://hub.xerj.org/contributors/emch04/badge.svg" alt="XERJ Corpus Hub contributor"></a>
12
+ </p>
13
+
7
14
  # Humalis
8
15
 
9
16
  **One language, in French, to build a complete application**: a website, a mobile app, a desktop app and its server, all from the same file.
@@ -59,7 +66,7 @@ humalis compile my-project.humalis --cible serveur --sortie my-project-server
59
66
  |---|---|
60
67
  | `web` | a React + Vite project, prerendered (one HTML page per address) |
61
68
  | `statique` | static HTML, CSS and JavaScript pages, no build step |
62
- | `serveur` | a Node server with the Astratra packs (login, permissions, sessions) |
69
+ | `serveur` | a Node server with the [Astratra](https://github.com/emch04/framework) packs (login, permissions, sessions) |
63
70
  | `mobile` | an Expo app (iPhone, Android) |
64
71
  | `desktop` | an Electron app (Mac, Windows, Linux) |
65
72
 
package/README.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  <p align="center"><strong>Français</strong> · <a href="README.en.md">English</a></p>
6
6
 
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/humalis"><img src="https://img.shields.io/npm/v/humalis" alt="npm"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/licence-Humalis%201.0-c1e928" alt="Licence"></a>
10
+ <a href="https://github.com/emch04/framework"><img src="https://img.shields.io/badge/serveur-Astratra-6f42c1" alt="Astratra"></a>
11
+ <a href="https://hub.xerj.org/contributors/emch04"><img src="https://hub.xerj.org/contributors/emch04/badge.svg" alt="XERJ Corpus Hub contributor"></a>
12
+ </p>
13
+
7
14
  # Humalis
8
15
 
9
16
  **Un seul langage, en français, pour créer une application complète** : un site web, une application mobile, une application de bureau et son serveur, à partir du même fichier.
@@ -57,7 +64,7 @@ humalis compile mon-projet.humalis --cible serveur --sortie mon-projet-serveur
57
64
  |---|---|
58
65
  | `web` | un projet React + Vite, prérendu (une page HTML par adresse) |
59
66
  | `statique` | des pages HTML, CSS et JavaScript, sans construction |
60
- | `serveur` | un serveur Node avec les packs Astratra (connexion, droits, sessions) |
67
+ | `serveur` | un serveur Node avec les packs [Astratra](https://github.com/emch04/framework) (connexion, droits, sessions) |
61
68
  | `mobile` | une application Expo (iPhone, Android) |
62
69
  | `desktop` | une application Electron (Mac, Windows, Linux) |
63
70
 
@@ -55,7 +55,7 @@ function modesVus(programme) {
55
55
  return ['clair', 'sombre'];
56
56
  return JSON.stringify(programme).includes('"action":"changerTheme"') ? ['clair', 'sombre'] : [mode];
57
57
  }
58
- export function analyserSens(programme) {
58
+ export function analyserSens(programme, classesCss) {
59
59
  const resultat = {
60
60
  diagnostics: [],
61
61
  elements: new Map(),
@@ -105,11 +105,10 @@ export function analyserSens(programme) {
105
105
  filtresDesPages.clear();
106
106
  for (const p of programme.pages)
107
107
  filtresDesPages.set(p.nom, p.filtres ?? []);
108
- // Les classes définies dans les blocs « style : ». Avec un fichier CSS
109
- // séparé, que l'analyse ne lit pas, on ne vérifie rien.
110
- const classes = programme.fichiersStyle.length > 0
108
+ // Un fichier CSS déclaré mais illisible rend la vérification des noms incertaine.
109
+ const classes = classesCss ?? (programme.fichiersStyle.length > 0
111
110
  ? undefined
112
- : new Set(programme.styles.flatMap((b) => [...b.css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((m) => m[1])));
111
+ : new Set(programme.styles.flatMap((b) => [...b.css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((m) => m[1]))));
113
112
  // Les variables du haut du fichier sont partagées entre les pages : on
114
113
  // les analyse une fois, puis chaque page les voit (portée « application »).
115
114
  const globale = new AnalysePage({ type: 'Page', nom: '', parametres: [], corps: [], ligne: 0 }, nomsPages, resultat, classes);
@@ -935,7 +934,7 @@ class AnalysePage {
935
934
  }
936
935
  for (const classe of element.classes) {
937
936
  if (this.classesDefinies && !this.classesDefinies.has(classe)) {
938
- this.avertissement(`la classe CSS « ${classe} » n'est définie dans aucun bloc style.`, element.ligne);
937
+ this.avertissement(`le style « ${classe} » n'est défini dans aucun CSS du projet.${proposerNom(classe, this.classesDefinies)}`, element.ligne);
939
938
  }
940
939
  }
941
940
  verifierElementEcran(element, this.signaler());
@@ -1110,7 +1109,7 @@ class AnalysePage {
1110
1109
  }
1111
1110
  for (const classe of bloc.classes) {
1112
1111
  if (this.classesDefinies && !this.classesDefinies.has(classe)) {
1113
- this.avertissement(`la classe CSS « ${classe} » n'est définie dans aucun bloc style.`, bloc.ligne);
1112
+ this.avertissement(`le style « ${classe} » n'est défini dans aucun CSS du projet.${proposerNom(classe, this.classesDefinies)}`, bloc.ligne);
1114
1113
  }
1115
1114
  }
1116
1115
  verifierBlocEcran(bloc, this.pileBlocs[this.pileBlocs.length - 1], this.signaler());
@@ -0,0 +1,28 @@
1
+ /** Repère les classes dans les sélecteurs et les déclarations qui forcent les styles Humalis. */
2
+ export function verifierCss(css, premiereLigne, fichier) {
3
+ const classes = new Set();
4
+ const diagnostics = [];
5
+ const sansCommentaires = css.replace(/\/\*[\s\S]*?\*\//g, (commentaire) => commentaire.replace(/[^\n]/g, ' '));
6
+ const regles = /([^{}]+)\{([^{}]*)\}/g;
7
+ for (const regle of sansCommentaires.matchAll(regles)) {
8
+ const selecteur = regle[1];
9
+ if (selecteur.trimStart().startsWith('@'))
10
+ continue;
11
+ const noms = [...selecteur.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((m) => m[1]);
12
+ for (const nom of noms)
13
+ classes.add(nom);
14
+ const humalis = noms.filter((nom) => nom.startsWith('humalis-'));
15
+ if (humalis.length === 0)
16
+ continue;
17
+ for (const declaration of regle[2].matchAll(/(?:^|;)\s*([\w-]+)\s*:\s*([^;]*!\s*important)\s*(?=;|$)/gi)) {
18
+ const avant = sansCommentaires.slice(0, regle.index + selecteur.length + 1 + (declaration.index ?? 0));
19
+ const ligne = premiereLigne + (avant.match(/\n/g)?.length ?? 0);
20
+ for (const nom of humalis)
21
+ diagnostics.push({
22
+ gravite: 'avertissement', ligne, ...(fichier ? { fichier } : {}),
23
+ message: `le CSS écrase « ${nom} » avec !important (${declaration[1]}) : les réglages Humalis de cet élément (remplissage, largeur…) ne s'appliqueront plus. Retirez !important, ou visez votre propre classe avec « avec style ».`,
24
+ });
25
+ }
26
+ }
27
+ return { classes, diagnostics };
28
+ }
@@ -256,6 +256,35 @@ ${OBSERVATEUR_DEFILEMENT.replace('function observerLeDefilement() {', 'export fu
256
256
  .replace('const morceaux = [];', 'const morceaux: [Text, string][] = [];')
257
257
  .replace('const noeud = parcours.currentNode;', 'const noeud = parcours.currentNode as Text;')}
258
258
  `;
259
+ /**
260
+ * La hauteur de l'en-tête fixe ou collé, gardée dans --humalis-hauteur-entete : « entete avec fixe »
261
+ * place la zone qui défile dessous, « hauteur ecran » s'en sert pour remplir l'écran.
262
+ */
263
+ export const SUIVI_ENTETE = `function suivreLaHauteurDeLEntete() {
264
+ const entete = () => document.querySelector('header.humalis-fixe-oui, header.humalis-colle-haut');
265
+ const regler = () => {
266
+ const e = entete();
267
+ document.documentElement.style.setProperty('--humalis-hauteur-entete', e ? e.offsetHeight + 'px' : '0px');
268
+ };
269
+ regler();
270
+ addEventListener('resize', regler);
271
+ if (!('ResizeObserver' in window)) return;
272
+ const observateur = new ResizeObserver(regler);
273
+ const suivre = () => {
274
+ const e = entete();
275
+ if (e) observateur.observe(e);
276
+ regler();
277
+ };
278
+ suivre();
279
+ // L'en-tête peut apparaître après le chargement (le web le dessine après le script).
280
+ new MutationObserver(suivre).observe(document.body, { childList: true, subtree: true });
281
+ }`;
282
+ /** Vrai si une page a besoin de la hauteur de l'en-tête. */
283
+ export const suitLEntete = (codes) => codes.some((c) => c.includes('humalis-fixe-oui') || c.includes('humalis-hauteur-ecran'));
284
+ /** src/humalis/entete.ts du web et du desktop : le même script, typé. */
285
+ export const ENTETE_WEB = `// Produit par Humalis : la hauteur de l'en-tête, pour « entete avec fixe » et « hauteur ecran ».
286
+ ${SUIVI_ENTETE.replace('function suivreLaHauteurDeLEntete() {', 'export function suivreLaHauteurDeLEntete(): void {').replace("const entete = () => document.querySelector(", "const entete = () => document.querySelector<HTMLElement>(")}
287
+ `;
259
288
  /** Le prérendu capture la page après le script : il retire les marques du mouvement (rien de caché sans JavaScript). */
260
289
  export const NETTOYAGE_PRERENDU = ` // Le script du mouvement marque la page (humalis-js, data-humalis-vu) : le HTML prérendu ne garde aucune de ces marques.
261
290
  transformHtml: (html) => html.replace(/(<html[^>]*class="[^"]*)\\bhumalis-js\\b/, '$1').replace(/ data-humalis-vu=""/g, '').replace(/ data-humalis-tape="[a-z-]*"/g, '').replace(/<code class="humalis-frappe-copie"[\\s\\S]*?<\\/code>/g, ''),
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Les repères du mode développement (« humalis developper ») : chaque
3
+ * élément de la page porte son fichier Humalis et sa ligne,
4
+ * `data-humalis-source="pages/accueil.humalis:12"`, pour que l'inspecteur
5
+ * du navigateur mène droit au code. Le chemin est relatif au fichier
6
+ * principal, comme l'auteur l'écrit ; la ligne est celle de ce fichier.
7
+ *
8
+ * Une compilation normale n'en reçoit aucun : les cibles ne les écrivent
9
+ * que si on leur donne la table des fichiers du projet (`sources`).
10
+ */
11
+ import { DECALAGE, ligneLocale } from '../../fichiers.js';
12
+ import { echapperAttribut } from '../statique/html.js';
13
+ /**
14
+ * « pages/accueil.humalis:12 » : la ligne fusionnée du projet (k × DECALAGE
15
+ * + ligne, voir src/fichiers.ts) rendue à son fichier. `fichiers` est la
16
+ * liste rendue par compilerProjet, le principal en premier. Undefined si la
17
+ * ligne est inconnue (0) ou hors de la table.
18
+ */
19
+ export function repereSource(ligne, fichiers) {
20
+ const locale = ligneLocale(ligne);
21
+ const fichier = fichiers[Math.floor(ligne / DECALAGE)];
22
+ return fichier === undefined || locale <= 0 ? undefined : `${fichier}:${locale}`;
23
+ }
24
+ /** L'attribut en HTML : ` data-humalis-source="…"`, la valeur échappée. */
25
+ export function attributSourceHtml(repere) {
26
+ return ` data-humalis-source="${echapperAttribut(repere)}"`;
27
+ }
28
+ /**
29
+ * L'attribut en JSX. Une chaîne JSX entre guillemets lit les entités HTML
30
+ * (« &amp; ») et ne connaît pas l'échappement : dès qu'un caractère
31
+ * pourrait gêner, la valeur devient une expression JavaScript.
32
+ */
33
+ export function attributSourceJsx(repere) {
34
+ return /^[\w./:@ -]*$/u.test(repere) ? ` data-humalis-source="${repere}"` : ` data-humalis-source={${JSON.stringify(repere)}}`;
35
+ }
36
+ /** Ajoute l'attribut juste après le nom de la balise ouvrante (`<h1 …`, `<Lien …`) ; un fragment (`<>`) reste tel quel. */
37
+ export function marquerBalise(balise, attribut) {
38
+ return balise.replace(/^<[A-Za-z][\w.]*/, (debut) => debut + attribut);
39
+ }
40
+ /**
41
+ * L'aide du mode développement : la touche Alt (Option sur Mac) tenue, le
42
+ * survol d'un élément montre son repère dans une petite étiquette près du
43
+ * curseur. Du JavaScript simple, servi par « humalis developper » avec le
44
+ * panneau des erreurs ; il n'entre jamais dans le code produit.
45
+ */
46
+ export const AIDE_REPERES = `(() => {
47
+ const etiquette = document.createElement('div');
48
+ etiquette.id = 'humalis-repere';
49
+ Object.assign(etiquette.style, {
50
+ position: 'fixed', zIndex: '999999', pointerEvents: 'none', display: 'none',
51
+ padding: '2px 6px', borderRadius: '4px', background: '#1d2b24', color: '#fff',
52
+ font: '12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace', whiteSpace: 'nowrap',
53
+ });
54
+ document.body.append(etiquette);
55
+ let dernier = null;
56
+ const cacher = () => { etiquette.style.display = 'none'; };
57
+ const montrer = (evenement) => {
58
+ const cible = evenement.target && evenement.target.closest ? evenement.target.closest('[data-humalis-source]') : null;
59
+ if (!evenement.altKey || !cible) return cacher();
60
+ etiquette.textContent = cible.getAttribute('data-humalis-source');
61
+ etiquette.style.left = (evenement.clientX + 12) + 'px';
62
+ etiquette.style.top = (evenement.clientY + 14) + 'px';
63
+ etiquette.style.display = 'block';
64
+ };
65
+ document.addEventListener('mousemove', (evenement) => { dernier = evenement; montrer(evenement); });
66
+ document.addEventListener('keydown', (evenement) => { if (evenement.key === 'Alt' && dernier) montrer({ altKey: true, target: dernier.target, clientX: dernier.clientX, clientY: dernier.clientY }); });
67
+ document.addEventListener('keyup', (evenement) => { if (evenement.key === 'Alt') cacher(); });
68
+ window.addEventListener('blur', cacher);
69
+ })();
70
+ `;
@@ -221,6 +221,12 @@ export function verifierPourLeMobile(application) {
221
221
  for (const ligne of lignesAvecReglage([...application.ecrans, ...application.composants].flatMap((e) => e.vue), 'colle')) {
222
222
  avertir(ligne, "« colle » n'a pas d'effet sur le téléphone, sauf pour l'en-tête et le pied de page.");
223
223
  }
224
+ // « defilement interne », « hauteur ecran » : sur un téléphone, toute la page défile.
225
+ for (const reglage of ['defilement', 'hauteur']) {
226
+ for (const ligne of lignesAvecReglage([...application.ecrans, ...application.composants].flatMap((e) => e.vue), reglage)) {
227
+ avertir(ligne, `« ${reglage === 'defilement' ? 'defilement interne' : 'hauteur ecran'} » n'a pas d'effet sur le téléphone : toute la page défile.`);
228
+ }
229
+ }
224
230
  // M7 : pas de souris ; les composants React sont recopiés tels quels.
225
231
  const gestionnaires = [...application.ecrans, ...application.composants].flatMap((e) => [
226
232
  ...e.gestionnaires,
@@ -11,6 +11,7 @@ import { verificationsDuChamp, messageEnCode, libellesEnLangues, messageEnLangue
11
11
  import { sousActions } from '../../ri/actions.js';
12
12
  import { colorerCodeHumalis } from '../commun/colorerCode.js';
13
13
  import { attributsFrappe } from '../commun/mouvement.js';
14
+ import { repereSource } from '../commun/reperes.js';
14
15
  import { arbreWidget, arbreComposite, initiales } from './widgets.js';
15
16
  import { iconeHtml, etoilesHtml } from './html-elements.js';
16
17
  import { appelGraphique, htmlGraphique } from './graphique.js';
@@ -912,7 +913,7 @@ ${prise}${code}}
912
913
  case 'bloc': {
913
914
  if (noeud.genre === 'liste' || noeud.genre === 'listeNumerotee') {
914
915
  const balise = noeud.genre === 'liste' ? 'ul' : 'ol';
915
- e.ligne(`creer('${balise}', { class: 'humalis-liste' },`);
916
+ e.ligne(`creer('${balise}', { ${this.repereObjetJs(noeud.ligne)}class: 'humalis-liste' },`);
916
917
  e.indenter(() => noeud.corps.forEach((enfant) => {
917
918
  if (enfant.type === 'repetition') {
918
919
  const debut = `...${expressionJs(enfant.liste, this.contexte)}.flatMap((${nomJs(enfant.variable)}) => `;
@@ -931,7 +932,7 @@ ${prise}${code}}
931
932
  const balise = BALISES_BLOC[noeud.genre] ?? 'div';
932
933
  // « menu repliable » : le bouton « Menu » en premier enfant (l'écouteur est dans humalis.js).
933
934
  const bouton = estRepliable(noeud) ? `creer('button', { type: 'button', class: 'humalis-menu-bouton', 'aria-expanded': 'false', 'aria-label': 'Menu' }, creer('span', { class: 'humalis-menu-icone', 'aria-hidden': 'true' })), ` : '';
934
- this.ecrireGroupe(e, noeud.corps, `creer('${balise}', { class: ${chaine(classesDuBloc(noeud))} }, ${bouton}...`, '),');
935
+ this.ecrireGroupe(e, noeud.corps, `creer('${balise}', { ${this.repereObjetJs(noeud.ligne)}class: ${chaine(classesDuBloc(noeud))} }, ${bouton}...`, '),');
935
936
  // « arrive en bas de » ce bloc : la sentinelle observée et le vrai bouton « Voir plus », tant que la condition est vraie.
936
937
  const bas = noeud.id !== undefined ? this.basDesBlocs.get(noeud.id) : undefined;
937
938
  if (bas) {
@@ -947,7 +948,7 @@ ${prise}${code}}
947
948
  case 'formulaire': {
948
949
  // Entrée ou le bouton envoient le formulaire, sans recharger la page.
949
950
  const soumission = this.formulaires.get(noeud.id)?.soumission ?? '() => {}';
950
- const ouverture = `creer('form', { 'data-humalis': ${chaine(noeud.id)}, class: 'humalis-formulaire', novalidate: true, onsubmit: ${soumission} }, ...`;
951
+ const ouverture = `creer('form', { ${this.repereObjetJs(noeud.ligne)}'data-humalis': ${chaine(noeud.id)}, class: 'humalis-formulaire', novalidate: true, onsubmit: ${soumission} }, ...`;
951
952
  if (this.montres.has(noeud.id) || this.caches.has(noeud.id)) {
952
953
  this.ecrireGroupe(e, noeud.corps, `...(visibilite[${chaine(noeud.id)}] ? [${ouverture}`, ')] : []),');
953
954
  }
@@ -970,7 +971,7 @@ ${prise}${code}}
970
971
  /** `creer('p', { … }, contenu)`. */
971
972
  elementJs(noeud) {
972
973
  const commun = (avant = []) => {
973
- const liste = [...avant, ["'data-humalis'", chaine(noeud.id)]];
974
+ const liste = [...this.repereJs(noeud.ligne), ...avant, ["'data-humalis'", chaine(noeud.id)]];
974
975
  const classes = this.classes(noeud);
975
976
  if (classes)
976
977
  liste.push(['class', chaine(classes)]);
@@ -1051,7 +1052,7 @@ ${prise}${code}}
1051
1052
  const parmi = saisie?.parmi ? expressionJs(saisie.parmi, this.contexte) : '[]';
1052
1053
  const radio = `creer('input', { type: 'radio', name: ${chaine(noeud.id)}, value: valeur, checked: ${variable} === valeur, oninput: () => { ${variable} = valeur; } })`;
1053
1054
  const classes = this.classes(noeud);
1054
- const groupe = [["'data-humalis'", chaine(noeud.id)], ['class', chaine(classes ? `${classes} humalis-choix` : 'humalis-choix')]];
1055
+ const groupe = [...this.repereJs(noeud.ligne), ["'data-humalis'", chaine(noeud.id)], ['class', chaine(classes ? `${classes} humalis-choix` : 'humalis-choix')]];
1055
1056
  return `creer('fieldset', ${objet(groupe)}, creer('legend', {}, ${libelle}), ...${parmi}.map((valeur) => creer('label', {}, ${radio}, valeur)))`;
1056
1057
  }
1057
1058
  case 'liste': {
@@ -1187,7 +1188,7 @@ ${prise}${code}}
1187
1188
  case 'bloc': {
1188
1189
  if (noeud.genre === 'liste' || noeud.genre === 'listeNumerotee') {
1189
1190
  const balise = noeud.genre === 'liste' ? 'ul' : 'ol';
1190
- lignes.push(`<${balise} class="humalis-liste">`);
1191
+ lignes.push(`<${balise}${attributs([this.repereHtml(noeud.ligne)])} class="humalis-liste">`);
1191
1192
  for (const enfant of noeud.corps) {
1192
1193
  if (enfant.type === 'repetition') {
1193
1194
  const valeurs = evaluer(enfant.liste, env);
@@ -1210,7 +1211,7 @@ ${prise}${code}}
1210
1211
  break;
1211
1212
  }
1212
1213
  const balise = BALISES_BLOC[noeud.genre] ?? 'div';
1213
- lignes.push(`<${balise}${attributs([['class', classesDuBloc(noeud)]])}>`);
1214
+ lignes.push(`<${balise}${attributs([this.repereHtml(noeud.ligne), ['class', classesDuBloc(noeud)]])}>`);
1214
1215
  if (estRepliable(noeud))
1215
1216
  lignes.push('<button type="button" class="humalis-menu-bouton" aria-expanded="false" aria-label="Menu"><span class="humalis-menu-icone" aria-hidden="true"></span></button>');
1216
1217
  this.ecrireGroupeHtml(noeud.corps, env, lignes);
@@ -1227,7 +1228,7 @@ ${prise}${code}}
1227
1228
  // Un formulaire que l'on montre est caché au départ.
1228
1229
  if (this.montres.has(noeud.id))
1229
1230
  break;
1230
- lignes.push(`<form${attributs([['data-humalis', noeud.id], ['class', 'humalis-formulaire'], ['novalidate', 'true']])}>`);
1231
+ lignes.push(`<form${attributs([this.repereHtml(noeud.ligne), ['data-humalis', noeud.id], ['class', 'humalis-formulaire'], ['novalidate', 'true']])}>`);
1231
1232
  this.ecrireGroupeHtml(noeud.corps, env, lignes);
1232
1233
  lignes.push('</form>');
1233
1234
  break;
@@ -1508,7 +1509,7 @@ ${prise}${code}}
1508
1509
  const saisie = noeud.saisie;
1509
1510
  const sorte = saisie?.sorte ?? 'texte';
1510
1511
  const libelle = saisie?.obligatoire ? `<span>${contenu}${ETOILE_HTML}</span>` : contenu;
1511
- const base = [['data-humalis', noeud.id], ['class', classes]];
1512
+ const base = [this.repereHtml(noeud.ligne), ['data-humalis', noeud.id], ['class', classes]];
1512
1513
  const options = (liste) => [
1513
1514
  ...liste,
1514
1515
  ['required', saisie?.obligatoire ? 'true' : undefined],
@@ -1527,7 +1528,7 @@ ${prise}${code}}
1527
1528
  const radios = parmi()
1528
1529
  .map((v) => `<label><input${attributs([['type', 'radio'], ['name', noeud.id], ['value', v]])}>${echapperTexte(v)}</label>`)
1529
1530
  .join('');
1530
- return `<fieldset${attributs([['data-humalis', noeud.id], ['class', classes ? `${classes} humalis-choix` : 'humalis-choix']])}><legend>${libelle}</legend>${radios}</fieldset>`;
1531
+ return `<fieldset${attributs([this.repereHtml(noeud.ligne), ['data-humalis', noeud.id], ['class', classes ? `${classes} humalis-choix` : 'humalis-choix']])}><legend>${libelle}</legend>${radios}</fieldset>`;
1531
1532
  }
1532
1533
  case 'liste': {
1533
1534
  const choix = parmi().map((v) => `<option${attributs([['value', v]])}>${echapperTexte(v)}</option>`).join('');
@@ -1542,9 +1543,22 @@ ${prise}${code}}
1542
1543
  }
1543
1544
  }
1544
1545
  }
1546
+ /** Le mode développement : le fichier et la ligne Humalis de l'élément, en HTML (attributs() l'échappe ; absent sans le mode). */
1547
+ repereHtml(ligne) {
1548
+ return ['data-humalis-source', this.site.reperes ? repereSource(ligne, this.site.reperes) : undefined];
1549
+ }
1550
+ /** Le même repère pour creer() : rien sans le mode. */
1551
+ repereJs(ligne) {
1552
+ const repere = this.site.reperes ? repereSource(ligne, this.site.reperes) : undefined;
1553
+ return repere === undefined ? [] : [["'data-humalis-source'", chaine(repere)]];
1554
+ }
1555
+ /** `'data-humalis-source': '…', ` au début d'un objet d'attributs écrit à la main. */
1556
+ repereObjetJs(ligne) {
1557
+ return this.repereJs(ligne).map(([nom, valeur]) => `${nom}: ${valeur}, `).join('');
1558
+ }
1545
1559
  elementHtml(noeud, env) {
1546
1560
  const classes = this.classes(noeud) || undefined;
1547
- const commun = (avant = []) => attributs([...avant, ['data-humalis', noeud.id], ['class', classes], ...(noeud.annonce ? [['aria-live', 'polite'], ['aria-atomic', 'true']] : []), ...attributsRaccourci(noeud)]);
1561
+ const commun = (avant = []) => attributs([this.repereHtml(noeud.ligne), ...avant, ['data-humalis', noeud.id], ['class', classes], ...(noeud.annonce ? [['aria-live', 'polite'], ['aria-atomic', 'true']] : []), ...attributsRaccourci(noeud)]);
1548
1562
  const lucideHtml = noeud.reglages?.icone ? chercherIcone(noeud.reglages.icone)?.lucide : undefined;
1549
1563
  const contenu = (lucideHtml ? iconeHtml(lucideHtml, 'humalis-icone') : '') + this.contenuAccentHtml(noeud.contenu, noeud.genre, env);
1550
1564
  switch (noeud.genre) {
@@ -14,7 +14,7 @@ import { cssGraphique } from '../web/graphique.js';
14
14
  import { cssEcran } from '../web/css-ecran.js';
15
15
  import { cssElements } from '../web/css-elements.js';
16
16
  import { cssInteractions } from '../web/interactions.js';
17
- import { cssMouvement, animeAuDefilement, OBSERVATEUR_DEFILEMENT } from '../commun/mouvement.js';
17
+ import { cssMouvement, animeAuDefilement, OBSERVATEUR_DEFILEMENT, SUIVI_ENTETE, suitLEntete } from '../commun/mouvement.js';
18
18
  import { cssTheme, changeLeTheme, modeParDefaut, SCRIPT_THEME, CLE_THEME } from '../web/theme.js';
19
19
  import { FORMAT_JS, FORMAT_JS_LANGUES, TAILLE_JS, DATES_JS } from '../format.js';
20
20
  import { CLE_LANGUE } from '../web/langue.js';
@@ -48,6 +48,7 @@ export function genererProjetStatique(application, options) {
48
48
  composants: new Map(application.composants.map((c) => [c.nom, c])),
49
49
  medias: options.medias,
50
50
  ...(application.langues ? { langues: application.langues } : {}),
51
+ ...(options.sources ? { reperes: options.sources } : {}),
51
52
  };
52
53
  const langue = application.langues?.[0].code;
53
54
  // Le CSS de l'utilisateur, recopié tel quel (décision W8), chargé après
@@ -137,6 +138,10 @@ ${fonctions.join('\n')}`);
137
138
  if ([...fichiers.values()].some((contenu) => contenu.includes('humalis-menu-bouton'))) {
138
139
  fichiers.set('humalis/humalis.js', `${fichiers.get('humalis/humalis.js')}\n${MENU_REPLIABLE_JS}`);
139
140
  }
141
+ // « entete avec fixe », « hauteur ecran » : la hauteur de l'en-tête, suivie par un petit script.
142
+ if (suitLEntete([...fichiers.values()])) {
143
+ fichiers.set('humalis/humalis.js', `${fichiers.get('humalis/humalis.js')}\n// La hauteur de l'en-tête, pour « entete avec fixe » et « hauteur ecran ».\n${SUIVI_ENTETE}\nsuivreLaHauteurDeLEntete();\n`);
144
+ }
140
145
  // L'observateur des animations « au defilement », seulement si une page en a : il pose humalis-js au chargement.
141
146
  if (animeAuDefilement([...fichiers.values()])) {
142
147
  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`);
@@ -24,7 +24,7 @@ const ANGLES = { diagonale: '135deg', vertical: '180deg', horizontal: '90deg' };
24
24
  /** Les classes `humalis-<réglage>-<valeur>` qui apparaissent dans le code produit. */
25
25
  export function reglagesUtilises(codes) {
26
26
  const trouves = new Set();
27
- const motif = /humalis-(colonnes|espacement|remplissage|alignement|repartition|largeur|ombre|degrade|colle|bordure|sur)-([a-z0-9_-]+)/g;
27
+ const motif = /humalis-(colonnes|espacement|remplissage|alignement|repartition|largeur|ombre|degrade|colle|bordure|sur|defilement|hauteur|fixe)-([a-z0-9_-]+)/g;
28
28
  for (const code of codes)
29
29
  for (const m of code.matchAll(motif))
30
30
  trouves.add(`${m[1]}-${m[2]}`);
@@ -130,6 +130,24 @@ export function cssEcran(codes) {
130
130
  case 'sur':
131
131
  appareil = true;
132
132
  break;
133
+ case 'defilement':
134
+ // « defilement interne » : le bloc défile seul, sans entraîner la page ; une barre fine.
135
+ if (valeur === 'interne')
136
+ regles.push(`${classe} { overflow-y: auto; overscroll-behavior: contain; min-height: 0; max-height: 100%; align-self: stretch; scrollbar-width: thin; }`);
137
+ break;
138
+ case 'hauteur':
139
+ // « hauteur ecran » : l'écran moins l'en-tête (mesuré par le script, 0 sans en-tête fixe ou collé).
140
+ if (valeur === 'ecran')
141
+ regles.push(`${classe} { height: calc(100dvh - var(--humalis-hauteur-entete, 0px)); min-height: 0; overflow: auto; }`);
142
+ break;
143
+ case 'fixe':
144
+ // « entete avec fixe » : l'en-tête ne bouge pas ; sur le web, la racine devient la zone qui
145
+ // défile, sous l'en-tête ; sur le statique, la page laisse la place de l'en-tête.
146
+ regles.push(`header${classe} { position: fixed; top: 0; left: 0; right: 0; z-index: 50; background: var(--humalis-page); }`);
147
+ regles.push('html:has(#racine > .humalis-fixe-oui), html:has(#racine > .humalis-fixe-oui) body { height: 100%; overflow: hidden; }');
148
+ regles.push('#racine:has(> .humalis-fixe-oui) { position: fixed; top: var(--humalis-hauteur-entete, 0px); left: 0; right: 0; bottom: 0; overflow-y: auto; overscroll-behavior: contain; }');
149
+ regles.push('body:has(header.humalis-fixe-oui) { padding-top: var(--humalis-hauteur-entete, 0px); }');
150
+ break;
133
151
  }
134
152
  }
135
153
  if (appareil) {
@@ -13,6 +13,7 @@ import { typeTS, typeDe, expression, contenuJSX, condition, ficheAvec } from './
13
13
  import { importsDesInteractions, decrireTouches } from './interactions.js';
14
14
  import { colorerCodeHumalis } from '../commun/colorerCode.js';
15
15
  import { attributsFrappe } from '../commun/mouvement.js';
16
+ import { repereSource, attributSourceJsx, marquerBalise } from '../commun/reperes.js';
16
17
  /** Les sortes de champ où l'on écrit une seule ligne : la touche Entrée y envoie le formulaire. */
17
18
  const UNE_LIGNE = new Set(['texte', 'email', 'telephone', 'nombre', 'date', 'heure', 'motDePasse', 'adresseWeb']);
18
19
  /** Un texte écrit en clair dans du JSX : tel quel, ou entre accolades s'il contient un caractère du JSX. */
@@ -1621,7 +1622,7 @@ class GenerateurEcran {
1621
1622
  break;
1622
1623
  }
1623
1624
  const balise = noeud.genre === 'liste' ? 'ul' : 'ol';
1624
- e.ligne(`<${balise} className="humalis-liste">`);
1625
+ e.ligne(this.marquer(`<${balise} className="humalis-liste">`, noeud.ligne));
1625
1626
  e.indenter(() => noeud.corps.forEach((enfant) => {
1626
1627
  if (enfant.type === 'repetition') {
1627
1628
  this.listeEnCours = noeud.genre;
@@ -1640,7 +1641,7 @@ class GenerateurEcran {
1640
1641
  this.ecrireComposite(e, noeud);
1641
1642
  break;
1642
1643
  }
1643
- e.ligne(this.rendu.bloc.ouvrir(noeud.genre, noeud.options, noeud.classes, noeud.ancre));
1644
+ e.ligne(this.marquer(this.rendu.bloc.ouvrir(noeud.genre, noeud.options, noeud.classes, noeud.ancre), noeud.ligne));
1644
1645
  // Des variables dans le bloc : écrites comme dans une condition.
1645
1646
  e.indenter(() => noeud.corps.some((n) => n.type === 'locale') ? this.ecrireGroupe(e, noeud.corps) : this.ecrireVue(e, noeud.corps));
1646
1647
  e.ligne(this.rendu.bloc.fermer(noeud.genre, noeud.options));
@@ -1668,7 +1669,7 @@ class GenerateurEcran {
1668
1669
  break;
1669
1670
  case 'formulaire': {
1670
1671
  const ecrire = () => {
1671
- e.ligne(this.rendu.formulaire.ouvrir(noeud.id, this.formulaires.get(noeud.id)?.soumission));
1672
+ e.ligne(this.marquer(this.rendu.formulaire.ouvrir(noeud.id, this.formulaires.get(noeud.id)?.soumission), noeud.ligne));
1672
1673
  // Des variables dans le bloc : écrites comme dans une condition.
1673
1674
  e.indenter(() => noeud.corps.some((n) => n.type === 'locale') ? this.ecrireGroupe(e, noeud.corps) : this.ecrireVue(e, noeud.corps));
1674
1675
  e.ligne(this.rendu.formulaire.fermer);
@@ -1876,6 +1877,11 @@ class GenerateurEcran {
1876
1877
  ...(image.prioritaire ? { prioritaire: true } : {}),
1877
1878
  };
1878
1879
  }
1880
+ /** Le mode développement (le web seulement) : la balise ouvrante porte le fichier et la ligne Humalis. */
1881
+ marquer(balise, ligne) {
1882
+ const repere = this.application.reperes && !this.application.rendu ? repereSource(ligne, this.application.reperes) : undefined;
1883
+ return repere === undefined ? balise : marquerBalise(balise, attributSourceJsx(repere));
1884
+ }
1879
1885
  ecrireElement(e, noeud) {
1880
1886
  const parAttribut = new Map(this.attributs.get(noeud.id) ?? []);
1881
1887
  // Dans un formulaire : un bouton sans clic propre l'envoie, une saisie d'une ligne aussi (touche du clavier).
@@ -1914,7 +1920,7 @@ class GenerateurEcran {
1914
1920
  const source = texteLitteral(noeud.contenu);
1915
1921
  const variable = this.contexte.champs.get(noeud.id) ?? '';
1916
1922
  const ouvert = this.ouvertes.get(noeud.id);
1917
- const balise = this.rendu.element({
1923
+ const balise = this.marquer(this.rendu.element({
1918
1924
  genre: noeud.genre,
1919
1925
  id: noeud.id,
1920
1926
  options: noeud.options,
@@ -1949,7 +1955,7 @@ class GenerateurEcran {
1949
1955
  },
1950
1956
  envoie: noeud.genre === 'bouton' && formulaire !== undefined && !aSonClic,
1951
1957
  ...(noeud.image ? { image: this.image(noeud.image, noeud.contenu) } : {}),
1952
- });
1958
+ }), noeud.ligne);
1953
1959
  if (this.montres.has(noeud.id) || this.caches.has(noeud.id)) {
1954
1960
  e.ligne(`{visibilite[${chaine(noeud.id)}] && ${balise}}`);
1955
1961
  }
@@ -51,6 +51,9 @@ ${entete(langues)}const CLE = ${chaine(CLE_LANGUE)};
51
51
  */
52
52
  function langueDeDepart(): Langue {
53
53
  if (typeof window === 'undefined') return LANGUES[0];
54
+ // Le prérendu (un navigateur automatisé) écrit les pages dans la langue de base du site,
55
+ // celle que les moteurs de recherche doivent lire, quelle que soit la langue de ce navigateur.
56
+ if (navigator.webdriver) return LANGUES[0];
54
57
  try {
55
58
  const gardee = localStorage.getItem(CLE);
56
59
  if (estLangue(gardee)) return gardee;
@@ -3,7 +3,7 @@ import { robotsTxt, manifeste, fichiersDuSite, balisesIcone, estExterne } from '
3
3
  import { sousActions } from '../../ri/actions.js';
4
4
  import { cssEcran } from './css-ecran.js';
5
5
  import { cssElements } from './css-elements.js';
6
- import { cssMouvement, animeAuDefilement, MOUVEMENT_WEB, NETTOYAGE_PRERENDU } from '../commun/mouvement.js';
6
+ import { cssMouvement, animeAuDefilement, MOUVEMENT_WEB, NETTOYAGE_PRERENDU, ENTETE_WEB, suitLEntete } from '../commun/mouvement.js';
7
7
  import { fichiersDesElements, dependancesDesElements, dependancesDeDeveloppement } from './widgets.js';
8
8
  import { fichiersGraphique, cssGraphique } from './graphique.js';
9
9
  import { cssTheme, changeLeTheme, modeParDefaut, themeTs, SCRIPT_THEME } from './theme.js';
@@ -79,6 +79,7 @@ export function genererProjetWeb(application, options) {
79
79
  // Le bloc « site » ou un « apercu » : chaque page pose ses balises de partage.
80
80
  ...(application.site || application.ecrans.some((e) => e.apercu) ? { publication: { site: application.site, adresse: options.adresse } } : {}),
81
81
  ...(application.langues ? { langues: application.langues } : {}),
82
+ ...(options.sources ? { reperes: options.sources } : {}),
82
83
  };
83
84
  let session = false;
84
85
  for (const c of application.composants) {
@@ -199,6 +200,10 @@ export function genererProjetWeb(application, options) {
199
200
  const defilementAnime = animeAuDefilement(codes);
200
201
  if (defilementAnime)
201
202
  fichiers.set('src/humalis/mouvement.ts', MOUVEMENT_WEB);
203
+ // « entete avec fixe », « hauteur ecran » : la hauteur de l'en-tête, suivie par un petit script.
204
+ const enteteSuivie = suitLEntete(codes);
205
+ if (enteteSuivie)
206
+ fichiers.set('src/humalis/entete.ts', ENTETE_WEB);
202
207
  // Le CSS de l'utilisateur, recopié tel quel (décision W8), chargé après
203
208
  // celui de Humalis pour pouvoir le redéfinir.
204
209
  const feuilles = [];
@@ -225,7 +230,7 @@ export function genererProjetWeb(application, options) {
225
230
  ...(ecran.reservee ? { reservee: { qui: ecran.reservee.qui, sinon: chemins.get(ecran.reservee.sinon) ?? '/' } } : {}),
226
231
  sansEntete: ecran.sansEntete,
227
232
  sansPied: ecran.sansPied,
228
- })), feuilles, application.partages.length > 0, session, introuvable?.page.composant, dispositions, utiliseDefilement, defilementAnime));
233
+ })), feuilles, application.partages.length > 0, session, introuvable?.page.composant, dispositions, utiliseDefilement, defilementAnime, enteteSuivie));
229
234
  for (const { page } of pages)
230
235
  fichiers.set(`src/pages/${page.composant}.tsx`, page.code);
231
236
  // Les images locales, recopiées là où le site les cherche (/images/…) : décision M5.
@@ -506,7 +511,7 @@ function lignesDeRoute(r, toutes, niveau, cheminParent) {
506
511
  lignes.push(`${espaces(0)}</Route>`);
507
512
  return lignes;
508
513
  }
509
- function mainTsx(routes, feuilles, partage, session, introuvable, dispositions = {}, defilement = false, mouvement = false) {
514
+ function mainTsx(routes, feuilles, partage, session, introuvable, dispositions = {}, defilement = false, mouvement = false, entete = false) {
510
515
  const imports = routes.map((r) => `import ${r.composant} from './pages/${r.composant}';`).join('\n');
511
516
  // Les pages qui ont le même en-tête et le même pied partagent une disposition ; une sous-page suit son parent.
512
517
  const groupes = new Map();
@@ -532,7 +537,7 @@ import { Messages } from './humalis/messages';${defilement ? "\nimport { Defilem
532
537
  ${session ? "import { Session } from './humalis/session';\n" : ''}${partage ? "import { Partage } from './humalis/partage';\n" : ''}${routes.some((r) => r.reservee) ? "import { Reservee } from './humalis/reservee';\n" : ''}${introuvable ? `import ${introuvable} from './pages/${introuvable}';` : "import Introuvable from './Introuvable';"}
533
538
  ${dispositions.entete || dispositions.pied ? "import Disposition from './Disposition';\n" : ''}${imports}
534
539
  import './humalis/styles.css';
535
- ${mouvement ? "import { observerLeDefilement } from './humalis/mouvement';\n" : ''}${feuilles.map((f) => `import './styles/${f}';\n`).join('')}
540
+ ${mouvement ? "import { observerLeDefilement } from './humalis/mouvement';\n" : ''}${entete ? "import { suivreLaHauteurDeLEntete } from './humalis/entete';\n" : ''}${feuilles.map((f) => `import './styles/${f}';\n`).join('')}
536
541
  // Un hébergeur statique sert /documentation/index.html pour /documentation/ :
537
542
  // le routeur lit l'adresse sans « index.html », comme le dossier.
538
543
  if (/\\/index\\.html$/.test(location.pathname)) {
@@ -548,7 +553,7 @@ ${routesTsx}
548
553
  </BrowserRouter>
549
554
  </StrictMode>,
550
555
  );
551
- ${mouvement ? '\n// Les animations « au defilement » attendent que l\'élément entre à l\'écran.\nobserverLeDefilement();\n' : ''}`;
556
+ ${mouvement ? '\n// Les animations « au defilement » attendent que l\'élément entre à l\'écran.\nobserverLeDefilement();\n' : ''}${entete ? '\n// « entete avec fixe », « hauteur ecran » : la hauteur de l\'en-tête.\nsuivreLaHauteurDeLEntete();\n' : ''}`;
552
557
  }
553
558
  /**
554
559
  * Les aides de navigation du code produit : le lien « actif », le défilement
package/dist/cli.js CHANGED
@@ -163,11 +163,16 @@ function compile(arguments_) {
163
163
  const medias = lireMedias(application, chemin);
164
164
  if (!medias)
165
165
  return 1;
166
- const optionsWeb = { nom, adresse: adresse ?? 'http://localhost', source: basename(chemin), fichiersStyle, composantsReact, polices, images, medias };
166
+ // --diagnostics-port n'est passé que par « humalis developper » : le mode développement, où chaque
167
+ // élément porte son fichier et sa ligne Humalis (data-humalis-source). Jamais dans une compilation normale.
168
+ const portDiagnostics = option(arguments_, 'diagnostics-port');
169
+ const optionsWeb = {
170
+ nom, adresse: adresse ?? 'http://localhost', source: basename(chemin), fichiersStyle, composantsReact, polices, images, medias,
171
+ ...(portDiagnostics !== undefined ? { sources: fichiersDuProjet } : {}),
172
+ };
167
173
  const fichiers = composants
168
174
  ? genererComposantsWeb(application, optionsWeb)
169
175
  : genererProjetWeb(application, optionsWeb);
170
- const portDiagnostics = option(arguments_, 'diagnostics-port');
171
176
  if (portDiagnostics !== undefined && fichiers.has('index.html')) {
172
177
  const index = fichiers.get('index.html');
173
178
  fichiers.set('index.html', index.replace('</body>', `<script type="module" src="http://localhost:${portDiagnostics}/diagnostics.js"></script></body>`));
package/dist/fichiers.js CHANGED
@@ -5,6 +5,7 @@ import { ErreurSyntaxique } from './parser/erreurs.js';
5
5
  import { lireCsv } from './donnees/csv.js';
6
6
  import { lireJson } from './donnees/json.js';
7
7
  import { analyserSens } from './analyse/analyse.js';
8
+ import { verifierCss } from './analyse/css.js';
8
9
  import { construireRepresentation } from './ri/construire.js';
9
10
  /**
10
11
  * Plusieurs fichiers (trou G4) : les lignes du k-ième fichier utilisé sont
@@ -158,7 +159,26 @@ export function compilerProjet(principal, lire, sourcePrincipale) {
158
159
  }
159
160
  if (diagnostics.some((d) => d.gravite === 'erreur'))
160
161
  return fin();
161
- const analyse = analyserSens(programme);
162
+ const classesCss = new Set();
163
+ let cssIllisible = false;
164
+ for (const bloc of programme.styles) {
165
+ const resultat = verifierCss(bloc.css, bloc.ligne + 1);
166
+ for (const nom of resultat.classes)
167
+ classesCss.add(nom);
168
+ diagnostics.push(...resultat.diagnostics);
169
+ }
170
+ for (const fichier of programme.fichiersStyle) {
171
+ const contenu = lire(joindre(base, fichier.chemin));
172
+ if (contenu === undefined) {
173
+ cssIllisible = true;
174
+ continue;
175
+ }
176
+ const resultat = verifierCss(contenu, 1, fichier.chemin.replace(/^\.\//, ''));
177
+ for (const nom of resultat.classes)
178
+ classesCss.add(nom);
179
+ diagnostics.push(...resultat.diagnostics);
180
+ }
181
+ const analyse = analyserSens(programme, cssIllisible ? undefined : classesCss);
162
182
  diagnostics.push(...analyse.diagnostics);
163
183
  if (diagnostics.some((d) => d.gravite === 'erreur'))
164
184
  return fin();