story-theme-mcp 1.7.1 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,7 +63,7 @@ Le MCP ne contient pas le thème : au démarrage, il demande à l'espace membre
63
63
  | Livrer | `export_theme` (zip à importer + check-list), `import_theme` (auditer un thème téléchargé d'une boutique) |
64
64
  | 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 |
65
65
  | Vos boutiques (Theme Access) | `list_shops` (et la marche à suivre pour brancher une boutique), `pull_from_shop` (relire le thème de la boutique), `push_to_shop` (écrire le projet dans un thème non publié, sans zip), `update_theme` (mettre à jour le Story Thème dans un thème neuf non publié) — la boutique se branche une fois depuis l'espace membre avec un mot de passe Theme Access, qui n'ouvre que ses thèmes |
66
- | L'appli Story Thème (Story Apps, gratuite) | `app_status`, `app_reviews` (import CSV, modération, réponses), `app_ab_tests`, `app_offers` (la remise des offres du thème), `app_discount_codes`, `shops_overview`, `shop_graphql` — sur les boutiques où le marchand a installé l'appli (depuis son espace membre) |
66
+ | L'appli Story Thème (Story Theme Apps, gratuite) | `app_status`, `app_reviews` (import CSV, modération, réponses), `app_ab_tests`, `app_offers` (la remise des offres du thème), `app_discount_codes`, `shops_overview`, `shop_graphql` — sur les boutiques où le marchand a installé l'appli (depuis son espace membre) |
67
67
 
68
68
  Prompts prêts à l'emploi : `creer-boutique`, `refaire-une-page`, `auditer-un-theme`. Les guides sont aussi exposés en ressources (`storytheme://guides/<sujet>`).
69
69
 
@@ -106,7 +106,7 @@ Prompts prêts à l'emploi : `creer-boutique`, `refaire-une-page`, `auditer-un-t
106
106
 
107
107
  ## Limites
108
108
 
109
- Le MCP ne publie jamais un thème : il écrit dans un thème non publié, le marchand vérifie et publie lui-même. Sans l'appli Story Apps installée sur la boutique, il fabrique seulement un zip à importer. Il ne crée ni produits, ni collections, ni menus, ni images, ni réductions par lui-même : `shop_graphql` le permet quand une boutique est connectée, sinon la check-list les liste. Et il ne remplace pas une vérification visuelle dans l'éditeur Shopify.
109
+ Le MCP ne publie jamais un thème : il écrit dans un thème non publié, le marchand vérifie et publie lui-même. Sans l'appli Story Theme Apps installée sur la boutique, il fabrique seulement un zip à importer. Il ne crée ni produits, ni collections, ni menus, ni images, ni réductions par lui-même : `shop_graphql` le permet quand une boutique est connectée, sinon la check-list les liste. Et il ne remplace pas une vérification visuelle dans l'éditeur Shopify.
110
110
 
111
111
  ## Développement (New Story)
112
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "story-theme-mcp",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
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/index.js CHANGED
@@ -57,6 +57,7 @@ try {
57
57
  // l'interface de Claude.
58
58
  }
59
59
 
60
- const server = createServer({ ...config, source });
60
+ // L'appli Story Theme Apps n'est proposée que si l'espace membre la dit disponible pour ce compte.
61
+ const server = createServer({ ...config, source, appli: source.appli });
61
62
  await server.connect(new StdioServerTransport());
62
63
  journal(`prêt (projets : ${config.homeDir})`);
@@ -217,8 +217,8 @@ Formats conseillés :
217
217
  limites: {
218
218
  title: 'Ce que le MCP (V1) ne fait pas',
219
219
  body: `- Il ne publie jamais un thème : il écrit dans un thème NON publié (push_to_shop) ou fabrique un zip à importer (export_theme) ; le marchand vérifie et publie lui-même.
220
- - Sans l'appli Story Apps sur la boutique, il n'a accès à rien d'elle : ni ses produits, ni ses thèmes. Appelez list_shops pour le savoir.
221
- - Il ne crée pas de lui-même produits, collections, menus, pages, réductions ni images : avec une boutique connectée, shop_graphql le permet ; sinon la check-list les liste.
220
+ - Il n'ouvre que les THÈMES d'une boutique branchée par Theme Access (list_shops le dit) : ni ses produits, ni ses commandes, ni ses clients.
221
+ - Il ne crée pas de lui-même produits, collections, menus, pages, réductions ni images : le connecteur Shopify officiel du marchand le permet ; sinon la check-list les liste.
222
222
  - Il ne modifie jamais le code du thème (Liquid, CSS, JS) : seulement les modèles, les groupes de sections et les réglages, comme l'éditeur de thème.
223
223
  - Il ne garantit pas le rendu visuel : prévisualisez toujours dans l'éditeur Shopify avant de publier.`,
224
224
  },
@@ -53,7 +53,9 @@ const ROLES = [
53
53
  ['title', /^(heading|title|image_heading|review_title|tab_label|headline|heading_text|question)$/],
54
54
  ['eyebrow', /^(subheading|image_subheading|subtitle|label|tagline|eyebrow|caption|announcment)$/],
55
55
  ['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)$/],
56
- ['image', /^(image|image_custom|custom_icon|icon_custom|icon_image|avatar|cover_image|background_image|image_before|image_after|logo|photo)$/],
56
+ // L'image qui remplace un pictogramme (3.x : image_custom, icon_N_custom_icon) reste un pictogramme.
57
+ ['icon_image', /^(image_custom|custom_icon|icon_custom|icon_image)$/],
58
+ ['image', /^(image|avatar|cover_image|background_image|image_before|image_after|logo|photo)$/],
57
59
  ['image_mobile', /^(image_mobile|background_image_mobile)$/],
58
60
  ['video', /^(video|video_url|video_link|video_mobile|video_link_mobile)$/],
59
61
  ];
@@ -92,9 +94,14 @@ const texteVide = (valeur) => !plainText(isHtml(valeur) ? valeur.html : valeur).
92
94
  // (« Écrire un avis », « Voir plus », jours/heures du compte à rebours…), que la 4.0 traduit elle-même.
93
95
  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)$/;
94
96
 
97
+ // Le pictogramme choisi dans la bibliothèque du thème (« truck », « leaf ») : la 4.0 a la même.
98
+ const PICTO = /(^|_)icon(_type)?$/;
99
+
95
100
  /** Un réglage lu : sa clé, son rôle (titre, texte, lien…), son genre et sa valeur. */
96
101
  function lireReglage(cle, valeur, type) {
97
102
  if (INTERFACE.test(cle)) return null;
103
+ // « none » compte aussi : la colonne n'avait pas de pictogramme, la 4.0 n'en devine pas un.
104
+ if (PICTO.test(cle) && typeof valeur === 'string' && /^[a-z_]+$/.test(valeur)) return { cle, genre: 'icon', valeur };
98
105
  const genre = type ? SCHEMA_GENRE[type] ?? null : genreDeviné(cle, valeur);
99
106
  if (!genre || valeur === '' || valeur == null) return null;
100
107
  if (genre === 'html' || genre === 'text') {
@@ -119,11 +126,13 @@ function ranger(contenu, reglages) {
119
126
  const { genre, valeur } = reglage;
120
127
 
121
128
  if (genre === 'palette') contenu.palette ??= valeur;
129
+ else if (genre === 'icon') contenu.icon ??= valeur;
122
130
  else if (genre === 'code') contenu.code.push(valeur);
123
131
  else if (['product', 'collection', 'blog', 'page', 'menu', 'article'].includes(genre)) contenu[genre] ??= valeur;
124
132
  else if (genre === 'products' || genre === 'collections') contenu[genre] = [...(contenu[genre] || []), ...(Array.isArray(valeur) ? valeur : [valeur])];
125
133
  else if (genre === 'image') {
126
- if (role === 'image_mobile') contenu.image_mobile ??= valeur;
134
+ if (role === 'icon_image') contenu.icon_image ??= valeur;
135
+ else if (role === 'image_mobile') contenu.image_mobile ??= valeur;
127
136
  else if (/after/.test(base)) contenu.image_after ??= valeur;
128
137
  else if (/before/.test(base)) contenu.image_before ??= valeur;
129
138
  else if (!contenu.image) contenu.image = valeur;
@@ -191,7 +200,7 @@ function element(reglages, genre = null) {
191
200
  }
192
201
 
193
202
  const utile = (item) =>
194
- item.title !== undefined || item.text !== undefined || item.name !== undefined || item.image || item.video || item.product || item.collection || item.buttons.length || item.eyebrow !== undefined;
203
+ item.title !== undefined || item.text !== undefined || item.name !== undefined || item.image || item.icon_image || item.video || item.product || item.collection || item.buttons.length || item.eyebrow !== undefined;
195
204
 
196
205
  /**
197
206
  * Le contenu d'une section 3.x.
@@ -244,13 +253,16 @@ export function extraireSection(section, schema, { elements: typesElements = nul
244
253
  }
245
254
  if (lu.elements.length && !lu.simples.some((r) => ['text', 'html'].includes(r.genre) && /^(heading|title|name|description|text)$/.test(r.cle))) {
246
255
  // Un bloc qui porte plusieurs éléments numérotés (icônes, questions, avis).
247
- contenu.items.push(...lu.elements.map((el) => ({ ...el, kind: bloc.type })));
256
+ contenu.items.push(...lu.elements.map((el) => ({ ...el, kind: bloc.type, bloc: id })));
248
257
  continue;
249
258
  }
250
259
  const item = element(lu.simples, bloc.type);
260
+ item.bloc = id;
251
261
  if (lu.elements.length) item.sous = lu.elements;
252
262
  if (bloc.blocks) item.enfants = bloc;
253
263
  if (utile(item) || item.enfants) contenu.items.push(item);
264
+ // Un bloc qui ne porte que du code (Liquid, HTML) : le code de la section, dans l'ordre.
265
+ else if (item.code.length) contenu.code.push(...item.code);
254
266
  }
255
267
  return contenu;
256
268
  }
@@ -15,26 +15,80 @@
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { unzipSync, strFromU8 } from 'fflate';
18
- import { parseThemeJson } from '../theme/loader.js';
18
+ import { stripJsonComment } from '../theme/loader.js';
19
19
  import { fileKind } from '../validate/template.js';
20
20
  import { devinerLangue, textesDesModeles } from '../validate/design.js';
21
21
 
22
22
  const SCHEMA_RE = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/;
23
23
 
24
+ /**
25
+ * Le JSON tel que Shopify l'accepte : commentaires et virgule finale permis. Le thème 3.x d'origine
26
+ * en a (schéma de main-product, cart-drawer…), et un settings_schema.json retouché à la main aussi :
27
+ * un JSON.parse strict perdait la version du thème et le type de chaque réglage.
28
+ */
29
+ export function parseJsonSouple(texte) {
30
+ const source = stripJsonComment(String(texte));
31
+ try {
32
+ return JSON.parse(source);
33
+ } catch (erreur) {
34
+ try {
35
+ return JSON.parse(sansVirguleFinale(sansCommentaires(source)));
36
+ } catch {
37
+ throw erreur;
38
+ }
39
+ }
40
+ }
41
+
42
+ // Parcourt le texte hors des chaînes : `quoi(c, i)` rend ce qu'il faut écrire et où reprendre.
43
+ function horsChaines(source, quoi) {
44
+ let sortie = '';
45
+ for (let i = 0; i < source.length; i++) {
46
+ if (source[i] === '"') {
47
+ let j = i + 1;
48
+ while (j < source.length && source[j] !== '"') j += source[j] === '\\' ? 2 : 1;
49
+ sortie += source.slice(i, j + 1);
50
+ i = j;
51
+ continue;
52
+ }
53
+ const [texte, suite] = quoi(source, i);
54
+ sortie += texte;
55
+ i = suite;
56
+ }
57
+ return sortie;
58
+ }
59
+
60
+ const sansCommentaires = (source) =>
61
+ horsChaines(source, (t, i) => {
62
+ if (t[i] === '/' && t[i + 1] === '/') {
63
+ const fin = t.indexOf('\n', i);
64
+ return ['\n', fin === -1 ? t.length : fin];
65
+ }
66
+ if (t[i] === '/' && t[i + 1] === '*') {
67
+ const fin = t.indexOf('*/', i + 2);
68
+ return [' ', fin === -1 ? t.length : fin + 1];
69
+ }
70
+ return [t[i], i];
71
+ });
72
+
73
+ const sansVirguleFinale = (source) => horsChaines(source, (t, i) => (t[i] === ',' && /^\s*[}\]]/.test(t.slice(i + 1, i + 200)) ? ['', i] : [t[i], i]));
74
+
24
75
  function lireSchema(liquid) {
25
76
  const match = SCHEMA_RE.exec(liquid);
26
77
  if (!match) return null;
27
78
  try {
28
- return JSON.parse(match[1]);
79
+ return parseJsonSouple(match[1]);
29
80
  } catch {
30
81
  return null;
31
82
  }
32
83
  }
33
84
 
85
+ // La langue par défaut du thème 3.x : locales/da.default.json → « da ». Seul le nom compte.
86
+ const LOCALE_DEFAUT = /^locales\/([a-z]{2}(?:-[A-Za-z]{2,4})?)\.default\.json$/;
87
+
34
88
  /** Les fichiers utiles d'un thème : chemin relatif → texte. */
35
89
  function lireFichiers(source) {
36
90
  const fichiers = new Map();
37
- const garder = (rel) => fileKind(rel) || rel === 'config/settings_schema.json' || /^(sections|blocks)\/[^/]+\.liquid$/.test(rel);
91
+ const garder = (rel) => fileKind(rel) || rel === 'config/settings_schema.json' || /^(sections|blocks)\/[^/]+\.liquid$/.test(rel) || LOCALE_DEFAUT.test(rel);
38
92
 
39
93
  if (fs.statSync(source).isDirectory()) {
40
94
  const parcourir = (dossier) => {
@@ -46,7 +100,7 @@ function lireFichiers(source) {
46
100
  else if (garder(rel)) fichiers.set(rel, fs.readFileSync(path.join(source, rel), 'utf8'));
47
101
  }
48
102
  };
49
- for (const dossier of ['templates', 'sections', 'blocks', 'config']) parcourir(dossier);
103
+ for (const dossier of ['templates', 'sections', 'blocks', 'config', 'locales']) parcourir(dossier);
50
104
  return fichiers;
51
105
  }
52
106
 
@@ -81,7 +135,14 @@ export function lireThemeV3DepuisFichiers(fichiers, origine = 'boutique') {
81
135
  const schemas = { sections: new Map(), blocks: new Map() };
82
136
  let version = null;
83
137
  let nom = null;
138
+ let langueParDefaut = null;
139
+ let reglagesSchema = [];
84
140
  for (const [rel, texte] of fichiers) {
141
+ const locale = LOCALE_DEFAUT.exec(rel);
142
+ if (locale) {
143
+ langueParDefaut = locale[1];
144
+ continue;
145
+ }
85
146
  const liquid = /^(sections|blocks)\/([^/]+)\.liquid$/.exec(rel);
86
147
  if (liquid) {
87
148
  const schema = lireSchema(texte);
@@ -90,28 +151,33 @@ export function lireThemeV3DepuisFichiers(fichiers, origine = 'boutique') {
90
151
  }
91
152
  if (rel === 'config/settings_schema.json') {
92
153
  try {
93
- const info = parseThemeJson(texte).find((groupe) => groupe.name === 'theme_info');
154
+ const groupes = parseJsonSouple(texte);
155
+ const info = groupes.find((groupe) => groupe.name === 'theme_info');
94
156
  version = info?.theme_version || null;
95
157
  nom = info?.theme_name || null;
158
+ reglagesSchema = groupes.flatMap((groupe) => groupe.settings || []).filter((reglage) => reglage?.id);
96
159
  } catch {
97
160
  /* version inconnue */
98
161
  }
99
162
  continue;
100
163
  }
101
164
  try {
102
- fichiersJson.set(rel, parseThemeJson(texte));
165
+ fichiersJson.set(rel, parseJsonSouple(texte));
103
166
  } catch (error) {
104
167
  erreurs.push(`${rel} : JSON illisible (${error.message})`);
105
168
  }
106
169
  }
107
170
 
108
- // La langue de la boutique se lit dans SES textes : le fichier de traductions par défaut du
109
- // thème (en.default.json) est l'anglais pour toutes les boutiques Story Thème.
110
- const langue = devinerLangue(textesDesModeles([...fichiersJson].filter(([rel]) => rel.startsWith('templates/') || rel.startsWith('sections/')).map(([, data]) => data)));
111
-
112
171
  const reglages = fichiersJson.get('config/settings_data.json');
113
172
  const courant = typeof reglages?.current === 'string' ? reglages.presets?.[reglages.current] : reglages?.current;
114
- return { origine, version, nom, langue, fichiers: fichiersJson, schemas, reglages: courant || {}, erreurs };
173
+
174
+ // La langue de la boutique se lit dans SES textes (modèles, groupes, et sections fixes comme le
175
+ // panier). Le fichier de traductions par défaut du thème (en.default.json) est l'anglais pour
176
+ // toutes les boutiques Story Thème : il ne départage que s'il a été changé (da.default.json…).
177
+ const modeles = [...fichiersJson].filter(([rel]) => rel.startsWith('templates/') || rel.startsWith('sections/')).map(([, data]) => data);
178
+ const textes = textesDesModeles([...modeles, { sections: courant?.sections || {} }]);
179
+ const langue = devinerLangue(textes) || (langueParDefaut && langueParDefaut !== 'en' ? langueParDefaut : null);
180
+ return { origine, version, nom, langue, langueParDefaut, fichiers: fichiersJson, schemas, reglages: courant || {}, reglagesSchema, erreurs };
115
181
  }
116
182
 
117
183
  /** La version majeure d'un numéro « 3.7.0 », ou null. */