@jonathankablan/noraivoire-cli 0.0.0-stage → 0.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 +64 -0
- package/README.md +16 -2
- package/package.json +37 -4
- package/src/check.js +332 -0
- package/src/cli.js +76 -0
- package/src/tokenize.js +406 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
NORAIVOIRE — LICENCE PROPRIÉTAIRE
|
|
2
|
+
Copyright (c) 2026 Jonathan KABLAN. Tous droits réservés.
|
|
3
|
+
|
|
4
|
+
Le présent logiciel, incluant sans limitation son code source, ses spécifications
|
|
5
|
+
(fichiers .nora.json), son compilateur, son moteur d'effets, ses adaptateurs, ses
|
|
6
|
+
modèles (templates), sa documentation, ses jeux de tests et ses éléments graphiques
|
|
7
|
+
(ci-après « le Logiciel »), est la propriété exclusive de Jonathan KABLAN
|
|
8
|
+
(ci-après « le Titulaire »).
|
|
9
|
+
|
|
10
|
+
1. ABSENCE DE LICENCE OPEN SOURCE
|
|
11
|
+
Le Logiciel n'est pas distribué sous une licence open source. Aucun droit
|
|
12
|
+
n'est concédé implicitement, par estoppel ou autrement. La simple mise à
|
|
13
|
+
disposition du Logiciel, y compris dans un dépôt privé ou un registre de
|
|
14
|
+
paquets privé, ne vaut pas concession de licence.
|
|
15
|
+
|
|
16
|
+
2. USAGE AUTORISÉ
|
|
17
|
+
Tout usage du Logiciel est subordonné à un contrat de licence écrit et signé
|
|
18
|
+
par le Titulaire (ci-après « Contrat de Licence »). En l'absence d'un tel
|
|
19
|
+
contrat, seul le Titulaire est autorisé à utiliser le Logiciel.
|
|
20
|
+
|
|
21
|
+
3. INTERDICTIONS
|
|
22
|
+
Sauf autorisation écrite et préalable du Titulaire, il est interdit de :
|
|
23
|
+
a) copier, reproduire, modifier, adapter, traduire ou créer des œuvres
|
|
24
|
+
dérivées du Logiciel, en tout ou partie ;
|
|
25
|
+
b) distribuer, publier, sous-licencier, louer, prêter, vendre ou mettre à
|
|
26
|
+
disposition le Logiciel à des tiers, y compris sous forme de service
|
|
27
|
+
(SaaS) ou intégré dans un autre produit distribué ;
|
|
28
|
+
c) décompiler, désassembler ou procéder à de l'ingénierie inverse du
|
|
29
|
+
Logiciel, sauf dans les limites strictement prévues par l'article
|
|
30
|
+
L.122-6-1 du Code de la propriété intellectuelle ;
|
|
31
|
+
d) supprimer ou altérer les mentions de propriété, de copyright ou la
|
|
32
|
+
présente licence ;
|
|
33
|
+
e) utiliser le Logiciel, ses spécifications ou sa documentation pour
|
|
34
|
+
entraîner, affiner ou évaluer un modèle d'intelligence artificielle
|
|
35
|
+
destiné à des tiers, ou pour concevoir un produit concurrent.
|
|
36
|
+
|
|
37
|
+
4. PRODUITS GÉNÉRÉS
|
|
38
|
+
Les fichiers produits par le compilateur NoraIvoire pour le compte d'un
|
|
39
|
+
licencié (CSS, Dart, TypeScript générés) peuvent être intégrés dans les
|
|
40
|
+
applications de ce licencié dans les conditions fixées par son Contrat de
|
|
41
|
+
Licence. Ils ne peuvent pas être redistribués en tant que bibliothèque,
|
|
42
|
+
framework ou kit de design autonome.
|
|
43
|
+
|
|
44
|
+
5. CONFIDENTIALITÉ
|
|
45
|
+
Le Logiciel contient des informations confidentielles et des secrets
|
|
46
|
+
d'affaires du Titulaire. Toute personne y ayant accès s'engage à en
|
|
47
|
+
préserver la confidentialité.
|
|
48
|
+
|
|
49
|
+
6. ABSENCE DE GARANTIE
|
|
50
|
+
Sauf stipulation contraire du Contrat de Licence, le Logiciel est fourni
|
|
51
|
+
« EN L'ÉTAT », sans garantie d'aucune sorte, expresse ou implicite. Dans
|
|
52
|
+
les limites autorisées par la loi, le Titulaire ne saurait être tenu
|
|
53
|
+
responsable d'aucun dommage résultant de l'utilisation du Logiciel.
|
|
54
|
+
|
|
55
|
+
7. COMPOSANTS TIERS
|
|
56
|
+
Le Logiciel peut référencer des composants tiers soumis à leurs propres
|
|
57
|
+
licences, listés dans THIRD_PARTY_NOTICES.md. La présente licence ne
|
|
58
|
+
modifie pas ces licences.
|
|
59
|
+
|
|
60
|
+
8. DROIT APPLICABLE
|
|
61
|
+
La présente licence est régie par le droit français. Tout litige relève de
|
|
62
|
+
la compétence des tribunaux du ressort de la Cour d'appel de Paris.
|
|
63
|
+
|
|
64
|
+
Contact licences : jonathan.kablan@gmail.com
|
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @jonathankablan/noraivoire-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`nora check` 0.1.0 : valide les templates (HTML, Twig, JSP, JSX/TSX, Vue, Angular, Svelte, Astro) contre le contrat NoraIvoire — classes, variantes, effets, accessibilité (libellés, `alt`, `aria-current`, rôles…) — et propose une correction pour chaque problème. 20 règles, zéro dépendance hors `@jonathankablan/noraivoire-core`.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install --save-dev @jonathankablan/noraivoire-cli
|
|
7
|
+
npx nora check src templates --format=text # ou --format=json ; code de sortie 1 en cas d'erreur
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
✖ src/page.html:12 [foreign-class] « btn-primary » : classe Bootstrap, inexistant dans NoraIvoire
|
|
12
|
+
→ class="btn" data-tone="primary"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
API : `import { checkSource } from '@jonathankablan/noraivoire-cli/check'`. Option `--manifest=<chemin>` pour un autre manifest.
|
|
16
|
+
|
|
17
|
+
Logiciel propriétaire © 2026 Jonathan KABLAN, tous droits réservés (voir LICENSE). La mise à disposition sur npm ne vaut pas concession de licence : tout usage est subordonné à un contrat de licence écrit.
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jonathankablan/noraivoire-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"description": "CLI NoraIvoire : validation des templates (nora check) et audit (nora audit). Propriétaire.",
|
|
6
|
+
"author": "Jonathan KABLAN",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"nora": "src/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./check": "./src/check.js",
|
|
13
|
+
"./tokenize": "./src/tokenize.js",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@jonathankablan/noraivoire-core": "0.1.0"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"noraivoire",
|
|
24
|
+
"lint",
|
|
25
|
+
"accessibility",
|
|
26
|
+
"templates"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
"src/cli.js",
|
|
30
|
+
"src/check.js",
|
|
31
|
+
"src/tokenize.js",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org/"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// NoraIvoire — Validateur de templates (HTML, Twig, JSP, JSX/TSX, Vue, Angular).
|
|
2
|
+
// © 2026 Jonathan KABLAN — Propriétaire, tous droits réservés.
|
|
3
|
+
//
|
|
4
|
+
// Conçu pour la boucle de correction des agents IA : chaque erreur porte une
|
|
5
|
+
// suggestion « fix » directement applicable.
|
|
6
|
+
|
|
7
|
+
import { readdirSync, readFileSync, lstatSync } from 'node:fs';
|
|
8
|
+
import { join, extname, relative } from 'node:path';
|
|
9
|
+
import { tokenize, attributeMap, parseDocument, DYNAMIC } from './tokenize.js';
|
|
10
|
+
|
|
11
|
+
const EXTENSIONS = new Set(['.html', '.htm', '.twig', '.jsp', '.jspx', '.tag', '.jsx', '.tsx', '.vue', '.svelte', '.astro']);
|
|
12
|
+
const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'vendor', 'var', '.dart_tool']);
|
|
13
|
+
const MAX_FILE = 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
const UTILITY_PREFIX = /^(p|px|py|m|mx|my|gap|rounded|text|shadow|font)-/;
|
|
16
|
+
const TEMPLATE_EXPR = /\{\{|\{%|\$\{|<%|#\{|\{\s*\w/;
|
|
17
|
+
|
|
18
|
+
const FOREIGN = [
|
|
19
|
+
{ re: /^btn-(primary|secondary|success|danger|warning|info|light|dark)$/, fix: (m) => `class="btn" data-tone="${map(m[1])}"`, msg: 'variante Bootstrap' },
|
|
20
|
+
{ re: /^btn-outline-(\w+)$/, fix: () => 'class="btn" data-tone="neutral" (pas de variante outline dans NoraIvoire)', msg: 'variante Bootstrap' },
|
|
21
|
+
{ re: /^btn-(lg|sm)$/, fix: (m) => `data-size="${m[1]}"`, msg: 'taille Bootstrap' },
|
|
22
|
+
{ re: /^badge-(\w+)$/, fix: (m) => `class="badge" data-tone="${map(m[1])}"`, msg: 'variante Bootstrap' },
|
|
23
|
+
{ re: /^(bg|text|bg-subtle)-(primary|secondary|success|danger|warning|info)$/, fix: () => 'utiliser un composant avec data-tone, ou la variable var(--nora-color-…)', msg: 'couleur Bootstrap' },
|
|
24
|
+
{ re: /^col(-(sm|md|lg|xl|xxl))?-\d{1,2}$/, fix: () => 'class="grid-auto" (ou .cluster / .stack)', msg: 'grille Bootstrap' },
|
|
25
|
+
{ re: /^row$/, fix: () => 'class="grid-auto" ou class="cluster"', msg: 'grille Bootstrap' },
|
|
26
|
+
{ re: /^(bg|text|border|ring|from|to)-[a-z]+-\d{2,3}$/, fix: () => 'utiliser les tokens : var(--nora-color-…) ou data-tone', msg: 'couleur Tailwind' },
|
|
27
|
+
{ re: /^(sm|md|lg|xl|2xl|hover|focus|active|dark|group-hover):/, fix: () => 'NoraIvoire gère états et responsive dans les composants ; utiliser .cq + container queries', msg: 'préfixe Tailwind' },
|
|
28
|
+
{ re: /^(flex|grid|items-center|justify-between|justify-center)$/, fix: () => 'class="cluster" (ligne) ou class="stack" (colonne)', msg: 'utilitaire Tailwind' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function map(bsTone) {
|
|
32
|
+
return { secondary: 'neutral', info: 'primary', light: 'neutral', dark: 'neutral' }[bsTone] ?? bsTone;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function levenshtein(a, b) {
|
|
36
|
+
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
37
|
+
for (let j = 1; j <= b.length; j++) dp[0][j] = j;
|
|
38
|
+
for (let i = 1; i <= a.length; i++) {
|
|
39
|
+
for (let j = 1; j <= b.length; j++) {
|
|
40
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return dp[a.length][b.length];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nearest(word, list) {
|
|
47
|
+
let best = null;
|
|
48
|
+
let score = Infinity;
|
|
49
|
+
for (const w of list) {
|
|
50
|
+
const d = levenshtein(word, w);
|
|
51
|
+
if (d < score) { score = d; best = w; }
|
|
52
|
+
}
|
|
53
|
+
return score <= 3 ? best : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Extrait les attributs d'une balise ouvrante (chaîne d'attributs seule). Les liaisons valent DYNAMIC. */
|
|
57
|
+
export function parseAttributes(src) {
|
|
58
|
+
const [tag] = tokenize(`<x ${src}>`);
|
|
59
|
+
return { ...attributeMap(tag.attributes) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Un contenu de bouton fourni par le framework (slot, doBody, block, {children}…) compte comme un texte. */
|
|
63
|
+
const SLOT_TAGS = new Set(['slot', 'ng-content', 'jsp:dobody', 'jsp:body']);
|
|
64
|
+
|
|
65
|
+
function hasAccessibleContent(text, tokens, el) {
|
|
66
|
+
if (el.selfClosing) return false;
|
|
67
|
+
if (el.closeTokenIndex === null) return true; // fermeture introuvable : on ne conclut pas
|
|
68
|
+
for (let t = el.tokenIndex + 1; t < el.closeTokenIndex; t++) {
|
|
69
|
+
const tok = tokens[t];
|
|
70
|
+
if (tok.type === 'template' || tok.type === 'expr') return true;
|
|
71
|
+
if (tok.type === 'tag' && SLOT_TAGS.has(tok.name)) return true;
|
|
72
|
+
if (tok.type === 'text' && text.slice(tok.start, tok.end).trim() !== '') return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Valide un contenu de template. */
|
|
78
|
+
export function checkSource(text, file, manifest) {
|
|
79
|
+
const issues = [];
|
|
80
|
+
const components = manifest.components;
|
|
81
|
+
const parts = Object.values(components).flatMap((c) => c.parts ?? []);
|
|
82
|
+
const utilities = new Set([...manifest.utilities, ...manifest.layout, ...manifest.surfaces, ...parts]);
|
|
83
|
+
const effects = new Set(Object.entries(manifest.effects).filter(([, e]) => e.kind !== 'surface-glass').map(([n]) => n));
|
|
84
|
+
const allKnown = [...Object.keys(components), ...utilities];
|
|
85
|
+
const { tokens, elements } = parseDocument(text);
|
|
86
|
+
const form = formIndex(elements);
|
|
87
|
+
|
|
88
|
+
for (const el of elements) {
|
|
89
|
+
const push = (level, rule, message, fix) => issues.push({ file, line: el.line, level, rule, message, ...(fix ? { fix } : {}) });
|
|
90
|
+
const tag = el.name;
|
|
91
|
+
const attrs = el.attrs;
|
|
92
|
+
const classAttr = attrs.class === DYNAMIC ? undefined : attrs.class;
|
|
93
|
+
const classes = classAttr && !TEMPLATE_EXPR.test(classAttr) ? classAttr.split(/\s+/).filter(Boolean)
|
|
94
|
+
: (classAttr ? classAttr.split(/\s+/).filter((c) => c && !/[{}%$<>]/.test(c)) : []);
|
|
95
|
+
|
|
96
|
+
const comps = classes.filter((c) => Object.hasOwn(components, c));
|
|
97
|
+
|
|
98
|
+
for (const c of classes) {
|
|
99
|
+
const foreign = FOREIGN.find((f) => f.re.test(c));
|
|
100
|
+
if (foreign) {
|
|
101
|
+
push('error', 'foreign-class', `« ${c} » : ${foreign.msg}, inexistant dans NoraIvoire`, foreign.fix(c.match(foreign.re)));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (c.startsWith('fx-') && !utilities.has(c)) {
|
|
105
|
+
push('error', 'unknown-surface', `surface « ${c} » inconnue`, nearest(c, manifest.surfaces) ? `class="${nearest(c, manifest.surfaces)}"` : `surfaces disponibles : ${manifest.surfaces.join(', ')}`);
|
|
106
|
+
} else if (UTILITY_PREFIX.test(c) && !utilities.has(c)) {
|
|
107
|
+
const n = nearest(c, allKnown);
|
|
108
|
+
push('error', 'unknown-utility', `utilitaire « ${c} » inexistant`, n ? `remplacer par « ${n} »` : 'voir llms-full.txt › Utilitaires');
|
|
109
|
+
} else if (!utilities.has(c) && !Object.hasOwn(components, c) && c.length > 3) {
|
|
110
|
+
// Classe propre au projet ou faute de frappe ? Avertissement seulement si très proche d'une classe NoraIvoire.
|
|
111
|
+
const n = nearest(c, allKnown);
|
|
112
|
+
if (n && levenshtein(c, n) <= 2) push('warning', 'possible-typo', `« ${c} » ressemble à « ${n} »`, `remplacer par « ${n} » si c'était l'intention`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const axis of ['tone', 'size']) {
|
|
117
|
+
const key = `data-${axis}`;
|
|
118
|
+
const v = attrs[key];
|
|
119
|
+
if (v === undefined || v === DYNAMIC || TEMPLATE_EXPR.test(v)) continue;
|
|
120
|
+
if (comps.length === 0) {
|
|
121
|
+
push('warning', 'orphan-variant', `${key}="${v}" sans composant NoraIvoire sur l'élément`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
for (const comp of comps) {
|
|
125
|
+
const allowed = components[comp].attributes[key];
|
|
126
|
+
if (!allowed.includes(v)) {
|
|
127
|
+
const n = nearest(v, allowed);
|
|
128
|
+
push('error', 'invalid-variant', `${key}="${v}" invalide pour .${comp} (valeurs : ${allowed.join(', ')})`, n ? `${key}="${n}"` : undefined);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const fxAttr = attrs['data-fx'];
|
|
134
|
+
if (fxAttr !== undefined && fxAttr !== DYNAMIC && !TEMPLATE_EXPR.test(fxAttr)) {
|
|
135
|
+
for (const fx of fxAttr.split(/\s+/).filter(Boolean)) {
|
|
136
|
+
if (!effects.has(fx)) {
|
|
137
|
+
const n = nearest(fx, [...effects]);
|
|
138
|
+
push('error', 'unknown-effect', `effet « ${fx} » inconnu`, n ? `data-fx="${n}"` : `effets : ${[...effects].join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const comp of comps) {
|
|
144
|
+
const spec = components[comp];
|
|
145
|
+
if (!spec.elements.includes(tag) && !/^[a-z]+[-:.][a-z]/i.test(el.rawName) && !/^[A-Z]/.test(el.rawName)) {
|
|
146
|
+
push('warning', 'unexpected-element', `.${comp} sur <${tag}> (éléments prévus : ${spec.elements.join(', ')})`);
|
|
147
|
+
}
|
|
148
|
+
if (spec.a11y?.anchorRequiresHref && tag === 'a' && attrs.href === undefined) {
|
|
149
|
+
push('error', 'a11y-anchor-href', `<a class="${comp}"> sans href : ce n'est pas un lien`, 'ajouter href="…" ou utiliser <button type="button">');
|
|
150
|
+
}
|
|
151
|
+
if (spec.a11y?.requiresLabel && tag === 'button' && attrs['aria-label'] === undefined && attrs['aria-labelledby'] === undefined
|
|
152
|
+
&& !hasAccessibleContent(text, tokens, el)) {
|
|
153
|
+
push('error', 'a11y-label', `<button class="${comp}"> sans texte ni aria-label`, 'ajouter un texte ou aria-label="…"');
|
|
154
|
+
}
|
|
155
|
+
checkFormComponent(comp, spec, el, text, tokens, elements, form, push);
|
|
156
|
+
checkPageComponent(comp, spec, el, text, tokens, elements, push);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (attrs['aria-invalid'] === 'true' && attrs['aria-describedby'] === undefined && attrs['aria-errormessage'] === undefined) {
|
|
160
|
+
push('warning', 'a11y-invalid-message', `<${tag} aria-invalid="true"> sans message d'erreur relié`,
|
|
161
|
+
'ajouter aria-describedby="id-du-message" vers un <p class="field-error" id="id-du-message">');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return issues;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ───────────── Formulaires ─────────────
|
|
168
|
+
|
|
169
|
+
const NOT_LABELLABLE = new Set(['hidden', 'submit', 'button', 'reset', 'image']);
|
|
170
|
+
const isStatic = (v) => typeof v === 'string' && v !== DYNAMIC && !TEMPLATE_EXPR.test(v);
|
|
171
|
+
|
|
172
|
+
/** Index des libellés du document : <label for> statiques, présence de for dynamiques. */
|
|
173
|
+
function formIndex(elements) {
|
|
174
|
+
const labelFor = new Set();
|
|
175
|
+
let dynamicFor = false;
|
|
176
|
+
for (const el of elements) {
|
|
177
|
+
if (el.name !== 'label') continue;
|
|
178
|
+
const f = el.attrs.for ?? el.attrs.htmlFor;
|
|
179
|
+
if (f === undefined) continue;
|
|
180
|
+
if (isStatic(f)) labelFor.add(f);
|
|
181
|
+
else dynamicFor = true;
|
|
182
|
+
}
|
|
183
|
+
return { labelFor, dynamicFor };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function ancestor(el, pred) {
|
|
187
|
+
for (let p = el.parent; p; p = p.parent) if (pred(p)) return p;
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Descendants d'un élément : contigus après lui dans l'ordre du document. */
|
|
192
|
+
function descendants(el, elements) {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (let j = elements.indexOf(el) + 1; j < elements.length; j++) {
|
|
195
|
+
if (!ancestor(elements[j], (p) => p === el)) break;
|
|
196
|
+
out.push(elements[j]);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Un contrôle a-t-il un nom accessible : aria-label(ledby), <label> ancêtre ou <label for> relié à son id ? */
|
|
202
|
+
function hasFormLabel(el, form) {
|
|
203
|
+
const a = el.attrs;
|
|
204
|
+
if ((a['aria-label'] !== undefined && a['aria-label'] !== '') || a['aria-labelledby'] !== undefined) return true;
|
|
205
|
+
if (ancestor(el, (p) => p.name === 'label')) return true;
|
|
206
|
+
const id = a.id;
|
|
207
|
+
if (id === undefined) return false;
|
|
208
|
+
if (!isStatic(id)) return form.dynamicFor || form.labelFor.size > 0; // identifiant calculé : on ne conclut pas
|
|
209
|
+
return form.labelFor.has(id) || form.dynamicFor;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Règles des composants de formulaire : label associé, input attendu, rôle d'interrupteur. */
|
|
213
|
+
function checkFormComponent(comp, spec, el, text, tokens, elements, form, push) {
|
|
214
|
+
const shape = spec.shape;
|
|
215
|
+
const tag = el.name;
|
|
216
|
+
const opaque = (d) => /^[A-Z]/.test(d.rawName) || /[-:.]/.test(d.rawName); // composant de framework : contenu inconnu
|
|
217
|
+
if (shape === 'control') {
|
|
218
|
+
if (!spec.elements.includes(tag)) return;
|
|
219
|
+
if (tag === 'input' && isStatic(el.attrs.type) && NOT_LABELLABLE.has(el.attrs.type)) return;
|
|
220
|
+
if (!hasFormLabel(el, form)) {
|
|
221
|
+
const id = isStatic(el.attrs.id) ? el.attrs.id : 'mon-champ';
|
|
222
|
+
push('error', 'a11y-form-label', `<${tag} class="${comp}"> sans label associé`,
|
|
223
|
+
`ajouter <label for="${id}">…</label>${el.attrs.id === undefined ? ` et id="${id}"` : ''}, ou aria-label="…"`);
|
|
224
|
+
}
|
|
225
|
+
} else if (shape === 'toggle') {
|
|
226
|
+
if (tag !== 'label') return;
|
|
227
|
+
const inner = descendants(el, elements);
|
|
228
|
+
const inputType = spec.a11y?.inputType ?? 'checkbox';
|
|
229
|
+
const input = inner.find((d) => d.name === 'input');
|
|
230
|
+
if (!input) {
|
|
231
|
+
if (!inner.some(opaque)) {
|
|
232
|
+
const role = spec.a11y?.role && spec.a11y.role !== inputType ? ` role="${spec.a11y.role}"` : '';
|
|
233
|
+
push('error', 'a11y-toggle-input', `<label class="${comp}"> sans <input type="${inputType}">`, `<label class="${comp}"><input type="${inputType}"${role}> Texte</label>`);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (isStatic(input.attrs.type) && input.attrs.type !== inputType) {
|
|
238
|
+
push('error', 'a11y-toggle-input', `<input type="${input.attrs.type}"> dans .${comp} : type="${inputType}" attendu`, `type="${inputType}"`);
|
|
239
|
+
}
|
|
240
|
+
if (inputType === 'radio' && (input.attrs.name === undefined || input.attrs.name === '')) {
|
|
241
|
+
push('warning', 'a11y-radio-name', `<input type="radio"> de .${comp} sans name`, 'ajouter name="…" commun aux options du groupe (sélection exclusive, flèches du clavier)');
|
|
242
|
+
}
|
|
243
|
+
const role = spec.a11y?.role;
|
|
244
|
+
if (role && role !== inputType && input.attrs.role !== DYNAMIC && input.attrs.role !== role) {
|
|
245
|
+
push('error', 'a11y-switch-role', `<input> de .${comp} sans role="${role}"`, `ajouter role="${role}" sur l'input`);
|
|
246
|
+
}
|
|
247
|
+
const named = input.attrs['aria-label'] !== undefined || input.attrs['aria-labelledby'] !== undefined;
|
|
248
|
+
if (!named && !hasAccessibleContent(text, tokens, el)) {
|
|
249
|
+
push('error', 'a11y-form-label', `<label class="${comp}"> sans texte`, 'ajouter le texte du libellé dans le <label>');
|
|
250
|
+
}
|
|
251
|
+
} else if (shape === 'group') {
|
|
252
|
+
const inner = descendants(el, elements);
|
|
253
|
+
const hasLabel = inner.some((d) => d.name === 'label' || d.name === 'legend'
|
|
254
|
+
|| (isStatic(d.attrs.class) && d.attrs.class.split(/\s+/).includes(`${comp}-label`)));
|
|
255
|
+
if (!hasLabel && !inner.some(opaque)) {
|
|
256
|
+
push('warning', 'field-label-missing', `.${comp} sans libellé`, `ajouter <label class="${comp}-label" for="…">…</label> (ou <legend> dans un <fieldset>)`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ───────────── Mise en page : lien, navigation, avatar, média ─────────────
|
|
262
|
+
|
|
263
|
+
/** Classes qui signalent visuellement l'élément courant d'une navigation (Bootstrap, Tailwind, maison). */
|
|
264
|
+
const ACTIVE_CLASSES = new Set(['active', 'is-active', 'current', 'is-current', 'selected', 'router-link-active', 'router-link-exact-active']);
|
|
265
|
+
const classesOf = (el) => (isStatic(el.attrs.class) ? el.attrs.class.split(/\s+/).filter(Boolean) : []);
|
|
266
|
+
const hasName = (a) => (a['aria-label'] !== undefined && a['aria-label'] !== '') || a['aria-labelledby'] !== undefined;
|
|
267
|
+
const isHidden = (a) => a['aria-hidden'] === 'true' || a['aria-hidden'] === DYNAMIC;
|
|
268
|
+
|
|
269
|
+
/** Une image a-t-elle un texte alternatif déclaré (alt, même vide pour une image décorative) ? */
|
|
270
|
+
function imgAltOk(img) {
|
|
271
|
+
const a = img.attrs;
|
|
272
|
+
return a.alt !== undefined || a[':alt'] !== undefined || a['[alt]'] !== undefined || hasName(a) || isHidden(a) || a.role === 'presentation' || a.role === 'none';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function checkPageComponent(comp, spec, el, text, tokens, elements, push) {
|
|
276
|
+
const shape = spec.shape;
|
|
277
|
+
const tag = el.name;
|
|
278
|
+
const a = el.attrs;
|
|
279
|
+
if (shape === 'link') {
|
|
280
|
+
if (tag === 'a' && !hasName(a) && !hasAccessibleContent(text, tokens, el)) {
|
|
281
|
+
push('error', 'a11y-label', `<a class="${comp}"> sans texte ni aria-label`, 'ajouter le texte du lien (il doit dire où il mène)');
|
|
282
|
+
}
|
|
283
|
+
} else if (shape === 'avatar') {
|
|
284
|
+
if (tag === 'img' && !imgAltOk(el)) {
|
|
285
|
+
push('error', 'a11y-img-alt', `<img class="${comp}"> sans alt`, 'ajouter alt="Nom de la personne" (ou alt="" si l\'avatar est décoratif)');
|
|
286
|
+
} else if (tag !== 'img' && !hasName(a) && !isHidden(a)) {
|
|
287
|
+
push('error', 'a11y-img-alt', `<${tag} class="${comp}"> (initiales) sans nom accessible`, 'ajouter role="img" aria-label="Nom de la personne" (ou aria-hidden="true" si décoratif)');
|
|
288
|
+
} else if (tag !== 'img' && hasName(a) && a.role !== 'img' && a.role !== DYNAMIC) {
|
|
289
|
+
push('warning', 'a11y-img-role', `<${tag} class="${comp}" aria-label> sans role="img"`, 'ajouter role="img" : sans rôle, aria-label est ignoré sur un <span>');
|
|
290
|
+
}
|
|
291
|
+
} else if (shape === 'media') {
|
|
292
|
+
for (const img of descendants(el, elements).filter((d) => d.name === 'img')) {
|
|
293
|
+
if (!imgAltOk(img)) {
|
|
294
|
+
push('error', 'a11y-img-alt', `<img> de .${comp} sans alt`, 'décrire l\'image dans alt="…" (ou alt="" si elle est purement décorative)');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} else if (shape === 'nav') {
|
|
298
|
+
if (!hasName(a)) {
|
|
299
|
+
push('warning', 'a11y-nav-label', `<${tag} class="${comp}"> sans aria-label`, 'ajouter aria-label="Principale" (ou aria-labelledby) : plusieurs navigations doivent se distinguer');
|
|
300
|
+
}
|
|
301
|
+
for (const d of descendants(el, elements).filter((x) => x.name === 'a')) {
|
|
302
|
+
const flagged = [d, d.parent].some((x) => x && classesOf(x).some((k) => ACTIVE_CLASSES.has(k)));
|
|
303
|
+
if (flagged && d.attrs['aria-current'] === undefined) {
|
|
304
|
+
push('warning', 'a11y-nav-current', `lien actif de .${comp} sans aria-current`, 'ajouter aria-current="page" sur le lien de la page courante (la classe seule n\'est pas annoncée)');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function walk(dir, out) {
|
|
311
|
+
for (const name of readdirSync(dir)) {
|
|
312
|
+
if (IGNORED_DIRS.has(name)) continue;
|
|
313
|
+
const full = join(dir, name);
|
|
314
|
+
const st = lstatSync(full);
|
|
315
|
+
if (st.isSymbolicLink()) continue;
|
|
316
|
+
if (st.isDirectory()) walk(full, out);
|
|
317
|
+
else if (st.isFile() && st.size <= MAX_FILE && (EXTENSIONS.has(extname(name)) || name.endsWith('.component.html'))) out.push(full);
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function checkPaths(paths, manifest, cwd = process.cwd()) {
|
|
323
|
+
const files = [];
|
|
324
|
+
for (const p of paths) {
|
|
325
|
+
const st = lstatSync(p);
|
|
326
|
+
if (st.isDirectory()) walk(p, files);
|
|
327
|
+
else if (st.isFile()) files.push(p);
|
|
328
|
+
}
|
|
329
|
+
const issues = [];
|
|
330
|
+
for (const f of files.sort()) issues.push(...checkSource(readFileSync(f, 'utf8'), relative(cwd, f), manifest));
|
|
331
|
+
return { files: files.length, issues };
|
|
332
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// NoraIvoire — CLI : nora check <chemins…> [--format=json|text] · nora audit [--format=json|text]
|
|
3
|
+
// © 2026 Jonathan KABLAN — Propriétaire, tous droits réservés.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { join, resolve, dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { checkPaths } from './check.js';
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const repoRoot = resolve(here, '..', '..', '..');
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const cmd = args[0];
|
|
15
|
+
const format = (args.find((a) => a.startsWith('--format=')) ?? '--format=text').split('=')[1];
|
|
16
|
+
const positional = args.slice(1).filter((a) => !a.startsWith('--'));
|
|
17
|
+
|
|
18
|
+
/** Manifest du dépôt source, sinon celui du paquet @jonathankablan/noraivoire-core installé. */
|
|
19
|
+
function defaultManifest() {
|
|
20
|
+
const inRepo = join(repoRoot, 'packages', 'core', 'dist', 'manifest.json');
|
|
21
|
+
if (existsSync(inRepo)) return inRepo;
|
|
22
|
+
try {
|
|
23
|
+
return createRequire(import.meta.url).resolve('@jonathankablan/noraivoire-core/manifest.json');
|
|
24
|
+
} catch {
|
|
25
|
+
return inRepo;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadManifest() {
|
|
30
|
+
const flag = args.find((a) => a.startsWith('--manifest='));
|
|
31
|
+
const path = flag ? flag.slice('--manifest='.length) : defaultManifest();
|
|
32
|
+
if (!existsSync(path)) {
|
|
33
|
+
console.error('✖ manifest.json introuvable : lancer « npm run build » ou passer --manifest=<chemin>');
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (cmd === 'check') {
|
|
40
|
+
const manifest = loadManifest();
|
|
41
|
+
const { files, issues } = checkPaths(positional.length ? positional : ['.'], manifest);
|
|
42
|
+
const errors = issues.filter((i) => i.level === 'error').length;
|
|
43
|
+
if (format === 'json') {
|
|
44
|
+
console.log(JSON.stringify({ files, errors, warnings: issues.length - errors, issues }, null, 2));
|
|
45
|
+
} else {
|
|
46
|
+
for (const i of issues) {
|
|
47
|
+
console.log(`${i.level === 'error' ? '✖' : '⚠'} ${i.file}:${i.line} [${i.rule}] ${i.message}${i.fix ? `\n → ${i.fix}` : ''}`);
|
|
48
|
+
}
|
|
49
|
+
console.log(`\n${files} fichier(s) analysé(s) — ${errors} erreur(s), ${issues.length - errors} avertissement(s)`);
|
|
50
|
+
}
|
|
51
|
+
process.exit(errors ? 1 : 0);
|
|
52
|
+
} else if (cmd === 'audit') {
|
|
53
|
+
// L'audit vérifie le dépôt source (spec, compilateur) : absent du paquet publié.
|
|
54
|
+
let runAudit;
|
|
55
|
+
try {
|
|
56
|
+
({ runAudit } = await import('./audit.js'));
|
|
57
|
+
} catch {
|
|
58
|
+
console.error('✖ nora audit ne s\'exécute que dans le dépôt source NoraIvoire');
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
const report = runAudit(repoRoot);
|
|
62
|
+
if (format === 'json') {
|
|
63
|
+
console.log(JSON.stringify(report, null, 2));
|
|
64
|
+
} else {
|
|
65
|
+
let cat = '';
|
|
66
|
+
for (const r of report.results) {
|
|
67
|
+
if (r.category !== cat) { cat = r.category; console.log(`\n■ ${cat.toUpperCase()}`); }
|
|
68
|
+
console.log(` ${r.status === 'pass' ? '✔' : '✖'} ${r.detail}`);
|
|
69
|
+
}
|
|
70
|
+
console.log(`\nAudit NoraIvoire : ${report.passed}/${report.total} contrôles réussis, ${report.failed} échec(s).`);
|
|
71
|
+
}
|
|
72
|
+
process.exit(report.failed ? 1 : 0);
|
|
73
|
+
} else {
|
|
74
|
+
console.log('Usage :\n nora check <chemins…> [--format=json|text] [--manifest=<chemin>]\n nora audit [--format=json|text]');
|
|
75
|
+
process.exit(cmd ? 1 : 0);
|
|
76
|
+
}
|
package/src/tokenize.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// NoraIvoire — Tokenizer HTML tolérant pour `nora check` (HTML, Twig, JSP, JSX/TSX, Vue, Angular, Svelte).
|
|
2
|
+
// © 2026 Jonathan KABLAN — Propriétaire, tous droits réservés.
|
|
3
|
+
//
|
|
4
|
+
// Écrit à la main, sans dépendance ni expression régulière à retour arrière :
|
|
5
|
+
// chaque caractère est lu un nombre borné de fois, l'analyse est linéaire même
|
|
6
|
+
// sur une entrée hostile. Le tokenizer ne rejette jamais un fichier : une
|
|
7
|
+
// construction non fermée est consommée jusqu'à la fin, comme le ferait un
|
|
8
|
+
// navigateur, ou relue comme du texte quand c'est plus prudent.
|
|
9
|
+
|
|
10
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
11
|
+
const RAW_TEXT = new Set(['script', 'style']);
|
|
12
|
+
const MAX_DEPTH = 16; // imbrication maximale d'expressions JSX analysées récursivement
|
|
13
|
+
const MAX_TEMPLATE = 4096; // longueur maximale d'une expression de gabarit dans une valeur entre guillemets
|
|
14
|
+
|
|
15
|
+
export const DYNAMIC = '__dynamic__';
|
|
16
|
+
|
|
17
|
+
const isAlpha = (c) => (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
18
|
+
const isSpace = (c) => c === 32 || c === 9 || c === 10 || c === 13 || c === 12;
|
|
19
|
+
const isNameChar = (c) => isAlpha(c) || (c >= 48 && c <= 57) || c === 95 || c === 45 || c === 58 || c === 46;
|
|
20
|
+
|
|
21
|
+
/** Ouvreurs de gabarit reconnus et leur fermeture. L'ordre compte (`<%--` avant `<%`). */
|
|
22
|
+
const TEMPLATES = [
|
|
23
|
+
['{{', '}}', 'mustache'], // Twig, Vue, Angular, Handlebars
|
|
24
|
+
['{%', '%}', 'twig'],
|
|
25
|
+
['{#', '#}', 'twig-comment'],
|
|
26
|
+
['<%--', '--%>', 'jsp-comment'],
|
|
27
|
+
['<%', '%>', 'jsp'],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Recherche mémoïsée : indexOf(needle, from) amorti en O(n) sur tout le fichier. */
|
|
31
|
+
function makeFinder(text) {
|
|
32
|
+
const cache = new Map();
|
|
33
|
+
return (needle, from) => {
|
|
34
|
+
const c = cache.get(needle);
|
|
35
|
+
if (c && c.from <= from && (c.at === -1 || c.at >= from)) return c.at;
|
|
36
|
+
const at = text.indexOf(needle, from);
|
|
37
|
+
cache.set(needle, { from, at });
|
|
38
|
+
return at;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Saute une expression JavaScript équilibrée commençant par `{` à la position i.
|
|
44
|
+
* Gère accolades imbriquées, chaînes '…' "…", gabarits `…${…}…` et commentaires.
|
|
45
|
+
* Retourne l'indice suivant l'accolade fermante, ou -1 si l'expression n'est pas fermée avant `limit`.
|
|
46
|
+
*/
|
|
47
|
+
export function skipBraces(text, i, limit = text.length) {
|
|
48
|
+
const stack = [0]; // profondeur d'accolades ; un niveau par gabarit `…` ouvert
|
|
49
|
+
let inTemplate = false;
|
|
50
|
+
i++;
|
|
51
|
+
while (i < limit) {
|
|
52
|
+
const c = text.charCodeAt(i);
|
|
53
|
+
if (inTemplate) {
|
|
54
|
+
if (c === 92) { i += 2; continue; }
|
|
55
|
+
if (c === 96) { inTemplate = false; i++; continue; }
|
|
56
|
+
if (c === 36 && text.charCodeAt(i + 1) === 123) { stack.push(0); inTemplate = false; i += 2; continue; }
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === 34 || c === 39) {
|
|
61
|
+
// chaîne simple : une fin de ligne la termine aussi (JS invalide, ou apostrophe dans du texte JSX)
|
|
62
|
+
i++;
|
|
63
|
+
while (i < limit) {
|
|
64
|
+
const d = text.charCodeAt(i);
|
|
65
|
+
if (d === 92) { i += 2; continue; }
|
|
66
|
+
if (d === c || d === 10) break;
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
i++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (c === 96) { inTemplate = true; i++; continue; }
|
|
73
|
+
if (c === 47 && text.charCodeAt(i + 1) === 42) {
|
|
74
|
+
const end = text.indexOf('*/', i + 2);
|
|
75
|
+
if (end === -1 || end >= limit) return -1;
|
|
76
|
+
i = end + 2;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (c === 123) { stack[stack.length - 1]++; i++; continue; }
|
|
80
|
+
if (c === 125) {
|
|
81
|
+
if (stack[stack.length - 1] > 0) { stack[stack.length - 1]--; i++; continue; }
|
|
82
|
+
if (stack.length === 1) return i + 1;
|
|
83
|
+
stack.pop();
|
|
84
|
+
inTemplate = true; // fin d'un ${…} : retour dans le gabarit
|
|
85
|
+
i++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
return -1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Normalise le nom d'un attribut selon les syntaxes de liaison des frameworks. */
|
|
94
|
+
export function normalizeAttribute(raw) {
|
|
95
|
+
if (raw === '') return { kind: 'ignored', name: raw };
|
|
96
|
+
const c = raw[0];
|
|
97
|
+
if (c === '@' || c === '(' || c === '#' || c === '*' || raw.startsWith('v-on:') || raw.startsWith('on:')) return { kind: 'event', name: raw };
|
|
98
|
+
if (c === '[') {
|
|
99
|
+
let inner = raw.endsWith(']') ? raw.slice(1, -1) : raw.slice(1);
|
|
100
|
+
if (inner.startsWith('(')) return { kind: 'event', name: raw }; // [(ngModel)]
|
|
101
|
+
if (inner.startsWith('attr.')) inner = inner.slice(5);
|
|
102
|
+
return { kind: 'bound', name: inner };
|
|
103
|
+
}
|
|
104
|
+
if (c === ':') return { kind: 'bound', name: raw.slice(1) };
|
|
105
|
+
for (const p of ['v-bind:', 'bind:', 'th:']) if (raw.startsWith(p)) return { kind: 'bound', name: raw.slice(p.length) };
|
|
106
|
+
return { kind: 'plain', name: raw === 'className' ? 'class' : raw };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Valeur statique d'une expression JSX `{"…"}`, `{'…'}` ou `{`…`}` sans interpolation ; sinon null. */
|
|
110
|
+
function jsxStaticString(expr) {
|
|
111
|
+
const s = expr.slice(1, -1).trim();
|
|
112
|
+
const q = s[0];
|
|
113
|
+
if ((q === '"' || q === "'" || q === '`') && s.length >= 2 && s.at(-1) === q) {
|
|
114
|
+
const body = s.slice(1, -1);
|
|
115
|
+
if (!body.includes(q) && !(q === '`' && body.includes('${'))) return body;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Découpe un texte en jetons. Positions absolues (base `offset`).
|
|
122
|
+
* Types : tag, endtag, text, comment, template, expr (accolade JSX dans le texte), raw, doctype.
|
|
123
|
+
*/
|
|
124
|
+
export function tokenize(text, offset = 0, depth = 0, end = text.length) {
|
|
125
|
+
const tokens = [];
|
|
126
|
+
const find = makeFinder(text);
|
|
127
|
+
let elFailed = false; // un `${` non fermé désactive l'analyse équilibrée des suivants (linéarité)
|
|
128
|
+
let i = offset;
|
|
129
|
+
let textStart = i;
|
|
130
|
+
|
|
131
|
+
const flushText = (to) => {
|
|
132
|
+
if (to > textStart) tokens.push({ type: 'text', start: textStart, end: to });
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Tente de reconnaître un gabarit à la position j ; retourne la fin ou -1. */
|
|
136
|
+
const matchTemplate = (j, maxLen = Infinity) => {
|
|
137
|
+
for (const [open, close, syntax] of TEMPLATES) {
|
|
138
|
+
if (!text.startsWith(open, j)) continue;
|
|
139
|
+
const at = find(close, j + open.length);
|
|
140
|
+
if (at === -1 || at + close.length > end || at - j > maxLen) return null;
|
|
141
|
+
return { end: at + close.length, syntax };
|
|
142
|
+
}
|
|
143
|
+
if ((text.charCodeAt(j) === 36 || text.charCodeAt(j) === 35) && text.charCodeAt(j + 1) === 123 && !elFailed) {
|
|
144
|
+
// ${…} (JSP EL, gabarits JS) et #{…} (JSF)
|
|
145
|
+
const stop = Math.min(end, maxLen === Infinity ? end : j + maxLen);
|
|
146
|
+
const e = skipBraces(text, j + 1, stop);
|
|
147
|
+
if (e === -1) { elFailed = true; return null; }
|
|
148
|
+
return { end: e, syntax: 'el' };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
while (i < end) {
|
|
154
|
+
const c = text.charCodeAt(i);
|
|
155
|
+
|
|
156
|
+
if (c === 123 || c === 36 || c === 35 || (c === 60 && text.charCodeAt(i + 1) === 37)) {
|
|
157
|
+
const t = matchTemplate(i);
|
|
158
|
+
if (t) {
|
|
159
|
+
flushText(i);
|
|
160
|
+
tokens.push({ type: 'template', syntax: t.syntax, start: i, end: t.end });
|
|
161
|
+
i = textStart = t.end;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (c === 123) {
|
|
165
|
+
// accolade JSX : on la note sans sauter son contenu, pour analyser les balises imbriquées
|
|
166
|
+
flushText(i);
|
|
167
|
+
tokens.push({ type: 'expr', start: i, end: i + 1 });
|
|
168
|
+
i = textStart = i + 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (c !== 60) { i++; continue; }
|
|
174
|
+
const n = text.charCodeAt(i + 1);
|
|
175
|
+
|
|
176
|
+
if (n === 33) { // <!
|
|
177
|
+
flushText(i);
|
|
178
|
+
if (text.startsWith('<!--', i)) {
|
|
179
|
+
const e = find('-->', i + 4);
|
|
180
|
+
const stop = e === -1 || e + 3 > end ? end : e + 3;
|
|
181
|
+
tokens.push({ type: 'comment', start: i, end: stop });
|
|
182
|
+
i = textStart = stop;
|
|
183
|
+
} else if (text.startsWith('<![CDATA[', i)) {
|
|
184
|
+
const e = find(']]>', i + 9);
|
|
185
|
+
const stop = e === -1 || e + 3 > end ? end : e + 3;
|
|
186
|
+
tokens.push({ type: 'comment', start: i, end: stop });
|
|
187
|
+
i = textStart = stop;
|
|
188
|
+
} else {
|
|
189
|
+
const e = find('>', i + 2);
|
|
190
|
+
const stop = e === -1 || e + 1 > end ? end : e + 1;
|
|
191
|
+
tokens.push({ type: 'doctype', start: i, end: stop });
|
|
192
|
+
i = textStart = stop;
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (n === 63) { // <? … ?> (XML, PHP)
|
|
197
|
+
flushText(i);
|
|
198
|
+
const e = find('?>', i + 2);
|
|
199
|
+
const stop = e === -1 || e + 2 > end ? end : e + 2;
|
|
200
|
+
tokens.push({ type: 'comment', start: i, end: stop });
|
|
201
|
+
i = textStart = stop;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (n === 47 && isAlpha(text.charCodeAt(i + 2))) { // </nom>
|
|
205
|
+
flushText(i);
|
|
206
|
+
let j = i + 2;
|
|
207
|
+
while (j < end && isNameChar(text.charCodeAt(j))) j++;
|
|
208
|
+
const rawName = text.slice(i + 2, j);
|
|
209
|
+
const e = find('>', j);
|
|
210
|
+
const stop = e === -1 || e + 1 > end ? end : e + 1;
|
|
211
|
+
tokens.push({ type: 'endtag', name: rawName.toLowerCase(), rawName, start: i, end: stop });
|
|
212
|
+
i = textStart = stop;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (n === 62 && depth > 0) { i += 2; continue; } // fragment JSX <>
|
|
216
|
+
if (!isAlpha(n)) { i++; continue; }
|
|
217
|
+
|
|
218
|
+
// Balise ouvrante
|
|
219
|
+
flushText(i);
|
|
220
|
+
const tag = readTag(i);
|
|
221
|
+
tokens.push(tag);
|
|
222
|
+
for (const nested of tag.nested) tokens.push(nested);
|
|
223
|
+
delete tag.nested;
|
|
224
|
+
i = textStart = tag.end;
|
|
225
|
+
|
|
226
|
+
if (!tag.selfClosing && RAW_TEXT.has(tag.name)) {
|
|
227
|
+
// contenu brut : rien n'y est une balise jusqu'à </script> ou </style>
|
|
228
|
+
let k = tag.end;
|
|
229
|
+
const closer = `</${tag.name}`;
|
|
230
|
+
let e = -1;
|
|
231
|
+
while (k < end) {
|
|
232
|
+
e = text.indexOf('<', k);
|
|
233
|
+
if (e === -1 || e >= end) { e = -1; break; }
|
|
234
|
+
if (text.slice(e, e + closer.length).toLowerCase() === closer && !isNameChar(text.charCodeAt(e + closer.length))) break;
|
|
235
|
+
k = e + 1;
|
|
236
|
+
e = -1;
|
|
237
|
+
}
|
|
238
|
+
const stop = e === -1 ? end : e;
|
|
239
|
+
if (stop > tag.end) tokens.push({ type: 'raw', start: tag.end, end: stop });
|
|
240
|
+
i = textStart = stop;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
flushText(end);
|
|
244
|
+
return tokens;
|
|
245
|
+
|
|
246
|
+
function readTag(start) {
|
|
247
|
+
let j = start + 1;
|
|
248
|
+
while (j < end && isNameChar(text.charCodeAt(j))) j++;
|
|
249
|
+
const rawName = text.slice(start + 1, j);
|
|
250
|
+
const tag = { type: 'tag', name: rawName.toLowerCase(), rawName, start, end, attributes: [], selfClosing: false, incomplete: true, nested: [] };
|
|
251
|
+
|
|
252
|
+
while (j < end) {
|
|
253
|
+
const c = text.charCodeAt(j);
|
|
254
|
+
if (isSpace(c)) { j++; continue; }
|
|
255
|
+
if (c === 62) { tag.end = j + 1; tag.incomplete = false; return tag; }
|
|
256
|
+
if (c === 47 && text.charCodeAt(j + 1) === 62) { tag.end = j + 2; tag.selfClosing = true; tag.incomplete = false; return tag; }
|
|
257
|
+
if (c === 47) { j++; continue; }
|
|
258
|
+
|
|
259
|
+
// Gabarits dans la balise : {% if %}disabled{% endif %}, {{ attrs }}, <%= … %>, ${…}
|
|
260
|
+
if (c === 123 && (text.charCodeAt(j + 1) === 123 || text.charCodeAt(j + 1) === 37 || text.charCodeAt(j + 1) === 35)
|
|
261
|
+
|| (c === 60 && text.charCodeAt(j + 1) === 37) || ((c === 36 || c === 35) && text.charCodeAt(j + 1) === 123)) {
|
|
262
|
+
const t = matchTemplate(j);
|
|
263
|
+
if (t) { tag.attributes.push({ name: '', rawName: text.slice(j, t.end), kind: 'template', value: DYNAMIC, start: j }); j = t.end; continue; }
|
|
264
|
+
}
|
|
265
|
+
// Décomposition JSX {...props}
|
|
266
|
+
if (c === 123) {
|
|
267
|
+
const e = skipBraces(text, j, end);
|
|
268
|
+
if (e === -1) { tag.end = end; return tag; }
|
|
269
|
+
tag.attributes.push({ name: '', rawName: text.slice(j, e), kind: 'spread', value: DYNAMIC, start: j });
|
|
270
|
+
if (depth < MAX_DEPTH) tag.nested.push(...tokenize(text, j + 1, depth + 1, e - 1).filter((t) => t.type === 'tag').map((t) => ({ ...t, inAttribute: true })));
|
|
271
|
+
j = e;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Nom d'attribut (jusqu'à espace, =, >, /> ou guillemet)
|
|
276
|
+
const nameStart = j;
|
|
277
|
+
while (j < end) {
|
|
278
|
+
const d = text.charCodeAt(j);
|
|
279
|
+
if (isSpace(d) || d === 61 || d === 62 || d === 34 || d === 39 || (d === 47 && text.charCodeAt(j + 1) === 62)) break;
|
|
280
|
+
j++;
|
|
281
|
+
}
|
|
282
|
+
if (j === nameStart) { j++; continue; } // guillemet isolé : ignoré
|
|
283
|
+
const rawAttr = text.slice(nameStart, j);
|
|
284
|
+
let k = j;
|
|
285
|
+
while (k < end && isSpace(text.charCodeAt(k))) k++;
|
|
286
|
+
const attr = { ...normalizeAttribute(rawAttr), rawName: rawAttr, value: '', quoted: false, start: nameStart };
|
|
287
|
+
if (text.charCodeAt(k) !== 61) { // attribut booléen
|
|
288
|
+
attr.boolean = true;
|
|
289
|
+
tag.attributes.push(attr);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
k++;
|
|
293
|
+
while (k < end && isSpace(text.charCodeAt(k))) k++;
|
|
294
|
+
const q = text.charCodeAt(k);
|
|
295
|
+
if (q === 34 || q === 39) {
|
|
296
|
+
// valeur entre guillemets ; les gabarits qu'elle contient peuvent eux-mêmes contenir ce guillemet
|
|
297
|
+
let m = k + 1;
|
|
298
|
+
let dynamic = false;
|
|
299
|
+
while (m < end && text.charCodeAt(m) !== q) {
|
|
300
|
+
const d = text.charCodeAt(m);
|
|
301
|
+
if (d === 123 || d === 36 || d === 35 || (d === 60 && text.charCodeAt(m + 1) === 37)) {
|
|
302
|
+
const t = matchTemplate(m, MAX_TEMPLATE);
|
|
303
|
+
if (t) { dynamic = true; m = t.end; continue; }
|
|
304
|
+
}
|
|
305
|
+
m++;
|
|
306
|
+
}
|
|
307
|
+
if (m >= end) { tag.end = end; return tag; } // guillemet jamais fermé
|
|
308
|
+
attr.value = text.slice(k + 1, m);
|
|
309
|
+
attr.quoted = true;
|
|
310
|
+
if (dynamic) attr.template = true;
|
|
311
|
+
j = m + 1;
|
|
312
|
+
} else if (q === 123) {
|
|
313
|
+
const e = skipBraces(text, k, end);
|
|
314
|
+
if (e === -1) { tag.end = end; return tag; }
|
|
315
|
+
const expr = text.slice(k, e);
|
|
316
|
+
const s = jsxStaticString(expr);
|
|
317
|
+
attr.value = s ?? DYNAMIC;
|
|
318
|
+
attr.jsx = true;
|
|
319
|
+
if (s === null && depth < MAX_DEPTH) tag.nested.push(...tokenize(text, k + 1, depth + 1, e - 1).filter((t) => t.type === 'tag').map((t) => ({ ...t, inAttribute: true })));
|
|
320
|
+
j = e;
|
|
321
|
+
} else {
|
|
322
|
+
let m = k;
|
|
323
|
+
while (m < end) {
|
|
324
|
+
const d = text.charCodeAt(m);
|
|
325
|
+
if (isSpace(d) || d === 62) break;
|
|
326
|
+
if (d === 47 && text.charCodeAt(m + 1) === 62) break;
|
|
327
|
+
m++;
|
|
328
|
+
}
|
|
329
|
+
attr.value = text.slice(k, m);
|
|
330
|
+
j = m;
|
|
331
|
+
}
|
|
332
|
+
tag.attributes.push(attr);
|
|
333
|
+
}
|
|
334
|
+
tag.end = end;
|
|
335
|
+
return tag;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Réduit la liste d'attributs d'une balise en dictionnaire nom → valeur.
|
|
341
|
+
* Les liaisons (`:href`, `[href]`, `v-bind:href`, `href={url}`) valent DYNAMIC ;
|
|
342
|
+
* une valeur statique l'emporte toujours sur une liaison du même nom ; les
|
|
343
|
+
* événements, décompositions et gabarits sont ignorés.
|
|
344
|
+
*/
|
|
345
|
+
export function attributeMap(attributes) {
|
|
346
|
+
const map = Object.create(null);
|
|
347
|
+
for (const a of attributes) {
|
|
348
|
+
if (a.kind === 'plain') {
|
|
349
|
+
if (!(a.name in map) || map[a.name] === DYNAMIC) map[a.name] = a.value;
|
|
350
|
+
} else if (a.kind === 'bound') {
|
|
351
|
+
if (!(a.name in map)) map[a.name] = DYNAMIC;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return map;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Analyse un document : jetons + éléments ouvrants avec parent, fermeture et dictionnaire d'attributs.
|
|
359
|
+
* Chaque élément : { name, rawName, start, end, line, attrs, attributes, selfClosing, parent, close, tokenIndex, closeTokenIndex }.
|
|
360
|
+
* `close` vaut la position de la balise fermante correspondante (null si implicite ou absente).
|
|
361
|
+
*/
|
|
362
|
+
export function parseDocument(text) {
|
|
363
|
+
const tokens = tokenize(text);
|
|
364
|
+
const lines = lineStarts(text);
|
|
365
|
+
const elements = [];
|
|
366
|
+
const stack = [];
|
|
367
|
+
for (let t = 0; t < tokens.length; t++) {
|
|
368
|
+
const tok = tokens[t];
|
|
369
|
+
if (tok.type === 'tag') {
|
|
370
|
+
if (tok.incomplete) continue;
|
|
371
|
+
const el = {
|
|
372
|
+
name: tok.name, rawName: tok.rawName, start: tok.start, end: tok.end, line: lineAt(lines, tok.start),
|
|
373
|
+
attrs: attributeMap(tok.attributes), attributes: tok.attributes, selfClosing: tok.selfClosing,
|
|
374
|
+
parent: stack.at(-1) ?? null, close: null, tokenIndex: t, closeTokenIndex: null,
|
|
375
|
+
};
|
|
376
|
+
elements.push(el);
|
|
377
|
+
if (!tok.selfClosing && !tok.inAttribute && !VOID.has(tok.name)) stack.push(el);
|
|
378
|
+
} else if (tok.type === 'endtag') {
|
|
379
|
+
// fermeture tolérante : on dépile jusqu'à l'élément correspondant, s'il est ouvert
|
|
380
|
+
let k = stack.length - 1;
|
|
381
|
+
while (k >= 0 && stack[k].rawName !== tok.rawName && stack[k].name !== tok.name) k--;
|
|
382
|
+
if (k < 0) continue;
|
|
383
|
+
stack[k].close = tok.start;
|
|
384
|
+
stack[k].closeTokenIndex = t;
|
|
385
|
+
stack.length = k;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return { tokens, elements, lines };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Débuts de ligne, pour convertir une position en numéro de ligne par dichotomie. */
|
|
392
|
+
export function lineStarts(text) {
|
|
393
|
+
const starts = [0];
|
|
394
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
|
|
395
|
+
return starts;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function lineAt(starts, index) {
|
|
399
|
+
let lo = 0;
|
|
400
|
+
let hi = starts.length - 1;
|
|
401
|
+
while (lo < hi) {
|
|
402
|
+
const mid = (lo + hi + 1) >> 1;
|
|
403
|
+
if (starts[mid] <= index) lo = mid; else hi = mid - 1;
|
|
404
|
+
}
|
|
405
|
+
return lo + 1;
|
|
406
|
+
}
|