@kiubi/mcp-designer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +219 -0
  3. package/package.json +57 -0
  4. package/src/app.ts +39 -0
  5. package/src/index.ts +21 -0
  6. package/src/lib/stemmer.ts +38 -0
  7. package/src/lib/theme-checker/block-validator.ts +353 -0
  8. package/src/lib/theme-checker/checker.ts +897 -0
  9. package/src/lib/theme-checker/filter-validator.ts +163 -0
  10. package/src/lib/theme-checker/index.ts +6 -0
  11. package/src/lib/theme-checker/inspector.ts +193 -0
  12. package/src/lib/theme-checker/lang.ts +42 -0
  13. package/src/lib/theme-checker/parsers/component-xml.ts +120 -0
  14. package/src/lib/theme-checker/parsers/model-xml.ts +82 -0
  15. package/src/lib/theme-checker/parsers/post-type-xml.ts +92 -0
  16. package/src/lib/theme-checker/parsers/symbol-xml.ts +162 -0
  17. package/src/lib/theme-checker/parsers/theme-xml.ts +102 -0
  18. package/src/lib/theme-checker/types.ts +97 -0
  19. package/src/logger.ts +28 -0
  20. package/src/tool.ts +13 -0
  21. package/src/tools/create_type.ts +535 -0
  22. package/src/tools/detail_type.ts +208 -0
  23. package/src/tools/get_doc_section.ts +201 -0
  24. package/src/tools/list_types.ts +87 -0
  25. package/src/tools/list_widgets.ts +181 -0
  26. package/src/tools/ruleset.ts +25 -0
  27. package/src/tools/search_doc.ts +160 -0
  28. package/src/tools/validate_theme.ts +63 -0
  29. package/storage/definitions/blog.json +1444 -0
  30. package/storage/definitions/catalogue.json +2817 -0
  31. package/storage/definitions/cms.json +1002 -0
  32. package/storage/definitions/commandes.json +2409 -0
  33. package/storage/definitions/comptes.json +780 -0
  34. package/storage/definitions/modules.json +418 -0
  35. package/storage/definitions/recherche.json +804 -0
  36. package/storage/documents/CLAUDE.exemple.md +281 -0
  37. package/storage/documents/generated/exemples.md +8054 -0
  38. package/storage/documents/generated/integration/01_theme_perso.md +182 -0
  39. package/storage/documents/generated/integration/02_modeles.md +220 -0
  40. package/storage/documents/generated/integration/03_widgets.md +139 -0
  41. package/storage/documents/generated/integration/04_blocs.md +45 -0
  42. package/storage/documents/generated/integration/05_balises.md +43 -0
  43. package/storage/documents/generated/integration/06_balises_globales.md +201 -0
  44. package/storage/documents/generated/integration/07_balises_widget.md +257 -0
  45. package/storage/documents/generated/integration/08_types_billets.md +154 -0
  46. package/storage/documents/generated/integration/09_types_produits.md +77 -0
  47. package/storage/documents/generated/integration/10_types_composants.md +142 -0
  48. package/storage/documents/generated/integration/11_symboles.md +156 -0
  49. package/storage/documents/generated/integration/12_mediatheque.md +66 -0
  50. package/storage/documents/generated/integration/13_includes.md +25 -0
  51. package/storage/documents/generated/integration/14_404.md +32 -0
  52. package/storage/documents/generated/integration/15_arianne.md +62 -0
  53. package/storage/documents/generated/integration/16_fermeture.md +36 -0
  54. package/storage/documents/generated/integration/17_recherche.md +36 -0
  55. package/storage/documents/generated/integration/18_cms.md +582 -0
  56. package/storage/documents/generated/integration/19_modules.md +479 -0
  57. package/storage/documents/generated/integration/20_blog.md +946 -0
  58. package/storage/documents/generated/integration/21_catalogue.md +1834 -0
  59. package/storage/documents/generated/integration/22_commandes.md +2772 -0
  60. package/storage/documents/generated/integration/23_comptes.md +1195 -0
  61. package/storage/documents/generated/integration/24_recherche.md +542 -0
  62. package/storage/documents/hors_sujet_regles.md +43 -0
  63. package/storage/documents/kiubi_api.md +6690 -0
  64. package/storage/documents/kiubi_ruleset.md +419 -0
  65. package/storage/documents/kiubi_workflows.md +121 -0
@@ -0,0 +1,897 @@
1
+ import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
2
+ import * as nodePath from 'node:path';
3
+ import type {ThemeReport, Issue, ThemeType, TypeDefinition, SymbolDef, ModelDef} from './types.ts';
4
+ import {
5
+ ThemeInspector
6
+ } from './inspector.ts';
7
+ import { validateFilters } from './filter-validator.ts';
8
+ import {
9
+ validateBlocks,
10
+ loadBlockDef,
11
+ applyCustomFieldsDefinition,
12
+ applyCollectionDefinition
13
+ } from './block-validator.ts';
14
+
15
+ // ─── Block definition mapping per type ───────────────────────────────────────
16
+
17
+ const TYPE_BLOCK_DEF: Record<ThemeType, { jsonFile: string; basePath: string }> = {
18
+ cms: { jsonFile: 'cms.json', basePath: 'billets' },
19
+ blog: { jsonFile: 'blog.json', basePath: 'billets_blog' },
20
+ product: { jsonFile: 'catalogue.json', basePath: 'produits' },
21
+ component: { jsonFile: 'cms.json', basePath: 'composants' },
22
+ symbol: { jsonFile: 'cms.json', basePath: 'symboles' },
23
+ model: { jsonFile: 'cms.json', basePath: 'templates' } // FIXME basePath
24
+ };
25
+
26
+ // ─── Mandatory widget files (relative to fr/widgets/) ───────────────────────
27
+
28
+ const WIDGETS = {
29
+ 'blog': {
30
+ 'archives' : {
31
+ templates : [{name: 'index.html', required: true}]
32
+ },
33
+ 'categories' : {
34
+ templates : [{name: 'index.html', required: true}]
35
+ },
36
+ 'derniers_billets_postes' : {
37
+ templates : [{name: 'index.html', required: true}]
38
+ },
39
+ 'derniers_commentaires_postes' : {
40
+ templates : [{name: 'index.html', required: true}]
41
+ },
42
+ 'detail_billet' : {
43
+ templates : [{name: 'index.html', required: true}]
44
+ },
45
+ 'les_plus_commentes' : {
46
+ templates : [{name: 'index.html', required: true}]
47
+ },
48
+ 'liens' : {
49
+ templates : [{name: 'index.html', required: true}]
50
+ },
51
+ 'liste_billets' : {
52
+ templates : [{name: 'index.html', required: true}]
53
+ },
54
+ 'liste_commentaires' : {
55
+ templates : [{name: 'index.html', required: true}]
56
+ },
57
+ 'poster_commentaire' : {
58
+ templates : [{name: 'index.html', required: true}]
59
+ },
60
+ },
61
+ 'catalogue': {
62
+ 'categories' : {
63
+ templates : [{name: 'index.html', required: true}]
64
+ },
65
+ 'liste_categories' : {
66
+ templates : [{name: 'index.html', required: true}]
67
+ },
68
+ 'liste_commentaires' : {
69
+ templates : [{name: 'index.html', required: true}]
70
+ },
71
+ 'liste_produits' : {
72
+ templates : [
73
+ {name: 'index.html', required: true},
74
+ {name: 'liste.html', required: true},
75
+ {name: 'vignettes.html', required: true},
76
+ ]
77
+ },
78
+ 'poster_commentaire' : {
79
+ templates : [{name: 'index.html', required: true}]
80
+ },
81
+ 'produits_associes' : {
82
+ templates : [{name: 'index.html', required: true}]
83
+ },
84
+ 'produits_egalement_achetes' : {
85
+ templates : [{name: 'index.html', required: true}]
86
+ },
87
+ 'produits_plus' : {
88
+ templates : [{name: 'index.html', required: true}]
89
+ },
90
+ 'produits_vedettes' : {
91
+ templates : [{name: 'index.html', required: true}]
92
+ },
93
+ 'tags' : {
94
+ templates : [{name: 'index.html', required: true}]
95
+ },
96
+ },
97
+ 'commandes': {
98
+ 'livraison' : {
99
+ templates : [
100
+ {name: 'index.html', required: true},
101
+ {name: 'transporteurs.html', required: true},
102
+ {name: 'dpd.html', required: true},
103
+ {name: 'mondialrelay.html', required: true},
104
+ {name: 'colissimo_pickup.html', required: true},
105
+ ]
106
+ },
107
+ 'paiement' : {
108
+ templates : [
109
+ {name: 'atos.html', required: true},
110
+ {name: 'cheque.html', required: true},
111
+ {name: 'cm_cic.html', required: true},
112
+ {name: 'index.html', required: true},
113
+ {name: 'paybox.html', required: true},
114
+ {name: 'payline.html', required: true},
115
+ {name: 'payplug.html', required: true},
116
+ {name: 'paypal.html', required: true},
117
+ {name: 'stripe.html', required: true},
118
+ {name: 'systempay.html', required: true},
119
+ {name: 'virement.html', required: true},
120
+ ]
121
+ },
122
+ 'panier' : {
123
+ templates : [{name: 'index.html', required: true}]
124
+ },
125
+ 'panier_detail' : {
126
+ templates : [{name: 'index.html', required: true}]
127
+ },
128
+ 'validation' : {
129
+ templates : [{name: 'index.html', required: true}]
130
+ },
131
+ },
132
+ 'communication': {
133
+ 'contact' : {
134
+ templates : [{name: 'index.html', required: true}]
135
+ },
136
+ 'formulaires' : {
137
+ templates : [{name: 'index.html', required: true}]
138
+ },
139
+ 'newsletter' : {
140
+ templates : [{name: 'index.html', required: true}]
141
+ },
142
+ 'syndication_blog' : {
143
+ templates : [{name: 'index.html', required: true}]
144
+ },
145
+ },
146
+ 'compte': {
147
+ 'identification' : {
148
+ templates : [{name: 'index.html', required: true}]
149
+ },
150
+ 'identification_rapide' : {
151
+ templates : [{name: 'index.html', required: true}]
152
+ },
153
+ 'inscription' : {
154
+ templates : [{name: 'index.html', required: true}]
155
+ },
156
+ 'tableau_bord' : {
157
+ templates : [
158
+ {name: 'index.html', required: true},
159
+ {name: 'mail_bon_reduction.html', required: true},
160
+ {name: 'mail_motdepasse_perdu.html', required: true},
161
+ {name: 'cloture.html', required: true},
162
+ {name: 'abonnements.html', required: true},
163
+ {name: 'avatar.html', required: true},
164
+ {name: 'id_web.html', required: true},
165
+ {name: 'infos_perso.html', required: true},
166
+ {name: 'motdepasse.html', required: true},
167
+ {name: 'motdepasse_perdu.html', required: true},
168
+ ]
169
+ },
170
+ },
171
+ 'recherche': {
172
+ 'simple' : {
173
+ templates : [
174
+ {name: 'index.html', required: true},
175
+ {name: 'blog.html', required: true},
176
+ {name: 'catalogue.html', required: true},
177
+ {name: 'cms.html', required: true},
178
+ ]
179
+ },
180
+ },
181
+ 'site_web': {
182
+ 'bloc_extrait' : {
183
+ templates : [{name: 'index.html', required: true}]
184
+ },
185
+ 'contenu_page_libre' : {
186
+ templates : [{name: 'index.html', required: true}]
187
+ },
188
+ 'menu_h' : {
189
+ templates : [{name: 'index.html', required: true}]
190
+ },
191
+ 'menu_v' : {
192
+ templates : [{name: 'index.html', required: true}]
193
+ },
194
+ },
195
+ };
196
+
197
+ const MANDATORY_MODULES = [
198
+ '/404/404.html',
199
+ '/chemin/index.html',
200
+ '/fermeture/fermeture.html',
201
+ ];
202
+
203
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
204
+
205
+ function walkFiles(dir: string, extensions: string[]): string[] {
206
+ const results: string[] = [];
207
+ try {
208
+ for (const name of readdirSync(dir)) {
209
+ const full = nodePath.join(dir, name);
210
+ try {
211
+ if (statSync(full).isDirectory()) {
212
+ results.push(...walkFiles(full, extensions));
213
+ } else if (!extensions.length || extensions.some(ext => name.endsWith(ext))) {
214
+ results.push(full);
215
+ }
216
+ } catch { /* skip inaccessible entries */ }
217
+ }
218
+ } catch { /* dir not accessible */ }
219
+ return results;
220
+ }
221
+
222
+ function readLatin1(filePath: string): string {
223
+ return readFileSync(filePath, 'latin1');
224
+ }
225
+
226
+ const ENCODING_SAMPLE_SIZE = 4096;
227
+
228
+ function isUtf8Encoded(filePath: string): boolean {
229
+ let buf: Buffer;
230
+ try { buf = readFileSync(filePath); } catch { return false; }
231
+ const sample = buf.length > ENCODING_SAMPLE_SIZE ? buf.subarray(0, ENCODING_SAMPLE_SIZE) : buf;
232
+ // BOM UTF-8 (EF BB BF)
233
+ if (sample.length >= 3 && sample[0] === 0xEF && sample[1] === 0xBB && sample[2] === 0xBF) {
234
+ return true;
235
+ }
236
+ // Fichier purement ASCII → valide dans les deux encodages
237
+ if (!sample.some(b => b > 0x7F)) return false;
238
+ // Décodage UTF-8 strict : succès = fichier UTF-8
239
+ try {
240
+ new TextDecoder('utf-8', { fatal: true }).decode(sample);
241
+ return true;
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ /** Returns field names declared in a type definition. */
248
+ function getFieldNames(def: TypeDefinition): string[] {
249
+ if ("fields" in def && def.fields) {
250
+ return def.fields.map(c => c.field);
251
+ }
252
+ return [];
253
+ }
254
+
255
+ /**
256
+ * Returns field names that are declared in `fieldNames` but NOT found in the template content.
257
+ * Template variables look like {varname} or {varname|filter}.
258
+ */
259
+ function findUnusedFields(fieldNames: string[], content: string): string[] {
260
+ if (fieldNames.length === 0) return [];
261
+ const remaining = new Set(fieldNames);
262
+ const regex = /\{([^|\s}]+)(?:\|[^}]+)?\}/g;
263
+ let m: RegExpExecArray | null;
264
+ while ((m = regex.exec(content)) !== null) {
265
+ remaining.delete(m[1]);
266
+ }
267
+ return Array.from(remaining);
268
+ }
269
+
270
+ function listSubdirs(dir: string): string[] {
271
+ try {
272
+ return readdirSync(dir).filter(name => {
273
+ try {
274
+ return statSync(nodePath.join(dir, name)).isDirectory();
275
+ } catch {
276
+ return false;
277
+ }
278
+ });
279
+ } catch {
280
+ return [];
281
+ }
282
+ }
283
+
284
+ // ─── ThemeChecker ─────────────────────────────────────────────────────────────
285
+
286
+ export class ThemeChecker {
287
+ private issues: Issue[] = [];
288
+ private themePath = '';
289
+
290
+ private rel(file: string): string {
291
+ if (!this.themePath) return file;
292
+ const rel = file.startsWith(this.themePath) ? file.slice(this.themePath.length) : file;
293
+ return rel || file;
294
+ }
295
+
296
+ private error(category: string, msg: string, file?: string, line?: number): void {
297
+ this.issues.push({ level: 'error', category, msg, file: file ? this.rel(file) : undefined, line });
298
+ }
299
+
300
+ private warning(category: string, msg: string, file?: string, line?: number): void {
301
+ this.issues.push({ level: 'warning', category, msg, file: file ? this.rel(file) : undefined, line });
302
+ }
303
+
304
+ private reportFilterIssues(content: string, category: string, filePath: string): void {
305
+ for (const issue of validateFilters(content)) {
306
+ if (issue.level === 'error') {
307
+ this.error(category, issue.msg, filePath, issue.line);
308
+ } else {
309
+ this.warning(category, issue.msg, filePath, issue.line);
310
+ }
311
+ }
312
+ }
313
+
314
+ private reportBlockIssues(content: string, category: string, filePath: string, jsonFile: string | null, basePath: string | null, typeDef: TypeDefinition | null): void {
315
+ let def = (jsonFile && basePath) ? loadBlockDef(jsonFile, basePath) : null;
316
+ if (def !== null && typeDef !== null) {
317
+ if ('fields' in typeDef && typeDef.fields.length > 0) {
318
+ def = applyCustomFieldsDefinition(def, typeDef.fields);
319
+ }
320
+ if ('collection' in typeDef && typeDef.collection.length > 0) {
321
+ def = applyCollectionDefinition(def, typeDef.collection);
322
+ }
323
+ }
324
+ for (const issue of validateBlocks(content, def)) {
325
+ if (issue.level === 'error') {
326
+ this.error(category, issue.msg, filePath, issue.line);
327
+ } else {
328
+ this.warning(category, issue.msg, filePath, issue.line);
329
+ }
330
+ }
331
+ }
332
+
333
+ private checkEncoding(filePath: string): void {
334
+ if (isUtf8Encoded(filePath)) {
335
+ this.error('encodage', 'Le fichier doit être encodé en ISO-8859-1, pas en UTF-8', filePath);
336
+ }
337
+ }
338
+
339
+ // ── Config checks ──────────────────────────────────────────────────────────
340
+
341
+ private themeDescription(inspector: ThemeInspector): void {
342
+ const path = inspector.getPath();
343
+ const desc = inspector.getDescription();
344
+ if (desc === null) {
345
+ const parseError = inspector.getDescriptionError();
346
+ this.error('config', parseError ?? 'Fichier xml mal formé', nodePath.join(path, 'theme.xml'));
347
+ return;
348
+ }
349
+ if (!existsSync(nodePath.join(path, 'illustration.jpg'))) {
350
+ this.error('config', "Illustration du thème introuvable", nodePath.join(path, 'illustration.jpg'));
351
+ }
352
+ if (!existsSync(nodePath.join(path, 'fr'))) {
353
+ this.error('config', "Dossier manquant", nodePath.join(path, 'fr'));
354
+ }
355
+ }
356
+
357
+ // ── Type declarations checks ────────────────────────────────────────────────
358
+
359
+ private checkTypeDeclarations(inspector: ThemeInspector, type: ThemeType, declared: TypeDefinition[]): void {
360
+ const subdirWithSlash = inspector.getTypeSubdir(type);
361
+ const subdir = subdirWithSlash.replace(/\/$/, '');
362
+ const basePath = inspector.getLgPath(subdir);
363
+ const requiredFiles = inspector.getRequiredFilesForType(type);
364
+
365
+ const rawDirs = listSubdirs(basePath);
366
+ if (rawDirs.length === 0) {
367
+ return; // Should not happen
368
+ }
369
+
370
+ // Track dirs found on disk; remove those that have a valid type declaration
371
+ // const foundDirs = new Set(rawDirs);
372
+
373
+ for (const dir of rawDirs) {
374
+ const fullDir = nodePath.join(basePath, dir);
375
+
376
+ // Check each disk dir has required files
377
+ let hasError = false;
378
+ for (const file of requiredFiles) {
379
+ const fullFile = nodePath.join(fullDir, file);
380
+ if (!existsSync(fullFile)) {
381
+ this.error(type, 'Fichier introuvable', fullFile);
382
+ hasError = true;
383
+ } else {
384
+ this.checkEncoding(fullFile);
385
+ }
386
+ }
387
+ if (hasError) continue;
388
+
389
+ // Report parse errors from malformed descriptors
390
+ hasError = false;
391
+ for (const { id, error } of inspector.getTypeErrors(type)) {
392
+ this.error(type, error, nodePath.join(basePath, id));
393
+ hasError = true;
394
+ }
395
+ if (hasError) continue;
396
+
397
+ const typeDef = declared.find((type)=> (type.id === dir));
398
+
399
+ // Warn about dirs without a valid type declaration
400
+ if (typeof typeDef == undefined) {
401
+ this.warning(type, 'Type incomplet', nodePath.join(basePath, dir));
402
+ continue;
403
+ }
404
+
405
+ if (typeDef?.tpl) {
406
+ const resolvedContent = this.resolveIncludes(typeDef.tpl, 0);
407
+
408
+ // Compare balise declaration against template
409
+ // Ex : texte2 in .xml is used as {texte2} index.html
410
+ const fieldNames = getFieldNames(typeDef);
411
+ for (const unused of findUnusedFields(fieldNames, resolvedContent)) {
412
+ if (unused === 'title' && (type === 'component' || type === 'symbol')) {
413
+ // title is forced into fieldNames so, not mandatory
414
+ continue;
415
+ }
416
+ this.warning(type, `Balise déclarée '${unused}' mais inutilisée`, typeDef.tpl);
417
+ }
418
+ // Resolve invert : Balise utilisée mais non déclarée
419
+
420
+ this.reportFilterIssues(resolvedContent, type, typeDef.tpl);
421
+
422
+ // Compare block and expected tags against tempalte
423
+ const blockDef = TYPE_BLOCK_DEF[type];
424
+ this.reportBlockIssues(resolvedContent, type, typeDef.tpl, blockDef.jsonFile, blockDef.basePath, typeDef);
425
+ }
426
+
427
+ }
428
+
429
+ }
430
+
431
+ // ── Template (model) zone checks ──────────────────────────────────────────
432
+
433
+ private checkModel(def: ModelDef, composantEnabled: boolean): void {
434
+ // if (!isModelDef(def) && !isSymbolDef(def)) return;
435
+ const tplPath = def.tpl;
436
+ const strPath = def.structure;
437
+
438
+ if (!existsSync(tplPath)) {
439
+ this.error('model', 'Template introuvable', tplPath);
440
+ return;
441
+ }
442
+ if (!existsSync(strPath)) {
443
+ this.error('model', 'Fichier structure introuvable', strPath);
444
+ return;
445
+ }
446
+
447
+ const expectedZoneIds = new Set(def.zones.map(z => z.id));
448
+
449
+ // Check template: {ZONE.xxx} patterns
450
+ const tplContent = this.resolveIncludes(tplPath, 0);
451
+ const tplZoneRegex = /\{ZONE\.([^}]+)\}/g;
452
+ let m: RegExpExecArray | null;
453
+ while ((m = tplZoneRegex.exec(tplContent)) !== null) {
454
+ if (!expectedZoneIds.has(m[1])) {
455
+ this.warning('model', `Utilisation d'une zone '${m[1]}' non déclarée`, tplPath);
456
+ }
457
+ }
458
+
459
+ // Check structure: {xxx} patterns
460
+ const strContent = readLatin1(strPath);
461
+ const remainingZones = new Set(def.zones.map(z => z.id));
462
+ const strZoneRegex = /\{([^}]+)\}/g;
463
+ while ((m = strZoneRegex.exec(strContent)) !== null) {
464
+ if (!expectedZoneIds.has(m[1])) {
465
+ this.warning('model', `Utilisation d'une zone '${m[1]}' non déclarée`, strPath);
466
+ } else {
467
+ remainingZones.delete(m[1]);
468
+ }
469
+ }
470
+ if (remainingZones.size > 0) {
471
+ this.warning(
472
+ 'model',
473
+ `Zone(s) '${Array.from(remainingZones).join(',')}' non utilisée(s) dans la structure`,
474
+ strPath,
475
+ );
476
+ }
477
+
478
+ if (composantEnabled) {
479
+ const hasContent = def.zones.some(z => z.isContent);
480
+ if (!hasContent) {
481
+ this.warning('model', "Le template ne comporte pas de zone de contenu", strPath);
482
+ }
483
+ }
484
+ }
485
+
486
+ // ── Symbol zone checks ────────────────────────────────────────────────────
487
+
488
+ private checkSymbole(def: SymbolDef): void {
489
+ // if (!isSymbolDef(def)) return;
490
+ const tplPath = def.tpl;
491
+ const strPath = def.structure;
492
+
493
+ if (!existsSync(tplPath)) {
494
+ this.error('symbol', 'Template de symbole introuvable', tplPath);
495
+ return;
496
+ }
497
+ if (!existsSync(strPath)) {
498
+ this.error('symbol', 'Structure de symbole introuvable', strPath);
499
+ return;
500
+ }
501
+
502
+ const zoneIds = new Set(def.zones.map(z => z.id));
503
+
504
+ // Check template: {ZONE.xxx} patterns
505
+ const tplContent = this.resolveIncludes(tplPath, 0);
506
+ const remainingFromTpl = new Set(def.zones.map(z => z.id));
507
+ const tplZoneRegex = /\{ZONE\.([^}]+)\}/g;
508
+ let m: RegExpExecArray | null;
509
+ while ((m = tplZoneRegex.exec(tplContent)) !== null) {
510
+ if (!zoneIds.has(m[1])) {
511
+ this.warning('symbol', `Utilisation d'une zone '${m[1]}' non déclarée`, tplPath);
512
+ } else {
513
+ remainingFromTpl.delete(m[1]);
514
+ }
515
+ }
516
+ if (remainingFromTpl.size > 0) {
517
+ this.warning(
518
+ 'symbol',
519
+ `Zone(s) '${Array.from(remainingFromTpl).join(',')}' non utilisée(s) dans la structure`,
520
+ tplPath,
521
+ );
522
+ }
523
+
524
+ // Check structure: {xxx} patterns
525
+ const strContent = readLatin1(strPath);
526
+ const remainingFromStr = new Set(def.zones.map(z => z.id));
527
+ const strZoneRegex = /\{([^}]+)\}/g;
528
+ while ((m = strZoneRegex.exec(strContent)) !== null) {
529
+ if (!zoneIds.has(m[1])) {
530
+ this.warning('symbol', `Utilisation d'une zone '${m[1]}' non déclarée`, strPath);
531
+ } else {
532
+ remainingFromStr.delete(m[1]);
533
+ }
534
+ }
535
+ if (remainingFromStr.size > 0) {
536
+ this.warning(
537
+ 'symbol',
538
+ `Zone(s) '${Array.from(remainingFromStr).join(',')}' non utilisée(s) dans la structure`,
539
+ strPath,
540
+ );
541
+ }
542
+
543
+ const hasContent = def.zones.some(z => z.isContent);
544
+ if (!hasContent) {
545
+ this.warning('symbol', "Le symbole ne comporte pas de zone de contenu", strPath);
546
+ }
547
+ }
548
+
549
+ // ── Components ─────────────────────────────────────────────────────────────
550
+
551
+ private composants(inspector: ThemeInspector): void {
552
+ const components = inspector.listComponents();
553
+ this.checkTypeDeclarations(inspector, 'component', components);
554
+ }
555
+
556
+ // ── Templates (models) ────────────────────────────────────────────────────
557
+
558
+ private templates(inspector: ThemeInspector, composantEnabled: boolean): void {
559
+ const models = inspector.listModels();
560
+ this.checkTypeDeclarations(inspector, 'model', models);
561
+ for (const def of models) {
562
+ this.checkModel(def, composantEnabled);
563
+ }
564
+ }
565
+
566
+ // ── Symbols ───────────────────────────────────────────────────────────────
567
+
568
+ private symboles(inspector: ThemeInspector): void {
569
+ const symbols = inspector.listSymbols();
570
+ this.checkTypeDeclarations(inspector, 'symbol', symbols);
571
+ for (const def of symbols) {
572
+ this.checkSymbole(def);
573
+ }
574
+ }
575
+
576
+ // ── Widgets ───────────────────────────────────────────────────────────────
577
+
578
+ private widgets(inspector: ThemeInspector): void {
579
+ const widgetsPath = inspector.getLgPath('widgets');
580
+
581
+ const categoryJsonMap: Record<string, string> = {
582
+ 'blog': 'blog.json',
583
+ 'catalogue': 'catalogue.json',
584
+ 'site_web': 'cms.json',
585
+ 'compte': 'comptes.json',
586
+ 'commandes': 'commandes.json',
587
+ 'recherche': 'recherche.json',
588
+ 'communication': 'modules.json',
589
+ };
590
+
591
+ for (const [category, widgetList] of Object.entries(WIDGETS)) {
592
+ const jsonFile = categoryJsonMap[category] ?? null;
593
+
594
+ for (const [widgetName, widgetDef] of Object.entries(widgetList)) {
595
+
596
+ // Alternates
597
+ const aternateDirs = listSubdirs(nodePath.join(widgetsPath, category, widgetName)).map((dir) => (nodePath.join(widgetsPath, category, widgetName, dir)));
598
+ const checkDirs = [nodePath.join(widgetsPath, category, widgetName), ...aternateDirs];
599
+
600
+ for (const template of widgetDef.templates) {
601
+
602
+ const basePath = `widgets/${category}/${widgetName}/${template.name}`;
603
+
604
+ let isMasterTpl = true;
605
+ for (const dir of checkDirs) {
606
+
607
+ const fullPath = nodePath.join(dir, template.name);
608
+
609
+ if (isMasterTpl && !existsSync(fullPath)) { // not for alternates
610
+ if (template.required) {
611
+ this.error('widgets', 'Fichier introuvable', fullPath);
612
+ }
613
+ isMasterTpl = false;
614
+ continue;
615
+ }
616
+ isMasterTpl = false;
617
+
618
+ this.checkEncoding(fullPath);
619
+ const resolvedContent = this.resolveIncludes(fullPath, 0);
620
+ this.reportFilterIssues(resolvedContent, 'widgets', fullPath);
621
+ this.reportBlockIssues(resolvedContent, 'widgets', fullPath, jsonFile, basePath, null);
622
+
623
+ }
624
+
625
+ }
626
+
627
+ }
628
+ }
629
+ }
630
+
631
+ private modules(inspector: ThemeInspector): void {
632
+ const modulesPath = inspector.getLgPath('modules');
633
+ for (const file of MANDATORY_MODULES) {
634
+ const fullPath = nodePath.join(modulesPath, file);
635
+ if (!existsSync(fullPath)) {
636
+ this.error('widgets', 'Fichier introuvable', fullPath);
637
+ }
638
+
639
+ // TODO reportBlockIssues for modules
640
+ }
641
+ }
642
+
643
+ private socolCompatible(inspector: ThemeInspector): void {
644
+ const widgetsPath = inspector.getLgPath('widgets');
645
+ const transporteursPath = nodePath.join(widgetsPath, 'commandes', 'livraison', 'transporteurs.html');
646
+ if (existsSync(transporteursPath)) {
647
+ const content = readLatin1(transporteursPath);
648
+ if (!content.includes('retrait_magasin')) {
649
+ this.warning('widgets', 'Liste des transporteurs incompatible avec socolissimo', transporteursPath);
650
+ }
651
+ }
652
+ const paiementPath = nodePath.join(widgetsPath, 'commandes', 'paiement', 'index.html');
653
+ if (existsSync(paiementPath)) {
654
+ const content = readLatin1(paiementPath);
655
+ if (!content.includes('transporteur_socolissimo')) {
656
+ this.warning('widgets', 'Récapitulatif de paiement incompatible avec socolissimo', paiementPath);
657
+ }
658
+ }
659
+ }
660
+
661
+ // ── Optimization checks ───────────────────────────────────────────────────
662
+
663
+ private smallFiles(themePath: string): void {
664
+ const cssAndJs = walkFiles(themePath, ['.css', '.js']);
665
+ for (const file of cssAndJs) {
666
+ try {
667
+ if (statSync(file).size <= 8) {
668
+ this.warning('optimization', 'Fichier vide', file);
669
+ }
670
+ } catch { /* skip */ }
671
+ }
672
+ }
673
+
674
+ private checkEscape(themePath: string): void {
675
+ const htmlFiles = walkFiles(themePath, ['.html']);
676
+ const attrRegex = /(alt|title)="\{[^|]+\}"/;
677
+
678
+ for (const file of htmlFiles) {
679
+ try {
680
+ const content = readLatin1(file);
681
+ const lines = content.split('\n');
682
+ lines.forEach((line, i) => {
683
+ if (attrRegex.test(line)) {
684
+ this.warning(
685
+ 'optimization',
686
+ "Une balise utilisée dans un attribut HTML devrait être filtrée avec \"|htmlentities\"",
687
+ file,
688
+ i + 1,
689
+ );
690
+ }
691
+ });
692
+ } catch { /* skip */ }
693
+ }
694
+
695
+ // Check templates and includes for variables that should NOT use htmlentities
696
+ const templatesDir = nodePath.join(themePath, 'fr', 'templates');
697
+ const includesDir = nodePath.join(themePath, 'fr', 'includes');
698
+ const tplFiles = [
699
+ ...(existsSync(templatesDir) ? walkFiles(templatesDir, ['.html']) : []),
700
+ ...(existsSync(includesDir) ? walkFiles(includesDir, ['.html']) : []),
701
+ ];
702
+ const htmlentitiesRegex = /\{(title|metaDescription|metaKeywords)\|htmlentities\}/;
703
+
704
+ for (const file of tplFiles) {
705
+ try {
706
+ const content = readLatin1(file);
707
+ const lines = content.split('\n');
708
+ lines.forEach((line, i) => {
709
+ if (htmlentitiesRegex.test(line)) {
710
+ this.warning(
711
+ 'optimization',
712
+ "La balise ne devrait pas être filtrée avec \"|htmlentities\"",
713
+ file,
714
+ i + 1,
715
+ );
716
+ }
717
+ });
718
+ } catch { /* skip */ }
719
+ }
720
+ }
721
+
722
+ private checkSourceMapping(frDir: string): void {
723
+ const allFiles = walkFiles(frDir, ['.css', '.js']);
724
+ const sourceMapRegex = /\/\*+#\s*sourceMappingURL=/;
725
+
726
+ for (const file of allFiles) {
727
+ try {
728
+ const content = readLatin1(file);
729
+ const lines = content.split('\n');
730
+ lines.forEach((line, i) => {
731
+ if (sourceMapRegex.test(line)) {
732
+ this.warning(
733
+ 'optimization',
734
+ "Détection d'une référence à un source map",
735
+ file,
736
+ i + 1,
737
+ );
738
+ }
739
+ });
740
+ } catch { /* skip */ }
741
+ }
742
+ }
743
+
744
+ // ── Includes resolution ───────────────────────────────────────────────────
745
+
746
+ /**
747
+ * Reads `filePath`, replaces every {FILE "..."} tag with the content of the
748
+ * referenced file (recursively), and reports errors for invalid/missing/cyclic
749
+ * or too-deep includes. Returns the fully resolved content.
750
+ */
751
+ private resolveIncludes(filePath: string, depth: number, ancestors: Set<string> = new Set()): string {
752
+ let content: string;
753
+ try { content = readLatin1(filePath); } catch { return ''; }
754
+
755
+ const currentAncestors = new Set(ancestors);
756
+ currentAncestors.add(filePath);
757
+
758
+ const resolvedTheme = nodePath.resolve(this.themePath);
759
+ const lines = content.split('\n');
760
+ const resultLines: string[] = [];
761
+
762
+ for (let i = 0; i < lines.length; i++) {
763
+ const lineNo = i + 1;
764
+ const line = lines[i];
765
+ const re = /\{FILE\s+"([^"]+)"\}/g;
766
+ let lastIndex = 0;
767
+ let resultLine = '';
768
+ let m: RegExpExecArray | null;
769
+
770
+ while ((m = re.exec(line)) !== null) {
771
+ resultLine += line.slice(lastIndex, m.index);
772
+ lastIndex = m.index + m[0].length;
773
+ const rawPath = m[1];
774
+
775
+ if (!rawPath.startsWith('/')) {
776
+ this.error('includes', `Chemin d'inclusion invalide (doit commencer par "/") : "${rawPath}"`, filePath, lineNo);
777
+ continue;
778
+ }
779
+
780
+ const resolved = nodePath.resolve(nodePath.join(resolvedTheme, rawPath));
781
+ if (!resolved.startsWith(resolvedTheme + nodePath.sep)) {
782
+ this.error('includes', `Inclusion hors du thème interdite : "${rawPath}"`, filePath, lineNo);
783
+ continue;
784
+ }
785
+
786
+ if (!existsSync(resolved)) {
787
+ this.error('includes', `Fichier inclus introuvable : "${rawPath}"`, filePath, lineNo);
788
+ continue;
789
+ }
790
+
791
+ if (currentAncestors.has(resolved)) {
792
+ this.error('includes', `Inclusion cyclique détectée : "${rawPath}"`, filePath, lineNo);
793
+ continue;
794
+ }
795
+
796
+ if (depth >= 2) {
797
+ this.error('includes', `Profondeur d'inclusion dépassée (maximum 2 niveaux)`, filePath, lineNo);
798
+ continue;
799
+ }
800
+
801
+ resultLine += this.resolveIncludes(resolved, depth + 1, currentAncestors);
802
+ }
803
+
804
+ resultLine += line.slice(lastIndex);
805
+ resultLines.push(resultLine);
806
+ }
807
+
808
+ return resultLines.join('\n');
809
+ }
810
+
811
+ // ── Main entry point ──────────────────────────────────────────────────────
812
+
813
+ run(inspector: ThemeInspector): ThemeReport {
814
+ const path = inspector.getPath();
815
+ this.themePath = path;
816
+ this.issues = [];
817
+
818
+ if (!existsSync(path) || !statSync(path).isDirectory()) {
819
+ this.error('config', 'Thème introuvable');
820
+ return { issues: this.issues, hasErrors: true };
821
+ }
822
+
823
+ // Config
824
+ this.smallFiles(path);
825
+ this.themeDescription(inspector);
826
+
827
+ // Composants
828
+ let composantEnabled = false;
829
+ const composantsDir = nodePath.join(path, 'fr', 'composants');
830
+ if (existsSync(composantsDir)) {
831
+ composantEnabled = true;
832
+ this.composants(inspector);
833
+ } else {
834
+ this.warning('component', "Aucun type de composant", nodePath.join(path, 'composants'));
835
+ }
836
+
837
+ // Symboles
838
+ const symbolesDir = nodePath.join(path, 'fr', 'symboles');
839
+ if (existsSync(symbolesDir)) {
840
+ composantEnabled = true;
841
+ this.symboles(inspector);
842
+ } else {
843
+ this.warning('symbol', "Aucun modèle de symboles", nodePath.join(path, 'symboles'));
844
+ }
845
+
846
+ // Templates
847
+ const templatesDir = nodePath.join(path, 'fr', 'templates');
848
+ if (existsSync(templatesDir)) {
849
+ this.templates(inspector, composantEnabled);
850
+ } else {
851
+ this.error('model', "Dossier manquant", nodePath.join(path, 'templates'));
852
+ }
853
+
854
+ // Types de billets CMS
855
+ const billetsDir = nodePath.join(path, 'fr', 'billets');
856
+ if (existsSync(billetsDir)) {
857
+ this.checkTypeDeclarations(inspector, 'cms', inspector.listCms());
858
+ } else {
859
+ this.error('cms', "Dossier manquant", nodePath.join(path, 'billets'));
860
+ }
861
+
862
+ // Types de produits
863
+ const produitsDir = nodePath.join(path, 'fr', 'produits');
864
+ if (existsSync(produitsDir)) {
865
+ this.checkTypeDeclarations(inspector, 'product', inspector.listProducts());
866
+ } else {
867
+ this.error('product', "Dossier manquant", nodePath.join(path, 'produits'));
868
+ }
869
+
870
+ // Types de billets blog (optional)
871
+ const billetsBlogDir = nodePath.join(path, 'fr', 'billets_blog');
872
+ if (existsSync(billetsBlogDir)) {
873
+ this.checkTypeDeclarations(inspector, 'blog', inspector.listBlogs());
874
+ } else {
875
+ this.warning('blog', "Dossier manquant", nodePath.join(path, 'fr', 'billets_blog'));
876
+ }
877
+
878
+ // Widgets
879
+ const widgetsDir = nodePath.join(path, 'fr', 'widgets');
880
+ if (existsSync(widgetsDir)) {
881
+ this.widgets(inspector);
882
+ this.modules(inspector);
883
+ this.socolCompatible(inspector);
884
+ } else {
885
+ this.error('widgets', "Dossier manquant", widgetsDir);
886
+ }
887
+
888
+ // Optimisation
889
+ this.checkEscape(path);
890
+ this.checkSourceMapping(nodePath.join(path, 'fr'));
891
+
892
+ return {
893
+ issues: this.issues,
894
+ hasErrors: this.issues.some(i => i.level === 'error'),
895
+ };
896
+ }
897
+ }