story-theme-mcp 1.6.0 → 1.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "story-theme-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
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",
@@ -167,6 +167,8 @@ function setOption(catalog, block, key, value, notes) {
167
167
  }
168
168
 
169
169
  const firstOf = (node, type) => [...walk(node)].find((entry) => entry.block.type === type);
170
+ // Un réglage « video » de Shopify n'accepte qu'une vidéo déposée dans Contenu > Fichiers.
171
+ const estVideoShopify = (valeur) => /^shopify:\/\/files\/videos\//.test(String(valeur));
170
172
  const blockHas = (catalog, block, id) => (catalog.block(block.type)?.schema?.settings || []).some((setting) => setting.id === id);
171
173
 
172
174
  // One item of content into one item block, whatever its kind.
@@ -231,7 +233,10 @@ function fillItem(catalog, entry, item, notes) {
231
233
  case '_logo':
232
234
  case '_ugc-item':
233
235
  if (content.image) block.settings.image = imageRef(content.image);
234
- if (content.video && blockHas(catalog, block, 'video')) block.settings.video = content.video;
236
+ if (content.video && blockHas(catalog, block, 'video')) {
237
+ if (estVideoShopify(content.video)) block.settings.video = content.video;
238
+ else notes.push(`vidéo externe (${content.video}) non reprise : ce bloc n'accepte que les vidéos déposées dans Shopify`);
239
+ }
235
240
  if (content.title && block.type === '_gallery-item') block.settings.caption = inline(content.title);
236
241
  if (content.title && block.type === '_logo') block.settings.name = plain(content.title);
237
242
  if (block.type === '_ugc-item') {
@@ -243,7 +248,8 @@ function fillItem(catalog, entry, item, notes) {
243
248
  return;
244
249
  case '_story':
245
250
  if (content.image) block.settings.image = imageRef(content.image);
246
- if (content.video) block.settings.video = content.video;
251
+ if (content.video && estVideoShopify(content.video)) block.settings.video = content.video;
252
+ else if (content.video) notes.push(`vidéo externe (${content.video}) non reprise : une story n'accepte que les vidéos déposées dans Shopify`);
247
253
  if (content.title) block.settings.title = plain(content.title);
248
254
  if (content.product) block.settings.product = content.product;
249
255
  if (content.link) block.settings.link = content.link;
@@ -49,7 +49,8 @@ const ROLES = [
49
49
  ['name', /^(name|customer_name|author_name|author)$/],
50
50
  ['role', /^(role|author_age|verified_text|job|position)$/],
51
51
  ['rating', /^(rating|rate_text|rating_text)$/],
52
- ['title', /^(heading|title|image_heading|review_title|tab_label|headline|heading_text|question|slides)$/],
52
+ ['rotation', /^(slides|rotating_words|words)$/],
53
+ ['title', /^(heading|title|image_heading|review_title|tab_label|headline|heading_text|question)$/],
53
54
  ['eyebrow', /^(subheading|image_subheading|subtitle|label|tagline|eyebrow|caption|announcment)$/],
54
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)$/],
55
56
  ['image', /^(image|image_custom|custom_icon|icon_custom|icon_image|avatar|cover_image|background_image|image_before|image_after|logo|photo)$/],
@@ -130,6 +131,7 @@ function ranger(contenu, reglages) {
130
131
  } else if (genre === 'video') contenu.video ??= valeur;
131
132
  else if (genre === 'link') (secondaire ? second : bouton).link ??= valeur;
132
133
  else if (role === 'label') (secondaire ? second : bouton).label ??= valeur;
134
+ else if (role === 'rotation') contenu.rotation = String(isHtml(valeur) ? lisible(valeur) : valeur).split(/\s*[\n|]\s*/).filter(Boolean);
133
135
  else if (role && ['title', 'eyebrow', 'text', 'name', 'role', 'rating'].includes(role) && contenu[role] === undefined) contenu[role] = valeur;
134
136
  else if (role === 'text' && contenu.text !== undefined) contenu.text = concat(contenu.text, valeur);
135
137
  else contenu.autres.push({ cle: reglage.cle, valeur });
@@ -17,6 +17,7 @@ import path from 'node:path';
17
17
  import { unzipSync, strFromU8 } from 'fflate';
18
18
  import { parseThemeJson } from '../theme/loader.js';
19
19
  import { fileKind } from '../validate/template.js';
20
+ import { devinerLangue, textesDesModeles } from '../validate/design.js';
20
21
 
21
22
  const SCHEMA_RE = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/;
22
23
 
@@ -33,9 +34,7 @@ function lireSchema(liquid) {
33
34
  /** Les fichiers utiles d'un thème : chemin relatif → texte. */
34
35
  function lireFichiers(source) {
35
36
  const fichiers = new Map();
36
- // La langue par défaut de la boutique se lit au nom du fichier de traductions (fr.default.json) :
37
- // on garde ce nom, pas son contenu.
38
- const garder = (rel) => fileKind(rel) || rel === 'config/settings_schema.json' || /^(sections|blocks)\/[^/]+\.liquid$/.test(rel) || /^locales\/[a-z-]+\.default\.json$/i.test(rel);
37
+ const garder = (rel) => fileKind(rel) || rel === 'config/settings_schema.json' || /^(sections|blocks)\/[^/]+\.liquid$/.test(rel);
39
38
 
40
39
  if (fs.statSync(source).isDirectory()) {
41
40
  const parcourir = (dossier) => {
@@ -82,14 +81,7 @@ export function lireThemeV3DepuisFichiers(fichiers, origine = 'boutique') {
82
81
  const schemas = { sections: new Map(), blocks: new Map() };
83
82
  let version = null;
84
83
  let nom = null;
85
- let langue = null;
86
-
87
84
  for (const [rel, texte] of fichiers) {
88
- const locale = /^locales\/([a-z-]+)\.default\.json$/i.exec(rel);
89
- if (locale) {
90
- langue = locale[1].toLowerCase().split('-')[0];
91
- continue;
92
- }
93
85
  const liquid = /^(sections|blocks)\/([^/]+)\.liquid$/.exec(rel);
94
86
  if (liquid) {
95
87
  const schema = lireSchema(texte);
@@ -113,6 +105,10 @@ export function lireThemeV3DepuisFichiers(fichiers, origine = 'boutique') {
113
105
  }
114
106
  }
115
107
 
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
+
116
112
  const reglages = fichiersJson.get('config/settings_data.json');
117
113
  const courant = typeof reglages?.current === 'string' ? reglages.presets?.[reglages.current] : reglages?.current;
118
114
  return { origine, version, nom, langue, fichiers: fichiersJson, schemas, reglages: courant || {}, erreurs };
package/src/migrate/v3.js CHANGED
@@ -16,7 +16,7 @@ import { blockFromPreset, createIdFactory, collectIds } from '../build/scaffold.
16
16
  import { localizeData } from '../build/localize.js';
17
17
  import { migrateData } from '../build/migrate.js';
18
18
  import { stripShowcase } from '../build/quick.js';
19
- import { validateFile } from '../validate/template.js';
19
+ import { validateFile, EXPECTED_MAIN } from '../validate/template.js';
20
20
  import { checkValue } from '../validate/values.js';
21
21
  import { templateTypeOf, isInternalTemplate } from '../theme/catalog.js';
22
22
 
@@ -194,7 +194,18 @@ export const CORRESPONDANCES = {
194
194
  'floating-video': { vers: (c) => ({ type: 'floating-video', preset: 'Floating video', settings: nonVide({ video: c.video, cover_image: c.image }) }) },
195
195
  newsletter: { vers: (c) => ({ type: 'newsletter', preset: 'Newsletter', content: tete(c) }) },
196
196
  'contact-form': { vers: (c) => ({ type: 'contact', preset: 'Contact form', content: tete(c) }) },
197
- 'featured-blog': { vers: (c) => ({ type: 'blog-posts', preset: 'Blog posts', settings: nonVide({ blog: c.blog }), content: tete(c, { buttons: [] }) }) },
197
+ 'featured-blog': {
198
+ vers: (c) => ({
199
+ type: 'blog-posts',
200
+ preset: 'Blog posts',
201
+ content: tete(c, { buttons: [] }),
202
+ // En 4.0, le blog se choisit dans le bloc de la liste d'articles, pas dans la section.
203
+ apres: (section) => {
204
+ const liste = premier(section, 'blog-posts');
205
+ if (liste && c.blog) liste.bloc.settings.blog = c.blog;
206
+ },
207
+ }),
208
+ },
198
209
  'featured-product': { vers: (c) => ({ type: 'featured-product', preset: 'Featured product', settings: nonVide({ product: c.product }) }) },
199
210
  'results-progress': {
200
211
  elements: ['stat'],
@@ -230,6 +241,7 @@ function heros(c, ctx) {
230
241
  content: {
231
242
  ...(c.eyebrow !== undefined ? { eyebrow: c.eyebrow } : c.lines.length ? { eyebrow: c.lines.map(lisible).join(' · ') } : {}),
232
243
  ...(c.title !== undefined ? { title: c.title, ...(ctx.premiereSection && ctx.typeModele === 'index' ? { title_tag: 'h1' } : {}) } : {}),
244
+ ...(c.rotation?.length ? { rotating_words: c.rotation } : {}),
233
245
  text: c.text ?? '',
234
246
  buttons: boutonsDe(c),
235
247
  },
@@ -480,8 +492,10 @@ function migrerModele(catalog, rel, donnees, v3, { langue, schemeIds }) {
480
492
  if (contenu.code.length) rapport.notes.push(`${type} : code personnalisé (Liquid/HTML) non repris`);
481
493
  });
482
494
 
483
- // Un modèle 4.0 qui exige sa section principale l'a toujours, même si la 3.x l'avait masquée.
484
- if (!principalePosee && principalesBase.length) {
495
+ // Un modèle 4.0 qui EXIGE sa section principale (produit, collection, panier…) l'a toujours,
496
+ // même si la 3.x n'en avait pas. Une page, elle, n'en a pas besoin : en ajouter une afficherait
497
+ // le titre et le texte de la page Shopify par-dessus ce que le marchand avait composé.
498
+ if (!principalePosee && principalesBase.length && EXPECTED_MAIN[typeModele]) {
485
499
  poserPrincipales();
486
500
  sortie.order.unshift(...sortie.order.splice(sortie.order.length - principalesBase.length));
487
501
  }
@@ -724,7 +738,7 @@ export function migrerBoutiqueV3({ catalog, store, source, v3: lu = null, name,
724
738
  throw new Error(`ce thème est déjà en ${v3.version} : utilisez import_theme (et migrate_project) pour une mise à jour dans la 4.x`);
725
739
  }
726
740
  if (!v3.fichiers.size) throw new Error('aucun modèle JSON trouvé dans la source : est-ce bien un thème Shopify exporté ?');
727
- // La langue de la boutique : imposée, sinon celle du thème 3.x (fr.default.json), sinon le français.
741
+ // La langue de la boutique : imposée, sinon devinée d'après ses textes, sinon le français.
728
742
  const langueBoutique = language || (v3.langue && catalog.languages.includes(v3.langue) ? v3.langue : 'fr');
729
743
 
730
744
  const meta = store.create({ name, language: langueBoutique, brief: { migrated_from: lu ? v3.origine : String(source), migrated_from_version: v3.version || 'inconnue' } });
@@ -800,20 +814,24 @@ export function migrerBoutiqueV3({ catalog, store, source, v3: lu = null, name,
800
814
  /** Le rapport, lisible par l'assistant et le marchand. */
801
815
  export function formaterMigration(resultat) {
802
816
  const lignes = [`Migration ${resultat.version || '3.x'} → 4.x : projet ${resultat.slug} (projet en cours, langue ${resultat.langue}).`];
803
- if (resultat.langueDevinee) lignes.push(`⚠️ Langue de la boutique introuvable dans le thème exporté : ${resultat.langue} par défaut. Si la boutique est dans une autre langue, relancez migrate_v3 avec language.`);
817
+ if (resultat.langueDevinee) lignes.push(`⚠️ Langue de la boutique impossible à deviner d'après ses textes : ${resultat.langue} par défaut. Si la boutique est dans une autre langue, relancez migrate_v3 avec language.`);
804
818
  lignes.push(
805
819
  resultat.reglages.ok
806
820
  ? `Réglages du thème : ${resultat.reglages.repris.length} repris (couleurs, polices, boutons, réseaux…). ${resultat.reglages.ignores.length} réglages de présentation 3.x n’ont pas d’équivalent : normal, la 4.0 les a remplacés par ses propres styles.`
807
821
  : 'Réglages du thème : non repris (refusés par la 4.0), ceux du thème s’appliquent.',
808
822
  );
809
823
  let devinees = 0;
824
+ let verifier = 0;
810
825
  let ecartes = 0;
811
826
  for (const rapport of resultat.rapports) {
812
827
  if (!rapport.sections.length && !rapport.notes.length) continue;
813
828
  lignes.push('', `## ${rapport.fichier}`);
814
829
  for (const s of rapport.sections) {
815
830
  if (s.devinee) devinees += 1;
816
- lignes.push(`- ${s.de} → ${s.vers || 'retirée'}${s.devinee ? ' ⚠️ devinée' : ''}${s.masquee ? ' (masquée, comme en 3.x)' : ''}${s.note ? ` — ${s.note}` : ''}`);
831
+ // Une note dit qu'une partie n'a pas suivi telle quelle : à regarder, comme une devinée.
832
+ const aVerifier = !s.devinee && Boolean(s.note) && s.vers && !/^(apps|\(même)/.test(s.vers) && !/section principale du modèle|blocs repris tels quels|fiche produit 4\.0/.test(s.note);
833
+ if (aVerifier) verifier += 1;
834
+ lignes.push(`- ${s.de} → ${s.vers || 'retirée'}${s.devinee ? ' ⚠️ devinée' : aVerifier ? ' ⚠️ à vérifier' : ''}${s.masquee ? ' (masquée, comme en 3.x)' : ''}${s.note ? ` — ${s.note}` : ''}`);
817
835
  }
818
836
  for (const note of rapport.notes) lignes.push(`- ${note}`);
819
837
  if (rapport.ecartes.length) {
@@ -826,7 +844,7 @@ export function formaterMigration(resultat) {
826
844
  lignes.push(
827
845
  '',
828
846
  'À faire maintenant :',
829
- `1. Passez en revue les sections ${devinees ? `devinées (${devinees}) et ` : ''}les notes ci-dessus : read_file sur chaque page, compose_page ou edit_template pour corriger.`,
847
+ `1. Passez en revue les sections ${devinees ? `devinées (${devinees}), ` : ''}${verifier ? `à vérifier (${verifier}) ` : ''}et les notes ci-dessus : read_file sur chaque page, compose_page ou edit_template pour corriger.`,
830
848
  ecartes ? `2. Replacez les textes écartés qui comptent (${ecartes}).` : '2. Aucun texte écarté.',
831
849
  '3. review_project, puis envoyez le thème dans un thème NON publié de la boutique (ou export_theme).',
832
850
  'Les photos et vidéos restent celles de la boutique (shopify://shop_images…) : elles s’affichent dès que le thème est dans la même boutique.',
@@ -68,7 +68,7 @@ function buildChecklist({ files, resources, usedBlocks, trustpilotMissing, langu
68
68
  const items = [];
69
69
  const add = (group, text) => items.push({ group, text });
70
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.');
71
+ add('Installer', 'Boutique branchée (Theme Access) : push_to_shop place le projet dans un thème non publié, sans zip. Sinon : 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
72
  const header = files.get('sections/header-group.json')?.data;
73
73
  const logoBlocks = header ? [...Object.values(header.sections || {})].flatMap((section) => [...blocksOf(section)]).filter((block) => block.type === '_header-logo') : [];
74
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).");
package/src/server.js CHANGED
@@ -1415,7 +1415,7 @@ Read the file first (read_file) to get ids.`,
1415
1415
  theme: z.string().optional().describe('Avec shop : identifiant numérique du thème 3.x (push_to_shop sans theme liste les thèmes)'),
1416
1416
  source: z.string().optional().describe('Sinon, chemin du thème 3.x exporté de la boutique : dossier, ou .zip (Boutique en ligne > Thèmes > … > Télécharger le fichier du thème)'),
1417
1417
  name: z.string().min(2).describe('Nom du projet (la marque)'),
1418
- language: z.string().optional().describe('Langue de la boutique ; par défaut celle du thème 3.x (fichier de traductions par défaut)'),
1418
+ language: z.string().optional().describe('Langue de la boutique ; par défaut, devinée d’après ses textes'),
1419
1419
  },
1420
1420
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1421
1421
  },
@@ -1426,7 +1426,7 @@ Read the file first (read_file) to get ids.`,
1426
1426
  let v3 = null;
1427
1427
  if (shop && theme) {
1428
1428
  // Le contenu, plus le schéma des sections 3.x et la langue : de quoi lire sans deviner.
1429
- const lu = await lireThemeBoutique(config, shop, theme, [...CONTENU, 'sections/*.liquid', 'blocks/*.liquid', 'config/settings_schema.json', 'locales/*.default.json']);
1429
+ const lu = await lireThemeBoutique(config, shop, theme, [...CONTENU, 'sections/*.liquid', 'blocks/*.liquid', 'config/settings_schema.json']);
1430
1430
  v3 = lireThemeV3DepuisFichiers(new Map((lu.fichiers || []).map((f) => [f.chemin, f.contenu])), `${lu.domaine || shop} › ${lu.theme?.nom || theme}`);
1431
1431
  }
1432
1432
  const resultat = migrerBoutiqueV3({ catalog, store, source, v3, name, language });
@@ -12,8 +12,9 @@
12
12
  import crypto from 'node:crypto';
13
13
  import { z } from 'zod';
14
14
  import { parseThemeJson } from '../theme/loader.js';
15
- import { fileKind } from '../validate/template.js';
15
+ import { fileKind, validateFile } from '../validate/template.js';
16
16
  import { majeure } from '../migrate/source.js';
17
+ import { devinerLangue, textesDesModeles } from '../validate/design.js';
17
18
 
18
19
  const chemin = (config, suite) => `${String(config.apiBase || 'https://story-theme.com').replace(/\/+$/, '')}/api/v1/mcp${suite}`;
19
20
 
@@ -51,7 +52,29 @@ const AIDE_BRANCHEMENT = [
51
52
  ].join('\n');
52
53
 
53
54
  const ATTENTE_MS = 3000;
54
- const ATTENTE_MAX_MS = 120000;
55
+ // La plateforme qui sert le connecteur (Heroku) coupe une requête au-delà de 30 secondes.
56
+ const ATTENTE_CREATION_MS = 18000;
57
+ // L'espace membre refuse une requête de plus de 500 Ko.
58
+ const TAILLE_LOT = 350000;
59
+
60
+ /** Des lots de fichiers sous une taille donnée (un fichier plus gros part seul). */
61
+ function parLots(fichiers, taille) {
62
+ const lots = [];
63
+ let lot = [];
64
+ let poids = 0;
65
+ for (const fichier of fichiers) {
66
+ const p = Buffer.byteLength(fichier.contenu) + fichier.chemin.length + 64;
67
+ if (lot.length && poids + p > taille) {
68
+ lots.push(lot);
69
+ lot = [];
70
+ poids = 0;
71
+ }
72
+ lot.push(fichier);
73
+ poids += p;
74
+ }
75
+ if (lot.length) lots.push(lot);
76
+ return lots;
77
+ }
55
78
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
56
79
 
57
80
  /** Les fichiers d'un thème de la boutique, relus par l'espace membre (Theme Access). */
@@ -84,11 +107,12 @@ const empreinte = (texte) => {
84
107
 
85
108
  const suiviDe = (meta, domaine, theme) => meta.boutiques?.[domaine]?.[String(theme)] || null;
86
109
 
87
- function memoriser(store, slug, domaine, theme, fichiers) {
110
+ function memoriser(store, slug, domaine, theme, fichiers, { cree = false } = {}) {
88
111
  const meta = store.meta(slug);
89
112
  meta.boutiques = meta.boutiques || {};
90
113
  meta.boutiques[domaine] = meta.boutiques[domaine] || {};
91
114
  const suivi = meta.boutiques[domaine][String(theme)] || { fichiers: {} };
115
+ if (cree) suivi.cree = true;
92
116
  for (const { chemin, contenu } of fichiers) suivi.fichiers[chemin] = empreinte(contenu);
93
117
  suivi.le = new Date().toISOString();
94
118
  meta.boutiques[domaine][String(theme)] = suivi;
@@ -117,7 +141,9 @@ export function registerDirectTools(server, config, { ctx, ok, garde }) {
117
141
  '',
118
142
  ...lignes,
119
143
  '',
120
- branchees ? 'push_to_shop écrit directement dans une boutique branchée.' : '',
144
+ branchees
145
+ ? '● branchée (Theme Access) = ses THÈMES sont accessibles : push_to_shop (lister, écrire, créer), pull_from_shop (relire), migrate_v3 (3.x → 4.x). Ses produits, collections, pages, menus et réductions passent par le connecteur Shopify officiel du marchand, pas par shop_graphql.'
146
+ : '',
121
147
  // Une seule boutique branchée sur trois, c'est deux marchands qui ignorent encore
122
148
  // que c'est possible : la marche à suivre reste affichée tant qu'il en manque une.
123
149
  branchees < boutiques.length ? `${branchees ? '\n' : ''}${AIDE_BRANCHEMENT}` : '',
@@ -148,115 +174,160 @@ export function registerDirectTools(server, config, { ctx, ok, garde }) {
148
174
  garde(async ({ shops, project, theme, create, confirm, overwrite }) => {
149
175
  const cible = String(shops || '').trim();
150
176
  if (!cible || cible === 'all') throw new Error('push_to_shop écrit dans UNE boutique à la fois : donnez son domaine (list_shops).');
177
+ const encode = encodeURIComponent(cible);
178
+
179
+ // Sans thème : on montre ce qui existe, le marchand choisit. Regarder ne demande pas de projet.
180
+ if (!theme && !create) {
181
+ const { themes = [], domaine } = await appel(config, `/boutiques/${encode}/themes`);
182
+ return ok(
183
+ [
184
+ `Thèmes de ${domaine || cible} (${themes.length}/20, la limite de Shopify) :`,
185
+ ...themes.map((t) => `- ${t.nom} — identifiant ${t.id}${t.role === 'main' ? ' — PUBLIÉ (la vitrine actuelle)' : ''}${t.traitement ? ' — installation en cours' : ''}`),
186
+ '',
187
+ `Écrire dedans : push_to_shop({ shops: "${cible}", theme: "<identifiant>" }).`,
188
+ `Créer un thème neuf à la place : push_to_shop({ shops: "${cible}", create: true }).`,
189
+ `Relire un thème dans un projet : pull_from_shop({ shops: "${cible}", theme: "<identifiant>" }).`,
190
+ 'Choisissez un thème NON publié : le marchand prévisualise, puis publie lui-même.',
191
+ ].join('\n'),
192
+ { themes },
193
+ );
194
+ }
151
195
 
152
196
  const { store, catalog } = ctx.get();
153
197
  const slug = store.resolve(project);
154
198
  const meta = store.meta(slug);
155
- const encode = encodeURIComponent(cible);
199
+
200
+ // Les fichiers du projet, et eux seuls. Un fichier que cette version du thème refuse (une
201
+ // section disparue, par exemple) ne part pas : Shopify le rejetterait, et le dire vaut mieux.
202
+ const reglages = store.read(slug, 'config/settings_data.json').data;
203
+ const courant = typeof reglages?.current === 'string' ? reglages.presets?.[reglages.current] : reglages?.current;
204
+ const schemeIds = Object.keys(courant?.color_schemes || {});
205
+ const invalides = [];
206
+ const tous = [];
207
+ for (const [rel, entree] of store.effectiveFiles(slug)) {
208
+ if (entree.origin !== 'project') continue;
209
+ const validation = validateFile(catalog, rel, entree.data, { schemeIds });
210
+ if (!validation.ok) {
211
+ invalides.push(`${rel} : ${validation.errors[0].message}`);
212
+ continue;
213
+ }
214
+ tous.push({ chemin: rel, contenu: `${JSON.stringify(entree.data, null, 2)}\n` });
215
+ }
216
+ if (!tous.length) {
217
+ throw new Error(
218
+ invalides.length
219
+ ? `Aucun fichier envoyable : ${invalides.join(' · ')}`
220
+ : `Le projet « ${slug} » n’a aucun fichier personnalisé : construisez la boutique d’abord.`,
221
+ );
222
+ }
156
223
 
157
224
  // Créer un thème : Shopify part de la dernière version publiée du Story Thème (lien signé
158
- // fabriqué par l'espace membre), puis on y écrit les pages du projet. Aucun zip ne transite :
159
- // ça marche aussi bien en local qu'en connecteur.
225
+ // fabriqué par l'espace membre), puis on y écrit les pages du projet. Aucun zip ne transite.
226
+ let cree = null;
160
227
  if (create) {
161
- const { theme: cree } = await appel(config, `/boutiques/${encode}/themes`, {
228
+ // Une relance ne doit pas créer un second thème : la limite de Shopify est de 20.
229
+ const voulu = cible.toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
230
+ for (const [domaineSuivi, themes] of Object.entries(meta.boutiques || {})) {
231
+ if (domaineSuivi !== voulu && !domaineSuivi.startsWith(`${voulu.replace(/\.myshopify\.com$/, '')}.`)) continue;
232
+ const recent = Object.entries(themes).find(([, suiviTheme]) => suiviTheme.cree && Date.now() - new Date(suiviTheme.le).getTime() < 10 * 60 * 1000);
233
+ if (recent) {
234
+ return ok(
235
+ `Ce projet a déjà créé un thème sur ${domaineSuivi} il y a moins de dix minutes (identifiant ${recent[0]}). Pour y écrire : push_to_shop({ shops: "${cible}", theme: "${recent[0]}" }). Un second thème n'a pas été créé.`,
236
+ );
237
+ }
238
+ }
239
+ ({ theme: cree } = await appel(config, `/boutiques/${encode}/themes`, {
162
240
  method: 'POST',
163
241
  body: JSON.stringify({ nom: (meta.name || slug).slice(0, 50), base: 'story' }),
164
- });
165
- // Shopify télécharge et décompresse l'archive : on attend qu'il ait fini pour écrire.
242
+ }));
243
+ // Shopify télécharge et décompresse l'archive. On attend, mais peu : la plateforme qui sert
244
+ // le connecteur coupe toute requête au-delà de 30 secondes.
166
245
  const pas = config.attenteMs ?? ATTENTE_MS;
167
- let pret = !cree.traitement;
168
- for (let attendu = 0; !pret && attendu < ATTENTE_MAX_MS; attendu += ATTENTE_MS) {
246
+ let etat = { ...cree, traitement: cree.traitement };
247
+ let domaine = cible;
248
+ for (let attendu = 0; ; attendu += ATTENTE_MS) {
249
+ const liste = await appel(config, `/boutiques/${encode}/themes`);
250
+ domaine = liste.domaine || domaine;
251
+ etat = (liste.themes || []).find((t) => String(t.id) === String(cree.id)) || etat;
252
+ if (!etat.traitement || attendu >= ATTENTE_CREATION_MS) break;
169
253
  await pause(pas);
170
- const { themes = [] } = await appel(config, `/boutiques/${encode}/themes`);
171
- const etat = themes.find((t) => String(t.id) === String(cree.id));
172
- pret = Boolean(etat) && !etat.traitement;
173
254
  }
174
- if (!pret) {
255
+ // Ce thème est à nous : ce qu'il contient avant notre envoi vient de l'archive, pas du marchand.
256
+ memoriser(store, slug, domaine, cree.id, [], { cree: true });
257
+ if (etat.traitement) {
175
258
  return ok(
176
- `Thème « ${cree.nom} » créé sur ${cible} en NON PUBLIÉ (identifiant ${cree.id}), mais Shopify le décompresse encore. Dans une minute : push_to_shop({ shops: "${cible}", theme: "${cree.id}" }) pour y écrire les pages du projet.`,
259
+ `Thème « ${cree.nom} » créé sur ${domaine} en NON PUBLIÉ (identifiant ${cree.id}). Shopify l'installe encore (une à deux minutes). Ensuite : push_to_shop({ shops: "${cible}", theme: "${cree.id}" }) y écrit les pages du projet. Ne relancez pas create : un second thème serait créé.`,
177
260
  { theme: cree },
178
261
  );
179
262
  }
180
263
  theme = String(cree.id);
181
264
  }
182
265
 
183
- // Sans thème : on montre ce qui existe, le marchand choisit.
184
- if (!theme) {
185
- const { themes = [], domaine } = await appel(config, `/boutiques/${encode}/themes`);
266
+ // Avant d'écrire : relire. Rien ne part par-dessus une modification faite dans l'éditeur.
267
+ const lu = await lireThemeBoutique(config, cible, theme, tous.map((f) => f.chemin));
268
+ const enBoutique = new Map((lu.fichiers || []).map((f) => [f.chemin, empreinte(f.contenu)]));
269
+ const domaine = lu.domaine || cible;
270
+ const suivi = suiviDe(store.meta(slug), domaine, theme);
271
+ const connu = suivi?.fichiers || {};
272
+ const conflits = [];
273
+ let inchanges = 0;
274
+ const fichiers = tous.filter((f) => {
275
+ const actuel = enBoutique.get(f.chemin);
276
+ if (actuel === undefined) return true; // nouveau modèle : rien à écraser
277
+ if (actuel === empreinte(f.contenu)) {
278
+ inchanges += 1;
279
+ return false; // déjà à jour
280
+ }
281
+ // Un fichier jamais échangé dans un thème créé par ce projet vient de l'archive du thème.
282
+ if (connu[f.chemin] === undefined && suivi?.cree) return true;
283
+ // Jamais relu, ou modifié dans Shopify depuis le dernier échange.
284
+ if (connu[f.chemin] !== actuel) conflits.push(f.chemin);
285
+ return true;
286
+ });
287
+ if (conflits.length && overwrite !== true) {
186
288
  return ok(
187
289
  [
188
- `Thèmes de ${domaine || cible} :`,
189
- ...themes.map((t) => `- ${t.nom} — identifiant ${t.id}${t.role === 'main' ? ' — PUBLIÉ (la vitrine actuelle)' : ''}`),
290
+ `⚠️ Rien n'a été écrit. ${conflits.length} fichier(s) du thème « ${lu.theme?.nom || theme} » ont été modifiés dans Shopify depuis le dernier échange avec ce projet (ou n'ont jamais été relus) :`,
291
+ ...conflits.slice(0, 20).map((chemin) => `- ${chemin}`),
292
+ conflits.length > 20 ? `- … ${conflits.length - 20} autre(s)` : '',
190
293
  '',
191
- `Écrire dedans : push_to_shop({ shops: "${cible}", theme: "<identifiant>" }).`,
192
- `Créer un thème neuf à la place : push_to_shop({ shops: "${cible}", create: true }).`,
193
- 'Choisissez un thème NON publié : le marchand prévisualise, puis publie lui-même.',
194
- ].join('\n'),
195
- { themes },
294
+ `Relisez d'abord : pull_from_shop({ shops: "${cible}", theme: "${theme}" }) — le projet reprend ce que le marchand a fait à la main, puis refaites vos changements et renvoyez.`,
295
+ 'Écraser malgré tout demande l’accord explicite du marchand (overwrite: true).',
296
+ ]
297
+ .filter(Boolean)
298
+ .join('\n'),
299
+ { conflits },
196
300
  );
197
301
  }
302
+ if (!fichiers.length) {
303
+ memoriser(store, slug, domaine, theme, tous);
304
+ return ok(`Le thème « ${lu.theme?.nom || theme} » est déjà à jour : les ${inchanges} fichier(s) du projet y sont identiques.${invalides.length ? `\nNon envoyés (refusés par cette version du thème) : ${invalides.join(' · ')}` : ''}`);
305
+ }
198
306
 
199
- // Les fichiers du projet, et eux seuls : le serveur refuse tout le reste de toute façon.
200
- const tous = [...store.effectiveFiles(slug)]
201
- .filter(([, entree]) => entree.origin === 'project')
202
- .map(([rel, entree]) => ({ chemin: rel, contenu: `${JSON.stringify(entree.data, null, 2)}\n` }));
203
- if (!tous.length) throw new Error(`Le projet « ${slug} » n’a aucun fichier personnalisé : construisez la boutique d’abord.`);
204
-
205
- // Un thème qu'on vient de créer n'a rien que le marchand ait touché. Sinon, on relit
206
- // d'abord : rien ne part par-dessus une modification faite dans l'éditeur Shopify.
207
- let fichiers = tous;
208
- let inchanges = 0;
209
- if (!create) {
210
- const lu = await lireThemeBoutique(config, cible, theme, tous.map((f) => f.chemin));
211
- const enBoutique = new Map((lu.fichiers || []).map((f) => [f.chemin, empreinte(f.contenu)]));
212
- const domaine = lu.domaine || cible;
213
- const connu = suiviDe(store.meta(slug), domaine, theme)?.fichiers || {};
214
- const conflits = [];
215
- fichiers = tous.filter((f) => {
216
- const actuel = enBoutique.get(f.chemin);
217
- if (actuel === undefined) return true; // nouveau modèle : rien à écraser
218
- if (actuel === empreinte(f.contenu)) {
219
- inchanges += 1;
220
- return false; // déjà à jour
221
- }
222
- // Jamais relu, ou modifié dans Shopify depuis le dernier échange.
223
- if (connu[f.chemin] !== actuel) conflits.push(f.chemin);
224
- return true;
307
+ // Par lots : l'espace membre refuse une requête de plus de 500 Ko, un accueil en pèse 150.
308
+ const resultat = { ecrits: [], refuses: [] };
309
+ for (const lot of parLots(fichiers, TAILLE_LOT)) {
310
+ const reponse = await appel(config, `/boutiques/${encode}/themes/${encodeURIComponent(theme)}`, {
311
+ method: 'PUT',
312
+ body: JSON.stringify({ fichiers: lot, confirm: confirm === true }),
225
313
  });
226
- if (conflits.length && overwrite !== true) {
227
- return ok(
228
- [
229
- `⚠️ Rien n'a été écrit. ${conflits.length} fichier(s) du thème « ${lu.theme?.nom || theme} » ont été modifiés dans Shopify depuis le dernier échange avec ce projet (ou n'ont jamais été relus) :`,
230
- ...conflits.map((chemin) => `- ${chemin}`),
231
- '',
232
- `Relisez d'abord : pull_from_shop({ shops: "${cible}", theme: "${theme}" }) — le projet reprend ce que le marchand a fait à la main, puis refaites vos changements et renvoyez.`,
233
- 'Écraser malgré tout demande l’accord explicite du marchand (overwrite: true).',
234
- ].join('\n'),
235
- { conflits },
236
- );
237
- }
238
- if (!fichiers.length) {
239
- memoriser(store, slug, domaine, theme, tous);
240
- return ok(`Le thème « ${lu.theme?.nom || theme} » est déjà à jour : les ${inchanges} fichier(s) du projet y sont identiques.`);
241
- }
314
+ Object.assign(resultat, { theme: reponse.theme, domaine: reponse.domaine, apercu: reponse.apercu });
315
+ resultat.ecrits.push(...(reponse.ecrits || []));
316
+ resultat.refuses.push(...(reponse.refuses || []));
317
+ memoriser(store, slug, reponse.domaine || domaine, theme, lot.filter((f) => (reponse.ecrits || []).includes(f.chemin)));
242
318
  }
243
319
 
244
- const resultat = await appel(config, `/boutiques/${encode}/themes/${encodeURIComponent(theme)}`, {
245
- method: 'PUT',
246
- body: JSON.stringify({ fichiers, confirm: confirm === true }),
247
- });
248
- memoriser(store, slug, resultat.domaine || cible, theme, fichiers.filter((f) => resultat.ecrits.includes(f.chemin)));
249
-
250
320
  const exclus = store.excluded(slug);
251
321
  return ok(
252
322
  [
253
- create ? `Thème « ${resultat.theme.nom} » créé sur ${resultat.domaine} à partir du Story Thème ${meta.theme?.version || ''}, en NON PUBLIÉ (identifiant ${resultat.theme.id}).` : '',
323
+ cree ? `Thème « ${resultat.theme.nom} » créé sur ${resultat.domaine} à partir du Story Thème, en NON PUBLIÉ (identifiant ${resultat.theme.id}).` : '',
254
324
  `${resultat.ecrits.length} fichier(s) écrits dans « ${resultat.theme.nom} » sur ${resultat.domaine}${resultat.theme.role === 'main' ? ' — THÈME PUBLIÉ, la boutique a changé' : ''}${inchanges ? ` (${inchanges} déjà à jour)` : ''}.`,
255
- resultat.refuses?.length ? `Refusés : ${resultat.refuses.join(' · ')}` : '',
325
+ resultat.refuses.length ? `Refusés par Shopify : ${resultat.refuses.join(' · ')}` : '',
326
+ invalides.length ? `Non envoyés (refusés par cette version du thème, à corriger dans le projet) : ${invalides.join(' · ')}` : '',
256
327
  '',
257
328
  `Prévisualiser : ${resultat.apercu}`,
258
329
  resultat.theme.role === 'main' ? '' : 'Le marchand vérifie sur ordinateur et sur mobile, puis publie lui-même.',
259
- exclus.length ? `Modèles exclus du projet mais toujours présents dans ce thème : ${exclus.join(', ')}.` : '',
330
+ exclus.length ? `Modèles exclus du projet mais toujours présents dans ce thème : ${exclus.slice(0, 8).join(', ')}${exclus.length > 8 ? '…' : ''}.` : '',
260
331
  'Images, produits, collections, menus et réductions restent à créer côté Shopify : review_project en donne la liste.',
261
332
  ]
262
333
  .filter(Boolean)
@@ -284,7 +355,7 @@ export function registerDirectTools(server, config, { ctx, ok, garde }) {
284
355
  const cible = String(shops || '').trim();
285
356
  if (!cible || cible === 'all') throw new Error('pull_from_shop relit UNE boutique à la fois : donnez son domaine (list_shops).');
286
357
  const { store, catalog } = ctx.get();
287
- const lu = await lireThemeBoutique(config, cible, theme, [...CONTENU, 'config/settings_schema.json', 'locales/*.default.json']);
358
+ const lu = await lireThemeBoutique(config, cible, theme, [...CONTENU, 'config/settings_schema.json']);
288
359
  const fichiers = lu.fichiers || [];
289
360
  const domaine = lu.domaine || cible;
290
361
 
@@ -311,7 +382,15 @@ export function registerDirectTools(server, config, { ctx, ok, garde }) {
311
382
  }
312
383
  }
313
384
  if (!slug) {
314
- const langue = /^locales\/([a-z]+)/.exec(fichiers.find((f) => f.chemin.startsWith('locales/'))?.chemin || '')?.[1];
385
+ // La langue de la boutique, d'après ses propres textes.
386
+ const modeles = fichiers.filter((f) => fileKind(f.chemin) && f.chemin !== 'config/settings_data.json').map((f) => {
387
+ try {
388
+ return parseThemeJson(f.contenu);
389
+ } catch {
390
+ return null;
391
+ }
392
+ });
393
+ const langue = devinerLangue(textesDesModeles(modeles.filter(Boolean)), catalog.languages);
315
394
  const meta = store.create({
316
395
  name: name || `${lu.theme?.nom || 'Thème'} (${domaine.replace('.myshopify.com', '')})`,
317
396
  language: langue && catalog.languages.includes(langue) ? langue : config.defaultLanguage || 'fr',
@@ -322,8 +401,16 @@ export function registerDirectTools(server, config, { ctx, ok, garde }) {
322
401
 
323
402
  const repris = [];
324
403
  const contenus = [];
404
+ const vitrines = [];
325
405
  for (const { chemin, contenu } of fichiers) {
326
406
  if (!fileKind(chemin)) continue;
407
+ // Le catalogue de sections du thème (collection.sections-…) sert à la démo, pas au marchand :
408
+ // ni relu, ni renvoyé, ni compté par la revue.
409
+ if (/^templates\/[^/]+\.sections-[^/]+\.json$/.test(chemin)) {
410
+ if (!store.excluded(slug).includes(chemin)) store.setExcluded(slug, chemin, true);
411
+ vitrines.push(chemin);
412
+ continue;
413
+ }
327
414
  let donnees;
328
415
  try {
329
416
  donnees = parseThemeJson(contenu);
@@ -341,11 +428,14 @@ export function registerDirectTools(server, config, { ctx, ok, garde }) {
341
428
 
342
429
  return ok(
343
430
  [
344
- `Thème « ${lu.theme?.nom || theme} » de ${domaine} relu dans le projet ${slug} (projet en cours) : ${repris.length} fichier(s) mis à jour${version ? `, Story Thème ${version}` : ''}.`,
431
+ `Thème « ${lu.theme?.nom || theme} » de ${domaine} relu dans le projet ${slug} (projet en cours, langue ${store.meta(slug).language}) : ${repris.length} fichier(s) mis à jour${version ? `, Story Thème ${version}` : ''}.`,
432
+ vitrines.length ? `${vitrines.length} modèle(s) vitrine du thème (collection.sections-…) laissés de côté : ils servent à la démo, pas à la boutique.` : '',
345
433
  repris.length ? repris.slice(0, 20).map((chemin) => `- ${chemin}`).join('\n') + (repris.length > 20 ? `\n- … ${repris.length - 20} autre(s)` : '') : 'Le projet était déjà identique à la boutique.',
346
434
  '',
347
435
  'Le projet reflète maintenant la boutique, modifications faites à la main comprises. Modifiez (read_file, edit_template, compose_page…), puis renvoyez avec push_to_shop dans ce même thème. undo revient à la version d’avant la relecture.',
348
- ].join('\n'),
436
+ ]
437
+ .filter(Boolean)
438
+ .join('\n'),
349
439
  { projet: slug, repris },
350
440
  );
351
441
  }),
@@ -17,6 +17,10 @@ const garde = (fn) => async (args) => {
17
17
  try {
18
18
  return await fn(args || {});
19
19
  } catch (e) {
20
+ if (e instanceof ShopsError && /sans Story Apps/.test(e.message)) {
21
+ // La boutique est sans doute branchée par Theme Access : l'outil n'est pas le bon.
22
+ return fail(`${e.message}\nSi elle est branchée par Theme Access (● dans list_shops), seuls ses thèmes sont accessibles : pull_from_shop, push_to_shop, migrate_v3. Pour ses produits, collections, pages, menus et réductions : le connecteur Shopify officiel du marchand.`);
23
+ }
20
24
  return fail(e instanceof ShopsError ? e.message : `Erreur inattendue : ${e.message}`);
21
25
  }
22
26
  };
@@ -49,7 +53,7 @@ export function registerShopTools(server, config, { fetchImpl, ctx } = {}) {
49
53
  {
50
54
  title: "Vue d'ensemble des boutiques",
51
55
  description:
52
- 'Key figures of all connected stores in one call: products, collections, Shopify plan, theme version and pending update, licence, and Story Apps modules (reviews, pending reviews, A/B tests, post-purchase offers).',
56
+ 'Only for stores where the Story Apps app is installed (Story Theme team stores). Key figures in one call: products, collections, Shopify plan, theme version and pending update, licence, and Story Apps modules (reviews, pending reviews, A/B tests, post-purchase offers). A merchant store connected with Theme Access is NOT reachable here.',
53
57
  annotations: { readOnlyHint: true, openWorldHint: true },
54
58
  },
55
59
  garde(async () => {
@@ -63,7 +67,7 @@ export function registerShopTools(server, config, { fetchImpl, ctx } = {}) {
63
67
  {
64
68
  title: 'Requête sur une ou plusieurs boutiques',
65
69
  description:
66
- 'Runs one Shopify Admin GraphQL operation (API 2025-10) on one, several or all connected stores, in parallel. Queries and mutations within the Story Apps scopes (products, collections, pages, menus, files, discounts, themes read). A mutation on several stores needs confirm: true, only after the user explicitly agreed. Every access is logged.',
70
+ 'Only for stores where the Story Apps app is installed (Story Theme team stores) — NOT for a merchant store connected with Theme Access, which only opens its themes (use pull_from_shop / push_to_shop; for products, collections, pages, menus and discounts, use the merchant\'s official Shopify connector). Runs one Shopify Admin GraphQL operation on one, several or all Story Apps stores, in parallel. A mutation on several stores needs confirm: true, only after the user explicitly agreed. Every access is logged.',
67
71
  inputSchema: {
68
72
  shops: cibleSchema,
69
73
  query: z.string().describe('Requête ou mutation GraphQL Admin.'),
@@ -31,6 +31,48 @@ export function looksEnglish(text) {
31
31
  return en >= 2 && en > fr * 2;
32
32
  }
33
33
 
34
+ // The language a store is written in, guessed from its own texts. The theme's default locale file
35
+ // (en.default.json) says nothing about it: it is English for every Story Theme store.
36
+ const MOTS = {
37
+ fr: FR_WORDS,
38
+ en: EN_WORDS,
39
+ de: new Set(['der', 'die', 'das', 'und', 'mit', 'fur', 'ihre', 'ihr', 'unsere', 'ist', 'nicht', 'auf', 'zu', 'den', 'dem', 'ein', 'eine', 'jetzt', 'versand', 'kostenloser']),
40
+ es: new Set(['el', 'los', 'las', 'y', 'con', 'para', 'tu', 'tus', 'nuestro', 'nuestros', 'es', 'del', 'por', 'una', 'envio', 'gratis', 'descubre', 'productos', 'tienda']),
41
+ it: new Set(['il', 'gli', 'e', 'con', 'per', 'tuo', 'tua', 'nostro', 'nostri', 'della', 'delle', 'una', 'sono', 'spedizione', 'gratuita', 'scopri', 'prodotti', 'negozio']),
42
+ nl: new Set(['het', 'een', 'en', 'met', 'voor', 'jouw', 'onze', 'van', 'is', 'op', 'niet', 'gratis', 'verzending', 'ontdek', 'producten', 'winkel']),
43
+ };
44
+
45
+ /**
46
+ * @param {string[]} textes textes de la boutique (HTML accepté)
47
+ * @param {string[]} langues langues proposées par le thème
48
+ * @returns {string|null} la langue la plus probable, ou null si les textes ne suffisent pas
49
+ */
50
+ export function devinerLangue(textes, langues = Object.keys(MOTS)) {
51
+ const compte = Object.fromEntries(Object.keys(MOTS).map((langue) => [langue, 0]));
52
+ for (const texte of textes) {
53
+ for (const mot of normalizeText(String(texte).replace(/<[^>]+>/g, ' ')).split(' ')) {
54
+ for (const [langue, mots] of Object.entries(MOTS)) if (mots.has(mot)) compte[langue] += 1;
55
+ }
56
+ }
57
+ const [meilleure, score] = Object.entries(compte)
58
+ .filter(([langue]) => langues.includes(langue))
59
+ .sort((a, b) => b[1] - a[1])[0] || [null, 0];
60
+ return score >= 8 ? meilleure : null;
61
+ }
62
+
63
+ /** Les textes saisis dans des modèles de thème (titres, paragraphes, boutons), pour devinerLangue. */
64
+ export function textesDesModeles(modeles) {
65
+ const textes = [];
66
+ const parcourir = (noeud) => {
67
+ for (const valeur of Object.values(noeud?.settings || {})) {
68
+ if (typeof valeur === 'string' && /\s/.test(valeur) && !/^shopify:\/\//.test(valeur)) textes.push(valeur);
69
+ }
70
+ for (const enfant of Object.values(noeud?.blocks || {})) parcourir(enfant);
71
+ };
72
+ for (const modele of modeles) for (const section of Object.values(modele?.sections || {})) parcourir(section);
73
+ return textes;
74
+ }
75
+
34
76
  // Texts shipped as samples: every translated preset string and every shipped template text.
35
77
  export function sampleTexts(catalog) {
36
78
  if (catalog._samples) return catalog._samples;
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Version du serveur MCP. Ici et dans package.json — les deux doivent être identiques
2
2
  // (npm test le vérifie), et rien d'autre ne la redéclare.
3
- export const VERSION = '1.6.0';
3
+ export const VERSION = '1.6.1';