@jonathankablan/noraivoire-cli 0.0.0-stage → 0.1.1

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 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
- # Temporary Holding Version
1
+ # @jonathankablan/noraivoire-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `nora check` 0.1.1 : 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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.1"
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
+ }
@@ -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
+ }