@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,163 @@
1
+ export type FilterValidationIssue = {
2
+ level: 'error' | 'warning';
3
+ msg: string;
4
+ line: number;
5
+ };
6
+
7
+ // Définitions des filtres : min/max arguments acceptés
8
+ const FILTER_DEFS: Record<string, { min: number; max: number }> = {
9
+ ucfirst: { min: 0, max: 0 },
10
+ strtoupper: { min: 0, max: 0 },
11
+ strtolower: { min: 0, max: 0 },
12
+ sanitize: { min: 0, max: 0 },
13
+ escapejson: { min: 0, max: 0 },
14
+ htmlentities: { min: 0, max: 1 },
15
+ rawurlencode: { min: 0, max: 1 },
16
+ left: { min: 1, max: 1 },
17
+ striptext: { min: 1, max: 1 },
18
+ mot: { min: 1, max: 1 },
19
+ alternate: { min: 1, max: 1 },
20
+ formatfloat: { min: 1, max: 1 },
21
+ date: { min: 1, max: 1 },
22
+ mapvalue: { min: 1, max: 2 },
23
+ empty: { min: 2, max: 2 },
24
+ slice: { min: 3, max: 3 },
25
+ };
26
+
27
+ const DATE_ARGS = new Set([
28
+ 'annee', 'num_mois', 'num_jour', 'mois', 'mois_abrev',
29
+ 'jour_semaine', 'heure', 'minute', 'seconde',
30
+ ]);
31
+
32
+ const NUMERIC_FILTER_ARGS = new Set(['left', 'striptext', 'mot', 'alternate', 'formatfloat']);
33
+
34
+ /**
35
+ * Valide les filtres de balises Kiubi dans un contenu HTML.
36
+ * Détecte : espaces dans la balise, filtre inconnu, mauvais nombre d'arguments,
37
+ * arguments invalides (non numérique, non-ASCII pour mapValue, etc.).
38
+ */
39
+ export function validateFilters(content: string): FilterValidationIssue[] {
40
+ const issues: FilterValidationIssue[] = [];
41
+ const lines = content.split('\n');
42
+
43
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
44
+ const line = lines[lineIdx];
45
+ const lineNo = lineIdx + 1;
46
+ const re = /\{([^}]+)\}/g;
47
+ let m: RegExpExecArray | null;
48
+
49
+ while ((m = re.exec(line)) !== null) {
50
+ const inner = m[1];
51
+
52
+ // Ignorer les includes {FILE "..."} et les zones {ZONE.xxx}
53
+ if (inner.startsWith('FILE ') || inner.startsWith('ZONE.')) continue;
54
+
55
+ // Ignorer les balises sans filtre
56
+ if (!inner.includes('|')) continue;
57
+
58
+ // 1. Espace interdit dans une balise filtrée
59
+ if (/\s/.test(inner)) {
60
+ issues.push({
61
+ level: 'error',
62
+ msg: `Espace interdit dans la balise filtrée : {${inner}}`,
63
+ line: lineNo,
64
+ });
65
+ continue;
66
+ }
67
+
68
+ const parts = inner.split('|');
69
+ // parts[0] = nom de la balise, parts[1] = filtre, parts[2..] = arguments
70
+ if (parts.length < 2) continue;
71
+
72
+ const filterRaw = parts[1];
73
+ const filterKey = filterRaw.toLowerCase();
74
+ const args = parts.slice(2);
75
+
76
+ // 2. Filtre inconnu
77
+ if (!(filterKey in FILTER_DEFS)) {
78
+ issues.push({
79
+ level: 'error',
80
+ msg: `Filtre inconnu : '${filterRaw}'`,
81
+ line: lineNo,
82
+ });
83
+ continue;
84
+ }
85
+
86
+ const def = FILTER_DEFS[filterKey];
87
+
88
+ // 3. Nombre d'arguments incorrect
89
+ if (args.length < def.min) {
90
+ issues.push({
91
+ level: 'error',
92
+ msg: `Le filtre '${filterRaw}' requiert au moins ${def.min} argument(s), ${args.length} fourni(s)`,
93
+ line: lineNo,
94
+ });
95
+ continue;
96
+ }
97
+ if (args.length > def.max) {
98
+ issues.push({
99
+ level: 'error',
100
+ msg: `Le filtre '${filterRaw}' accepte au plus ${def.max} argument(s), ${args.length} fourni(s)`,
101
+ line: lineNo,
102
+ });
103
+ continue;
104
+ }
105
+
106
+ // 4. Validations spécifiques par filtre
107
+ if ((filterKey === 'htmlentities' || filterKey === 'rawurlencode') && args.length === 1 && args[0] !== 'utf8') {
108
+ issues.push({
109
+ level: 'error',
110
+ msg: `Argument invalide pour '${filterRaw}' : '${args[0]}' (seul 'utf8' est accepté)`,
111
+ line: lineNo,
112
+ });
113
+ }
114
+
115
+ if (NUMERIC_FILTER_ARGS.has(filterKey) && !/^\d+$/.test(args[0])) {
116
+ issues.push({
117
+ level: 'error',
118
+ msg: `Le filtre '${filterRaw}' requiert un argument numérique, '${args[0]}' fourni`,
119
+ line: lineNo,
120
+ });
121
+ }
122
+
123
+ if (filterKey === 'date' && !DATE_ARGS.has(args[0])) {
124
+ issues.push({
125
+ level: 'error',
126
+ msg: `Argument invalide pour 'date' : '${args[0]}' (valeurs : annee, num_mois, num_jour, mois, mois_abrev, jour_semaine, heure, minute, seconde)`,
127
+ line: lineNo,
128
+ });
129
+ }
130
+
131
+ if (filterKey === 'mapvalue') {
132
+ for (const arg of args) {
133
+ if (/[^\x20-\x7E]/.test(arg)) {
134
+ issues.push({
135
+ level: 'error',
136
+ msg: `Caractère non autorisé dans les arguments de 'mapValue' : '${arg}'`,
137
+ line: lineNo,
138
+ });
139
+ }
140
+ }
141
+ }
142
+
143
+ if (filterKey === 'slice') {
144
+ if (!['-', '_', 'space'].includes(args[0])) {
145
+ issues.push({
146
+ level: 'error',
147
+ msg: `Séparateur invalide pour 'slice' : '${args[0]}' (attendu : '-', '_' ou 'space')`,
148
+ line: lineNo,
149
+ });
150
+ }
151
+ if (!/^\d+$/.test(args[1]) || !/^\d+$/.test(args[2])) {
152
+ issues.push({
153
+ level: 'error',
154
+ msg: `Les positions de 'slice' (start, length) doivent être numériques`,
155
+ line: lineNo,
156
+ });
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ return issues;
163
+ }
@@ -0,0 +1,6 @@
1
+ export { ThemeInspector } from './inspector.ts';
2
+ export { ThemeChecker } from './checker.ts';
3
+ export type { ThemeReport, Issue, IssueLevel, ThemeType } from './types.ts';
4
+ export { validateFilters } from './filter-validator.ts';
5
+ export type { FilterValidationIssue } from './filter-validator.ts';
6
+ export { resolveLangs, isLangValid } from './lang.ts';
@@ -0,0 +1,193 @@
1
+ import { readdirSync, statSync, existsSync } from 'node:fs';
2
+ import * as nodePath from 'node:path';
3
+ import { parseThemeXml } from './parsers/theme-xml.ts';
4
+ import { parseSymbolXml } from './parsers/symbol-xml.ts';
5
+ import { parseModelXml } from './parsers/model-xml.ts';
6
+ import { parseComponentXml } from './parsers/component-xml.ts';
7
+ import { parsePostTypeXml } from './parsers/post-type-xml.ts';
8
+ import type {
9
+ ThemeType,
10
+ ThemeDesc,
11
+ TypeDefinition,
12
+ ModelDef,
13
+ SymbolDef,
14
+ ComponentDef,
15
+ PostTypeDef,
16
+ ParseResult,
17
+ } from './types.ts';
18
+
19
+ const VALID_NAME = /^[a-zA-Z0-9_-]{1,40}$/;
20
+ const VALID_LANG = /^[a-z]{2}$/;
21
+
22
+ type TypeConfig = {
23
+ subdir: string;
24
+ descriptor: string;
25
+ parse: (dir: string, id: string) => ParseResult<TypeDefinition>;
26
+ };
27
+
28
+ const TYPE_CONFIGS: Record<ThemeType, TypeConfig> = {
29
+ model: {
30
+ subdir: '/templates/',
31
+ descriptor: 'desc.xml',
32
+ parse: (dir, id) => parseModelXml(dir, id),
33
+ },
34
+ symbol: {
35
+ subdir: '/symboles/',
36
+ descriptor: 'desc.xml',
37
+ parse: (dir, id) => parseSymbolXml(dir, id),
38
+ },
39
+ component: {
40
+ subdir: '/composants/',
41
+ descriptor: 'config.xml',
42
+ parse: (dir, id) => parseComponentXml(dir, id),
43
+ },
44
+ cms: {
45
+ subdir: '/billets/',
46
+ descriptor: 'config.xml',
47
+ parse: (dir, id) => parsePostTypeXml(dir, id, 'cms'),
48
+ },
49
+ blog: {
50
+ subdir: '/billets_blog/',
51
+ descriptor: 'config.xml',
52
+ parse: (dir, id) => parsePostTypeXml(dir, id, 'blog'),
53
+ },
54
+ product: {
55
+ subdir: '/produits/',
56
+ descriptor: 'config.xml',
57
+ parse: (dir, id) => parsePostTypeXml(dir, id, 'product'),
58
+ },
59
+ };
60
+
61
+ function listSubdirs(dir: string): string[] {
62
+ try {
63
+ return readdirSync(dir).filter(name => {
64
+ try {
65
+ return statSync(nodePath.join(dir, name)).isDirectory();
66
+ } catch {
67
+ return false;
68
+ }
69
+ });
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
75
+ export class ThemeInspector {
76
+ private readonly themePath: string;
77
+ private readonly lg: string;
78
+ private cacheTypes = new Map<ThemeType, TypeDefinition[]>();
79
+ private cacheTypeErrors = new Map<ThemeType, { id: string; error: string }[]>();
80
+ private cacheDesc: ThemeDesc | null | undefined = undefined;
81
+ private cacheDescError: string | null = null;
82
+
83
+ constructor(themePath: string, lang: string = 'fr') {
84
+ this.themePath = themePath;
85
+ this.lg = lang;
86
+ }
87
+
88
+ // Liste les dossiers de langue (2 lettres minuscules) à la racine du thème
89
+ static listThemeLangs(themePath: string): string[] {
90
+ return listSubdirs(themePath).filter(name => VALID_LANG.test(name)).sort();
91
+ }
92
+
93
+ getPath(): string {
94
+ return this.themePath;
95
+ }
96
+
97
+ getLgPath(subdir: string = ''): string {
98
+ return nodePath.join(this.themePath, this.lg, subdir);
99
+ }
100
+
101
+ getRelLgPath(subdir: string = ''): string {
102
+ return nodePath.join(this.lg, subdir);
103
+ }
104
+
105
+ getDescription(): ThemeDesc | null {
106
+ if (this.cacheDesc !== undefined) return this.cacheDesc;
107
+ const xmlPath = nodePath.join(this.themePath, 'theme.xml');
108
+ if (!existsSync(xmlPath)) {
109
+ this.cacheDesc = null;
110
+ } else {
111
+ const result = parseThemeXml(xmlPath);
112
+ this.cacheDesc = result.definition;
113
+ this.cacheDescError = result.error;
114
+ }
115
+ return this.cacheDesc;
116
+ }
117
+
118
+ getDescriptionError(): string | null {
119
+ return this.cacheDescError;
120
+ }
121
+
122
+ private listTypes(type: ThemeType): TypeDefinition[] {
123
+ const cached = this.cacheTypes.get(type);
124
+ if (cached !== undefined) return cached;
125
+
126
+ const config = TYPE_CONFIGS[type];
127
+ const basePath = nodePath.join(this.themePath, this.lg, config.subdir);
128
+ const dirs = listSubdirs(basePath);
129
+
130
+ const items: TypeDefinition[] = [];
131
+ const errors: { id: string; error: string }[] = [];
132
+ for (const dir of dirs) {
133
+ if (!VALID_NAME.test(dir)) continue;
134
+ const fullDir = nodePath.join(basePath, dir);
135
+ if (!existsSync(nodePath.join(fullDir, config.descriptor))) continue;
136
+ const result = config.parse(fullDir, dir);
137
+ if (result.definition !== null) {
138
+ items.push(result.definition);
139
+ } else {
140
+ errors.push({ id: dir, error: result.error });
141
+ }
142
+ }
143
+
144
+ this.cacheTypes.set(type, items);
145
+ this.cacheTypeErrors.set(type, errors);
146
+ return items;
147
+ }
148
+
149
+ getTypeErrors(type: ThemeType): { id: string; error: string }[] {
150
+ if (!this.cacheTypeErrors.has(type)) this.listTypes(type);
151
+ return this.cacheTypeErrors.get(type) ?? [];
152
+ }
153
+
154
+ listModels(): ModelDef[] {
155
+ return this.listTypes('model') as ModelDef[];
156
+ }
157
+
158
+ listSymbols(): SymbolDef[] {
159
+ return this.listTypes('symbol') as SymbolDef[];
160
+ }
161
+
162
+ listComponents(): ComponentDef[] {
163
+ return this.listTypes('component') as ComponentDef[];
164
+ }
165
+
166
+ listCms(): PostTypeDef[] {
167
+ return this.listTypes('cms') as PostTypeDef[];
168
+ }
169
+
170
+ listBlogs(): PostTypeDef[] {
171
+ return this.listTypes('blog') as PostTypeDef[];
172
+ }
173
+
174
+ listProducts(): PostTypeDef[] {
175
+ return this.listTypes('product') as PostTypeDef[];
176
+ }
177
+
178
+ getRequiredFilesForType(type: ThemeType): string[] {
179
+ switch (type) {
180
+ case 'component': return ['index.html', 'config.xml'];
181
+ case 'blog': return ['config.xml'];
182
+ case 'cms': return ['index.html', 'config.xml'];
183
+ case 'symbol': return ['index.html', 'desc.xml', 'structure.xhtml'];
184
+ case 'product': return ['index.html', 'config.xml'];
185
+ case 'model': return ['index.html', 'desc.xml', 'structure.xhtml'];
186
+ }
187
+ }
188
+
189
+ getTypeSubdir(type: ThemeType): string {
190
+ return TYPE_CONFIGS[type].subdir;
191
+ }
192
+ }
193
+
@@ -0,0 +1,42 @@
1
+ import { statSync } from 'node:fs';
2
+ import * as nodePath from 'node:path';
3
+ import { ThemeInspector } from './inspector.ts';
4
+
5
+ const VALID_LANG = /^[a-z]{2}$/;
6
+
7
+ function isDir(path: string): boolean {
8
+ try {
9
+ return statSync(path).isDirectory();
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ export function isLangValid(lang: string): boolean {
16
+ return VALID_LANG.test(lang);
17
+ }
18
+
19
+ // Résout la liste des langues cibles pour un thème : dossiers de 2 lettres à la racine
20
+ // (défaut) ou sous-ensemble validé/dédupliqué si `lang` est précisé.
21
+ export function resolveLangs(themePath: string, lang: string[] | null | undefined): string[] | { error: string } {
22
+ if (!lang || lang.length === 0) {
23
+ const found = ThemeInspector.listThemeLangs(themePath);
24
+ if (found.length === 0) {
25
+ return { error: `aucun dossier de langue (2 lettres) trouvé à la racine du thème.` };
26
+ }
27
+ return found;
28
+ }
29
+
30
+ const deduped = [...new Set(lang)];
31
+ const invalid = deduped.filter(l => !isLangValid(l));
32
+ if (invalid.length > 0) {
33
+ return { error: `code(s) de langue invalide(s) : ${invalid.map(l => `"${l}"`).join(', ')} (2 lettres minuscules attendues, ex. "fr").` };
34
+ }
35
+
36
+ const missing = deduped.filter(l => !isDir(nodePath.join(themePath, l)));
37
+ if (missing.length > 0) {
38
+ return { error: `dossier(s) de langue introuvable(s) à la racine du thème : ${missing.join(', ')}.` };
39
+ }
40
+
41
+ return deduped;
42
+ }
@@ -0,0 +1,120 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import * as nodePath from 'node:path';
3
+ import { XMLParser } from 'fast-xml-parser';
4
+ import type { ComponentDef, FieldDef, OptionDef, ParseResult } from '../types.ts';
5
+
6
+ const parser = new XMLParser({
7
+ ignoreAttributes: false,
8
+ attributeNamePrefix: '',
9
+ textNodeName: '#text',
10
+ isArray: (name: string) => ['champ', 'option'].includes(name),
11
+ transformTagName: (name: string) => name.toLowerCase(),
12
+ transformAttributeName: (name: string) => name.toUpperCase(),
13
+ });
14
+
15
+ const s = (v: unknown): string => v != null ? String(v) : '';
16
+
17
+ const MAX_FIELDS = 20;
18
+ const VALID_FIELD_NAME = /^([a-zA-Z0-9_]{1,40})$/;
19
+ const ALLOWED_TYPES = new Set([
20
+ 'text', 'textarea', 'image', 'wysiwyg', 'select', 'checkbox', 'color', 'date', 'datetime', 'file',
21
+ ]);
22
+
23
+ function parseFieldType(raw: string): string {
24
+ if (raw === 'fichier') return 'file';
25
+ return ALLOWED_TYPES.has(raw) ? raw : 'text';
26
+ }
27
+
28
+ function parseFields(rawChamps: Record<string, unknown>[]): FieldDef[] {
29
+ const fields: FieldDef[] = [];
30
+ const seenNames = new Set<string>();
31
+
32
+ for (const c of rawChamps) {
33
+ const name = s(c.CHAMP);
34
+ if (!VALID_FIELD_NAME.test(name)) continue;
35
+ if (seenNames.has(name)) continue;
36
+ if (fields.length >= MAX_FIELDS) break;
37
+
38
+ const type = parseFieldType(s(c.TYPE));
39
+ seenNames.add(name);
40
+
41
+ const rawOptions: Record<string, unknown>[] = Array.isArray(c.option)
42
+ ? c.option as Record<string, unknown>[]
43
+ : (c.option ? [c.option as Record<string, unknown>] : []);
44
+
45
+ const options: OptionDef[] = [];
46
+ for (const o of (rawOptions as unknown[]).slice(0, 100)) {
47
+ const isObj = o !== null && typeof o === 'object';
48
+ const label = isObj ? s((o as Record<string, unknown>)['#text']) : s(o);
49
+ const value = isObj ? (s((o as Record<string, unknown>).VALUE) || label) : label;
50
+ if (!options.some(existing => existing.value === value)) {
51
+ options.push({ value, label: label || value });
52
+ }
53
+ }
54
+
55
+ let defaultVal: string | null = s(c.DEFAUT) || null;
56
+ if (type === 'image' || type === 'file') defaultVal = null;
57
+ if (type === 'select' && defaultVal !== null && !options.some(o => o.value === defaultVal)) defaultVal = null;
58
+
59
+ const field: FieldDef = {
60
+ field: name,
61
+ name: s(c.INTITULE).slice(0, 80) || name,
62
+ type,
63
+ default: defaultVal,
64
+ help: s(c.AIDE).slice(0, 255),
65
+ };
66
+ if (type === 'select' || type === 'checkbox') field.options = options;
67
+ fields.push(field);
68
+ }
69
+
70
+ return fields;
71
+ }
72
+
73
+ function toArray(v: unknown): Record<string, unknown>[] {
74
+ if (Array.isArray(v)) return v as Record<string, unknown>[];
75
+ if (v) return [v as Record<string, unknown>];
76
+ return [];
77
+ }
78
+
79
+ function ensureTitleField(fields: FieldDef[]): void {
80
+ if (!fields.some(f => f.field === 'title')) {
81
+ fields.push({ field: 'title', name: 'Titre', type: 'text', default: null, help: '' });
82
+ }
83
+ }
84
+
85
+ export function parseComponentXml(dir: string, id: string): ParseResult<ComponentDef> {
86
+ try {
87
+ const filePath = nodePath.join(dir, 'config.xml');
88
+ const stat = statSync(filePath);
89
+ if (stat.size > 10 * 1024) return { definition: null, error: 'Fichier trop volumineux (> 10 Ko)' };
90
+ const content = readFileSync(filePath, 'latin1');
91
+ const doc = parser.parse(content);
92
+ const composant = doc?.composant;
93
+ if (!composant) return { definition: null, error: 'Balise <composant> manquante' };
94
+
95
+ const listechampsEl = composant.listechamps as Record<string, unknown> | undefined;
96
+ const fields = listechampsEl ? parseFields(toArray(listechampsEl.champ)) : [];
97
+
98
+ const collectionEl = composant.collection as Record<string, unknown> | undefined;
99
+ const hasCollection = !!collectionEl;
100
+ const collection = hasCollection ? parseFields(toArray(collectionEl!.champ)) : [];
101
+
102
+ ensureTitleField(fields);
103
+ if (hasCollection) ensureTitleField(collection);
104
+
105
+ return {
106
+ definition: {
107
+ id,
108
+ tpl: nodePath.join(dir, 'index.html'),
109
+ name: s(composant.INTITULE),
110
+ position: parseInt(s(composant.TRI), 10) || 0,
111
+ fields,
112
+ collection,
113
+ hasCollection,
114
+ },
115
+ error: null,
116
+ };
117
+ } catch {
118
+ return { definition: null, error: 'Impossible de lire ou parser le fichier' };
119
+ }
120
+ }
@@ -0,0 +1,82 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import * as nodePath from 'node:path';
3
+ import { XMLParser } from 'fast-xml-parser';
4
+ import type { ModelDef, ZoneDef, ParseResult } from '../types.ts';
5
+
6
+ const parser = new XMLParser({
7
+ ignoreAttributes: false,
8
+ attributeNamePrefix: '',
9
+ textNodeName: '#text',
10
+ isArray: (name: string) => name === 'zone',
11
+ transformTagName: (name: string) => name.toLowerCase(),
12
+ transformAttributeName: (name: string) => name.toUpperCase(),
13
+ });
14
+
15
+ const s = (v: unknown): string => v != null ? String(v) : '';
16
+
17
+ const ALLOWED_COLMAX = ['1', '2', '2d', '2g', '3'];
18
+
19
+ export function parseModelXml(dir: string, id: string): ParseResult<ModelDef> {
20
+ try {
21
+ const filePath = nodePath.join(dir, 'desc.xml');
22
+ const stat = statSync(filePath);
23
+ if (stat.size > 10 * 1024) return { definition: null, error: 'Fichier trop volumineux (> 10 Ko)' };
24
+ const content = readFileSync(filePath, 'latin1');
25
+ const doc = parser.parse(content);
26
+ const modele = doc?.modele;
27
+ if (!modele) return { definition: null, error: 'Balise <modele> manquante' };
28
+
29
+ const zonesEl = modele.zones as Record<string, unknown> | undefined;
30
+ const zones: ZoneDef[] = [];
31
+ let defaultContentZone: string | null = null;
32
+
33
+ if (zonesEl) {
34
+ const rawZones: Record<string, unknown>[] = Array.isArray(zonesEl.zone)
35
+ ? zonesEl.zone as Record<string, unknown>[]
36
+ : (zonesEl.zone ? [zonesEl.zone as Record<string, unknown>] : []);
37
+
38
+ const defautId = s(zonesEl.DEFAUT);
39
+ const seenIds = new Set<string>();
40
+ let firstContent: string | null = null;
41
+
42
+ for (const z of rawZones) {
43
+ const zoneId = s(z.ID).trim();
44
+ if (!zoneId || seenIds.has(zoneId)) continue;
45
+ seenIds.add(zoneId);
46
+ const colmax = ALLOWED_COLMAX.includes(s(z.COLMAX)) ? s(z.COLMAX) : '1';
47
+ const defaut = ALLOWED_COLMAX.includes(s(z.DEFAUT)) ? s(z.DEFAUT) : '1';
48
+ const isContent = s(z.TYPE).toUpperCase() === 'CONTENU';
49
+ zones.push({ id: zoneId, isContent, colmax, name: s(z.INTITULE), defaut });
50
+ if (isContent && firstContent === null) firstContent = zoneId;
51
+ }
52
+
53
+ // Resolve default content zone (force is_content on DEFAUT zone)
54
+ if (defautId) {
55
+ const defautZone = zones.find(z => z.id === defautId);
56
+ if (defautZone) {
57
+ defautZone.isContent = true;
58
+ defaultContentZone = defautZone.id;
59
+ } else {
60
+ defaultContentZone = firstContent;
61
+ }
62
+ } else {
63
+ defaultContentZone = firstContent;
64
+ }
65
+ }
66
+
67
+ void defaultContentZone; // used for internal logic only
68
+
69
+ return {
70
+ definition: {
71
+ id,
72
+ tpl: nodePath.join(dir, 'index.html'),
73
+ structure: nodePath.join(dir, 'structure.xhtml'),
74
+ zones,
75
+ name: s(modele.INTITULE),
76
+ },
77
+ error: null,
78
+ };
79
+ } catch {
80
+ return { definition: null, error: 'Impossible de lire ou parser le fichier' };
81
+ }
82
+ }