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,741 @@
1
+ import { generateColorSchemes } from './palette.js';
2
+ import { buildFromRecipe } from './recipe.js';
3
+ import { localizeData } from './localize.js';
4
+ import { createIdFactory, collectIds } from './scaffold.js';
5
+ import { findRecipe } from '../knowledge/recipes.js';
6
+ import { FONT_PAIRINGS } from '../knowledge/fonts.js';
7
+ import { validateFile } from '../validate/template.js';
8
+ import { normalizeText } from '../theme/catalog.js';
9
+ import { imageRef } from './quick-shared.js';
10
+ import { composeSections, fillSection, inline, plain, writeTestimonial } from './compose.js';
11
+ import { DIRECTIONS, STYLE_DIRECTION } from '../knowledge/directions.js';
12
+ import { checkValue } from '../validate/values.js';
13
+
14
+ const clone = (value) => JSON.parse(JSON.stringify(value));
15
+
16
+ // A style picks sensible defaults when the merchant has not said more: brand colour, neutrals,
17
+ // font pairing and home page recipe.
18
+ export const STYLES = {
19
+ essentiel: { label: 'polyvalent', color: '#2F4B3A', neutral: 'tinted', fonts: 'clean-sans', home: 'accueil-essentiel' },
20
+ premium: { label: 'premium, luxe', color: '#2B2622', neutral: 'warm', fonts: 'editorial', home: 'accueil-marque-premium' },
21
+ mode: { label: 'mode, prêt-à-porter', color: '#1A1A1A', neutral: 'pure', fonts: 'fashion-sans', home: 'accueil-marque-premium' },
22
+ beaute: { label: 'beauté, soin, bien-être', color: '#A45C68', neutral: 'warm', fonts: 'soft-modern', home: 'accueil-beaute' },
23
+ nature: { label: 'naturel, artisanal, épicerie', color: '#3F5A3C', neutral: 'warm', fonts: 'natural', home: 'accueil-artisan' },
24
+ maison: { label: 'maison, déco, grand catalogue', color: '#7A5C44', neutral: 'warm', fonts: 'minimal', home: 'accueil-catalogue' },
25
+ tech: { label: 'tech, accessoires', color: '#2F5BEA', neutral: 'cool', fonts: 'tech', home: 'accueil-produit-phare' },
26
+ sport: { label: 'sport, énergie', color: '#D8432B', neutral: 'pure', fonts: 'bold-sport', home: 'accueil-essentiel' },
27
+ enfant: { label: 'enfants, animaux, ludique', color: '#E08A2E', neutral: 'warm', fonts: 'friendly', home: 'accueil-essentiel' },
28
+ 'produit-unique': { label: 'marque d’un produit phare (DTC)', color: '#1F3A5F', neutral: 'cool', fonts: 'contemporary', home: 'accueil-produit-phare' },
29
+ };
30
+
31
+ const KEYWORDS = {
32
+ beaute: ['cosmet', 'beaute', 'soin', 'skincare', 'maquillage', 'parfum', 'cheveu', 'capillaire', 'bien etre', 'spa', 'serum', 'creme', 'visage', 'ongle'],
33
+ nature: ['bougie', 'artisan', 'fait main', 'a la main', 'epicerie', 'miel', 'the', 'cafe', 'vin', 'biere', 'chocolat', 'bio', 'terroir', 'ceramique', 'poterie', 'local', 'producteur', 'savon'],
34
+ maison: ['deco', 'maison', 'meuble', 'mobilier', 'linge', 'cuisine', 'jardin', 'luminaire', 'concept store', 'vaisselle'],
35
+ mode: ['vetement', 'mode', 'pret a porter', 'streetwear', 't shirt', 'sweat', 'robe', 'chaussure', 'sneaker', 'lingerie', 'chouchou', 'foulard', 'accessoire', 'sac'],
36
+ premium: ['bijou', 'joaill', 'montre', 'maroquin', 'luxe', 'haut de gamme', 'cachemire', 'soie'],
37
+ tech: ['tech', 'electroni', 'gadget', 'telephone', 'iphone', 'audio', 'casque', 'informatique', 'gaming'],
38
+ sport: ['sport', 'fitness', 'musculation', 'running', 'yoga', 'velo', 'outdoor', 'proteine', 'nutrition sportive'],
39
+ enfant: ['enfant', 'bebe', 'jouet', 'puericulture', 'animal', 'animaux', 'chien', 'chat', 'kids'],
40
+ };
41
+
42
+ // The style whose keywords appear most in the activity (ties go to the first listed).
43
+ export function inferStyle(activity) {
44
+ const text = ` ${normalizeText(activity)} `;
45
+ let best = 'essentiel';
46
+ let bestScore = 0;
47
+ for (const [style, words] of Object.entries(KEYWORDS)) {
48
+ const score = words.filter((word) => text.includes(` ${word}`)).length;
49
+ if (score > bestScore) [best, bestScore] = [style, score];
50
+ }
51
+ return best;
52
+ }
53
+
54
+ const PAGE_RECIPES = { contact: 'page-contact', faq: 'page-faq', suivi: 'page-suivi-commande' };
55
+ export const BRAND_PAGES = { 'a-propos': 'templates/page.story.json', utilisation: 'templates/page.utilisation.json' };
56
+ const DEFAULT_PAGES = ['a-propos', 'utilisation', 'contact', 'faq', 'suivi'];
57
+ const DEMO_TEMPLATES = ['templates/product.offers-bogo.json', 'templates/product.offers-bundle.json', 'templates/product.offers-minimal.json', 'templates/product.offers-packs.json', 'templates/product.offers-quantity.json', 'templates/product.offers-subscription.json', 'templates/page.help.json', 'templates/page.landing.json'];
58
+ const HERO_TYPES = ['banner', 'slideshow', 'video'];
59
+
60
+ // ---- brand copy placed into the built pages
61
+
62
+ function* inOrder(node) {
63
+ const ids = [...Object.keys(node.blocks || {}).filter((id) => node.blocks[id].static), ...(node.block_order || [])];
64
+ for (const id of ids) {
65
+ const block = node.blocks?.[id];
66
+ if (!block) continue;
67
+ yield { id, block, parent: node };
68
+ yield* inOrder(block);
69
+ }
70
+ }
71
+
72
+ const first = (node, type) => [...inOrder(node)].find((entry) => entry.block.type === type)?.block || null;
73
+ const set = (block, key, value, counter) => {
74
+ if (!block || value === undefined || value === null || value === '') return;
75
+ block.settings = block.settings || {};
76
+ block.settings[key] = value;
77
+ counter.count += 1;
78
+ };
79
+
80
+ function removeBlock(parent, id) {
81
+ delete parent.blocks[id];
82
+ parent.block_order = (parent.block_order || []).filter((item) => item !== id);
83
+ }
84
+
85
+ // ---- the theme's demo showcase, which a merchant store must not inherit
86
+
87
+ // Templates of the demo store's section catalogue (collection.sections-banners…): useful to
88
+ // browse the theme, meaningless in a merchant's store where they show up in the admin.
89
+ const SHOWCASE_TEMPLATE = /^templates\/[^/]+\.sections-[^/]+\.json$/;
90
+ // Links into that catalogue (/collections/all?view=sections-…).
91
+ const SHOWCASE_LINK = /[?&]view=sections-/;
92
+
93
+ const linksShowcase = (node) => [...inOrder(node)].some(({ block }) =>
94
+ Object.values(block.settings || {}).some((value) => typeof value === 'string' && SHOWCASE_LINK.test(value)));
95
+
96
+ // Removes what the theme ships for its own demo store: the catalogue templates, the footer
97
+ // navigation into them, and product lists naming the demo store's products (casquette-3,
98
+ // pull-2…), which do not exist in the merchant's catalogue. Every merchant build inherited
99
+ // them: a "Browse all sections" footer, and a cart upsell with nothing to show.
100
+ // Product handles named by the theme's own files: the demo store's catalogue. A merchant
101
+ // never has them, so a list made only of them is a demo leftover, whatever file it is in
102
+ // (after translation, cart.json is a project file but still names casquette-3).
103
+ function demoProductHandles(catalog) {
104
+ const handles = new Set();
105
+ for (const [, data] of [...catalog.theme.templates, ...catalog.theme.groups]) {
106
+ for (const section of Object.values(data?.sections || {})) {
107
+ for (const { block } of inOrder(section)) {
108
+ if (Array.isArray(block.settings?.products)) block.settings.products.forEach((handle) => handles.add(handle));
109
+ }
110
+ }
111
+ }
112
+ return handles;
113
+ }
114
+
115
+ export function stripShowcase(catalog, store, slug, { collections = [] } = {}) {
116
+ const removed = [];
117
+ const demoProducts = demoProductHandles(catalog);
118
+ for (const [rel, entry] of store.effectiveFiles(slug)) {
119
+ if (SHOWCASE_TEMPLATE.test(rel)) {
120
+ if (!store.excluded(slug).includes(rel)) { store.setExcluded(slug, rel, true); removed.push(rel); }
121
+ continue;
122
+ }
123
+ if (!entry.data?.sections) continue;
124
+ const data = clone(entry.data);
125
+ let changed = false;
126
+
127
+ for (const id of [...(data.order || [])]) {
128
+ const section = data.sections[id];
129
+ if (!section) continue;
130
+ if (linksShowcase(section)) {
131
+ delete data.sections[id];
132
+ data.order = data.order.filter((item) => item !== id);
133
+ removed.push(`${rel} › ${id}`);
134
+ changed = true;
135
+ continue;
136
+ }
137
+ // A list naming only the demo store's products: let Shopify recommend from the cart
138
+ // instead, with the main collection as a fallback.
139
+ for (const { block } of inOrder(section)) {
140
+ const list = block.settings?.products;
141
+ if (block.settings?.source !== 'products' || !Array.isArray(list) || !list.length) continue;
142
+ if (!list.every((handle) => demoProducts.has(handle))) continue;
143
+ const choices = (catalog.block(block.type)?.schema?.settings || []).find((setting) => setting.id === 'source')?.options?.map((option) => option.value) || [];
144
+ const settings = { ...block.settings, products: [] };
145
+ if (choices.includes('recommendations')) settings.source = 'recommendations';
146
+ else if (choices.includes('collection') && collections[0]) settings.source = 'collection';
147
+ else continue;
148
+ if (collections[0]) settings.collection = collections[0];
149
+ block.settings = settings;
150
+ removed.push(`${rel} › ${block.type} (produits de démo)`);
151
+ changed = true;
152
+ }
153
+ }
154
+
155
+ if (changed && validateFile(catalog, rel, data).ok) store.write(slug, rel, data, 'V1 : vitrine de démo retirée');
156
+ }
157
+ return removed;
158
+ }
159
+
160
+ // Fills the key texts of a page from the brand copy. Returns how many settings were written.
161
+ export function fillCopy(data, copy, { collection = null, templateType = 'index' } = {}) {
162
+ const counter = { count: 0 };
163
+ const sections = (data.order || []).map((id) => data.sections[id]).filter(Boolean);
164
+ const link = collection ? `shopify://collections/${collection}` : null;
165
+
166
+ if (templateType === 'index') {
167
+ let hero = sections.find((section) => HERO_TYPES.includes(section.type));
168
+ if (hero?.type === 'slideshow') hero = first(hero, '_slide') || hero;
169
+ if (hero) {
170
+ set(first(hero, 'heading'), 'title', copy.tagline && `<h1>${inline(copy.tagline)}</h1>`, counter);
171
+ set(first(hero, 'text'), 'description', copy.hero_text && `<p>${inline(copy.hero_text)}</p>`, counter);
172
+ const button = first(hero, 'button');
173
+ set(button, 'button_text', copy.hero_button && inline(copy.hero_button), counter);
174
+ if (link) set(button, 'button_link', link, counter);
175
+ }
176
+ const newsletter = sections.find((section) => section.type === 'newsletter');
177
+ if (newsletter) {
178
+ set(first(newsletter, 'heading'), 'title', copy.newsletter_title && `<p>${inline(copy.newsletter_title)}</p>`, counter);
179
+ set(first(newsletter, 'text'), 'description', copy.newsletter_text && `<p>${inline(copy.newsletter_text)}</p>`, counter);
180
+ }
181
+ if (collection) {
182
+ for (const section of sections) {
183
+ for (const { block } of inOrder(section)) {
184
+ if (block.type === 'product-list' && (block.settings?.source ?? 'collection') === 'collection') set(block, 'collection', collection, counter);
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ const story = sections.find((section) => section.type === 'image-with-text') || sections.find((section) => section.type === 'rich-text' && first(section, 'text'));
191
+ if (story) {
192
+ set(first(story, 'heading'), 'title', copy.brand_story_title && `<p>${inline(copy.brand_story_title)}</p>`, counter);
193
+ set(first(story, 'text'), 'description', copy.brand_story_text && `<p>${inline(copy.brand_story_text)}</p>`, counter);
194
+ }
195
+ const benefits = sections.find((section) => section.type === 'multicolumn');
196
+ if (benefits && copy.benefits?.length) {
197
+ const groups = [...inOrder(benefits)].filter(({ block }) => block.type === 'group' && [...inOrder(block)].some((entry) => entry.block.type === 'heading') && ![...inOrder(block)].some((entry) => entry.block.type === 'group'));
198
+ groups.forEach(({ id, block, parent }, index) => {
199
+ const item = copy.benefits[index];
200
+ if (!item) return removeBlock(parent, id);
201
+ set(first(block, 'heading'), 'title', item.title && `<p>${inline(item.title)}</p>`, counter);
202
+ set(first(block, 'text'), 'description', item.text && `<p>${inline(item.text)}</p>`, counter);
203
+ });
204
+ }
205
+
206
+ if (copy.reassurance?.length) {
207
+ for (const section of sections) {
208
+ for (const { block } of inOrder(section)) {
209
+ if (block.type === 'marquee-text') set(block, 'text_items', copy.reassurance.map(plain).join('\n'), counter);
210
+ if (block.type === 'checklist') {
211
+ set(block, 'items_rich', copy.reassurance.map((item) => `<p>${inline(item)}</p>`).join(''), counter);
212
+ if (block.settings.items !== undefined) block.settings.items = copy.reassurance.map(plain).join('\n');
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // The big image moment (zoom on scroll): its own line, or its sample instruction goes.
219
+ for (const section of sections.filter((item) => item.type === 'scroll-zoom')) {
220
+ set(first(section, 'heading'), 'title', copy.showcase_title && `<p>${inline(copy.showcase_title)}</p>`, counter);
221
+ const text = [...inOrder(section)].find((entry) => entry.block.type === 'text');
222
+ if (text && copy.showcase_text) set(text.block, 'description', `<p>${inline(copy.showcase_text)}</p>`, counter);
223
+ else if (text) removeBlock(text.parent, text.id);
224
+ }
225
+
226
+ // Detail accordions outside the FAQ (materials, use, care): real details, or none.
227
+ const details = sections.filter((item) => item.type !== 'faq' && item.type !== 'main-product-composed').flatMap((section) => [...inOrder(section)].filter(({ block }) => block.type === 'accordion'));
228
+ details.forEach(({ id, block, parent }, index) => {
229
+ const item = copy.details?.[index];
230
+ if (!item) return removeBlock(parent, id);
231
+ set(block, 'heading', plain(item.title), counter);
232
+ set(block, 'content', `<p>${inline(item.text)}</p>`, counter);
233
+ });
234
+
235
+ // Sample questions state policies that may be false: replaced when real ones are given.
236
+ if (copy.faq?.length) {
237
+ for (const section of sections.filter((item) => item.type === 'faq')) {
238
+ const intro = [...inOrder(section)].find((entry) => entry.block.type === 'text');
239
+ if (intro && copy.faq_intro) set(intro.block, 'description', `<p>${inline(copy.faq_intro)}</p>`, counter);
240
+ else if (intro) removeBlock(intro.parent, intro.id);
241
+ const accordions = [...inOrder(section)].filter(({ block }) => block.type === 'accordion');
242
+ accordions.forEach(({ id, block, parent }, index) => {
243
+ const item = copy.faq[index];
244
+ if (!item) return removeBlock(parent, id);
245
+ set(block, 'heading', plain(item.question), counter);
246
+ set(block, 'content', `<p>${inline(item.answer)}</p>`, counter);
247
+ });
248
+ }
249
+ }
250
+ return counter.count;
251
+ }
252
+
253
+ // What an announcement talks about decides its icon and link, never the block it lands in (a
254
+ // "shipping" block can carry a price message).
255
+ const ANNOUNCEMENT_TOPICS = [
256
+ { words: ['retour', 'rembours', 'echange', 'return', 'refund', 'exchange'], icon: 'return', link: '/policies/refund-policy' },
257
+ { words: ['livraison', 'livre', 'expedi', 'colis', 'shipping', 'delivery'], icon: 'truck', link: '/policies/shipping-policy' },
258
+ { words: ['paiement', 'securise', 'payment', 'secure'], icon: 'lock', link: null },
259
+ ];
260
+
261
+ export function announcementTopic(message) {
262
+ const text = ` ${normalizeText(message)} `;
263
+ return ANNOUNCEMENT_TOPICS.find((topic) => topic.words.some((word) => text.includes(` ${word}`))) || null;
264
+ }
265
+
266
+ // Announcement messages of the header group, added or removed to match the brand's list.
267
+ export function fillAnnouncements(data, messages, { collection = null } = {}) {
268
+ if (!messages?.length) return 0;
269
+ const bar = Object.values(data.sections || {}).find((section) => section.type === 'announcement-bar');
270
+ const container = bar ? first(bar, '_header-announcements') : null;
271
+ if (!container) return 0;
272
+ const existing = (container.block_order || []).filter((id) => container.blocks?.[id]?.type === '_announcement');
273
+ const nextId = createIdFactory(collectIds(data));
274
+ messages.slice(0, 4).forEach((message, index) => {
275
+ const id = existing[index] || nextId('announcement');
276
+ if (!existing[index]) {
277
+ container.blocks[id] = { type: '_announcement', settings: {} };
278
+ container.block_order.push(id);
279
+ }
280
+ const settings = container.blocks[id].settings || (container.blocks[id].settings = {});
281
+ const topic = announcementTopic(message);
282
+ const link = topic ? topic.link : collection && `shopify://collections/${collection}`;
283
+ settings.text = inline(message);
284
+ settings.icon = topic?.icon || 'none';
285
+ if (link) settings.link = link;
286
+ else delete settings.link;
287
+ });
288
+ for (const id of existing.slice(messages.length)) removeBlock(container, id);
289
+ return Math.min(messages.length, 4);
290
+ }
291
+
292
+ // ---- assets: images the merchant already has, proofs that are real
293
+
294
+ export { imageRef };
295
+
296
+ // Sections a pool of brand photos can fill, and sections that need specific media (a before and
297
+ // an after, a shoppable scene, customer photos, a film, logos) which a V1 never guesses.
298
+ const NEEDS_IMAGE = new Set(['image-with-text', 'scroll-zoom', 'gallery']);
299
+ // Sections that stretch one photo over the whole screen: only a photo known to be wide (images.large).
300
+ const NEEDS_LARGE_IMAGE = new Set(['scroll-zoom']);
301
+ const NEEDS_SPECIFIC_MEDIA = new Set(['before-after', 'hotspots', 'ugc', 'stories', 'video', 'logo-list', 'scroll-horizontal', 'scroll-story']);
302
+ const PROOF_SECTIONS = new Set(['testimonials', 'reviews', 'stats', 'ugc', 'logo-list']);
303
+
304
+ function hasProof(type, proofs) {
305
+ if (type === 'testimonials') return Boolean(proofs.testimonials?.length);
306
+ if (type === 'reviews') return Boolean(proofs.rating || proofs.testimonials?.length);
307
+ if (type === 'stats') return Boolean(proofs.figures?.length);
308
+ return false;
309
+ }
310
+
311
+ // Rewrites a recipe for what the store really has: no grey placeholder when there is no photo,
312
+ // no invented review, press or figure, and a home page that stays full (8 to 10 sections).
313
+ export function adaptSteps(steps, { templateType, hasImages, hasLarge = hasImages, proofs, collections }) {
314
+ const out = [];
315
+ const planned = steps.map((step) => `${step.type}|${step.preset}`);
316
+ const has = (type, preset) => out.some((step) => step.type === type && (!preset || step.preset === preset)) || (preset && planned.includes(`${type}|${preset}`));
317
+ const filler = () =>
318
+ [
319
+ { type: 'multicolumn', preset: 'Icons with text', role: 'Bénéfices concrets du produit' },
320
+ { type: 'faq', preset: 'FAQ', role: 'Questions qui freinent l’achat' },
321
+ templateType === 'index' && collections.length > 1 ? { type: 'product-list', preset: 'Product carousel', role: `Sélection : ${collections[1]}`, collectionIndex: 1 } : null,
322
+ templateType === 'index' ? { type: 'product-list', preset: 'Product carousel', role: 'Nouveautés', source: 'all' } : null,
323
+ templateType === 'index' ? { type: 'collection-list', preset: 'Collection grid', role: 'Toutes les collections' } : null,
324
+ ]
325
+ .filter(Boolean)
326
+ .find((candidate) => !has(candidate.type, candidate.preset));
327
+
328
+ for (const original of steps) {
329
+ let step = { ...original };
330
+ if (step.type === 'banner') {
331
+ step.hero = true;
332
+ if (step.preset === 'Video banner') step.preset = 'Image banner';
333
+ } else if (PROOF_SECTIONS.has(step.type) && !hasProof(step.type, proofs)) {
334
+ step = filler();
335
+ } else if (NEEDS_SPECIFIC_MEDIA.has(step.type) || (NEEDS_IMAGE.has(step.type) && !hasImages) || (NEEDS_LARGE_IMAGE.has(step.type) && !hasLarge)) {
336
+ if (step.type === 'image-with-text') {
337
+ step = templateType === 'product' ? (has('multicolumn') ? filler() : { type: 'multicolumn', preset: 'Icons with text', role: step.role }) : has('rich-text', 'Rich text') ? filler() : { type: 'rich-text', preset: 'Rich text', role: step.role, story: true };
338
+ } else {
339
+ step = !has('marquee') ? { type: 'marquee', preset: 'Scrolling text', role: 'Arguments qui défilent', settings: { color_palette: 'inverse' } } : filler();
340
+ }
341
+ } else if (!hasImages && /with image|avec image/i.test(step.preset || '')) {
342
+ step = { ...step, preset: step.type === 'testimonials' ? 'Testimonials carousel' : 'Newsletter' };
343
+ }
344
+ if (step) out.push(step);
345
+ }
346
+
347
+ if (templateType === 'index') {
348
+ const heroIndex = out.findIndex((step) => step.hero || step.type === 'slideshow');
349
+ const marqueeIndex = out.findIndex((step) => step.type === 'marquee');
350
+ const marquee = marqueeIndex === -1 ? { type: 'marquee', preset: 'Scrolling text', role: 'Arguments qui défilent', settings: { color_palette: 'inverse' } } : out.splice(marqueeIndex, 1)[0];
351
+ out.splice(heroIndex + 1, 0, marquee);
352
+ const beforeLast = () => (out.at(-1)?.type === 'newsletter' ? out.length - 1 : out.length);
353
+ while (out.length < 8) {
354
+ const next = filler();
355
+ if (!next) break;
356
+ out.splice(beforeLast(), 0, next);
357
+ }
358
+ }
359
+ return out;
360
+ }
361
+
362
+ // Two neighbours on the same coloured background read as one block: the second goes back to
363
+ // the main background. Hero sections are covered by their media and left alone.
364
+ function fixPaletteRhythm(catalog, data) {
365
+ let previous = null;
366
+ for (const id of data.order || []) {
367
+ const section = data.sections[id];
368
+ if (!section || HERO_TYPES.includes(section.type)) {
369
+ previous = null;
370
+ continue;
371
+ }
372
+ const setting = ['frame_palette', 'color_palette'].find((key) => (catalog.section(section.type)?.schema?.settings || []).some((item) => item.id === key));
373
+ if (!setting) continue;
374
+ const value = section.settings?.[setting] ?? (catalog.section(section.type).schema.settings.find((item) => item.id === setting).default);
375
+ if (previous && value === previous && value !== 'background-1') section.settings[setting] = 'background-1';
376
+ previous = section.settings?.[setting] ?? value;
377
+ }
378
+ }
379
+
380
+ // Photos into the pages: the hero, then every empty image of the other sections from the pool.
381
+ export function fillImages(data, { hero, heroMobile, pool, large = [] }) {
382
+ let count = 0;
383
+ let next = 0;
384
+ let zoomed = 0;
385
+ const take = () => (pool.length ? imageRef(pool[next++ % pool.length]) : null);
386
+ const pruneEmpty = (node) => {
387
+ for (const [id, block] of Object.entries(node.blocks || {})) {
388
+ pruneEmpty(block);
389
+ if (block.type === 'group' && !block.static && !Object.keys(block.blocks || {}).length) removeBlock(node, id);
390
+ }
391
+ };
392
+ for (const id of data.order || []) {
393
+ const section = data.sections[id];
394
+ if (!section) continue;
395
+ if (section.type === 'banner' && hero && !section.settings?.image) {
396
+ section.settings.media_type = 'image';
397
+ section.settings.image = imageRef(hero);
398
+ if (heroMobile) section.settings.image_mobile = imageRef(heroMobile);
399
+ count += 1;
400
+ continue;
401
+ }
402
+ if (section.type === 'scroll-zoom' && !section.settings?.image && large.length) {
403
+ section.settings.image = imageRef(large[zoomed++ % large.length]);
404
+ count += 1;
405
+ }
406
+ const used = new Set();
407
+ for (const { id: blockId, block, parent } of [...inOrder(section)]) {
408
+ if (block.type !== 'image' || block.settings?.image) continue;
409
+ const image = take();
410
+ if (!image) continue;
411
+ if (used.has(image)) {
412
+ removeBlock(parent, blockId);
413
+ continue;
414
+ }
415
+ used.add(image);
416
+ block.settings = { ...(block.settings || {}), image };
417
+ count += 1;
418
+ }
419
+ pruneEmpty(section);
420
+ }
421
+ return count;
422
+ }
423
+
424
+ // A hero without a photo becomes a typographic one on the brand colour, text and background
425
+ // from the same scheme (an image hero keeps white text for the overlay, unreadable without it).
426
+ function typographicHero(data) {
427
+ const hero = (data.order || []).map((id) => data.sections[id]).find((section) => section?.type === 'banner');
428
+ if (!hero || hero.settings?.image) return false;
429
+ Object.assign(hero.settings, { media_type: 'none', frame_palette: 'scheme', color_palette: 'scheme', overlay: 'none', height: 'medium', height_mobile: 'medium', media_effect: 'none' });
430
+ return true;
431
+ }
432
+
433
+ // Real proofs in, sample ones out: testimonials, stars typed by hand, figures.
434
+ export function fillProofs(data, proofs) {
435
+ let count = 0;
436
+ for (const id of data.order || []) {
437
+ const section = data.sections[id];
438
+ if (!section) continue;
439
+ const entries = [...inOrder(section)];
440
+ const testimonials = entries.filter(({ block }) => block.type === 'testimonial');
441
+ testimonials.forEach(({ id: blockId, block, parent }, index) => {
442
+ const item = proofs.testimonials?.[index];
443
+ if (!item) return removeBlock(parent, blockId);
444
+ writeTestimonial(block, { text: item.text, name: item.name, role: item.role });
445
+ count += 1;
446
+ });
447
+ for (const { id: blockId, block, parent } of entries.filter(({ block }) => block.type === 'rating')) {
448
+ if (proofs.rating) block.settings = { ...(block.settings || {}), rating: Math.round(Math.min(5, Math.max(0, proofs.rating)) * 10) / 10 };
449
+ else removeBlock(parent, blockId);
450
+ }
451
+ entries.filter(({ block }) => block.type === 'counter').forEach(({ id: blockId, block, parent }, index) => {
452
+ const figure = proofs.figures?.[index];
453
+ if (!figure) return removeBlock(parent, blockId);
454
+ block.settings = { ...(block.settings || {}), heading: plain(figure.value), subheading: plain(figure.label) };
455
+ count += 1;
456
+ });
457
+ }
458
+ return count;
459
+ }
460
+
461
+ // Lists show the store's real catalogue: chosen collections, else every collection.
462
+ function wireCatalog(data, collections, steps) {
463
+ const sections = (data.order || []).map((id) => data.sections[id]);
464
+ sections.forEach((section, index) => {
465
+ const step = steps[index] || {};
466
+ for (const { block } of inOrder(section || {})) {
467
+ if (block.type === 'collection-list' && (block.settings?.source ?? 'collections') === 'collections') {
468
+ if (collections.length) block.settings.collections = collections.slice(0, block.settings.limit || 8);
469
+ else block.settings.source = 'all';
470
+ }
471
+ if (block.type === 'product-list' && (block.settings?.source ?? 'collection') === 'collection') {
472
+ const handle = step.source === 'all' ? null : collections[step.collectionIndex ?? 0];
473
+ if (handle) block.settings.collection = handle;
474
+ }
475
+ }
476
+ });
477
+ }
478
+
479
+ // ---- art direction
480
+
481
+ // The direction's global look, keeping only values the theme accepts.
482
+ export function applyDirectionSettings(catalog, current, direction, notes) {
483
+ const index = catalog.globalSettingIndex();
484
+ for (const [key, value] of Object.entries(direction.settings)) {
485
+ const setting = index.get(key);
486
+ if (!setting || checkValue(setting, value).some((problem) => problem.level === 'error')) {
487
+ notes.push(`direction : réglage ${key} ignoré (absent ou refusé par cette version du thème)`);
488
+ continue;
489
+ }
490
+ current[key] = value;
491
+ }
492
+ }
493
+
494
+ // Coloured bands get the direction's shape at their edges; the hero gets it at the bottom.
495
+ export function decorate(catalog, data, direction) {
496
+ if (!direction.separator) return 0;
497
+ let count = 0;
498
+ const has = (section, id) => (catalog.section(section.type)?.schema?.settings || []).some((setting) => setting.id === id);
499
+ (data.order || []).forEach((id, position) => {
500
+ const section = data.sections[id];
501
+ if (!section || !has(section, 'separator_bottom')) return;
502
+ const palette = section.settings?.frame_palette ?? section.settings?.color_palette ?? 'background-1';
503
+ const hero = position === 0 && ['banner', 'slideshow'].includes(section.type);
504
+ if (!hero && palette === 'background-1') return;
505
+ if (!hero) section.settings.separator_top = direction.separator.shape;
506
+ section.settings.separator_bottom = direction.separator.shape;
507
+ if (direction.separator.animation && has(section, 'separator_animation')) section.settings.separator_animation = direction.separator.animation;
508
+ count += 1;
509
+ });
510
+ return count;
511
+ }
512
+
513
+ const PALETTE_CYCLE = ['scheme', 'background-2', 'inverse', 'background-2'];
514
+
515
+ // Signature moments of the home page, from the brand copy the model wrote.
516
+ function addSignatures(catalog, data, { direction, copy, lang }) {
517
+ const added = [];
518
+ const position = (type) => data.order.findIndex((id) => data.sections[id]?.type === type);
519
+ const insertAt = (index, specs) => {
520
+ const before = new Set(data.order);
521
+ const { data: next } = composeSections(catalog, specs, { language: lang, data, h1First: false });
522
+ const fresh = next.order.filter((id) => !before.has(id));
523
+ next.order = next.order.filter((id) => before.has(id));
524
+ next.order.splice(Math.max(0, Math.min(index, next.order.length)), 0, ...fresh);
525
+ Object.assign(data, next);
526
+ return fresh;
527
+ };
528
+ if (copy.rotating_words?.length) {
529
+ const hero = data.sections[data.order[0]];
530
+ if (hero) fillSection(catalog, hero, { content: { rotating_words: copy.rotating_words } }, { nextId: createIdFactory(collectIds(data)) });
531
+ added.push('mots qui tournent dans le titre');
532
+ }
533
+ for (const signature of direction.signatures) {
534
+ if (signature === 'manifesto' && copy.manifesto) {
535
+ // Between the catalogue sections, so text and pictures alternate.
536
+ const after = (position('collection-list') !== -1 ? position('collection-list') : Math.max(position('product-list'), 1)) + 1;
537
+ insertAt(after, [{ type: 'rich-text', preset: 'Big statement', content: { manifesto: copy.manifesto } }]);
538
+ added.push('manifeste révélé au défilement');
539
+ }
540
+ if (signature === 'cards' && copy.benefits?.length >= 3) {
541
+ const columns = position('multicolumn');
542
+ if (columns !== -1) {
543
+ delete data.sections[data.order[columns]];
544
+ data.order.splice(columns, 1);
545
+ }
546
+ const items = copy.benefits.map((item, index) => ({ ...item, palette: PALETTE_CYCLE[index % PALETTE_CYCLE.length] }));
547
+ insertAt(columns === -1 ? data.order.length - 1 : columns, [{ type: 'scroll-cards', preset: 'Stacking cards', content: { eyebrow: copy.benefits_eyebrow ?? '', title: copy.benefits_title || (lang === 'fr' ? 'Pourquoi nous choisir' : null), items } }]);
548
+ added.push('cartes empilées au défilement');
549
+ }
550
+ if (signature === 'process' && copy.process?.length) {
551
+ insertAt(position('newsletter') === -1 ? data.order.length : position('newsletter') - 1, [{ type: 'timeline', preset: 'How it works', palette: 'background-2', content: { title: copy.process_title || (lang === 'fr' ? 'De l’atelier à chez vous' : null), items: copy.process } }]);
552
+ added.push('étapes de fabrication');
553
+ }
554
+ }
555
+ return added;
556
+ }
557
+
558
+ // ---- brand pages: our story, how to use
559
+
560
+ // A wide photo for a brand page hero, the page's own photos first; none means a typographic hero
561
+ // (a small photo stretched over the screen is blurry, the home hero again is repetitive).
562
+ function wideCover(images, preferred = []) {
563
+ const large = images.large || [];
564
+ return [...(preferred || []), ...large].find((photo) => large.includes(photo) && photo !== images.hero) || null;
565
+ }
566
+
567
+ function storySpecs({ copy, images, collections, proofs, lang, name }) {
568
+ const fr = lang === 'fr';
569
+ const link = collections[0] ? `shopify://collections/${collections[0]}` : 'shopify://collections/all';
570
+ const photos = [...(images.story || []), ...(images.lifestyle || [])];
571
+ const cover = wideCover(images, images.story);
572
+ return [
573
+ { type: 'banner', preset: 'Image banner', ...(cover ? { image: cover } : {}), content: { eyebrow: name, title: copy.story_title || (fr ? 'Notre histoire' : null), text: copy.story_intro || copy.brand_story_text || '', buttons: [{ label: copy.hero_button || (fr ? 'Découvrir la collection' : 'Shop now'), link }] } },
574
+ copy.manifesto ? { type: 'rich-text', preset: 'Big statement', palette: 'background-2', content: { manifesto: copy.manifesto } } : null,
575
+ copy.story_steps?.length ? { type: 'timeline', preset: 'Our story', content: { eyebrow: fr ? 'Les grandes étapes' : '', title: copy.story_steps_title || (fr ? 'Comment tout a commencé' : null), items: copy.story_steps } } : null,
576
+ photos.length ? { type: 'image-with-text', preset: 'Brand story', images: photos.slice(0, 2), content: { title: copy.brand_story_title, text: copy.brand_story_text, buttons: [] } } : { type: 'rich-text', preset: 'Rich text', content: { title: copy.brand_story_title, text: copy.brand_story_text, buttons: [] } },
577
+ copy.values?.length ? { type: 'multicolumn', preset: 'Icons with text', palette: 'background-2', content: { title: copy.values_title || (fr ? 'Ce qui compte pour nous' : null), items: copy.values } } : null,
578
+ proofs.figures?.length ? { type: 'stats', preset: 'Key figures', content: { title: fr ? 'En quelques chiffres' : null, items: proofs.figures } } : null,
579
+ { type: 'product-list', preset: 'Featured collection', content: { title: fr ? 'Nos incontournables' : null } },
580
+ ].filter(Boolean);
581
+ }
582
+
583
+ function usageSpecs({ copy, images, lang, name }) {
584
+ const fr = lang === 'fr';
585
+ const cover = wideCover(images, images.lifestyle);
586
+ const tips = (copy.tips || []).map((tip, index) => ({ ...tip, palette: PALETTE_CYCLE[index % PALETTE_CYCLE.length] }));
587
+ return [
588
+ { type: 'banner', preset: 'Image banner', ...(cover ? { image: cover } : {}), content: { eyebrow: fr ? 'Mode d’emploi' : 'How to use', title: copy.usage_title || (fr ? `Bien utiliser ${name}` : null), text: copy.usage_intro || '', buttons: [] } },
589
+ { type: 'timeline', preset: 'How it works', content: { title: copy.usage_steps_title || (fr ? 'En quelques étapes' : null), items: copy.usage_steps, buttons: [] } },
590
+ tips.length ? { type: 'scroll-cards', preset: 'Stacking cards', content: { title: fr ? 'Nos astuces' : null, items: tips } } : null,
591
+ copy.usage_faq?.length ? { type: 'faq', preset: 'FAQ', palette: 'background-2', content: { title: fr ? 'Vos questions' : null, text: '', items: copy.usage_faq.map((item) => ({ question: item.question, answer: item.answer })) } } : null,
592
+ { type: 'product-list', preset: 'Product carousel', content: { title: fr ? 'À vous de jouer' : null } },
593
+ ].filter(Boolean);
594
+ }
595
+
596
+ // ---- the whole first version, in one call
597
+
598
+ export function buildStore({ catalog, store }, input) {
599
+ const language = input.language || 'fr';
600
+ const style = STYLES[input.style] ? input.style : inferStyle(`${input.activity || ''} ${input.name}`);
601
+ const preset = STYLES[style];
602
+ const directionId = DIRECTIONS[input.direction] ? input.direction : STYLE_DIRECTION[style];
603
+ const direction = DIRECTIONS[directionId];
604
+ const choices = [];
605
+ const notes = [];
606
+ const collections = [...new Set([...(input.collections || []), ...(input.collection ? [input.collection] : [])])];
607
+ const images = { ...(input.images || {}) };
608
+ const pool = [...(images.story || []), ...(images.lifestyle || [])];
609
+ for (const photo of images.large || []) if (!pool.includes(photo)) pool.push(photo);
610
+ if (!images.hero && pool.length) images.hero = (images.large || [])[0] || pool[0];
611
+ if (!pool.length && images.hero) pool.push(images.hero);
612
+ const hasImages = pool.length > 0;
613
+ // Photos known to be wide enough for full-screen spots, the hero apart (already on screen).
614
+ images.large = (images.large || []).filter((photo) => photo !== images.hero);
615
+ const hasLarge = images.large.length > 0;
616
+ const proofs = input.proofs || {};
617
+
618
+ // Project: reused when it exists, so "refais la V1" rebuilds the same store.
619
+ const slug = store.slugFor(input.name);
620
+ const existed = store.exists(slug);
621
+ const brief = {
622
+ brand: input.name,
623
+ ...(input.activity ? { activity: input.activity } : {}),
624
+ style,
625
+ ...(input.primary_color ? { brand_colors: [input.primary_color, input.secondary_color].filter(Boolean) } : {}),
626
+ ...(input.shop_domain ? { shop_domain: input.shop_domain } : {}),
627
+ ...(collections.length ? { collections } : {}),
628
+ };
629
+ if (existed) store.updateBrief(slug, brief);
630
+ else store.create({ name: input.name, language, brief });
631
+ store.setCurrent(slug);
632
+ const meta = store.meta(slug);
633
+ const lang = meta.language || language;
634
+
635
+ // Identity
636
+ const primary = input.primary_color || preset.color;
637
+ choices.push(input.primary_color ? `couleurs à partir de ${input.primary_color}` : `couleur par défaut du style « ${preset.label} » (${preset.color}) : aucune couleur de marque donnée`);
638
+ const palette = generateColorSchemes({ primary, secondary: input.secondary_color || null, background: input.background_color || null, neutral: preset.neutral, mood: input.dark ? 'dark' : 'light' });
639
+ notes.push(...palette.adjustments);
640
+ const pairing = FONT_PAIRINGS.find((pair) => pair.id === input.fonts) || FONT_PAIRINGS.find((pair) => pair.id === direction.fonts) || FONT_PAIRINGS.find((pair) => pair.id === preset.fonts);
641
+ choices.push(`polices ${pairing.heading} / ${pairing.body} (${pairing.style})`);
642
+ const settings = clone(store.read(slug, 'config/settings_data.json').data);
643
+ if (typeof settings.current === 'string') settings.current = clone(settings.presets?.[settings.current] || {});
644
+ Object.assign(settings.current, palette.button_palettes, { headings_font: pairing.heading, body_font: pairing.body });
645
+ settings.current.color_schemes = { ...(settings.current.color_schemes || {}), ...palette.color_schemes };
646
+ settings.current.palette = { ...(settings.current.palette || {}), ...palette.palette };
647
+ applyDirectionSettings(catalog, settings.current, direction, notes);
648
+ choices.push(`direction artistique « ${direction.label} »`);
649
+ const settingsCheck = validateFile(catalog, 'config/settings_data.json', settings);
650
+ if (!settingsCheck.ok) throw new Error(`réglages invalides : ${settingsCheck.errors[0].message}`);
651
+ store.write(slug, 'config/settings_data.json', settings, 'V1 : identité');
652
+
653
+ // Pages
654
+ const recipes = [];
655
+ const home = findRecipe(input.home) ? input.home : preset.home;
656
+ recipes.push([home, 'templates/index.json']);
657
+ recipes.push([input.offers ? 'produit-offres' : 'produit-complet', 'templates/product.json']);
658
+ recipes.push(['collection-complete', 'templates/collection.json']);
659
+ const pages = input.pages || DEFAULT_PAGES;
660
+ for (const page of pages) if (PAGE_RECIPES[page]) recipes.push([PAGE_RECIPES[page], findRecipe(PAGE_RECIPES[page]).template]);
661
+ choices.push(`accueil : recette ${home} ; fiche produit : ${input.offers ? 'avec offres' : 'complète'} ; pages : ${pages.join(', ')}`);
662
+ choices.push(hasImages ? `photos placées : ${[images.hero, ...pool].filter(Boolean).length}` : 'aucune photo fournie : héro typographique sur la couleur de marque, sections à photos remplacées (rien de vide)');
663
+ if (hasImages && !hasLarge) notes.push('aucune photo large en dehors du héro (images.large, 1600 px de large ou plus) : pas de zoom au défilement, héro typographique sur les pages de marque');
664
+
665
+ const copy = { ...(input.copy || {}) };
666
+ if (!copy.reassurance?.length && copy.announcements?.length) copy.reassurance = copy.announcements;
667
+ const written = {};
668
+ const sectionsPerFile = {};
669
+ let signatures = [];
670
+ for (const [recipeId, file] of recipes) {
671
+ const recipe = findRecipe(recipeId);
672
+ const templateType = file === 'templates/index.json' ? 'index' : file.startsWith('templates/product') ? 'product' : 'other';
673
+ const steps = adaptSteps(recipe.steps, { templateType, hasImages, hasLarge, proofs, collections });
674
+ const { data } = buildFromRecipe(catalog, { ...recipe, steps }, { language: lang, base: recipe.base ? catalog.theme.templates.get(recipe.base) : null });
675
+ const baseCount = recipe.base ? catalog.theme.templates.get(recipe.base).order.length : 0;
676
+ const filled = clone(data);
677
+ wireCatalog(filled, collections, [...Array(baseCount).fill({}), ...steps]);
678
+ let count = fillCopy(filled, copy, { collection: collections[0], templateType: templateType === 'index' ? 'index' : 'other' });
679
+ count += fillImages(filled, { hero: images.hero, heroMobile: images.hero_mobile, pool, large: images.large });
680
+ count += fillProofs(filled, proofs);
681
+ if (templateType === 'index') signatures = addSignatures(catalog, filled, { direction, copy, lang });
682
+ typographicHero(filled);
683
+ fixPaletteRhythm(catalog, filled);
684
+ decorate(catalog, filled, direction);
685
+ const check = validateFile(catalog, file, filled);
686
+ const final = check.ok ? filled : data;
687
+ if (!check.ok) notes.push(`${file} : personnalisation non appliquée (${check.errors[0].path} : ${check.errors[0].message})`);
688
+ else if (count) written[file] = count;
689
+ const ok = validateFile(catalog, file, final);
690
+ if (!ok.ok) throw new Error(`${file} : ${ok.errors[0].message}`);
691
+ store.write(slug, file, final, `V1 : recette ${recipeId}`);
692
+ sectionsPerFile[file] = final.order.length;
693
+ }
694
+
695
+ // Brand pages composed from the copy: our story, how to use.
696
+ const brandPages = [];
697
+ for (const page of pages.filter((item) => BRAND_PAGES[item])) {
698
+ const file = BRAND_PAGES[page];
699
+ const specs = page === 'a-propos' ? storySpecs({ copy, images, collections, proofs, lang, name: input.name }) : usageSpecs({ copy, images, lang, name: input.name });
700
+ const { data, notes: composeNotes } = composeSections(catalog, specs, { language: lang });
701
+ notes.push(...composeNotes.map((note) => `${file} ${note}`));
702
+ wireCatalog(data, collections, specs);
703
+ typographicHero(data);
704
+ fixPaletteRhythm(catalog, data);
705
+ decorate(catalog, data, direction);
706
+ const check = validateFile(catalog, file, data);
707
+ if (!check.ok) {
708
+ notes.push(`${file} : non créée (${check.errors[0].path} : ${check.errors[0].message})`);
709
+ continue;
710
+ }
711
+ store.write(slug, file, data, `V1 : page ${page}`);
712
+ sectionsPerFile[file] = data.order.length;
713
+ brandPages.push(file);
714
+ }
715
+
716
+ // The rest of the theme in the store's language, without the demo templates.
717
+ for (const rel of DEMO_TEMPLATES) if (!input.offers || !rel.includes('offers')) if (!store.excluded(slug).includes(rel)) store.setExcluded(slug, rel, true);
718
+ let translated = 0;
719
+ if (lang !== catalog.defaultLanguage) {
720
+ for (const [rel, entry] of store.effectiveFiles(slug)) {
721
+ if (entry.origin !== 'theme' || rel === 'config/settings_data.json' || !entry.data?.sections) continue;
722
+ const { data, translated: count } = localizeData(catalog, entry.data, lang);
723
+ if (!count || !validateFile(catalog, rel, data).ok) continue;
724
+ store.write(slug, rel, data, `V1 : traduction (${count} textes)`);
725
+ translated += count;
726
+ }
727
+ }
728
+ const showcase = stripShowcase(catalog, store, slug, { collections });
729
+ if (showcase.length) notes.push(`vitrine de démo retirée : ${showcase.length} élément(s)`);
730
+
731
+ const header = store.read(slug, 'sections/header-group.json');
732
+ if (header.data && copy.announcements?.length) {
733
+ const data = clone(header.data);
734
+ const count = fillAnnouncements(data, copy.announcements, { collection: collections[0] });
735
+ if (count && validateFile(catalog, 'sections/header-group.json', data).ok) {
736
+ store.write(slug, 'sections/header-group.json', data, 'V1 : annonces');
737
+ written['sections/header-group.json'] = count;
738
+ }
739
+ }
740
+ return { slug, existed, style, direction: directionId, signatures, brandPages, choices, notes, written, translated, sectionsPerFile, hasImages, meta: store.meta(slug) };
741
+ }