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,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { zipSync, unzipSync, strFromU8 } from 'fflate';
4
+ import { isInternalTemplate } from '../theme/catalog.js';
5
+ import { parseThemeJson } from '../theme/loader.js';
6
+ import { validateFile, fileKind, LIMITS } from '../validate/template.js';
7
+ import { reviewProject, formatReview } from './review.js';
8
+ import { migrateData } from '../build/migrate.js';
9
+ import { slugify } from './store.js';
10
+
11
+ const THEME_DIRS = ['assets', 'blocks', 'config', 'layout', 'locales', 'sections', 'snippets', 'templates'];
12
+
13
+ function themeEntries(themeDir) {
14
+ const entries = new Map();
15
+ for (const top of THEME_DIRS) {
16
+ const walk = (folder, prefix) => {
17
+ if (!fs.existsSync(folder)) return;
18
+ for (const name of fs.readdirSync(folder)) {
19
+ if (name.startsWith('.')) continue;
20
+ const full = path.join(folder, name);
21
+ const rel = `${prefix}${name}`;
22
+ if (fs.statSync(full).isDirectory()) walk(full, `${rel}/`);
23
+ else if (!(rel.startsWith('templates/') && isInternalTemplate(rel))) entries.set(rel, full);
24
+ }
25
+ };
26
+ walk(path.join(themeDir, top), `${top}/`);
27
+ }
28
+ return entries;
29
+ }
30
+
31
+ // Brings every project file up to the current theme (settings it dropped, values it no longer
32
+ // accepts), saving each changed file with a snapshot so undo can restore it.
33
+ export function migrateProject({ catalog, store, slug }) {
34
+ const settings = store.read(slug, 'config/settings_data.json').data;
35
+ const current = typeof settings?.current === 'string' ? settings.presets?.[settings.current] : settings?.current;
36
+ const schemeIds = Object.keys(current?.color_schemes || {});
37
+ const report = {};
38
+ for (const rel of store.projectFiles(slug)) {
39
+ const { data } = store.read(slug, rel);
40
+ const { data: migrated, changes } = migrateData(catalog, data, { schemeIds });
41
+ if (!changes.length) continue;
42
+ store.write(slug, rel, migrated, `mise à jour vers le thème ${catalog.version} (${changes.length} réglage(s))`);
43
+ report[rel] = changes;
44
+ }
45
+ return report;
46
+ }
47
+
48
+ // Builds a theme zip ready for "Add theme > Upload zip file": the theme source, minus its QA
49
+ // templates, with the project's templates, groups and settings on top. Refuses on any error.
50
+ export function exportTheme({ catalog, store, slug }) {
51
+ const migrated = migrateProject({ catalog, store, slug });
52
+ const review = reviewProject(catalog, store, slug);
53
+ // Theme files count too: a project change (a removed colour scheme) can break them.
54
+ const blocking = review.perFile.filter((entry) => entry.validation.errors.length);
55
+ if (blocking.length) {
56
+ return {
57
+ ok: false,
58
+ migrated,
59
+ message: `Export refusé : ${blocking.reduce((total, entry) => total + entry.validation.errors.length, 0)} erreur(s) bloquante(s). Shopify refuserait ces fichiers.`,
60
+ errors: blocking.map((entry) => ({ file: entry.file, errors: entry.validation.errors.slice(0, 10) })),
61
+ };
62
+ }
63
+ const excluded = new Set(store.excluded(slug));
64
+ const files = {};
65
+ for (const [rel, full] of themeEntries(catalog.theme.dir)) if (!excluded.has(rel)) files[rel] = new Uint8Array(fs.readFileSync(full));
66
+ const overlays = store.projectFiles(slug).filter((rel) => !excluded.has(rel));
67
+ for (const rel of overlays) {
68
+ const content = fs.readFileSync(store.filePath(slug, rel));
69
+ const limit = rel === 'config/settings_data.json' ? LIMITS.settingsDataBytes : LIMITS.fileBytes;
70
+ if (content.length > limit) return { ok: false, message: `${rel} dépasse la taille maximale acceptée par Shopify (${Math.round(limit / 1024)} Ko)` };
71
+ files[rel] = new Uint8Array(content);
72
+ }
73
+ const meta = review.meta;
74
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/^(\d{8})(\d{4})$/, '$1-$2');
75
+ const dir = store.exportsDir(slug);
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ const zipName = `${slugify(meta.name)}-story-theme-${catalog.version}-${stamp}.zip`;
78
+ const zip = zipSync(files, { level: 6 });
79
+ fs.writeFileSync(path.join(dir, zipName), zip);
80
+ const reportText = formatReview(review);
81
+ fs.writeFileSync(path.join(dir, zipName.replace(/\.zip$/, '-checklist.md')), reportText + '\n');
82
+ return {
83
+ ok: true,
84
+ migrated,
85
+ zip: path.join(dir, zipName),
86
+ size_mb: Math.round((zip.length / 1024 / 1024) * 10) / 10,
87
+ files: Object.keys(files).length,
88
+ overlays,
89
+ checklist: path.join(dir, zipName.replace(/\.zip$/, '-checklist.md')),
90
+ report: reportText,
91
+ };
92
+ }
93
+
94
+ function readSource(source) {
95
+ const files = new Map();
96
+ if (fs.statSync(source).isDirectory()) {
97
+ const pick = (rel) => {
98
+ const full = path.join(source, rel);
99
+ if (fs.existsSync(full)) files.set(rel, fs.readFileSync(full, 'utf8'));
100
+ };
101
+ pick('config/settings_data.json');
102
+ pick('config/settings_schema.json');
103
+ for (const folder of ['templates', 'templates/customers', 'sections']) {
104
+ const full = path.join(source, folder);
105
+ if (!fs.existsSync(full)) continue;
106
+ for (const name of fs.readdirSync(full)) if (name.endsWith('.json')) pick(`${folder}/${name}`);
107
+ }
108
+ return files;
109
+ }
110
+ if (!source.endsWith('.zip')) throw new Error('source attendue : un dossier de thème ou un fichier .zip exporté depuis Shopify');
111
+ const archive = unzipSync(new Uint8Array(fs.readFileSync(source)));
112
+ // Shopify exports sometimes nest everything under one top folder.
113
+ const names = Object.keys(archive);
114
+ const root = names.find((name) => name.endsWith('config/settings_schema.json'))?.replace('config/settings_schema.json', '') ?? '';
115
+ for (const name of names) {
116
+ if (!name.startsWith(root) || !name.endsWith('.json')) continue;
117
+ const rel = name.slice(root.length);
118
+ if (rel === 'config/settings_schema.json' || fileKind(rel)) files.set(rel, strFromU8(archive[name]));
119
+ }
120
+ return files;
121
+ }
122
+
123
+ // Creates a project from a theme exported from a store (folder or zip), to audit or rework it.
124
+ export function importTheme({ catalog, store, source, name, language = 'fr' }) {
125
+ const resolved = path.resolve(source.replace(/^~/, process.env.HOME || ''));
126
+ if (!fs.existsSync(resolved)) throw new Error(`« ${source} » introuvable`);
127
+ const files = readSource(resolved);
128
+ let version = 'inconnue';
129
+ if (files.has('config/settings_schema.json')) {
130
+ try {
131
+ version = parseThemeJson(files.get('config/settings_schema.json')).find((group) => group.name === 'theme_info')?.theme_version || version;
132
+ } catch {
133
+ /* keep unknown */
134
+ }
135
+ }
136
+ files.delete('config/settings_schema.json');
137
+ let schemeIds;
138
+ try {
139
+ const settings = files.has('config/settings_data.json') ? parseThemeJson(files.get('config/settings_data.json')) : null;
140
+ const current = typeof settings?.current === 'string' ? settings.presets?.[settings.current] : settings?.current;
141
+ schemeIds = current?.color_schemes ? Object.keys(current.color_schemes) : undefined;
142
+ } catch {
143
+ schemeIds = undefined;
144
+ }
145
+ const meta = store.create({ name, language, brief: { imported_from: resolved, imported_version: version } });
146
+ const results = [];
147
+ for (const [rel, text] of files) {
148
+ if (!fileKind(rel)) continue;
149
+ let data;
150
+ try {
151
+ data = parseThemeJson(text);
152
+ } catch (error) {
153
+ results.push({ file: rel, imported: false, errors: [`JSON illisible : ${error.message}`] });
154
+ continue;
155
+ }
156
+ const validation = validateFile(catalog, rel, data, { schemeIds });
157
+ store.write(meta.slug, rel, data, 'import');
158
+ results.push({ file: rel, imported: true, errors: validation.errors.length, warnings: validation.warnings.length });
159
+ }
160
+ return { project: meta.slug, version, themeVersion: catalog.version, results };
161
+ }
@@ -0,0 +1,143 @@
1
+ import { validateFile } from '../validate/template.js';
2
+ import { lintTemplate, lintSettings } from '../validate/design.js';
3
+
4
+ function schemeIdsOf(files) {
5
+ const settings = files.get('config/settings_data.json')?.data;
6
+ const current = typeof settings?.current === 'string' ? settings.presets?.[settings.current] : settings?.current;
7
+ return Object.keys(current?.color_schemes || {});
8
+ }
9
+
10
+ function* blocksOf(node) {
11
+ for (const block of Object.values(node?.blocks || {})) {
12
+ yield block;
13
+ yield* blocksOf(block);
14
+ }
15
+ }
16
+
17
+ // Where colour schemes are used: every color_scheme setting of every section and block.
18
+ export function findSchemeReferences(catalog, files, ids) {
19
+ const wanted = new Set(ids);
20
+ const found = [];
21
+ const visit = (file, kind, node, path) => {
22
+ const schema = kind === 'section' ? catalog.section(node.type)?.schema : catalog.block(node.type)?.schema;
23
+ for (const setting of schema?.settings || []) {
24
+ if (setting.type === 'color_scheme' && wanted.has(node.settings?.[setting.id] ?? setting.default)) found.push(`${file} › ${path}.${setting.id}`);
25
+ }
26
+ for (const [id, block] of Object.entries(node.blocks || {})) visit(file, 'block', block, `${path}/${id}`);
27
+ };
28
+ for (const [file, { data }] of files) {
29
+ const current = file === 'config/settings_data.json' ? (typeof data?.current === 'string' ? data.presets?.[data.current] : data?.current) : null;
30
+ const sections = current ? current.sections : data?.sections;
31
+ for (const [id, section] of Object.entries(sections || {})) visit(file, 'section', section, id);
32
+ }
33
+ return found;
34
+ }
35
+
36
+ // Validates and reviews every editable file of a project, and derives the launch checklist.
37
+ export function reviewProject(catalog, store, slug) {
38
+ const meta = store.meta(slug);
39
+ const language = meta.language || 'fr';
40
+ const files = store.effectiveFiles(slug);
41
+ const schemeIds = schemeIdsOf(files);
42
+ const perFile = [];
43
+ const resources = { collections: new Set(), products: new Set(), pages: new Set(), blogs: new Set(), menus: new Set(), images: new Set(), videos: new Set() };
44
+ const usedBlocks = new Set();
45
+ const trustpilotMissing = [];
46
+ let errorCount = 0;
47
+
48
+ for (const [rel, { data, origin }] of files) {
49
+ const validation = validateFile(catalog, rel, data, { schemeIds });
50
+ errorCount += validation.errors.length;
51
+ for (const [key, list] of Object.entries(validation.resources || {})) if (resources[key]) list.forEach((item) => resources[key].add(item));
52
+ const design = rel === 'config/settings_data.json' ? lintSettings(catalog, data) : lintTemplate(catalog, rel, data, { language });
53
+ for (const section of Object.values(data?.sections || {})) {
54
+ for (const block of blocksOf(section)) {
55
+ usedBlocks.add(block.type);
56
+ if (block.type === 'trustpilot' && !block.settings?.business_unit_id) trustpilotMissing.push(rel);
57
+ }
58
+ }
59
+ perFile.push({ file: rel, origin, validation, design });
60
+ }
61
+
62
+ const checklist = buildChecklist({ files, resources, usedBlocks, trustpilotMissing, language });
63
+ const pending = perFile.filter((entry) => entry.origin === 'theme' && entry.file !== 'config/settings_data.json' && entry.design.some((finding) => finding.level === 'important'));
64
+ return { meta, perFile, errorCount, checklist, pendingThemeTemplates: pending.map((entry) => entry.file) };
65
+ }
66
+
67
+ function buildChecklist({ files, resources, usedBlocks, trustpilotMissing, language }) {
68
+ const items = [];
69
+ const add = (group, text) => items.push({ group, text });
70
+
71
+ add('Installer', 'Boutique en ligne > Thèmes > Ajouter un thème > Importer un fichier zip, puis « Personnaliser » pour vérifier chaque page sur ordinateur et sur mobile avant de publier.');
72
+ const header = files.get('sections/header-group.json')?.data;
73
+ const logoBlocks = header ? [...Object.values(header.sections || {})].flatMap((section) => [...blocksOf(section)]).filter((block) => block.type === '_header-logo') : [];
74
+ if (!logoBlocks.length || logoBlocks.every((block) => !block.settings?.logo)) add('Images', "Logo : l'ajouter dans le bloc Logo de l'en-tête (PNG ou SVG sur fond transparent, 2× la taille affichée).");
75
+ if (resources.images.size) add('Images', `Images citées par le thème, à déposer dans Contenu > Fichiers avec exactement ces noms : ${[...resources.images].map((value) => value.replace('shopify://shop_images/', '')).join(', ')}`);
76
+ if (resources.collections.size) add('Catalogue', `Collections à créer (identifiant exact) : ${[...resources.collections].join(', ')}`);
77
+ if (resources.products.size) add('Catalogue', `Produits cités (identifiant exact) : ${[...resources.products].join(', ')}`);
78
+ if (resources.menus.size) add('Navigation', `Menus utilisés : ${[...resources.menus].join(', ')} (Contenu > Menus). main-menu et footer existent déjà dans toute boutique.`);
79
+ if (resources.pages.size) add('Pages', `Pages citées dans les liens : ${[...resources.pages].join(', ')}`);
80
+
81
+ const pageTemplates = [...files.keys()].filter((rel) => /^templates\/page\.[^.]+\.json$/.test(rel));
82
+ if (pageTemplates.length) add('Pages', `Modèles de page disponibles : ${pageTemplates.map((rel) => rel.replace('templates/', '').replace('.json', '')).join(', ')}. Pour chacun, créez la page (Boutique en ligne > Pages) et choisissez le modèle dans « Modèle de thème ». Le thème doit être publié pour que ses modèles apparaissent dans la liste.`);
83
+ const productTemplates = [...files.keys()].filter((rel) => /^templates\/product\.[^.]+\.json$/.test(rel));
84
+ if (productTemplates.length) add('Pages', `Modèles de produit alternatifs : ${productTemplates.map((rel) => rel.replace('templates/', '').replace('.json', '')).join(', ')}. À attribuer produit par produit (fiche produit > Modèle de thème).`);
85
+
86
+ if (['product-offers', 'product-addons', 'bundle-builder', '_offer', '_cart-rewards'].some((type) => usedBlocks.has(type))) {
87
+ add('Offres', "Le thème affiche les offres mais ne fixe aucun prix : créez dans Réductions la réduction automatique (ou le code) qui correspond à chaque palier, lot ou cadeau, puis testez une commande.");
88
+ }
89
+ if (usedBlocks.has('product-subscription')) add('Offres', "Abonnements : installez une application d'abonnement (plans de vente Shopify), sinon le bloc n'affiche rien.");
90
+ if (usedBlocks.has('product-rating') || usedBlocks.has('_card-rating') || usedBlocks.has('review-summary')) add('Applications', "Avis produits : installez une application d'avis qui remplit les métachamps reviews.rating et reviews.rating_count (Judge.me, Yotpo, Loox…).");
91
+ if (trustpilotMissing.length) add('Applications', 'Trustpilot : renseignez l’identifiant d’entreprise (business_unit_id) et le modèle du widget dans le bloc Trustpilot.');
92
+ if (usedBlocks.has('newsletter')) add('Marketing', 'Newsletter : les inscrits arrivent dans Clients avec le consentement marketing. Branchez un outil d’e-mailing (Shopify Email, Klaviyo, Brevo).');
93
+
94
+ add('Réglages Shopify', 'Paramètres > Politiques : conditions générales de vente, politique de remboursement, confidentialité, mentions légales, expédition.');
95
+ if (language === 'fr') add('Réglages Shopify', 'Obligations en France : mentions légales (éditeur, hébergeur, SIRET), CGV avec droit de rétractation de 14 jours, médiateur de la consommation, bannière cookies (Paramètres > Confidentialité des clients).');
96
+ add('Réglages Shopify', 'Paiements, expédition (zones et tarifs), taxes, domaine, e-mails de notification à votre nom.');
97
+ add('Avant de publier', 'Passer une commande test de bout en bout (paiement, e-mail de confirmation, remises appliquées).');
98
+ add('Avant de publier', 'Vérifier chaque page sur mobile : c’est là que se font la majorité des ventes.');
99
+ add('Avant de publier', 'Retirer le mot de passe de la boutique (Préférences) une fois tout vérifié.');
100
+ return items;
101
+ }
102
+
103
+ export function formatReview(review, { maxPerFile = 12 } = {}) {
104
+ const lines = [];
105
+ const { meta, perFile, errorCount, checklist, pendingThemeTemplates } = review;
106
+ const own = perFile.filter((entry) => entry.origin === 'project');
107
+ const count = (level) => own.reduce((total, entry) => total + entry.design.filter((finding) => finding.level === level).length, 0);
108
+ lines.push(`# Revue du projet « ${meta.name} » (Story Thème ${meta.theme?.version}, langue ${meta.language})`);
109
+ lines.push(`Fichiers du projet : ${errorCount} erreur(s) bloquante(s) · ${count('important')} point(s) à corriger avant lancement · ${count('suggestion')} suggestion(s) · ${count('info')} info(s)${pendingThemeTemplates.length ? ` · ${pendingThemeTemplates.length} modèle(s) du thème encore avec leurs textes d'exemple` : ''}`);
110
+ lines.push(errorCount ? '→ Export impossible tant que les erreurs bloquantes restent.' : count('important') || pendingThemeTemplates.length ? '→ Shopify acceptera le thème, mais il n’est pas prêt à publier.' : '→ Prêt : aucun point bloquant ni à corriger.');
111
+
112
+ for (const entry of perFile) {
113
+ const important = entry.design.filter((finding) => finding.level === 'important');
114
+ const suggestions = entry.design.filter((finding) => finding.level === 'suggestion');
115
+ const infos = entry.design.filter((finding) => finding.level === 'info');
116
+ if (!entry.validation.errors.length && !important.length && !suggestions.length && !infos.length && !entry.validation.warnings.length) continue;
117
+ if (entry.origin === 'theme' && !entry.validation.errors.length) continue;
118
+ lines.push('');
119
+ lines.push(`## ${entry.file}${entry.origin === 'theme' ? ' (version du thème)' : ''}`);
120
+ for (const error of entry.validation.errors.slice(0, maxPerFile)) lines.push(`- ❌ ${error.path || '(racine)'} : ${error.message}`);
121
+ for (const warning of entry.validation.warnings.slice(0, 5)) lines.push(`- ⚠️ ${warning.path || '(racine)'} : ${warning.message}`);
122
+ for (const finding of important.slice(0, maxPerFile)) lines.push(`- 🔴 ${finding.path || '(page)'} : ${finding.message}`);
123
+ if (important.length > maxPerFile) lines.push(`- 🔴 … et ${important.length - maxPerFile} autre(s)`);
124
+ for (const finding of suggestions.slice(0, 8)) lines.push(`- 🟡 ${finding.path || '(page)'} : ${finding.message}`);
125
+ if (infos.length) lines.push(`- ℹ️ ${infos.slice(0, 6).map((finding) => `${finding.path ? finding.path + ' : ' : ''}${finding.message}`).join(' · ')}${infos.length > 6 ? ` · … (+${infos.length - 6})` : ''}`);
126
+ }
127
+ if (pendingThemeTemplates.length) {
128
+ lines.push('');
129
+ lines.push(`## Modèles encore dans leur version d'origine (textes d'exemple du thème)`);
130
+ lines.push(pendingThemeTemplates.map((file) => `- ${file} : à personnaliser (apply_recipe, edit_template) ou laisser si la page n'est pas utilisée`).join('\n'));
131
+ }
132
+ lines.push('');
133
+ lines.push('## Check-list de mise en ligne');
134
+ let group = null;
135
+ for (const item of checklist) {
136
+ if (item.group !== group) {
137
+ lines.push(`**${item.group}**`);
138
+ group = item.group;
139
+ }
140
+ lines.push(`- [ ] ${item.text}`);
141
+ }
142
+ return lines.join('\n');
143
+ }
@@ -0,0 +1,260 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileKind } from '../validate/template.js';
4
+ import { isInternalTemplate } from '../theme/catalog.js';
5
+
6
+ const clone = (value) => JSON.parse(JSON.stringify(value));
7
+ let sequence = 0;
8
+
9
+ export function slugify(name) {
10
+ return String(name || '')
11
+ .normalize('NFD')
12
+ .replace(/[̀-ͯ]/g, '')
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .slice(0, 48)
16
+ .replace(/^-+|-+$/g, '');
17
+ }
18
+
19
+ // Only the files a merchant changes through the editor: templates, section groups, settings.
20
+ export function checkRelativePath(rel) {
21
+ const clean = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
22
+ if (clean.includes('..') || !fileKind(clean)) {
23
+ throw new Error(`« ${rel} » : seuls templates/<nom>.json, templates/customers/<nom>.json, sections/<groupe>.json et config/settings_data.json sont modifiables (le code du thème ne l'est jamais)`);
24
+ }
25
+ return clean;
26
+ }
27
+
28
+ function writeAtomic(file, content) {
29
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
+ const temp = `${file}.${process.pid}.tmp`;
31
+ fs.writeFileSync(temp, content);
32
+ fs.renameSync(temp, file);
33
+ }
34
+
35
+ export class ProjectStore {
36
+ constructor({ homeDir, catalog }) {
37
+ this.homeDir = homeDir;
38
+ this.catalog = catalog;
39
+ this.projectsDir = path.join(homeDir, 'projects');
40
+ }
41
+
42
+ dir(slug) {
43
+ const clean = slugify(slug);
44
+ if (!clean || clean !== slug) throw new Error(`projet « ${slug} » : identifiant invalide`);
45
+ return path.join(this.projectsDir, clean);
46
+ }
47
+
48
+ slugFor(name) {
49
+ return slugify(name);
50
+ }
51
+
52
+ // The project the merchant is working on: the last one created or changed.
53
+ setCurrent(slug) {
54
+ fs.mkdirSync(this.homeDir, { recursive: true });
55
+ writeAtomic(path.join(this.homeDir, 'current.json'), JSON.stringify({ slug }) + '\n');
56
+ }
57
+
58
+ current() {
59
+ try {
60
+ const { slug } = JSON.parse(fs.readFileSync(path.join(this.homeDir, 'current.json'), 'utf8'));
61
+ return this.exists(slug) ? slug : null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ // An explicit project, else the current one.
68
+ resolve(slug) {
69
+ if (slug) return slug;
70
+ const current = this.current();
71
+ if (current) return current;
72
+ const projects = this.list();
73
+ if (projects.length === 1) return projects[0].slug;
74
+ throw new Error(projects.length ? `plusieurs projets existent (${projects.map((project) => project.slug).join(', ')}) : précisez lequel` : 'aucun projet : lancez build_store avec le nom de la boutique');
75
+ }
76
+
77
+ exists(slug) {
78
+ try {
79
+ return fs.existsSync(path.join(this.dir(slug), 'project.json'));
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ list() {
86
+ if (!fs.existsSync(this.projectsDir)) return [];
87
+ return fs
88
+ .readdirSync(this.projectsDir)
89
+ .filter((slug) => fs.existsSync(path.join(this.projectsDir, slug, 'project.json')))
90
+ .map((slug) => this.meta(slug))
91
+ .sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
92
+ }
93
+
94
+ meta(slug) {
95
+ const file = path.join(this.dir(slug), 'project.json');
96
+ if (!fs.existsSync(file)) {
97
+ const known = fs.existsSync(this.projectsDir) ? fs.readdirSync(this.projectsDir) : [];
98
+ throw new Error(`projet « ${slug} » introuvable${known.length ? ` (projets : ${known.join(', ')})` : ' (aucun projet : lancez build_store avec le nom de la boutique)'}`);
99
+ }
100
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
101
+ }
102
+
103
+ saveMeta(slug, meta) {
104
+ meta.updated_at = new Date().toISOString();
105
+ writeAtomic(path.join(this.dir(slug), 'project.json'), JSON.stringify(meta, null, 2) + '\n');
106
+ return meta;
107
+ }
108
+
109
+ create({ name, language = 'fr', brief = {} }) {
110
+ const slug = slugify(name);
111
+ if (!slug) throw new Error('nom de projet vide');
112
+ if (this.exists(slug)) throw new Error(`le projet « ${slug} » existe déjà : utilisez-le ou choisissez un autre nom`);
113
+ const now = new Date().toISOString();
114
+ const meta = { name, slug, language, theme: { name: this.catalog.name, version: this.catalog.version }, created_at: now, updated_at: now, brief, log: [] };
115
+ fs.mkdirSync(path.join(this.dir(slug), 'theme'), { recursive: true });
116
+ this.saveMeta(slug, meta);
117
+ this.setCurrent(slug);
118
+ // Settings start from the theme defaults so partial updates merge into a complete file.
119
+ writeAtomic(this.filePath(slug, 'config/settings_data.json'), JSON.stringify(clone(this.catalog.theme.settingsData), null, 2) + '\n');
120
+ return meta;
121
+ }
122
+
123
+ updateBrief(slug, patch) {
124
+ const meta = this.meta(slug);
125
+ meta.brief = { ...(meta.brief || {}), ...patch };
126
+ return this.saveMeta(slug, meta);
127
+ }
128
+
129
+ filePath(slug, rel) {
130
+ return path.join(this.dir(slug), 'theme', checkRelativePath(rel));
131
+ }
132
+
133
+ // The project's version of a file, else the theme's default.
134
+ read(slug, rel) {
135
+ const clean = checkRelativePath(rel);
136
+ const file = this.filePath(slug, clean);
137
+ if (fs.existsSync(file)) return { data: JSON.parse(fs.readFileSync(file, 'utf8')), origin: 'project' };
138
+ if (clean === 'config/settings_data.json') return { data: clone(this.catalog.theme.settingsData), origin: 'theme' };
139
+ const fromTheme = this.catalog.theme.templates.get(clean) || this.catalog.theme.groups.get(clean);
140
+ if (fromTheme && !fromTheme.__error) return { data: clone(fromTheme), origin: 'theme' };
141
+ return { data: null, origin: 'none' };
142
+ }
143
+
144
+ projectFiles(slug) {
145
+ const root = path.join(this.dir(slug), 'theme');
146
+ const found = [];
147
+ const walk = (folder, prefix) => {
148
+ if (!fs.existsSync(folder)) return;
149
+ for (const name of fs.readdirSync(folder).sort()) {
150
+ const full = path.join(folder, name);
151
+ if (fs.statSync(full).isDirectory()) walk(full, `${prefix}${name}/`);
152
+ else if (name.endsWith('.json')) found.push(`${prefix}${name}`);
153
+ }
154
+ };
155
+ walk(root, '');
156
+ return found;
157
+ }
158
+
159
+ // Every file the exported theme will hold that a merchant can edit, with where it comes from.
160
+ effectiveFiles(slug) {
161
+ const excluded = new Set(this.excluded(slug));
162
+ const files = new Map();
163
+ for (const [rel, data] of [...this.catalog.theme.templates, ...this.catalog.theme.groups]) {
164
+ if (isInternalTemplate(rel) || data?.__error || excluded.has(rel)) continue;
165
+ files.set(rel, { data, origin: 'theme' });
166
+ }
167
+ files.set('config/settings_data.json', { data: this.catalog.theme.settingsData, origin: 'theme' });
168
+ for (const rel of this.projectFiles(slug)) {
169
+ if (!excluded.has(rel)) files.set(rel, { data: JSON.parse(fs.readFileSync(this.filePath(slug, rel), 'utf8')), origin: 'project' });
170
+ }
171
+ return files;
172
+ }
173
+
174
+ excluded(slug) {
175
+ return this.meta(slug).excluded || [];
176
+ }
177
+
178
+ // Alternate templates the store does not use (demo offers, help page…) stay out of the export,
179
+ // so they never appear in the admin's template picker with sample content.
180
+ setExcluded(slug, rel, exclude) {
181
+ const clean = checkRelativePath(rel);
182
+ if (!/^templates\/[a-z-]+\.[a-z0-9_-]+\.json$/.test(clean)) {
183
+ throw new Error(`seuls les modèles alternatifs (ex. templates/product.offers-bogo.json, templates/page.help.json) peuvent être retirés : « ${clean} » est un modèle de base dont la boutique a besoin`);
184
+ }
185
+ const meta = this.meta(slug);
186
+ this.#snapshot(slug, clean, { kind: 'exclusion', excluded: meta.excluded || [] });
187
+ const set = new Set(meta.excluded || []);
188
+ if (exclude) set.add(clean);
189
+ else set.delete(clean);
190
+ meta.excluded = [...set].sort();
191
+ meta.log = [...(meta.log || []), { at: new Date().toISOString(), file: clean, summary: exclude ? "retiré de l'export" : "remis dans l'export" }].slice(-60);
192
+ this.saveMeta(slug, meta);
193
+ return meta.excluded;
194
+ }
195
+
196
+ #snapshot(slug, rel, extra = null) {
197
+ const file = this.filePath(slug, rel);
198
+ const historyDir = path.join(this.dir(slug), '.history');
199
+ fs.mkdirSync(historyDir, { recursive: true });
200
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
201
+ const existed = fs.existsSync(file);
202
+ const entry = extra ? { rel, ...extra, at: new Date().toISOString() } : { rel, existed, data: existed ? JSON.parse(fs.readFileSync(file, 'utf8')) : null, at: new Date().toISOString() };
203
+ sequence = (sequence + 1) % 100000;
204
+ const name = `${stamp}-${String(sequence).padStart(5, '0')}__${rel.replace(/\//g, '~')}`;
205
+ writeAtomic(path.join(historyDir, name), JSON.stringify(entry));
206
+ }
207
+
208
+ write(slug, rel, data, summary) {
209
+ const clean = checkRelativePath(rel);
210
+ const meta = this.meta(slug);
211
+ this.#snapshot(slug, clean);
212
+ writeAtomic(this.filePath(slug, clean), JSON.stringify(data, null, 2) + '\n');
213
+ meta.log = [...(meta.log || []), { at: new Date().toISOString(), file: clean, summary }].slice(-60);
214
+ this.saveMeta(slug, meta);
215
+ this.setCurrent(slug);
216
+ }
217
+
218
+ // Drops the project's version: the theme default applies again.
219
+ revert(slug, rel) {
220
+ const clean = checkRelativePath(rel);
221
+ const file = this.filePath(slug, clean);
222
+ if (!fs.existsSync(file)) return false;
223
+ this.#snapshot(slug, clean);
224
+ fs.unlinkSync(file);
225
+ const meta = this.meta(slug);
226
+ meta.log = [...(meta.log || []), { at: new Date().toISOString(), file: clean, summary: 'retour à la version du thème' }].slice(-60);
227
+ this.saveMeta(slug, meta);
228
+ return true;
229
+ }
230
+
231
+ history(slug, rel = null) {
232
+ const historyDir = path.join(this.dir(slug), '.history');
233
+ if (!fs.existsSync(historyDir)) return [];
234
+ return fs
235
+ .readdirSync(historyDir)
236
+ .sort()
237
+ .filter((name) => !rel || name.endsWith(`__${checkRelativePath(rel).replace(/\//g, '~')}`))
238
+ .map((name) => ({ name, ...JSON.parse(fs.readFileSync(path.join(historyDir, name), 'utf8')) }));
239
+ }
240
+
241
+ // Restores the state before the last change (of one file, or of the project).
242
+ undo(slug, rel = null) {
243
+ const entries = this.history(slug, rel);
244
+ const last = entries[entries.length - 1];
245
+ if (!last) throw new Error(rel ? `aucune modification à annuler pour ${rel}` : 'aucune modification à annuler');
246
+ const file = this.filePath(slug, last.rel);
247
+ fs.unlinkSync(path.join(this.dir(slug), '.history', last.name));
248
+ const meta = this.meta(slug);
249
+ if (last.kind === 'exclusion') meta.excluded = last.excluded;
250
+ else if (last.existed) writeAtomic(file, JSON.stringify(last.data, null, 2) + '\n');
251
+ else if (fs.existsSync(file)) fs.unlinkSync(file);
252
+ meta.log = [...(meta.log || []), { at: new Date().toISOString(), file: last.rel, summary: `annulation (état du ${last.at})` }].slice(-60);
253
+ this.saveMeta(slug, meta);
254
+ return last;
255
+ }
256
+
257
+ exportsDir(slug) {
258
+ return path.join(this.dir(slug), 'exports');
259
+ }
260
+ }