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 +1 -0
- package/package.json +1 -1
- package/src/build/compose.js +71 -14
- package/src/migrate/extract.js +256 -0
- package/src/migrate/source.js +125 -0
- package/src/migrate/v3.js +835 -0
- package/src/server.js +71 -8
- package/src/shops/direct.js +353 -0
- package/src/shops/tools.js +5 -3
- package/src/version.js +1 -1
- package/src/shops/push.js +0 -198
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.
|
|
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",
|
package/src/build/compose.js
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
15
|
-
.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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')
|
|
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, '&').replace(/</g, '<')}</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
|
+
};
|