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.
- package/LICENSE +85 -201
- package/LICENSE.en.md +86 -0
- package/NOTICE +6 -3
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/dist/analyse/accessibilite.js +13 -5
- package/dist/analyse/analyse.js +43 -3
- package/dist/analyse/ecran.js +1 -1
- package/dist/analyse/langues.js +30 -10
- package/dist/analyse/medias.js +4 -1
- package/dist/analyse/navigation.js +35 -0
- package/dist/analyse/niveaux.js +33 -0
- package/dist/analyse/noms.js +1 -1
- package/dist/analyse/site.js +30 -8
- package/dist/cibles/commun/colorerCode.js +59 -0
- package/dist/cibles/commun/mouvement.js +110 -4
- package/dist/cibles/mobile/projet.js +7 -2
- package/dist/cibles/mobile/rendu.js +6 -3
- package/dist/cibles/statique/elements-js.js +1 -1
- package/dist/cibles/statique/page.js +99 -6
- package/dist/cibles/statique/projet.js +31 -0
- package/dist/cibles/statique/valeurs.js +5 -1
- package/dist/cibles/statique/widgets.js +1 -1
- package/dist/cibles/web/css-ecran.js +22 -1
- package/dist/cibles/web/ecran.js +161 -16
- package/dist/cibles/web/interactions.js +50 -3
- package/dist/cibles/web/projet.js +29 -2
- package/dist/cibles/web/theme.js +11 -2
- package/dist/cibles/web/valeurs.js +17 -0
- package/dist/cibles/web/widgets.js +1 -1
- package/dist/i18n/messages.js +2 -0
- package/dist/lexer/lexer.js +106 -3
- package/dist/navigateur.js +5274 -0
- package/dist/parser/interactions.js +6 -2
- package/dist/parser/langues.js +1 -1
- package/dist/parser/medias.js +6 -1
- package/dist/parser/miseEnPage.js +1 -1
- package/dist/parser/parser.js +85 -3
- package/dist/parser/theme.js +10 -1
- package/dist/ri/construire.js +24 -1
- package/dist/vocabulaire/table-ecran.js +3 -3
- package/dist/vocabulaire/table-interactions.js +16 -1
- package/dist/vocabulaire/table.js +4 -4
- package/docs/guide.en.md +170 -2
- package/docs/guide.md +171 -1
- package/llms.txt +14 -1
- package/package.json +7 -4
package/dist/analyse/langues.js
CHANGED
|
@@ -55,8 +55,18 @@ function textesDesOptions(instruction, vus) {
|
|
|
55
55
|
vus.push(serie.texteNom ? plat(serie.texteNom) : litteralDe(serie.nom, serie.ligne));
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/** « si langue est anglais : » (ou « n'est pas ») : ses deux branches sont chacune écrites dans une seule langue. */
|
|
59
|
+
const testeLaLangue = (instruction) => {
|
|
60
|
+
if (instruction.type !== 'Condition')
|
|
61
|
+
return false;
|
|
62
|
+
const test = instruction.test;
|
|
63
|
+
return test.type === 'Comparaison' && test.gauche.type === 'Reference' && test.gauche.nom === 'langue' && test.droite?.type === 'Reference' && langueDuMot(test.droite.nom) !== undefined;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Les textes que la personne voit : contenus d'éléments, titres de blocs, messages, aperçu.
|
|
67
|
+
* Ceux d'une branche « si langue est … » vont aussi dans `uneLangue` : ils n'ont pas à être traduits.
|
|
68
|
+
*/
|
|
69
|
+
function textesVisibles(instructions, vus, uneLangue) {
|
|
60
70
|
const ajouter = (e) => {
|
|
61
71
|
if (e?.type === 'Chaine')
|
|
62
72
|
vus.push(e);
|
|
@@ -86,12 +96,21 @@ function textesVisibles(instructions, vus) {
|
|
|
86
96
|
ajouter(instruction.titre);
|
|
87
97
|
for (const cle of ['corps', 'alors', 'sinon']) {
|
|
88
98
|
const enfants = instruction[cle];
|
|
89
|
-
if (Array.isArray(enfants))
|
|
90
|
-
|
|
99
|
+
if (!Array.isArray(enfants))
|
|
100
|
+
continue;
|
|
101
|
+
if (cle !== 'corps' && uneLangue && testeLaLangue(instruction)) {
|
|
102
|
+
const dedans = [];
|
|
103
|
+
textesVisibles(enfants, dedans, uneLangue);
|
|
104
|
+
for (const texte of dedans)
|
|
105
|
+
uneLangue.add(texte);
|
|
106
|
+
vus.push(...dedans);
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
textesVisibles(enfants, vus, uneLangue);
|
|
91
110
|
}
|
|
92
111
|
if (instruction.type === 'Selon')
|
|
93
112
|
for (const cas of instruction.cas)
|
|
94
|
-
textesVisibles(cas.corps, vus);
|
|
113
|
+
textesVisibles(cas.corps, vus, uneLangue);
|
|
95
114
|
}
|
|
96
115
|
}
|
|
97
116
|
/** Tous les littéraux de texte d'un arbre, où qu'ils soient. */
|
|
@@ -235,17 +254,18 @@ export function analyserLangues(programme, diagnostics) {
|
|
|
235
254
|
}
|
|
236
255
|
// Les textes visibles : ceux des pages, des options et du bloc « site ».
|
|
237
256
|
const visibles = [];
|
|
257
|
+
const uneLangue = new Set();
|
|
238
258
|
const pages = programme.pages;
|
|
239
259
|
for (const page of pages) {
|
|
240
|
-
textesVisibles(page.corps, visibles);
|
|
260
|
+
textesVisibles(page.corps, visibles, uneLangue);
|
|
241
261
|
if (page.apercu)
|
|
242
|
-
textesVisibles([page.apercu], visibles);
|
|
262
|
+
textesVisibles([page.apercu], visibles, uneLangue);
|
|
243
263
|
}
|
|
244
264
|
for (const disposition of [programme.entete, programme.pied])
|
|
245
265
|
if (disposition)
|
|
246
|
-
textesVisibles(disposition.corps, visibles);
|
|
266
|
+
textesVisibles(disposition.corps, visibles, uneLangue);
|
|
247
267
|
for (const composant of programme.composants)
|
|
248
|
-
textesVisibles(composant.corps, visibles);
|
|
268
|
+
textesVisibles(composant.corps, visibles, uneLangue);
|
|
249
269
|
for (const cle of ['nom', 'description']) {
|
|
250
270
|
const valeur = programme.site?.[cle];
|
|
251
271
|
if (valeur !== undefined)
|
|
@@ -298,7 +318,7 @@ export function analyserLangues(programme, diagnostics) {
|
|
|
298
318
|
}
|
|
299
319
|
// Les textes visibles sans version dans une langue déclarée.
|
|
300
320
|
for (const texte of visibles) {
|
|
301
|
-
if (!aDesLettres(texte.parties))
|
|
321
|
+
if (!aDesLettres(texte.parties) || uneLangue.has(texte) || texte.sansTraduction)
|
|
302
322
|
continue;
|
|
303
323
|
const cle = texteSource(texte.parties);
|
|
304
324
|
for (const langue of autres) {
|
package/dist/analyse/medias.js
CHANGED
|
@@ -27,6 +27,9 @@ export function verifierMouvement(styles, genre, dansFormulaire, ligne, s) {
|
|
|
27
27
|
s.erreur('« au defilement » suit le nom d’une animation : « animation montee au defilement ».', ligne);
|
|
28
28
|
return false;
|
|
29
29
|
}
|
|
30
|
+
if (animation.cascade && !['bloc', 'section', 'ligne', 'colonne', 'grille'].includes(genre)) {
|
|
31
|
+
s.erreur('« en cascade » fait apparaître les enfants d’un bloc l’un après l’autre : il s’écrit sur un bloc (section, bloc, ligne, colonne ou grille).', ligne);
|
|
32
|
+
}
|
|
30
33
|
if (genre === 'champ' || genre === 'formulaire' || dansFormulaire) {
|
|
31
34
|
s.erreur('pas d’animation sur un champ, ni dans un formulaire : un champ qui bouge pendant qu’on écrit gêne la saisie.', ligne);
|
|
32
35
|
}
|
|
@@ -191,7 +194,7 @@ function verifierReglagesGraphique(g, s) {
|
|
|
191
194
|
s.erreur(couleur.erreur, ligne);
|
|
192
195
|
continue;
|
|
193
196
|
}
|
|
194
|
-
const mode =
|
|
197
|
+
const mode = s.modes.find((m) => contraste(couleur[m], FOND_PAGE[m]) < 3);
|
|
195
198
|
if (mode) {
|
|
196
199
|
const rapport = contraste(couleur[mode], FOND_PAGE[mode]).toFixed(1).replace('.', ',');
|
|
197
200
|
s.avertissement(`la couleur « ${couleur.nom} » se voit mal sur le fond de la page en mode ${mode} (contraste ${rapport} pour 1, il en faut au moins 3) : choisissez une couleur plus ${mode === 'clair' ? 'foncée' : 'claire'}.`, ligne);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { simplifier } from './elements.js';
|
|
2
2
|
import { proposerNom } from './suggestions.js';
|
|
3
|
+
import { pascal } from '../cibles/web/noms.js';
|
|
3
4
|
const SCHEMAS_PERMIS = ['http', 'https', 'mailto', 'tel'];
|
|
4
5
|
/** Vrai si l'adresse est un schéma permis pour un lien externe (http, https, mailto, tel). */
|
|
5
6
|
export function estAdresseExterne(valeur) {
|
|
@@ -88,6 +89,16 @@ export function analyserNavigation(programme, diagnostics) {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
// Une page devient un composant React nommé comme elle (« bac_a_sable » → BacASable) :
|
|
93
|
+
// un composant du même nom serait caché par la page qui l'affiche.
|
|
94
|
+
const composants = new Map([...programme.composants, ...programme.composantsReact].flatMap((c) => [[c.nom, c.nom], [pascal(c.nom), c.nom]]));
|
|
95
|
+
for (const page of programme.pages) {
|
|
96
|
+
const produit = pascal(page.nom);
|
|
97
|
+
const composant = composants.get(produit);
|
|
98
|
+
if (composant !== undefined) {
|
|
99
|
+
erreur(`la page « ${page.nom} » et le composant « ${composant} » auraient le même nom dans le code produit (${produit}) : renommez l'un des deux (par exemple le composant « ${composant}Bloc »).`, page.ligne);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
91
102
|
analyserSousPages(programme, erreur);
|
|
92
103
|
analyserAncres(programme, diagnostics, erreur);
|
|
93
104
|
}
|
|
@@ -228,4 +239,28 @@ function analyserAncres(programme, diagnostics, erreur) {
|
|
|
228
239
|
verifier(page, i.cible, i.ligne);
|
|
229
240
|
});
|
|
230
241
|
}
|
|
242
|
+
// L'en-tête et le pied sont sur chaque page : « carte#desserts » se vérifie comme ailleurs ;
|
|
243
|
+
// « #desserts » vise la page affichée, qui change : il faut au moins une page qui l'ait.
|
|
244
|
+
for (const disposition of [programme.entete, programme.pied]) {
|
|
245
|
+
if (!disposition)
|
|
246
|
+
continue;
|
|
247
|
+
const de = disposition.genre === 'entete' ? "de l'en-tête" : 'du pied de page';
|
|
248
|
+
parcourir(disposition.corps, (i) => {
|
|
249
|
+
if (i.type !== 'Element' || i.cible === undefined)
|
|
250
|
+
return;
|
|
251
|
+
const lue = lireCible(i.cible);
|
|
252
|
+
if (lue.ancre === undefined)
|
|
253
|
+
return;
|
|
254
|
+
if (lue.page !== undefined && lue.page !== '') {
|
|
255
|
+
const visee = pages.get(lue.page);
|
|
256
|
+
if (visee)
|
|
257
|
+
verifier(visee, i.cible, i.ligne);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const montrees = programme.pages.filter((p) => !(disposition.genre === 'entete' ? p.sansEntete : p.sansPied) && p.parametres.length === 0);
|
|
261
|
+
if (!montrees.some((p) => ancres.get(p.nom)?.includes(lue.ancre))) {
|
|
262
|
+
erreur(`l'ancre « ${lue.ancre} » ${de} n'existe dans aucune page : écrivez avec ancre "${lue.ancre}" sur l'élément ou le bloc où le lien doit mener, ou écrivez le nom de la page (par exemple "accueil#${lue.ancre}").`, i.ligne);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
231
266
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function niveauxDesSousTitres(instructions, parent = 1) {
|
|
2
|
+
let dernier = parent;
|
|
3
|
+
const parcourir = (enfants) => {
|
|
4
|
+
for (const instruction of enfants) {
|
|
5
|
+
if (instruction.type === 'Element' && instruction.element === 'titre')
|
|
6
|
+
dernier = 1;
|
|
7
|
+
else if (instruction.type === 'Element' && instruction.element === 'soustitre') {
|
|
8
|
+
if (instruction.niveau === undefined) {
|
|
9
|
+
const niveau = Math.min(parent + 1, 6);
|
|
10
|
+
if (niveau !== 2)
|
|
11
|
+
instruction.niveau = niveau;
|
|
12
|
+
dernier = niveau;
|
|
13
|
+
}
|
|
14
|
+
else
|
|
15
|
+
dernier = instruction.niveau;
|
|
16
|
+
}
|
|
17
|
+
else if (instruction.type === 'Bloc')
|
|
18
|
+
niveauxDesSousTitres(instruction.corps, dernier);
|
|
19
|
+
else {
|
|
20
|
+
// Les branches d'une condition, le corps d'une boucle ou d'un formulaire : le même niveau.
|
|
21
|
+
for (const cle of ['corps', 'alors', 'sinon']) {
|
|
22
|
+
const suite = instruction[cle];
|
|
23
|
+
if (Array.isArray(suite) && instruction.type !== 'Evenement')
|
|
24
|
+
parcourir(suite);
|
|
25
|
+
}
|
|
26
|
+
if (instruction.type === 'Selon')
|
|
27
|
+
for (const cas of instruction.cas)
|
|
28
|
+
parcourir(cas.corps);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
parcourir(instructions);
|
|
33
|
+
}
|
package/dist/analyse/noms.js
CHANGED
|
@@ -12,7 +12,7 @@ export function avertirNomsDuLangage(programme) {
|
|
|
12
12
|
const diagnostics = [];
|
|
13
13
|
const avertir = (nom, ligne) => {
|
|
14
14
|
const canonique = formeCanonique(nom);
|
|
15
|
-
if (canonique && MOTS_CONTEXTUELS.has(canonique)) {
|
|
15
|
+
if (canonique && canonique !== 'liste' && MOTS_CONTEXTUELS.has(canonique)) {
|
|
16
16
|
diagnostics.push({
|
|
17
17
|
gravite: 'avertissement',
|
|
18
18
|
message: `la variable « ${nom} » porte le nom d'un mot du langage ; c'est permis, mais cela peut prêter à confusion.`,
|
package/dist/analyse/site.js
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
|
+
import { langueDuMot } from '../i18n/langues.js';
|
|
1
2
|
/** Le texte d'un littéral sans « {…} », sinon `undefined`. */
|
|
2
3
|
export function texteLitteral(expression) {
|
|
3
4
|
if (expression?.type !== 'Chaine' || !expression.parties.every((p) => p.type === 'texte'))
|
|
4
5
|
return undefined;
|
|
5
6
|
return expression.parties.map((p) => p.valeur).join('');
|
|
6
7
|
}
|
|
7
|
-
/**
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* « si langue est anglais » : vrai si le test est vrai dans la langue `code`,
|
|
10
|
+
* faux sinon ; `undefined` pour un test qui ne porte pas sur la langue.
|
|
11
|
+
*/
|
|
12
|
+
function testDeLangue(test, code) {
|
|
13
|
+
if (test.type !== 'Comparaison' || test.gauche.type !== 'Reference' || test.gauche.nom !== 'langue' || test.droite?.type !== 'Reference')
|
|
14
|
+
return undefined;
|
|
15
|
+
if (test.operateur !== 'egal' && test.operateur !== 'different')
|
|
16
|
+
return undefined;
|
|
17
|
+
const langue = langueDuMot(test.droite.nom);
|
|
18
|
+
if (!langue)
|
|
19
|
+
return undefined;
|
|
20
|
+
return (langue.code === code) !== (test.operateur === 'different') !== Boolean(test.negation);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Le premier « titre » ou « texte » écrit dans la page, même dans un bloc de mise en page.
|
|
24
|
+
* Avec des langues, une branche « si langue est … » compte : celle de la langue de base.
|
|
25
|
+
*/
|
|
26
|
+
function premier(genre, instructions, langueDeBase) {
|
|
9
27
|
for (const instruction of instructions) {
|
|
10
28
|
if (instruction.type === 'Element' && instruction.element === genre)
|
|
11
29
|
return instruction.contenu;
|
|
12
|
-
|
|
13
|
-
|
|
30
|
+
const vraie = instruction.type === 'Condition' && langueDeBase !== undefined ? testDeLangue(instruction.test, langueDeBase) : undefined;
|
|
31
|
+
const corps = instruction.type === 'Bloc' ? instruction.corps : instruction.type === 'Condition' && vraie !== undefined ? (vraie ? instruction.alors : instruction.sinon) : undefined;
|
|
32
|
+
if (corps) {
|
|
33
|
+
const trouve = premier(genre, corps, langueDeBase);
|
|
14
34
|
if (trouve)
|
|
15
35
|
return trouve;
|
|
16
36
|
}
|
|
@@ -18,10 +38,10 @@ function premier(genre, instructions) {
|
|
|
18
38
|
return undefined;
|
|
19
39
|
}
|
|
20
40
|
/** Le titre et la description propres à la page : son aperçu, sinon son premier titre et son premier texte. */
|
|
21
|
-
export function apercuDeLaPage(page) {
|
|
41
|
+
export function apercuDeLaPage(page, langueDeBase) {
|
|
22
42
|
return {
|
|
23
|
-
titre: page.apercu?.titre ?? premier('titre', page.corps),
|
|
24
|
-
description: page.apercu?.description ?? premier('texte', page.corps),
|
|
43
|
+
titre: page.apercu?.titre ?? premier('titre', page.corps, langueDeBase),
|
|
44
|
+
description: page.apercu?.description ?? premier('texte', page.corps, langueDeBase),
|
|
25
45
|
};
|
|
26
46
|
}
|
|
27
47
|
export function analyserPublication(programme, diagnostics) {
|
|
@@ -43,10 +63,12 @@ export function analyserPublication(programme, diagnostics) {
|
|
|
43
63
|
avertir(`l'image de partage « ${site.image} » est sur un autre site : si elle disparaît, l'aperçu des liens du site la perd. Préférez un fichier du projet.`, site.lignes.image ?? site.ligne);
|
|
44
64
|
}
|
|
45
65
|
const titres = new Map();
|
|
66
|
+
const premiereLangue = programme.langues?.langues[0];
|
|
67
|
+
const langueDeBase = premiereLangue ? langueDuMot(premiereLangue.mot)?.code : undefined;
|
|
46
68
|
for (const page of programme.pages) {
|
|
47
69
|
if (page.nom === 'introuvable')
|
|
48
70
|
continue;
|
|
49
|
-
const { titre, description } = apercuDeLaPage(page);
|
|
71
|
+
const { titre, description } = apercuDeLaPage(page, langueDeBase);
|
|
50
72
|
const ligneApercu = page.apercu?.ligne ?? page.ligne;
|
|
51
73
|
const titreLu = texteLitteral(titre);
|
|
52
74
|
const descriptionLue = texteLitteral(description);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { analyser } from '../../lexer/lexer.js';
|
|
2
|
+
const echapper = (texte) => texte.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
3
|
+
/** Colore un exemple Humalis valide ; un exemple incomplet reste lisible sans couleur. */
|
|
4
|
+
export function colorerCodeHumalis(code, langage) {
|
|
5
|
+
if (langage !== 'humalis')
|
|
6
|
+
return echapper(code);
|
|
7
|
+
try {
|
|
8
|
+
const tokens = analyser(code);
|
|
9
|
+
const lignes = code.split('\n');
|
|
10
|
+
const debuts = [];
|
|
11
|
+
let offset = 0;
|
|
12
|
+
for (const ligne of lignes) {
|
|
13
|
+
debuts.push(offset);
|
|
14
|
+
offset += ligne.length + 1;
|
|
15
|
+
}
|
|
16
|
+
const plages = [];
|
|
17
|
+
for (const token of tokens) {
|
|
18
|
+
if (!['MOT_CLE', 'CHAINE', 'NOMBRE'].includes(token.type))
|
|
19
|
+
continue;
|
|
20
|
+
const debut = debuts[token.ligne - 1] + token.colonne - 1;
|
|
21
|
+
let fin = debut + (token.original ?? token.valeur).length;
|
|
22
|
+
if (token.type === 'CHAINE') {
|
|
23
|
+
fin = debut + 1;
|
|
24
|
+
while (fin < code.length) {
|
|
25
|
+
if (code[fin] === '"' && code[fin - 1] !== '\\') {
|
|
26
|
+
fin++;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
fin++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
plages.push({ debut, fin, classe: token.type === 'MOT_CLE' ? 'mot' : token.type === 'CHAINE' ? 'texte' : 'nombre' });
|
|
33
|
+
}
|
|
34
|
+
for (let index = 0; index < lignes.length; index++) {
|
|
35
|
+
const ligne = lignes[index];
|
|
36
|
+
for (let colonne = 0; colonne < ligne.length; colonne++) {
|
|
37
|
+
const debut = debuts[index] + colonne;
|
|
38
|
+
if (ligne[colonne] === '#' && !plages.some((p) => p.debut <= debut && debut < p.fin)) {
|
|
39
|
+
plages.push({ debut, fin: debuts[index] + ligne.length, classe: 'commentaire' });
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
plages.sort((a, b) => a.debut - b.debut);
|
|
45
|
+
let rendu = '';
|
|
46
|
+
let position = 0;
|
|
47
|
+
for (const plage of plages) {
|
|
48
|
+
if (plage.debut < position)
|
|
49
|
+
continue;
|
|
50
|
+
rendu += echapper(code.slice(position, plage.debut));
|
|
51
|
+
rendu += `<span class="humalis-code-${plage.classe}">${echapper(code.slice(plage.debut, plage.fin))}</span>`;
|
|
52
|
+
position = plage.fin;
|
|
53
|
+
}
|
|
54
|
+
return rendu + echapper(code.slice(position));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return echapper(code);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -31,8 +31,20 @@ function classesUtilisees(codes) {
|
|
|
31
31
|
trouvees.add(`${m[1]}-${m[2]}`);
|
|
32
32
|
return trouvees;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Les attributs d'un bloc « code … avec animation frappe » : sa classe, le moment de la frappe et une
|
|
36
|
+
* clé stable (la ligne et une empreinte du code), pour ne pas refaire la frappe quand la page se redessine.
|
|
37
|
+
*/
|
|
38
|
+
export function attributsFrappe(bloc) {
|
|
39
|
+
if (!bloc.frappe)
|
|
40
|
+
return undefined;
|
|
41
|
+
let empreinte = 0;
|
|
42
|
+
for (const lettre of bloc.code)
|
|
43
|
+
empreinte = (empreinte * 31 + lettre.charCodeAt(0)) >>> 0;
|
|
44
|
+
return { classe: 'humalis-animation-frappe', frappe: bloc.frappe, cle: `${bloc.ligne}-${empreinte.toString(36)}` };
|
|
45
|
+
}
|
|
34
46
|
/** Vrai si une page anime un élément « au defilement » (l'observateur doit alors être écrit). */
|
|
35
|
-
export const animeAuDefilement = (codes) => codes.some((c) => c.includes('humalis-animation-defilement'));
|
|
47
|
+
export const animeAuDefilement = (codes) => codes.some((c) => c.includes('humalis-animation-defilement') || c.includes('humalis-animation-frappe'));
|
|
36
48
|
/** Le CSS du mouvement utilisé, rangé dans le bloc « no-preference » ; une chaîne vide s'il n'y en a pas. */
|
|
37
49
|
export function cssMouvement(codes) {
|
|
38
50
|
const utilisees = classesUtilisees(codes);
|
|
@@ -53,6 +65,15 @@ export function cssMouvement(codes) {
|
|
|
53
65
|
regles.push('.humalis-animation-defilement:not([data-humalis-vu]) { animation: none; }');
|
|
54
66
|
regles.push('.humalis-js .humalis-animation-defilement:not([data-humalis-vu]) { opacity: 0; }');
|
|
55
67
|
}
|
|
68
|
+
else if (option === 'animation' && valeur === 'frappe') {
|
|
69
|
+
// « code … avec animation frappe » : le vrai code reste en place mais transparent (lecteurs d'écran,
|
|
70
|
+
// copie, taille du bloc) ; le script pose par-dessus une copie qui s'écrit lettre par lettre.
|
|
71
|
+
regles.push('.humalis-animation-frappe { position: relative; }');
|
|
72
|
+
regles.push('.humalis-js .humalis-animation-frappe:not([data-humalis-tape="fini"]) > code:not(.humalis-frappe-copie), .humalis-js .humalis-animation-frappe:not([data-humalis-tape="fini"]) > code:not(.humalis-frappe-copie) * { color: transparent; }');
|
|
73
|
+
regles.push('.humalis-frappe-copie { position: absolute; inset: 0; padding: inherit; overflow: hidden; pointer-events: none; }');
|
|
74
|
+
regles.push('.humalis-frappe-copie::after { content: ""; display: inline-block; width: 0.55em; height: 1.1em; margin-left: 1px; vertical-align: text-bottom; background: currentColor; animation: humalis-curseur 1s steps(1) infinite; }');
|
|
75
|
+
regles.push('@keyframes humalis-curseur { 50% { opacity: 0; } }');
|
|
76
|
+
}
|
|
56
77
|
else if (option === 'duree' && DUREES[valeur]) {
|
|
57
78
|
regles.push(`${selecteur} { --humalis-duree: ${DUREES[valeur]}; }`);
|
|
58
79
|
}
|
|
@@ -71,6 +92,16 @@ export function cssMouvement(codes) {
|
|
|
71
92
|
regles.push(`${selecteur}:hover, ${selecteur}:focus-within, ${selecteur}:active { transform: scale(1.03); }`);
|
|
72
93
|
}
|
|
73
94
|
}
|
|
95
|
+
// « en cascade » : le bloc reste en place, ses enfants s'animent l'un après l'autre (80 ms d'écart).
|
|
96
|
+
if (utilisees.has('animation-cascade')) {
|
|
97
|
+
regles.push('.humalis-animation-cascade.humalis-animation-cascade { animation: none; }');
|
|
98
|
+
for (const valeur of Object.keys(ENTREES).filter((v) => utilisees.has(`animation-${v}`))) {
|
|
99
|
+
regles.push(`.humalis-animation-cascade.humalis-animation-${valeur} > * { animation: humalis-${valeur} var(--humalis-duree, 300ms) ease-out var(--humalis-delai, 0ms) backwards; }`);
|
|
100
|
+
}
|
|
101
|
+
for (let rang = 2; rang <= 12; rang++)
|
|
102
|
+
regles.push(`.humalis-animation-cascade > :nth-child(${rang}) { --humalis-delai: ${(rang - 1) * 80}ms; }`);
|
|
103
|
+
regles.push('.humalis-animation-cascade:not([data-humalis-vu]) > * { animation: none; }');
|
|
104
|
+
}
|
|
74
105
|
if (regles.length === 0)
|
|
75
106
|
return '';
|
|
76
107
|
return `\n/* Le mouvement : seulement si l'appareil ne demande pas de réduire les animations. */\n${REQUETE_MOUVEMENT} {\n${regles.map((r) => ` ${r}`).join('\n')}\n}\n`;
|
|
@@ -127,10 +158,73 @@ export const OBSERVATEUR_DEFILEMENT = `function observerLeDefilement() {
|
|
|
127
158
|
if (!('IntersectionObserver' in window)) return;
|
|
128
159
|
const reduire = matchMedia('(prefers-reduced-motion: reduce)');
|
|
129
160
|
const montrer = (element) => element.setAttribute('data-humalis-vu', '');
|
|
161
|
+
// « code … avec animation frappe » : une copie du code s'écrit par-dessus le vrai, puis s'efface.
|
|
162
|
+
// Chaque bloc garde où il en est : redessiné en cours de frappe, il reprend au même endroit ;
|
|
163
|
+
// déjà tapé (un retour sur la page), il est montré entier tout de suite.
|
|
164
|
+
const etats = new Map();
|
|
165
|
+
const taper = (pre) => {
|
|
166
|
+
const code = pre.querySelector(':scope > code');
|
|
167
|
+
const cle = pre.getAttribute('data-humalis-frappe-cle');
|
|
168
|
+
const etat = etats.get(cle) ?? { ecrites: 0, fini: false };
|
|
169
|
+
etats.set(cle, etat);
|
|
170
|
+
if (!code || reduire.matches || etat.fini) {
|
|
171
|
+
pre.setAttribute('data-humalis-tape', 'fini');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const copie = code.cloneNode(true);
|
|
175
|
+
copie.className = 'humalis-frappe-copie';
|
|
176
|
+
copie.setAttribute('aria-hidden', 'true');
|
|
177
|
+
const morceaux = [];
|
|
178
|
+
const parcours = document.createTreeWalker(copie, NodeFilter.SHOW_TEXT);
|
|
179
|
+
while (parcours.nextNode()) {
|
|
180
|
+
const noeud = parcours.currentNode;
|
|
181
|
+
morceaux.push([noeud, noeud.data]);
|
|
182
|
+
noeud.data = '';
|
|
183
|
+
}
|
|
184
|
+
pre.append(copie);
|
|
185
|
+
pre.setAttribute('data-humalis-tape', 'en-cours');
|
|
186
|
+
// Les lettres déjà tapées sur le bloc d'avant reviennent d'un coup.
|
|
187
|
+
let deja = etat.ecrites;
|
|
188
|
+
for (const morceau of morceaux) {
|
|
189
|
+
const part = morceau[1].slice(0, deja);
|
|
190
|
+
morceau[0].data = part;
|
|
191
|
+
deja -= part.length;
|
|
192
|
+
}
|
|
193
|
+
// Une lettre toutes les 30 ms au plus, et pas plus de 5 secondes en tout ; une pause en fin de ligne.
|
|
194
|
+
const total = morceaux.reduce((somme, morceau) => somme + morceau[1].length, 0);
|
|
195
|
+
const pas = Math.max(8, Math.min(30, 5000 / Math.max(total, 1)));
|
|
196
|
+
let rang = 0;
|
|
197
|
+
let position = 0;
|
|
198
|
+
for (let reste = etat.ecrites; rang < morceaux.length && reste > 0; ) {
|
|
199
|
+
const pris = Math.min(reste, morceaux[rang][1].length);
|
|
200
|
+
reste -= pris;
|
|
201
|
+
if (pris === morceaux[rang][1].length) rang++;
|
|
202
|
+
else position = pris;
|
|
203
|
+
}
|
|
204
|
+
const suivante = () => {
|
|
205
|
+
if (!copie.isConnected) return;
|
|
206
|
+
while (rang < morceaux.length && position >= morceaux[rang][1].length) {
|
|
207
|
+
rang++;
|
|
208
|
+
position = 0;
|
|
209
|
+
}
|
|
210
|
+
if (rang >= morceaux.length || reduire.matches) {
|
|
211
|
+
etat.fini = true;
|
|
212
|
+
copie.remove();
|
|
213
|
+
pre.setAttribute('data-humalis-tape', 'fini');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const lettre = morceaux[rang][1][position++];
|
|
217
|
+
morceaux[rang][0].data += lettre;
|
|
218
|
+
etat.ecrites++;
|
|
219
|
+
setTimeout(suivante, lettre === '\\n' ? pas * 6 : pas);
|
|
220
|
+
};
|
|
221
|
+
suivante();
|
|
222
|
+
};
|
|
130
223
|
const observateur = new IntersectionObserver((entrees) => {
|
|
131
224
|
for (const entree of entrees) {
|
|
132
225
|
if (!entree.isIntersecting) continue;
|
|
133
|
-
|
|
226
|
+
if (entree.target.hasAttribute('data-humalis-frappe')) taper(entree.target);
|
|
227
|
+
else montrer(entree.target);
|
|
134
228
|
observateur.unobserve(entree.target);
|
|
135
229
|
}
|
|
136
230
|
});
|
|
@@ -139,6 +233,12 @@ export const OBSERVATEUR_DEFILEMENT = `function observerLeDefilement() {
|
|
|
139
233
|
if (reduire.matches) montrer(element);
|
|
140
234
|
else observateur.observe(element);
|
|
141
235
|
}
|
|
236
|
+
for (const pre of document.querySelectorAll('[data-humalis-frappe]:not([data-humalis-tape])')) {
|
|
237
|
+
if (pre.getAttribute('data-humalis-frappe') === 'defilement' && !reduire.matches) {
|
|
238
|
+
pre.setAttribute('data-humalis-tape', 'attente');
|
|
239
|
+
observateur.observe(pre);
|
|
240
|
+
} else taper(pre);
|
|
241
|
+
}
|
|
142
242
|
};
|
|
143
243
|
document.documentElement.classList.add('humalis-js');
|
|
144
244
|
suivre();
|
|
@@ -148,9 +248,15 @@ export const OBSERVATEUR_DEFILEMENT = `function observerLeDefilement() {
|
|
|
148
248
|
}`;
|
|
149
249
|
/** src/humalis/mouvement.ts du web et du desktop : le même observateur, typé. */
|
|
150
250
|
export const MOUVEMENT_WEB = `// Produit par Humalis : les animations « au defilement » se jouent quand l'élément entre à l'écran.
|
|
151
|
-
${OBSERVATEUR_DEFILEMENT.replace('function observerLeDefilement() {', 'export function observerLeDefilement(): void {')
|
|
251
|
+
${OBSERVATEUR_DEFILEMENT.replace('function observerLeDefilement() {', 'export function observerLeDefilement(): void {')
|
|
252
|
+
.replace('(element) =>', '(element: Element) =>')
|
|
253
|
+
.replace('const etats = new Map();', 'const etats = new Map<string | null, { ecrites: number; fini: boolean }>();')
|
|
254
|
+
.replace('(pre) =>', '(pre: Element) =>')
|
|
255
|
+
.replace('code.cloneNode(true);', 'code.cloneNode(true) as HTMLElement;')
|
|
256
|
+
.replace('const morceaux = [];', 'const morceaux: [Text, string][] = [];')
|
|
257
|
+
.replace('const noeud = parcours.currentNode;', 'const noeud = parcours.currentNode as Text;')}
|
|
152
258
|
`;
|
|
153
259
|
/** Le prérendu capture la page après le script : il retire les marques du mouvement (rien de caché sans JavaScript). */
|
|
154
260
|
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.
|
|
155
|
-
transformHtml: (html) => html.replace(/(<html[^>]*class="[^"]*)\\bhumalis-js\\b/, '$1').replace(/ data-humalis-vu=""/g, ''),
|
|
261
|
+
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, ''),
|
|
156
262
|
`;
|
|
@@ -885,6 +885,9 @@ function styles(formulaires = false, blocs = false, theme, codes = []) {
|
|
|
885
885
|
`titre: { fontSize: 28, fontWeight: '700', color: '${encre}', marginVertical: 12 },`,
|
|
886
886
|
`sousTitre: { fontSize: 22, fontWeight: '700', color: '${encre}', marginVertical: 10 },`,
|
|
887
887
|
`texte: { fontSize: 16, lineHeight: 24, color: '${encre}', marginVertical: 6 },`,
|
|
888
|
+
...(codes.some((c) => c.includes('s.accent')) ? [theme?.couleurs.some((c) => c.nom === 'marque')
|
|
889
|
+
? `accent: { color: '${theme.couleurs.find((c) => c.nom === 'marque')[mode === 'clair' ? 'clair' : 'sombre']}' },`
|
|
890
|
+
: `accent: { fontWeight: '700' },`] : []),
|
|
888
891
|
`bouton: { alignSelf: 'flex-start', paddingVertical: 8, paddingHorizontal: 16, marginVertical: 4, borderWidth: 1, borderColor: '${encre}', borderRadius: 6 },`,
|
|
889
892
|
`texteBouton: { fontSize: 16, color: '${encre}' },`,
|
|
890
893
|
`lien: { alignSelf: 'flex-start', fontSize: 16, color: '${bleu}', marginVertical: 12, textDecorationLine: 'underline' },`,
|
|
@@ -1046,10 +1049,12 @@ l'écraser.
|
|
|
1046
1049
|
/** « changer theme par sombre » sur le téléphone : le réglage de l'application, pour cette session. */
|
|
1047
1050
|
const THEME_MOBILE = `import { Appearance } from 'react-native';
|
|
1048
1051
|
|
|
1049
|
-
export type Theme = 'clair' | 'sombre' | 'automatique';
|
|
1052
|
+
export type Theme = 'clair' | 'sombre' | 'automatique' | 'bascule';
|
|
1050
1053
|
|
|
1051
1054
|
/** Change le thème de l'application (le choix n'est pas gardé après la fermeture). */
|
|
1052
|
-
export function definirTheme(
|
|
1055
|
+
export function definirTheme(demande: Theme): void {
|
|
1056
|
+
// « basculer theme » : l'inverse du thème affiché.
|
|
1057
|
+
const theme = demande === 'bascule' ? (Appearance.getColorScheme() === 'dark' ? 'clair' : 'sombre') : demande;
|
|
1053
1058
|
Appearance.setColorScheme(theme === 'clair' ? 'light' : theme === 'sombre' ? 'dark' : 'unspecified');
|
|
1054
1059
|
}
|
|
1055
1060
|
`;
|
|
@@ -13,7 +13,7 @@ const SANS_STYLE = new Set(['sur', 'degrade', 'colle', 'animation', 'duree', 'de
|
|
|
13
13
|
* et « effet » sont sans effet sur le téléphone.
|
|
14
14
|
*/
|
|
15
15
|
function ouvrirAnime(options) {
|
|
16
|
-
const animation = options.find((o) => o.nom === 'animation' && o.valeur !== 'defilement');
|
|
16
|
+
const animation = options.find((o) => o.nom === 'animation' && o.valeur !== 'defilement' && o.valeur !== 'cascade');
|
|
17
17
|
if (!animation)
|
|
18
18
|
return '';
|
|
19
19
|
const duree = MILLISECONDES.duree[options.find((o) => o.nom === 'duree')?.valeur ?? 'moyenne'] ?? 300;
|
|
@@ -136,12 +136,15 @@ function elementMobile(el) {
|
|
|
136
136
|
const options = stylesDe(el.options);
|
|
137
137
|
// Le texte posé sur un fond prend la couleur d'encre de ce fond.
|
|
138
138
|
const encres = el.options.filter((o) => o.nom === 'fond').map((o) => camel(`encre_${o.valeur}`));
|
|
139
|
+
const contenu = ['titre', 'soustitre', 'texte'].includes(el.genre)
|
|
140
|
+
? el.contenu.replace(/<span className="humalis-accent">/g, '<Text style={s.accent}>').replace(/<code className="humalis-code-ligne">/g, '<Text style={{ fontFamily: "monospace" }}>').replace(/<\/(span|code)>/g, '</Text>')
|
|
141
|
+
: el.contenu;
|
|
139
142
|
switch (el.genre) {
|
|
140
143
|
case 'titre':
|
|
141
144
|
case 'soustitre':
|
|
142
|
-
return `<Text ${id} accessibilityRole="header" ${style([el.niveau === 1 ? 'titre' : 'sousTitre', ...options, ...encres])}${evenements}>${
|
|
145
|
+
return `<Text ${id} accessibilityRole="header" ${style([el.niveau === 1 ? 'titre' : 'sousTitre', ...options, ...encres])}${evenements}>${contenu}</Text>`;
|
|
143
146
|
case 'texte':
|
|
144
|
-
return `<Text ${id} ${style(['texte', ...options, ...encres])}${evenements}>${
|
|
147
|
+
return `<Text ${id} ${style(['texte', ...options, ...encres])}${evenements}>${contenu}</Text>`;
|
|
145
148
|
case 'bouton': {
|
|
146
149
|
// Le fond, la bordure et la position vont au bouton ; la couleur et la taille, à son texte.
|
|
147
150
|
const duBouton = el.options.filter((o) => ['fond', 'bordure', 'position'].includes(o.nom)).map(nomStyle);
|
|
@@ -60,7 +60,7 @@ ${formes}
|
|
|
60
60
|
parties.push(`
|
|
61
61
|
// ─── L'avatar : la photo, ou les initiales ──────────────────────────────
|
|
62
62
|
|
|
63
|
-
/** «
|
|
63
|
+
/** « Camille Martin » donne « CM ». */
|
|
64
64
|
function initiales(nom) {
|
|
65
65
|
const mots = String(nom).trim().split(/\\s+/).filter(Boolean);
|
|
66
66
|
return ((mots[0]?.[0] ?? '') + (mots.length > 1 ? (mots[mots.length - 1][0] ?? '') : '')).toUpperCase();
|