story-theme-mcp 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.
@@ -0,0 +1,362 @@
1
+ import { checkValue, newResources, resourcesToJson } from './values.js';
2
+ import { closest, templateTypeOf, NON_VALUE_TYPES } from '../theme/catalog.js';
3
+
4
+ export const LIMITS = {
5
+ sectionsPerTemplate: 25,
6
+ sectionsPerGroup: 25,
7
+ blocksPerSection: 50,
8
+ blocksPerFile: 1250,
9
+ nestingDepth: 8,
10
+ fileBytes: 512 * 1024,
11
+ settingsDataBytes: 1.5 * 1024 * 1024,
12
+ };
13
+
14
+ const ID_RE = /^[A-Za-z0-9_-]+$/;
15
+
16
+ // Ids become object keys: names every JS object inherits (constructor, __proto__…) are refused.
17
+ export const isSafeId = (id) => typeof id === 'string' && ID_RE.test(id) && !Object.hasOwn(Object.prototype, id);
18
+ const has = (object, key) => object !== null && typeof object === 'object' && Object.hasOwn(object, key);
19
+
20
+ // JSON template types Shopify accepts (gift_card and robots.txt stay Liquid).
21
+ export const TEMPLATE_TYPES = new Set([
22
+ '404', 'article', 'blog', 'cart', 'collection', 'index', 'list-collections', 'page', 'password', 'product', 'search',
23
+ 'customers/account', 'customers/activate_account', 'customers/addresses', 'customers/login', 'customers/order', 'customers/register', 'customers/reset_password',
24
+ ]);
25
+ const SECTION_KEYS = new Set(['type', 'settings', 'blocks', 'block_order', 'disabled', 'custom_css', 'name', 'static']);
26
+ const BLOCK_KEYS = new Set(['type', 'settings', 'blocks', 'block_order', 'disabled', 'static', 'name', 'id']);
27
+
28
+ // Main sections a template type is expected to hold: without them the page cannot sell or list.
29
+ export const EXPECTED_MAIN = {
30
+ product: 'main-product-composed',
31
+ collection: 'main-collection',
32
+ search: 'main-search',
33
+ cart: 'main-cart',
34
+ blog: 'main-blog',
35
+ article: 'main-article',
36
+ 'list-collections': 'main-list-collections',
37
+ password: 'main-password',
38
+ };
39
+
40
+ export function fileKind(file) {
41
+ if (file === 'config/settings_data.json') return 'settings_data';
42
+ if (/^templates\/(customers\/|metaobject\/)?[a-z0-9_-]+(\.[a-z0-9_-]+)?\.json$/.test(file)) return 'template';
43
+ if (/^sections\/[a-z0-9_-]+\.json$/.test(file)) return 'group';
44
+ return null;
45
+ }
46
+
47
+ class Report {
48
+ constructor() {
49
+ this.errors = [];
50
+ this.warnings = [];
51
+ this.resources = newResources();
52
+ this.counts = { sections: 0, blocks: 0 };
53
+ }
54
+
55
+ add(level, path, message) {
56
+ (level === 'error' ? this.errors : this.warnings).push({ path, message });
57
+ }
58
+
59
+ toJson() {
60
+ const links = this.resources.links;
61
+ for (const link of links) {
62
+ const match = link.match(/^shopify:\/\/(collections|products|pages|blogs)\/([^/?#]+)/);
63
+ if (match && match[2] !== 'all') this.resources[match[1]].add(decodeURIComponent(match[2]));
64
+ }
65
+ return { ok: this.errors.length === 0, errors: this.errors, warnings: this.warnings, counts: this.counts, resources: resourcesToJson(this.resources) };
66
+ }
67
+ }
68
+
69
+ function checkSettings(report, catalog, path, values, settingsList, context) {
70
+ if (values === undefined) return;
71
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
72
+ report.add('error', path, '"settings" doit être un objet { id: valeur }');
73
+ return;
74
+ }
75
+ const index = new Map((settingsList || []).filter((setting) => setting.id && !NON_VALUE_TYPES.has(setting.type)).map((setting) => [setting.id, setting]));
76
+ for (const [key, value] of Object.entries(values)) {
77
+ const setting = index.get(key);
78
+ if (!setting) {
79
+ const hint = closest(key, index.keys());
80
+ report.add('error', `${path}.settings.${key}`, `réglage inconnu${hint.length ? ` (vouliez-vous dire : ${hint.join(', ')} ?)` : ''}`);
81
+ continue;
82
+ }
83
+ for (const problem of checkValue(setting, value, context)) report.add(problem.level, `${path}.settings.${key}`, problem.message);
84
+ }
85
+ }
86
+
87
+ function checkChildren(report, catalog, path, node, parent, depth, context) {
88
+ const { blocks, block_order: order } = node;
89
+ if (blocks === undefined) {
90
+ if (order !== undefined && order.length) report.add('error', path, 'block_order sans blocks');
91
+ return;
92
+ }
93
+ if (Array.isArray(blocks)) {
94
+ report.add('error', path, 'format de preset (tableau) : dans un fichier du thème, "blocks" est un objet { id: bloc } accompagné de "block_order"');
95
+ return;
96
+ }
97
+ if (!blocks || typeof blocks !== 'object') {
98
+ report.add('error', path, '"blocks" doit être un objet');
99
+ return;
100
+ }
101
+ const entries = Object.entries(blocks);
102
+ if (entries.length && !parent.schema?.blocks) {
103
+ report.add('error', path, `« ${parent.type} » n'accepte aucun bloc`);
104
+ return;
105
+ }
106
+ const statics = catalog.staticChildren(parent.kind, parent.type);
107
+ const dynamicIds = [];
108
+ for (const [id, block] of entries) {
109
+ const blockPath = `${path}/${id}`;
110
+ if (!isSafeId(id)) report.add('error', blockPath, 'identifiant de bloc invalide (lettres, chiffres, - et _ uniquement, hors noms réservés comme constructor)');
111
+ if (!block || typeof block !== 'object' || Array.isArray(block)) {
112
+ report.add('error', blockPath, 'un bloc doit être un objet');
113
+ continue;
114
+ }
115
+ for (const key of Object.keys(block)) if (!BLOCK_KEYS.has(key)) report.add('warning', blockPath, `clé « ${key} » ignorée par Shopify`);
116
+ if (!block.type) {
117
+ report.add('error', blockPath, 'bloc sans "type"');
118
+ continue;
119
+ }
120
+ if (block.type === '@app' || block.type === '@theme') {
121
+ report.add('error', blockPath, `« ${block.type} » n'est pas un type de bloc : indiquez le type réel (ex. heading)`);
122
+ continue;
123
+ }
124
+ if (block.static) {
125
+ if (!statics.some((entry) => entry.type === block.type && entry.id === id)) {
126
+ const expected = statics.filter((entry) => entry.type === block.type).map((entry) => entry.id);
127
+ report.add('error', blockPath, expected.length
128
+ ? `bloc statique « ${block.type} » : son identifiant doit être ${expected.map((value) => `« ${value} »`).join(' ou ')}`
129
+ : `« ${block.type} » n'est pas un bloc statique de « ${parent.type} » (retirez "static": true)`);
130
+ }
131
+ } else {
132
+ dynamicIds.push(id);
133
+ report.counts.blocks += 1;
134
+ }
135
+ const resolved = catalog.resolveChild(parent.schema, block.type);
136
+ if (!resolved.ok && !(block.static && catalog.block(block.type))) {
137
+ const hint = resolved.unknown ? closest(block.type, catalog.blockTypes) : [];
138
+ report.add('error', blockPath, `« ${block.type} » refusé dans « ${parent.type} » : ${resolved.reason}${hint.length ? ` (vouliez-vous dire : ${hint.join(', ')} ?)` : ''}`);
139
+ continue;
140
+ }
141
+ if (resolved.app) {
142
+ if (block.blocks && Object.keys(block.blocks).length) report.add('error', blockPath, "un bloc d'application ne contient pas de blocs du thème");
143
+ continue;
144
+ }
145
+ const schema = resolved.schema || catalog.block(block.type)?.schema;
146
+ if (depth + 1 > LIMITS.nestingDepth) {
147
+ report.add('error', blockPath, `imbrication trop profonde (${depth + 1} niveaux, ${LIMITS.nestingDepth} maximum sous la section)`);
148
+ continue;
149
+ }
150
+ if (block.disabled !== undefined && typeof block.disabled !== 'boolean') report.add('error', blockPath, '"disabled" doit valoir true ou false');
151
+ checkSettings(report, catalog, blockPath, block.settings, schema?.settings, context);
152
+ checkChildren(report, catalog, blockPath, block, { kind: resolved.local ? 'local' : 'block', type: block.type, schema }, depth + 1, context);
153
+ }
154
+
155
+ const policy = catalog.childPolicy(parent.schema);
156
+ if (dynamicIds.length > policy.maxBlocks) report.add('error', path, `${dynamicIds.length} blocs, ${policy.maxBlocks} maximum ici`);
157
+
158
+ if (order === undefined) {
159
+ if (dynamicIds.length) report.add('error', path, `"block_order" manquant : il doit lister ${dynamicIds.join(', ')}`);
160
+ return;
161
+ }
162
+ if (!Array.isArray(order)) {
163
+ report.add('error', path, '"block_order" doit être un tableau');
164
+ return;
165
+ }
166
+ const seen = new Set();
167
+ for (const id of order) {
168
+ if (seen.has(id)) report.add('error', path, `« ${id} » apparaît deux fois dans block_order`);
169
+ seen.add(id);
170
+ if (!has(blocks, id)) report.add('error', path, `block_order cite « ${id} », absent de blocks`);
171
+ else if (blocks[id]?.static) report.add('warning', path, `le bloc statique « ${id} » n'a pas besoin d'être dans block_order`);
172
+ }
173
+ const missing = dynamicIds.filter((id) => !seen.has(id));
174
+ if (missing.length) report.add('error', path, `blocs absents de block_order (ils ne s'afficheraient pas) : ${missing.join(', ')}`);
175
+ }
176
+
177
+ function checkSection(report, catalog, path, section, placement, context) {
178
+ if (!section || typeof section !== 'object' || Array.isArray(section)) {
179
+ report.add('error', path, 'une section doit être un objet');
180
+ return null;
181
+ }
182
+ for (const key of Object.keys(section)) if (!SECTION_KEYS.has(key)) report.add('warning', path, `clé « ${key} » ignorée par Shopify`);
183
+ const entry = catalog.section(section.type);
184
+ if (!entry) {
185
+ const hint = closest(section.type ?? '', catalog.sectionTypes);
186
+ report.add('error', path, `section « ${section.type} » inexistante${hint.length ? ` (vouliez-vous dire : ${hint.join(', ')} ?)` : ''}`);
187
+ return null;
188
+ }
189
+ if (!entry.schema) {
190
+ // A section without {% schema %} can be placed, but has neither settings nor blocks.
191
+ if (section.settings && Object.keys(section.settings).length) report.add('error', path, `« ${section.type} » n'a pas de réglages`);
192
+ if (section.blocks && Object.keys(section.blocks).length) report.add('error', path, `« ${section.type} » n'accepte aucun bloc`);
193
+ report.add('warning', path, `« ${section.type} » est une section sans schéma, rendue telle quelle par le code du thème`);
194
+ return null;
195
+ }
196
+ if (placement && !catalog.sectionAllowedIn(entry.schema, placement)) {
197
+ report.add('error', path, `« ${section.type} » n'est pas autorisée ici (${catalog.describePlacement(entry.schema)})`);
198
+ }
199
+ if (section.disabled !== undefined && typeof section.disabled !== 'boolean') report.add('error', path, '"disabled" doit valoir true ou false');
200
+ checkSettings(report, catalog, path, section.settings, entry.schema.settings, context);
201
+ checkChildren(report, catalog, path, section, { kind: 'section', type: section.type, schema: entry.schema }, 0, context);
202
+ return entry;
203
+ }
204
+
205
+ function checkSectionMap(report, catalog, data, placement, context, max) {
206
+ const { sections, order } = data;
207
+ if (!sections || typeof sections !== 'object' || Array.isArray(sections)) {
208
+ report.add('error', '', '"sections" doit être un objet { id: section }');
209
+ return [];
210
+ }
211
+ if (!Array.isArray(order)) report.add('error', '', '"order" doit être un tableau des identifiants de sections');
212
+ const ids = Object.keys(sections);
213
+ report.counts.sections = ids.length;
214
+ if (ids.length > max) report.add('error', '', `${ids.length} sections, ${max} maximum par fichier (limite Shopify)`);
215
+ const seen = new Set();
216
+ for (const id of order || []) {
217
+ if (seen.has(id)) report.add('error', 'order', `« ${id} » apparaît deux fois`);
218
+ seen.add(id);
219
+ if (!has(sections, id)) report.add('error', 'order', `« ${id} » est dans order mais pas dans sections`);
220
+ }
221
+ const notOrdered = ids.filter((id) => !seen.has(id));
222
+ if (Array.isArray(order) && notOrdered.length) report.add('error', 'order', `sections absentes de order : ${notOrdered.join(', ')}`);
223
+ const types = [];
224
+ for (const id of ids) {
225
+ if (!isSafeId(id)) {
226
+ report.add('error', id, 'identifiant de section invalide (lettres, chiffres, - et _ uniquement, hors noms réservés comme constructor)');
227
+ continue;
228
+ }
229
+ const entry = checkSection(report, catalog, id, sections[id], placement, context);
230
+ if (entry) types.push({ id, type: sections[id].type, schema: entry.schema });
231
+ }
232
+ const counts = new Map();
233
+ for (const { type, schema } of types) {
234
+ counts.set(type, (counts.get(type) || 0) + 1);
235
+ if (schema.limit && counts.get(type) === schema.limit + 1) report.add('error', '', `« ${type} » est limitée à ${schema.limit} par page`);
236
+ }
237
+ if (report.counts.blocks > LIMITS.blocksPerFile) report.add('error', '', `${report.counts.blocks} blocs, ${LIMITS.blocksPerFile} maximum par fichier`);
238
+ return types;
239
+ }
240
+
241
+ export function validateTemplateData(catalog, data, { file, schemeIds } = {}) {
242
+ const report = new Report();
243
+ const context = { schemeIds: schemeIds || catalog.defaultSchemeIds(), resources: report.resources };
244
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
245
+ report.add('error', '', 'le modèle doit être un objet JSON { sections, order }');
246
+ return report.toJson();
247
+ }
248
+ const templateType = templateTypeOf(file);
249
+ if (!TEMPLATE_TYPES.has(templateType) && !templateType.startsWith('metaobject/')) {
250
+ report.add('error', '', `type de modèle « ${templateType} » inconnu de Shopify (types : ${[...TEMPLATE_TYPES].join(', ')}, metaobject/<type>). Pour une page : templates/page.${templateType}.json`);
251
+ }
252
+ for (const key of Object.keys(data)) {
253
+ if (!['sections', 'order', 'layout', 'wrapper'].includes(key)) report.add('warning', '', `clé « ${key} » inconnue dans un modèle`);
254
+ }
255
+ if ('layout' in data && data.layout !== false && !['theme', 'password'].includes(data.layout)) {
256
+ report.add('warning', 'layout', `layout « ${data.layout} » : le thème ne fournit que theme et password`);
257
+ }
258
+ const types = checkSectionMap(report, catalog, data, { template: templateType }, context, LIMITS.sectionsPerTemplate);
259
+ const expected = EXPECTED_MAIN[templateType];
260
+ if (expected && !types.some(({ type }) => type === expected)) {
261
+ report.add('warning', '', `un modèle ${templateType} sans section « ${expected} » n'affichera pas l'essentiel de la page`);
262
+ }
263
+ return report.toJson();
264
+ }
265
+
266
+ export function validateGroupData(catalog, data, { file, schemeIds } = {}) {
267
+ const report = new Report();
268
+ const context = { schemeIds: schemeIds || catalog.defaultSchemeIds(), resources: report.resources };
269
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
270
+ report.add('error', '', 'le groupe doit être un objet JSON { type, name, sections, order }');
271
+ return report.toJson();
272
+ }
273
+ if (typeof data.type !== 'string' || !/^(header|footer|aside|custom\.[a-z0-9_-]+)$/.test(data.type)) {
274
+ report.add('error', 'type', 'type de groupe attendu : header, footer, aside ou custom.<nom>');
275
+ }
276
+ if (typeof data.name !== 'string' || !data.name) report.add('error', 'name', 'nom du groupe manquant');
277
+ checkSectionMap(report, catalog, data, { group: data.type }, context, LIMITS.sectionsPerGroup);
278
+ return report.toJson();
279
+ }
280
+
281
+ export function validateSettingsData(catalog, data) {
282
+ const report = new Report();
283
+ if (!data || typeof data !== 'object') {
284
+ report.add('error', '', 'settings_data doit être un objet { current, presets }');
285
+ return report.toJson();
286
+ }
287
+ let current = data.current;
288
+ if (typeof current === 'string') current = data.presets?.[current];
289
+ if (!current || typeof current !== 'object') {
290
+ report.add('error', 'current', '"current" doit être un objet de réglages (ou le nom d’un preset existant)');
291
+ return report.toJson();
292
+ }
293
+ const index = catalog.globalSettingIndex();
294
+ const schemeIds = Object.keys(current.color_schemes || {});
295
+ const context = { schemeIds: schemeIds.length ? schemeIds : catalog.defaultSchemeIds(), resources: report.resources };
296
+ for (const [key, value] of Object.entries(current)) {
297
+ if (key === 'sections') {
298
+ for (const [id, section] of Object.entries(value || {})) checkSection(report, catalog, `current.sections.${id}`, section, null, context);
299
+ continue;
300
+ }
301
+ if (key === 'content_for_index' || key === 'blocks') continue;
302
+ const setting = index.get(key);
303
+ if (!setting) {
304
+ const hint = closest(key, index.keys());
305
+ report.add('warning', `current.${key}`, `réglage du thème inconnu, Shopify l'ignorera${hint.length ? ` (vouliez-vous dire : ${hint.join(', ')} ?)` : ''}`);
306
+ continue;
307
+ }
308
+ if (setting.type === 'color_scheme_group') {
309
+ checkSchemes(report, catalog, value, setting);
310
+ continue;
311
+ }
312
+ if (setting.type === 'color_palette') {
313
+ checkPalette(report, value, setting);
314
+ continue;
315
+ }
316
+ for (const problem of checkValue(setting, value, context)) report.add(problem.level, `current.${key}`, problem.message);
317
+ }
318
+ return report.toJson();
319
+ }
320
+
321
+ function checkSchemes(report, catalog, value, setting) {
322
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
323
+ report.add('error', 'current.color_schemes', 'attendu un objet { id_palette: { settings: {…} } }');
324
+ return;
325
+ }
326
+ const definition = setting.definition || [];
327
+ const ids = Object.keys(value);
328
+ if (!ids.length) report.add('error', 'current.color_schemes', 'au moins une palette est nécessaire');
329
+ for (const id of ids) {
330
+ const path = `current.color_schemes.${id}`;
331
+ if (!isSafeId(id)) report.add('error', path, 'identifiant de palette invalide');
332
+ const scheme = value[id];
333
+ if (!scheme?.settings || typeof scheme.settings !== 'object') {
334
+ report.add('error', path, 'attendu { settings: { text, background, … } }');
335
+ continue;
336
+ }
337
+ checkSettings(report, catalog, path, scheme.settings, definition, {});
338
+ for (const role of definition.filter((entry) => entry.type === 'color')) {
339
+ if (!scheme.settings[role.id] && !role.default) report.add('warning', path, `couleur « ${role.id} » vide`);
340
+ }
341
+ }
342
+ }
343
+
344
+ function checkPalette(report, value, setting) {
345
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
346
+ report.add('error', 'current.palette', 'attendu un objet { background, surface, text, … }');
347
+ return;
348
+ }
349
+ const keys = Object.keys(setting.default || {});
350
+ for (const [key, color] of Object.entries(value)) {
351
+ if (keys.length && !keys.includes(key)) report.add('warning', `current.palette.${key}`, `couleur inconnue de la palette (clés : ${keys.join(', ')})`);
352
+ for (const problem of checkValue({ type: 'color' }, color)) report.add(problem.level, `current.palette.${key}`, problem.message);
353
+ }
354
+ }
355
+
356
+ export function validateFile(catalog, file, data, options = {}) {
357
+ const kind = fileKind(file);
358
+ if (kind === 'template') return validateTemplateData(catalog, data, { ...options, file });
359
+ if (kind === 'group') return validateGroupData(catalog, data, { ...options, file });
360
+ if (kind === 'settings_data') return validateSettingsData(catalog, data);
361
+ return { ok: false, errors: [{ path: file, message: 'fichier non géré : seuls templates/*.json, sections/<groupe>.json et config/settings_data.json le sont' }], warnings: [], counts: {}, resources: {} };
362
+ }
@@ -0,0 +1,173 @@
1
+ import { checkRichtext, checkInlineRichtext } from './richtext.js';
2
+ import { checkFontHandle } from '../knowledge/fonts.js';
3
+
4
+ const DYNAMIC_RE = /^\s*\{\{[\s\S]+\}\}\s*$/;
5
+ const HANDLE_RE = /^[\p{Ll}\p{N}][\p{Ll}\p{N}_-]*$/u;
6
+ const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
7
+ const RGB_RE = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|1\.0+|0?\.\d+)\s*)?\)$/i;
8
+ const ARTICLE_RE = /^[\p{Ll}\p{N}][\p{Ll}\p{N}_-]*\/[\p{Ll}\p{N}][\p{Ll}\p{N}_-]*$/u;
9
+ const NO_DYNAMIC = new Set(['select', 'radio', 'color_scheme', 'font_picker', 'text_alignment']);
10
+
11
+ export const isDynamic = (value) => typeof value === 'string' && DYNAMIC_RE.test(value);
12
+
13
+ const fail = (message) => [{ level: 'error', message }];
14
+ const warn = (message) => [{ level: 'warning', message }];
15
+
16
+ function checkUrl(value) {
17
+ if (value === '') return [];
18
+ if (typeof value !== 'string') return fail('un lien doit être une chaîne');
19
+ if (/\s/.test(value.trim())) return fail(`lien « ${value} » : contient des espaces`);
20
+ if (/^shopify:\/\/(collections|products|pages|blogs|policies|search|customer|account|cart)(\/|$)/.test(value)) return [];
21
+ if (/^shopify:\/\//.test(value)) return warn(`lien « ${value} » : format shopify:// inhabituel (attendu : shopify://collections/<handle>, shopify://products/<handle>, shopify://pages/<handle>, shopify://blogs/<handle>)`);
22
+ if (/^(https?:\/\/|\/|#|mailto:|tel:|sms:)/.test(value)) return [];
23
+ return fail(`lien « ${value} » invalide : utilisez shopify://collections/<handle>, /chemin, https://…, mailto:, tel: ou #ancre`);
24
+ }
25
+
26
+ function checkHandle(value, what) {
27
+ if (value === '' || value == null) return [];
28
+ if (typeof value !== 'string') return fail(`${what} : attendu l'identifiant (handle) sous forme de texte`);
29
+ if (!HANDLE_RE.test(value)) return fail(`${what} « ${value} » : un handle Shopify est en minuscules, sans espace ni accent (ex. robes-ete)`);
30
+ return [];
31
+ }
32
+
33
+ function checkList(value, setting, what) {
34
+ if (value === '' || value == null) return [];
35
+ if (!Array.isArray(value)) return fail(`${what} : attendu une liste de handles`);
36
+ const problems = value.flatMap((item) => checkHandle(item, what));
37
+ if (setting.limit && value.length > setting.limit) problems.push({ level: 'error', message: `${what} : ${value.length} éléments, ${setting.limit} maximum` });
38
+ return problems;
39
+ }
40
+
41
+ function onStep(value, min, step) {
42
+ const ratio = (value - min) / step;
43
+ return Math.abs(ratio - Math.round(ratio)) < 1e-9;
44
+ }
45
+
46
+ // Validates one setting value against its schema entry. `context` carries the colour scheme ids
47
+ // and collects the resources the value points to.
48
+ export function checkValue(setting, value, context = {}) {
49
+ const type = setting.type;
50
+ if (value === null || value === undefined) return [];
51
+ if (typeof value === 'string' && value.startsWith('t:')) {
52
+ return fail(`« ${value} » est une clé de traduction : dans un modèle, écrivez le texte lui-même`);
53
+ }
54
+ if (isDynamic(value) && !NO_DYNAMIC.has(type)) return [];
55
+
56
+ switch (type) {
57
+ case 'text':
58
+ case 'textarea':
59
+ if (typeof value !== 'string') return fail('attendu du texte');
60
+ if (type === 'text' && /<(p|div|h[1-6])\b/i.test(value)) return warn('HTML dans un champ texte simple : il sera affiché tel quel');
61
+ return [];
62
+ case 'html':
63
+ case 'liquid':
64
+ if (typeof value !== 'string') return fail('attendu du texte');
65
+ if (value.length > 50000) return fail('plus de 50 Ko dans un seul réglage (limite Shopify)');
66
+ return [];
67
+ case 'richtext':
68
+ if (typeof value !== 'string') return fail('attendu du texte riche (HTML)');
69
+ return checkRichtext(value);
70
+ case 'inline_richtext':
71
+ if (typeof value !== 'string') return fail('attendu du texte riche en ligne');
72
+ return checkInlineRichtext(value);
73
+ case 'number':
74
+ if (value === '') return [];
75
+ return typeof value === 'number' && Number.isFinite(value) ? [] : fail('attendu un nombre');
76
+ case 'range': {
77
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fail(`attendu un nombre entre ${setting.min} et ${setting.max}`);
78
+ if (value < setting.min || value > setting.max) return fail(`${value} hors limites (${setting.min} à ${setting.max}${setting.unit ? ' ' + setting.unit : ''})`);
79
+ if (!onStep(value, setting.min, setting.step ?? 1)) return fail(`${value} n'est pas sur un pas de ${setting.step ?? 1} depuis ${setting.min}`);
80
+ return [];
81
+ }
82
+ case 'checkbox':
83
+ return typeof value === 'boolean' ? [] : fail('attendu true ou false (sans guillemets)');
84
+ case 'select':
85
+ case 'radio': {
86
+ const values = (setting.options || []).map((option) => option.value);
87
+ return values.includes(value) ? [] : fail(`« ${value} » n'est pas une option (options : ${values.join(', ')})`);
88
+ }
89
+ case 'text_alignment':
90
+ return ['left', 'center', 'right'].includes(value) ? [] : fail('attendu left, center ou right');
91
+ case 'color':
92
+ if (value === '') return [];
93
+ if (typeof value !== 'string' || !(HEX_RE.test(value) || RGB_RE.test(value))) return fail(`couleur « ${value} » invalide : utilisez #RRGGBB`);
94
+ return [];
95
+ case 'color_background':
96
+ if (value === '') return [];
97
+ if (typeof value !== 'string' || !/gradient\(/.test(value)) return fail('attendu un dégradé CSS (linear-gradient(…), radial-gradient(…)) ou une chaîne vide');
98
+ return [];
99
+ case 'color_scheme': {
100
+ // An empty value falls back to the setting's default scheme.
101
+ if (value === '') return [];
102
+ const ids = context.schemeIds || [];
103
+ if (ids.length && !ids.includes(value)) return fail(`palette « ${value} » inexistante (palettes : ${ids.join(', ')})`);
104
+ return [];
105
+ }
106
+ case 'font_picker':
107
+ return checkFontHandle(value);
108
+ case 'url':
109
+ if (typeof value === 'string' && value.startsWith('shopify://')) context.resources?.links.add(value);
110
+ return checkUrl(value);
111
+ case 'image_picker':
112
+ if (value === '') return [];
113
+ if (typeof value !== 'string' || !/^shopify:\/\/shop_images\/[^/\s]+$/.test(value)) {
114
+ return fail(`image « ${value} » : une image se désigne par shopify://shop_images/<nom-du-fichier> (fichier déjà présent dans Contenu > Fichiers), sinon laissez vide`);
115
+ }
116
+ context.resources?.images.add(value);
117
+ return [];
118
+ case 'video':
119
+ if (value === '') return [];
120
+ if (typeof value !== 'string' || !/^shopify:\/\/files\/videos\/[^/\s]+$/.test(value)) return fail('une vidéo se désigne par shopify://files/videos/<nom-du-fichier>, sinon laissez vide');
121
+ context.resources?.videos.add(value);
122
+ return [];
123
+ case 'video_url': {
124
+ if (value === '') return [];
125
+ if (typeof value !== 'string') return fail('attendu une adresse YouTube ou Vimeo');
126
+ const youtube = /^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//.test(value);
127
+ const vimeo = /^https?:\/\/(www\.|player\.)?vimeo\.com\//.test(value);
128
+ const accept = setting.accept || ['youtube', 'vimeo'];
129
+ if ((youtube && accept.includes('youtube')) || (vimeo && accept.includes('vimeo'))) return [];
130
+ return fail(`vidéo « ${value} » : adresse ${accept.join(' ou ')} attendue`);
131
+ }
132
+ case 'product':
133
+ case 'collection':
134
+ case 'page':
135
+ case 'blog':
136
+ case 'link_list':
137
+ case 'metaobject': {
138
+ const problems = checkHandle(value, type);
139
+ if (!problems.length && value) context.resources?.[type === 'link_list' ? 'menus' : `${type}s`]?.add(value);
140
+ return problems;
141
+ }
142
+ case 'article':
143
+ if (value === '') return [];
144
+ return typeof value === 'string' && ARTICLE_RE.test(value) ? [] : fail(`article « ${value} » : attendu handle-du-blog/handle-de-l-article`);
145
+ case 'article_list': {
146
+ if (value === '' || value == null) return [];
147
+ if (!Array.isArray(value)) return fail('article_list : attendu une liste « blog/article »');
148
+ const problems = value.filter((item) => typeof item !== 'string' || !ARTICLE_RE.test(item)).map((item) => ({ level: 'error', message: `article « ${item} » : attendu handle-du-blog/handle-de-l-article` }));
149
+ if (setting.limit && value.length > setting.limit) problems.push({ level: 'error', message: `article_list : ${value.length} éléments, ${setting.limit} maximum` });
150
+ return problems;
151
+ }
152
+ case 'product_list':
153
+ case 'collection_list':
154
+ case 'metaobject_list': {
155
+ const problems = checkList(value, setting, type);
156
+ if (!problems.length && Array.isArray(value)) {
157
+ const bucket = type === 'product_list' ? 'products' : type === 'collection_list' ? 'collections' : null;
158
+ if (bucket) value.forEach((item) => context.resources?.[bucket].add(item));
159
+ }
160
+ return problems;
161
+ }
162
+ default:
163
+ return [];
164
+ }
165
+ }
166
+
167
+ export function newResources() {
168
+ return { links: new Set(), images: new Set(), videos: new Set(), products: new Set(), collections: new Set(), pages: new Set(), blogs: new Set(), menus: new Set(), metaobjects: new Set() };
169
+ }
170
+
171
+ export function resourcesToJson(resources) {
172
+ return Object.fromEntries(Object.entries(resources).filter(([, set]) => set.size).map(([key, set]) => [key, [...set].sort()]));
173
+ }
package/src/version.js ADDED
@@ -0,0 +1,3 @@
1
+ // Version du serveur MCP. Ici et dans package.json — les deux doivent être identiques
2
+ // (npm test le vérifie), et rien d'autre ne la redéclare.
3
+ export const VERSION = '1.0.0';