story-theme-mcp 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,7 @@ Le MCP ne contient pas le thème : au démarrage, il demande à l'espace membre
59
59
  | Construire | `create_project`, `update_brief`, `apply_recipe`, `localize_template`, `list_texts`, `edit_template`, `write_file`, `update_theme_settings`, `read_file`, `exclude_template` |
60
60
  | Contrôler | `validate_json`, `review_project`, `undo`, `revert_file` |
61
61
  | Livrer | `export_theme` (zip à importer + check-list), `import_theme` (auditer un thème téléchargé d'une boutique) |
62
+ | Passer en 4.x | `migrate_v3` : une boutique en Story Thème 3.7 ou avant, reconstruite en 4.x avec son contenu (textes, photos, avis, FAQ, produits, couleurs, polices, logo, menus), section par section — ce que la mise à jour de l'espace membre ne sait pas faire |
62
63
  | Vos boutiques | `list_shops`, `shops_overview`, `shop_graphql` (Admin GraphQL sur une, plusieurs ou toutes les boutiques), `push_to_shop` (écrire le projet dans un thème non publié, sans zip) — via l'appli Shopify Story Apps, sans aucune clé Shopify |
63
64
 
64
65
  Prompts prêts à l'emploi : `creer-boutique`, `refaire-une-page`, `auditer-un-theme`. Les guides sont aussi exposés en ressources (`storytheme://guides/<sujet>`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "story-theme-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Serveur MCP du Story Thème : votre assistant IA connaît le thème Shopify Story par cœur et construit des boutiques propres et professionnelles.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,22 +1,49 @@
1
1
  import { sectionFromPreset, blockFromPreset, createIdFactory, collectIds } from './scaffold.js';
2
2
  import { imageRef } from './quick-shared.js';
3
3
  import { closest } from '../theme/catalog.js';
4
+ import { plainText } from '../validate/richtext.js';
4
5
 
5
6
  const clone = (value) => JSON.parse(JSON.stringify(value));
6
7
  const escapeHtml = (text) => String(text ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
7
8
 
9
+ // Text that is already theme HTML — a merchant's own rich text read from another theme — travels
10
+ // as { html } and is placed as it is, instead of being escaped like the assistant's plain text.
11
+ export const isHtml = (value) => Boolean(value) && typeof value === 'object' && typeof value.html === 'string';
12
+ const RICH_ALLOWED = /^(p|ul|ol|li|h[1-6]|br|strong|b|em|i|u|span|a|s|sub|sup)$/i;
13
+ const INLINE_ALLOWED = /^(strong|b|em|i|u|span|a|s|sub|sup)$/i;
14
+ // Tags Shopify refuses in rich text go, their text stays.
15
+ const keepTags = (html, allowed) => String(html).replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g, (tag, name) => (allowed.test(name) ? tag : ''));
16
+ const htmlInline = (html) =>
17
+ keepTags(
18
+ String(html)
19
+ .replace(/<li\b[^>]*>/gi, ' · ')
20
+ .replace(/<br\s*\/?>/gi, ' ')
21
+ .replace(/<\/(p|h[1-6]|li|ul|ol)>\s*<(p|h[1-6]|ul|ol)\b[^>]*>/gi, ' '),
22
+ INLINE_ALLOWED,
23
+ )
24
+ .replace(/\s+/g, ' ')
25
+ .trim();
26
+ const htmlBlocks = (html) => {
27
+ const kept = keepTags(html, RICH_ALLOWED).trim();
28
+ if (!kept) return '';
29
+ // Every top-level node of a rich text must be a block tag.
30
+ return /^<(p|ul|ol|h[1-6])\b/i.test(kept) ? kept : `<p>${kept}</p>`;
31
+ };
32
+
8
33
  // Plain text in, theme rich text out: **word** becomes bold (the theme's heading styles colour
9
34
  // bold words), blank lines split paragraphs.
10
- export const inline = (text) => escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
35
+ export const inline = (text) => (isHtml(text) ? htmlInline(text.html) : escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'));
11
36
  // Plain text settings (labels, accordion titles, eyebrows) cannot hold bold: the stars go.
12
- export const plain = (text) => escapeHtml(String(text ?? '').replace(/\*\*(.+?)\*\*/g, '$1'));
37
+ export const plain = (text) => (isHtml(text) ? escapeHtml(plainText(text.html)) : escapeHtml(String(text ?? '').replace(/\*\*(.+?)\*\*/g, '$1')));
13
38
  export const paragraphs = (text) =>
14
- String(text ?? '')
15
- .split(/\n\s*\n/)
16
- .map((part) => part.trim())
17
- .filter(Boolean)
18
- .map((part) => `<p>${inline(part).replace(/\n/g, '<br>')}</p>`)
19
- .join('');
39
+ isHtml(text)
40
+ ? htmlBlocks(text.html)
41
+ : String(text ?? '')
42
+ .split(/\n\s*\n/)
43
+ .map((part) => part.trim())
44
+ .filter(Boolean)
45
+ .map((part) => `<p>${inline(part).replace(/\n/g, '<br>')}</p>`)
46
+ .join('');
20
47
 
21
48
  // Blocks that repeat inside a section and carry one item of content each.
22
49
  const ITEM_TYPES = ['_timeline-step', '_scroll-card', '_story-step', '_slide', 'accordion', 'testimonial', 'counter', 'progress-bar', '_spec-row', '_comparison-row', '_hotspot', '_gallery-item', '_story', '_logo', '_marquee-image', '_trust-badge', '_ugc-item'];
@@ -95,14 +122,20 @@ function largestSiblings(entries, keyOf) {
95
122
 
96
123
  // The repeated items of a section: its most frequent item block type, else groups holding a
97
124
  // heading (columns, benefits), else alike groups of an image and a caption (horizontal gallery).
98
- function findItems(section) {
125
+ export function findItems(section) {
99
126
  const entries = [...walk(section)];
100
127
  const counts = new Map();
101
128
  for (const { block } of entries) if (ITEM_TYPES.includes(block.type)) counts.set(block.type, (counts.get(block.type) || 0) + 1);
102
129
  const type = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
103
130
  if (type) return entries.filter(({ block }) => block.type === type);
104
- const titled = largestSiblings(entries.filter(({ block }) => block.type === 'group' && holds(block, 'heading') && ![...walk(block)].slice(1).some((entry) => entry.block.type === 'group')), () => '');
131
+ const titledAll = entries.filter(({ block }) => block.type === 'group' && holds(block, 'heading') && ![...walk(block)].slice(1).some((entry) => entry.block.type === 'group'));
132
+ const titled = largestSiblings(titledAll, () => '');
105
133
  if (titled.length) return titled;
134
+ // Columns whose title sits in a nested group next to the photo (« Features with images »):
135
+ // the items are the outer groups, alike siblings each holding one titled group.
136
+ const outer = titledAll.map(({ parent }) => entries.find((entry) => entry.block === parent)).filter((entry) => entry && entry.block.type === 'group');
137
+ const columns = largestSiblings(outer.filter((entry, index) => outer.indexOf(entry) === index), signature);
138
+ if (columns.length) return columns;
106
139
  return largestSiblings(entries.filter(({ block }) => leafGroup(block) && (holds(block, 'image') || holds(block, 'text'))), signature);
107
140
  }
108
141
 
@@ -134,6 +167,7 @@ function setOption(catalog, block, key, value, notes) {
134
167
  }
135
168
 
136
169
  const firstOf = (node, type) => [...walk(node)].find((entry) => entry.block.type === type);
170
+ const blockHas = (catalog, block, id) => (catalog.block(block.type)?.schema?.settings || []).some((setting) => setting.id === id);
137
171
 
138
172
  // One item of content into one item block, whatever its kind.
139
173
  function fillItem(catalog, entry, item, notes) {
@@ -146,7 +180,8 @@ function fillItem(catalog, entry, item, notes) {
146
180
  case '_timeline-step':
147
181
  if (content.label !== undefined) block.settings.label = plain(content.label);
148
182
  if (content.title) block.settings.title = inline(content.title);
149
- if (text) block.settings.text = paragraphs(text);
183
+ // A step given without text keeps no sample text under the brand's title.
184
+ block.settings.text = text ? paragraphs(text) : '';
150
185
  if (content.image) block.settings.image = imageRef(content.image);
151
186
  setOption(catalog, block, 'icon', content.icon, notes);
152
187
  return;
@@ -196,13 +231,21 @@ function fillItem(catalog, entry, item, notes) {
196
231
  case '_logo':
197
232
  case '_ugc-item':
198
233
  if (content.image) block.settings.image = imageRef(content.image);
234
+ if (content.video && blockHas(catalog, block, 'video')) block.settings.video = content.video;
199
235
  if (content.title && block.type === '_gallery-item') block.settings.caption = inline(content.title);
200
236
  if (content.title && block.type === '_logo') block.settings.name = plain(content.title);
237
+ if (block.type === '_ugc-item') {
238
+ if (content.name) block.settings.author = plain(content.name);
239
+ if (content.text || (content.title && content.name)) block.settings.caption = inline(content.text || content.title);
240
+ if (content.product) block.settings.product = content.product;
241
+ }
201
242
  if (content.link) block.settings.link = content.link;
202
243
  return;
203
244
  case '_story':
204
245
  if (content.image) block.settings.image = imageRef(content.image);
246
+ if (content.video) block.settings.video = content.video;
205
247
  if (content.title) block.settings.title = plain(content.title);
248
+ if (content.product) block.settings.product = content.product;
206
249
  if (content.link) block.settings.link = content.link;
207
250
  return;
208
251
  default: {
@@ -210,11 +253,19 @@ function fillItem(catalog, entry, item, notes) {
210
253
  // icon and image blocks.
211
254
  const heading = firstOf(block, 'heading');
212
255
  if (heading && title) heading.block.settings = { ...(heading.block.settings || {}), title: `<p>${inline(title)}</p>` };
256
+ // An item given without a title keeps no sample one.
257
+ else if (heading) remove(heading.parent, heading.id);
213
258
  const body = firstOf(block, 'text');
214
259
  // No heading (a captioned photo): the title opens the text, in bold.
215
- const caption = !heading && title ? `**${String(title).replace(/\*\*/g, '')}**${text ? `\n${text}` : ''}` : text;
260
+ const caption =
261
+ !heading && title
262
+ ? isHtml(title) || isHtml(text)
263
+ ? { html: `<p><strong>${plain(title)}</strong></p>${text ? paragraphs(text) : ''}` }
264
+ : `**${String(title).replace(/\*\*/g, '')}**${text ? `\n${text}` : ''}`
265
+ : text;
216
266
  if (body && caption) body.block.settings = { ...(body.block.settings || {}), description: paragraphs(caption) };
217
- else if (body && !caption && content.title) remove(body.parent, body.id);
267
+ // No text for this item: the preset's sample sentence would read as the brand's.
268
+ else if (body && !caption) remove(body.parent, body.id);
218
269
  const button = firstOf(block, 'button');
219
270
  if (button) {
220
271
  if (content.button?.label) button.block.settings = { ...(button.block.settings || {}), button_text: inline(content.button.label), button_link: content.button.link || button.block.settings?.button_link || '' };
@@ -287,6 +338,8 @@ export function fillSection(catalog, section, spec, { nextId, h1 = false } = {})
287
338
  if (content.title) heading.block.settings.title = `<${tag}>${inline(content.title)}</${tag}>`;
288
339
  if (content.eyebrow !== undefined) heading.block.settings.subheading = plain(content.eyebrow);
289
340
  if (content.rotating_words?.length) heading.block.settings.rotating_words = content.rotating_words.map(plain).join('\n');
341
+ // title: "" removes the sample title, as text: "" removes the sample text.
342
+ if (content.title === '' && !content.eyebrow && !content.rotating_words?.length) remove(heading.parent, heading.id);
290
343
  }
291
344
  const text = outside.find(({ block }) => block.type === 'text');
292
345
  if (text && content.text) text.block.settings.description = paragraphs(content.text);
@@ -305,7 +358,11 @@ export function fillSection(catalog, section, spec, { nextId, h1 = false } = {})
305
358
  if (content.lines?.length) {
306
359
  for (const { block } of outside) {
307
360
  if (block.type === 'marquee-text') block.settings.text_items = content.lines.map(plain).join('\n');
308
- if (block.type === 'checklist') block.settings.items_rich = content.lines.map((line) => `<p>${inline(line)}</p>`).join('');
361
+ if (block.type === 'checklist') {
362
+ block.settings.items_rich = content.lines.map((line) => `<p>${inline(line)}</p>`).join('');
363
+ // The plain list (older setting) keeps the preset's instructions otherwise.
364
+ if (block.settings.items !== undefined) block.settings.items = content.lines.map(plain).join('\n');
365
+ }
309
366
  }
310
367
  }
311
368
 
@@ -0,0 +1,256 @@
1
+ // =====================================================================
2
+ // Ce qu'une section 3.x DIT, sans la façon dont elle le montre
3
+ // =====================================================================
4
+ // Une section 3.x mêle le contenu saisi par le marchand (titres, textes, boutons, photos,
5
+ // vidéos, produits, éléments répétés) et sa présentation (alignements, couleurs, polices,
6
+ // animations, espacements). Seul le contenu passe en 4.0 : les presets 4.0 apportent leur
7
+ // propre présentation, réglée par le designer pour les nouvelles sections.
8
+ //
9
+ // Avec le schéma de la section (lu dans les `.liquid` du thème exporté), le type de chaque
10
+ // réglage est connu. Sans lui, on devine d'après la valeur : une référence shopify://shop_images
11
+ // est une photo, une chaîne en minuscules sans espace (« left », « fade-up ») un choix de
12
+ // présentation, une phrase un texte.
13
+
14
+ import { isHtml } from '../build/compose.js';
15
+ import { plainText } from '../validate/richtext.js';
16
+
17
+ // Blocs qui ne portent que de la décoration ou une fonction du thème, rien que le marchand ait écrit.
18
+ const DECOR = new Set(['picto', 'custom_typo', 'divider', 'spacer', 'email_form', 'trust', 'share', 'media', 'featured_image']);
19
+ // Blocs qui portent le titre, le texte ou les boutons de la section elle-même, quand ils sont seuls.
20
+ const TETE = new Set(['heading', 'subheading', 'text', 'paragraph', 'button', 'more_image', 'icons', 'timer', 'image_heading', 'collapsible_subheading']);
21
+ // Blocs dont chaque occurrence est une ligne d'une liste (puces, badges, bandeau défilant).
22
+ const LIGNES = new Set(['badge', 'marquee', 'announcement-marquee', '_marquee-text-item']);
23
+
24
+ const SCHEMA_GENRE = {
25
+ image_picker: 'image',
26
+ video: 'video',
27
+ video_url: 'video',
28
+ url: 'link',
29
+ richtext: 'html',
30
+ inline_richtext: 'html',
31
+ text: 'text',
32
+ textarea: 'text',
33
+ html: 'code',
34
+ liquid: 'code',
35
+ product: 'product',
36
+ collection: 'collection',
37
+ product_list: 'products',
38
+ collection_list: 'collections',
39
+ blog: 'blog',
40
+ page: 'page',
41
+ link_list: 'menu',
42
+ article: 'article',
43
+ color_scheme: 'palette',
44
+ };
45
+
46
+ const ROLES = [
47
+ ['link', /^(button_link|button_url|link|url|shop_link|link_url|cta_link|button_link_url)$/],
48
+ ['label', /^(button_text|button|button_label|link_label|link_title|cta|cta_text)$/],
49
+ ['name', /^(name|customer_name|author_name|author)$/],
50
+ ['role', /^(role|author_age|verified_text|job|position)$/],
51
+ ['rating', /^(rating|rate_text|rating_text)$/],
52
+ ['title', /^(heading|title|image_heading|review_title|tab_label|headline|heading_text|question|slides)$/],
53
+ ['eyebrow', /^(subheading|image_subheading|subtitle|label|tagline|eyebrow|caption|announcment)$/],
54
+ ['text', /^(description|text|row_content|content|text_footer|caption_text|form_text|text_above|message|body|paragraph|info|tooltip|answer|text_before|text_after|helper_text)$/],
55
+ ['image', /^(image|image_custom|custom_icon|icon_custom|icon_image|avatar|cover_image|background_image|image_before|image_after|logo|photo)$/],
56
+ ['image_mobile', /^(image_mobile|background_image_mobile)$/],
57
+ ['video', /^(video|video_url|video_link|video_mobile|video_link_mobile)$/],
58
+ ];
59
+
60
+ const roleDe = (base) => ROLES.find(([, motif]) => motif.test(base))?.[0] || null;
61
+
62
+ // « icon_1_text » → icon, 1, text ; « heading1 » → heading, 1 ; « image0 » → image, 0.
63
+ const NUMERO = /^([a-z][a-z_]*?)_?(\d+)(?:_([a-z_]+))?$/;
64
+ // Réglages numérotés qui ne sont PAS des éléments répétés.
65
+ const PAS_NUMEROTE = /^(accent|heading_\d+_font_size|h\d)$/;
66
+
67
+ function genreDeviné(cle, valeur) {
68
+ if (typeof valeur !== 'string') return null;
69
+ const v = valeur.trim();
70
+ if (!v) return null;
71
+ if (/^shopify:\/\/shop_images\//.test(v)) return 'image';
72
+ if (/^shopify:\/\/files\/videos\//.test(v) || /youtube\.com|youtu\.be|vimeo\.com/.test(v)) return 'video';
73
+ if (/(^|_)(collection)$/.test(cle)) return 'collection';
74
+ if (/(^|_)(product)$/.test(cle)) return 'product';
75
+ if (/(^|_)(blog)$/.test(cle)) return 'blog';
76
+ if (/(^|_)(page)$/.test(cle)) return 'page';
77
+ if (/(^|_)(menu)$/.test(cle)) return 'menu';
78
+ if (/^#[0-9a-f]{3,8}$/i.test(v) || /^rgba?\(/.test(v)) return null;
79
+ if (/^(shopify:\/\/|\/|https?:|mailto:|tel:|#[a-z])/i.test(v) && !/\s/.test(v)) return 'link';
80
+ if (/_[nib]\d$/.test(v)) return null; // police de la bibliothèque Shopify
81
+ if (/^(background-\d|inverse|scheme(-\d+)?|accent-\d)$/.test(v) && /palette|scheme/.test(cle)) return 'palette';
82
+ if (/<[a-z][^>]*>/i.test(v)) return 'html';
83
+ // Un choix de présentation : minuscules, chiffres, tirets, sans espace.
84
+ if (/^[a-z0-9_\-.:%]+$/.test(v)) return null;
85
+ return 'text';
86
+ }
87
+
88
+ const texteVide = (valeur) => !plainText(isHtml(valeur) ? valeur.html : valeur).replace(/ /g, '').trim();
89
+
90
+ // Réglages qui ne sont pas du contenu : un ancre technique, ou un libellé d'interface du thème
91
+ // (« Écrire un avis », « Voir plus », jours/heures du compte à rebours…), que la 4.0 traduit elle-même.
92
+ const INTERFACE = /^(section_id|custom_class|anchor|write_review_\w+|success_message|plus|less|button_show_all_text|placeholder|error_message|default_value|days|hours|mins|secs|free_label|reviews_based_on_text|reviews_suffix_text|reviews_count|cart_\w+|sale_badge|share_label|size_guide_trigger|product_trigger|trigger|final_message|track)$/;
93
+
94
+ /** Un réglage lu : sa clé, son rôle (titre, texte, lien…), son genre et sa valeur. */
95
+ function lireReglage(cle, valeur, type) {
96
+ if (INTERFACE.test(cle)) return null;
97
+ const genre = type ? SCHEMA_GENRE[type] ?? null : genreDeviné(cle, valeur);
98
+ if (!genre || valeur === '' || valeur == null) return null;
99
+ if (genre === 'html' || genre === 'text') {
100
+ const v = genre === 'html' ? { html: String(valeur) } : String(valeur);
101
+ if (texteVide(v)) return null;
102
+ return { cle, genre, valeur: v };
103
+ }
104
+ return { cle, genre, valeur };
105
+ }
106
+
107
+ /**
108
+ * Range des réglages lus dans un contenu : titre, sur-titre, texte, boutons, médias, produits.
109
+ * Ce qui n'a pas de place est gardé dans `autres`, pour que rien ne se perde en silence.
110
+ */
111
+ function ranger(contenu, reglages) {
112
+ const bouton = {};
113
+ const second = {};
114
+ for (const reglage of reglages) {
115
+ const secondaire = /_secondary$|_2$/.test(reglage.cle) && /button|link/.test(reglage.cle);
116
+ const base = reglage.cle.replace(/_secondary$/, '');
117
+ const role = roleDe(base);
118
+ const { genre, valeur } = reglage;
119
+
120
+ if (genre === 'palette') contenu.palette ??= valeur;
121
+ else if (genre === 'code') contenu.code.push(valeur);
122
+ else if (['product', 'collection', 'blog', 'page', 'menu', 'article'].includes(genre)) contenu[genre] ??= valeur;
123
+ else if (genre === 'products' || genre === 'collections') contenu[genre] = [...(contenu[genre] || []), ...(Array.isArray(valeur) ? valeur : [valeur])];
124
+ else if (genre === 'image') {
125
+ if (role === 'image_mobile') contenu.image_mobile ??= valeur;
126
+ else if (/after/.test(base)) contenu.image_after ??= valeur;
127
+ else if (/before/.test(base)) contenu.image_before ??= valeur;
128
+ else if (!contenu.image) contenu.image = valeur;
129
+ else contenu.images.push(valeur);
130
+ } else if (genre === 'video') contenu.video ??= valeur;
131
+ else if (genre === 'link') (secondaire ? second : bouton).link ??= valeur;
132
+ else if (role === 'label') (secondaire ? second : bouton).label ??= valeur;
133
+ else if (role && ['title', 'eyebrow', 'text', 'name', 'role', 'rating'].includes(role) && contenu[role] === undefined) contenu[role] = valeur;
134
+ else if (role === 'text' && contenu.text !== undefined) contenu.text = concat(contenu.text, valeur);
135
+ else contenu.autres.push({ cle: reglage.cle, valeur });
136
+ }
137
+ for (const b of [bouton, second]) if (b.label && !texteVide(b.label)) contenu.buttons.push({ label: b.label, link: b.link || '' });
138
+ // Un lien seul (bloc image cliquable, logo) reste au contenu.
139
+ if (bouton.link && !bouton.label) contenu.link ??= bouton.link;
140
+ return contenu;
141
+ }
142
+
143
+ const enHtml = (v) => (isHtml(v) ? v.html : `<p>${String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;')}</p>`);
144
+ const concat = (a, b) => ({ html: `${enHtml(a)}${enHtml(b)}` });
145
+
146
+ export const contenuVide = () => ({ buttons: [], images: [], items: [], lines: [], apps: [], code: [], autres: [], ignores: [] });
147
+
148
+ /**
149
+ * Sépare les réglages simples des réglages numérotés (icon_1_text, heading2, image0…),
150
+ * qui décrivent des éléments répétés dans un seul bloc ou une seule section.
151
+ */
152
+ function lire(settings, schemaSettings) {
153
+ const types = new Map((schemaSettings || []).filter((s) => s.id).map((s) => [s.id, s.type]));
154
+ const simples = [];
155
+ const numerotes = new Map();
156
+ for (const [cle, valeur] of Object.entries(settings || {})) {
157
+ const reglage = lireReglage(cle, valeur, types.get(cle));
158
+ if (!reglage) continue;
159
+ const match = PAS_NUMEROTE.test(cle) ? null : NUMERO.exec(cle);
160
+ if (match && !/secondary/.test(cle)) {
161
+ const n = Number(match[2]);
162
+ const base = (match[3] ? `${match[1]}_${match[3]}` : match[1]).replace(/^(icon|item|slot|gift_slot|brand|spec|stat|step|review|tab|feature)_/, '');
163
+ if (!numerotes.has(n)) numerotes.set(n, []);
164
+ numerotes.get(n).push({ ...reglage, cle: base });
165
+ } else simples.push(reglage);
166
+ }
167
+ const elements = [...numerotes.entries()]
168
+ .sort((a, b) => a[0] - b[0])
169
+ .map(([, reglages]) => element(reglages))
170
+ .filter(utile);
171
+ return { simples, elements };
172
+ }
173
+
174
+ /** Un élément répété (une colonne, un avis, une question…) à partir de ses réglages. */
175
+ function element(reglages, genre = null) {
176
+ const item = ranger(contenuVide(), reglages);
177
+ // Une ligne d'icône n'a qu'un texte : c'est son titre.
178
+ if (item.title === undefined && item.text !== undefined && item.name === undefined) {
179
+ item.title = item.text;
180
+ delete item.text;
181
+ }
182
+ if (genre) item.kind = genre;
183
+ return item;
184
+ }
185
+
186
+ const utile = (item) =>
187
+ item.title !== undefined || item.text !== undefined || item.name !== undefined || item.image || item.video || item.product || item.collection || item.buttons.length || item.eyebrow !== undefined;
188
+
189
+ /**
190
+ * Le contenu d'une section 3.x.
191
+ * @param {object} section la section telle qu'elle est dans le JSON du thème
192
+ * @param {object|null} schema son schéma 3.x (null : lecture par devinette)
193
+ * @param {{ elements?: string[], schemasBlocs?: Map }} options `elements` : types de blocs à lire
194
+ * comme des éléments répétés même seuls (un avis unique reste un avis)
195
+ */
196
+ export function extraireSection(section, schema, { elements: typesElements = null, schemasBlocs = new Map() } = {}) {
197
+ const contenu = contenuVide();
198
+ contenu.type = section.type;
199
+ contenu.disabled = Boolean(section.disabled);
200
+
201
+ const { simples, elements } = lire(section.settings, schema?.settings);
202
+ ranger(contenu, simples);
203
+ contenu.items.push(...elements);
204
+
205
+ const ordre = (section.block_order || Object.keys(section.blocks || {})).filter((id) => section.blocks?.[id]);
206
+ const compte = new Map();
207
+ for (const id of ordre) compte.set(section.blocks[id].type, (compte.get(section.blocks[id].type) || 0) + 1);
208
+
209
+ for (const id of ordre) {
210
+ const bloc = section.blocks[id];
211
+ if (String(bloc.type).startsWith('shopify://apps/')) {
212
+ contenu.apps.push({ id, bloc });
213
+ continue;
214
+ }
215
+ if (bloc.disabled) {
216
+ contenu.ignores.push(`bloc ${bloc.type} masqué`);
217
+ continue;
218
+ }
219
+ if (DECOR.has(bloc.type)) continue;
220
+ const schemaBloc = schema?.blocks?.find((b) => b.type === bloc.type) || schemasBlocs.get(bloc.type) || null;
221
+ const lu = lire(bloc.settings, schemaBloc?.settings);
222
+
223
+ if (LIGNES.has(bloc.type)) {
224
+ const ligne = lu.simples.find((r) => r.genre === 'text' || r.genre === 'html');
225
+ if (ligne) contenu.lines.push(...decouperLigne(ligne.valeur));
226
+ continue;
227
+ }
228
+ const tete = typesElements ? !typesElements.includes(bloc.type) : TETE.has(bloc.type) && compte.get(bloc.type) === 1;
229
+ if (tete) {
230
+ ranger(contenu, lu.simples);
231
+ // Une liste d'icônes dans la section (puces sous le texte) : des lignes.
232
+ for (const el of lu.elements) if (el.title !== undefined) contenu.lines.push(el.title);
233
+ continue;
234
+ }
235
+ if (lu.elements.length && !lu.simples.some((r) => ['text', 'html'].includes(r.genre) && /^(heading|title|name|description|text)$/.test(r.cle))) {
236
+ // Un bloc qui porte plusieurs éléments numérotés (icônes, questions, avis).
237
+ contenu.items.push(...lu.elements.map((el) => ({ ...el, kind: bloc.type })));
238
+ continue;
239
+ }
240
+ const item = element(lu.simples, bloc.type);
241
+ if (lu.elements.length) item.sous = lu.elements;
242
+ if (bloc.blocks) item.enfants = bloc;
243
+ if (utile(item) || item.enfants) contenu.items.push(item);
244
+ }
245
+ return contenu;
246
+ }
247
+
248
+ // Un bandeau défilant 3.x tient parfois toute sa liste dans un seul texte (« ✓ A ✓ B ✓ C »).
249
+ function decouperLigne(valeur) {
250
+ const texte = plainText(isHtml(valeur) ? valeur.html : valeur);
251
+ const morceaux = texte.split(/\s*(?:✓|✔|•|\||·)\s*/).map((m) => m.trim()).filter(Boolean);
252
+ return morceaux.length > 1 ? morceaux : [texte];
253
+ }
254
+
255
+ /** Le texte lisible d'une valeur, pour le rapport. */
256
+ export const lisible = (valeur) => plainText(isHtml(valeur) ? valeur.html : valeur);
@@ -0,0 +1,125 @@
1
+ // =====================================================================
2
+ // Lire une boutique en Story Thème 3.x
3
+ // =====================================================================
4
+ // La 4.0 a refait le schéma des sections : 39 sections de la 3.7 n'existent plus, 32 sont
5
+ // nouvelles. La mise à jour de l'espace membre, qui recopie les réglages d'une version à
6
+ // l'autre, rendrait donc une boutique vide. La migration lit plutôt ce que le marchand a
7
+ // SAISI (textes, photos, liens, produits, couleurs) pour le reposer dans les sections 4.0.
8
+ //
9
+ // La source est un thème exporté de la boutique (dossier, ou zip de Boutique en ligne >
10
+ // Thèmes > Télécharger). Ses `.liquid` donnent le schéma de chaque section 3.x, y compris
11
+ // celles qu'une agence ou une appli a ajoutées : on sait alors sans deviner quel réglage est
12
+ // un texte, une image ou un lien. Sans eux (seulement les JSON), la lecture devine d'après
13
+ // la valeur et le nom du réglage.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { unzipSync, strFromU8 } from 'fflate';
18
+ import { parseThemeJson } from '../theme/loader.js';
19
+ import { fileKind } from '../validate/template.js';
20
+
21
+ const SCHEMA_RE = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/;
22
+
23
+ function lireSchema(liquid) {
24
+ const match = SCHEMA_RE.exec(liquid);
25
+ if (!match) return null;
26
+ try {
27
+ return JSON.parse(match[1]);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /** Les fichiers utiles d'un thème : chemin relatif → texte. */
34
+ function lireFichiers(source) {
35
+ const fichiers = new Map();
36
+ // La langue par défaut de la boutique se lit au nom du fichier de traductions (fr.default.json) :
37
+ // on garde ce nom, pas son contenu.
38
+ const garder = (rel) => fileKind(rel) || rel === 'config/settings_schema.json' || /^(sections|blocks)\/[^/]+\.liquid$/.test(rel) || /^locales\/[a-z-]+\.default\.json$/i.test(rel);
39
+
40
+ if (fs.statSync(source).isDirectory()) {
41
+ const parcourir = (dossier) => {
42
+ const complet = path.join(source, dossier);
43
+ if (!fs.existsSync(complet)) return;
44
+ for (const nom of fs.readdirSync(complet)) {
45
+ const rel = `${dossier}/${nom}`;
46
+ if (fs.statSync(path.join(source, rel)).isDirectory()) parcourir(rel);
47
+ else if (garder(rel)) fichiers.set(rel, fs.readFileSync(path.join(source, rel), 'utf8'));
48
+ }
49
+ };
50
+ for (const dossier of ['templates', 'sections', 'blocks', 'config']) parcourir(dossier);
51
+ return fichiers;
52
+ }
53
+
54
+ if (!source.endsWith('.zip')) throw new Error('source attendue : le dossier ou le .zip d’un thème exporté de Shopify');
55
+ const archive = unzipSync(new Uint8Array(fs.readFileSync(source)));
56
+ const noms = Object.keys(archive);
57
+ // Les exports Shopify rangent parfois tout sous un dossier racine.
58
+ const racine = noms.find((nom) => nom.endsWith('config/settings_schema.json'))?.replace('config/settings_schema.json', '') ?? '';
59
+ for (const nom of noms) {
60
+ if (!nom.startsWith(racine)) continue;
61
+ const rel = nom.slice(racine.length);
62
+ if (garder(rel)) fichiers.set(rel, strFromU8(archive[nom]));
63
+ }
64
+ return fichiers;
65
+ }
66
+
67
+ /**
68
+ * Le thème 3.x d'une boutique : ses modèles, groupes et réglages en JSON, les schémas de ses
69
+ * sections et blocs, et sa version.
70
+ * @param {string} source dossier ou .zip
71
+ */
72
+ export function lireThemeV3(source) {
73
+ const chemin = path.resolve(String(source).replace(/^~/, process.env.HOME || ''));
74
+ if (!fs.existsSync(chemin)) throw new Error(`« ${source} » introuvable`);
75
+ return lireThemeV3DepuisFichiers(lireFichiers(chemin), chemin);
76
+ }
77
+
78
+ /** Même lecture, à partir de fichiers déjà en mémoire (thème relu dans la boutique). */
79
+ export function lireThemeV3DepuisFichiers(fichiers, origine = 'boutique') {
80
+ const fichiersJson = new Map();
81
+ const erreurs = [];
82
+ const schemas = { sections: new Map(), blocks: new Map() };
83
+ let version = null;
84
+ let nom = null;
85
+ let langue = null;
86
+
87
+ for (const [rel, texte] of fichiers) {
88
+ const locale = /^locales\/([a-z-]+)\.default\.json$/i.exec(rel);
89
+ if (locale) {
90
+ langue = locale[1].toLowerCase().split('-')[0];
91
+ continue;
92
+ }
93
+ const liquid = /^(sections|blocks)\/([^/]+)\.liquid$/.exec(rel);
94
+ if (liquid) {
95
+ const schema = lireSchema(texte);
96
+ if (schema) schemas[liquid[1]].set(liquid[2], schema);
97
+ continue;
98
+ }
99
+ if (rel === 'config/settings_schema.json') {
100
+ try {
101
+ const info = parseThemeJson(texte).find((groupe) => groupe.name === 'theme_info');
102
+ version = info?.theme_version || null;
103
+ nom = info?.theme_name || null;
104
+ } catch {
105
+ /* version inconnue */
106
+ }
107
+ continue;
108
+ }
109
+ try {
110
+ fichiersJson.set(rel, parseThemeJson(texte));
111
+ } catch (error) {
112
+ erreurs.push(`${rel} : JSON illisible (${error.message})`);
113
+ }
114
+ }
115
+
116
+ const reglages = fichiersJson.get('config/settings_data.json');
117
+ const courant = typeof reglages?.current === 'string' ? reglages.presets?.[reglages.current] : reglages?.current;
118
+ return { origine, version, nom, langue, fichiers: fichiersJson, schemas, reglages: courant || {}, erreurs };
119
+ }
120
+
121
+ /** La version majeure d'un numéro « 3.7.0 », ou null. */
122
+ export const majeure = (version) => {
123
+ const match = /^(\d+)\./.exec(String(version || ''));
124
+ return match ? Number(match[1]) : null;
125
+ };