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 +1 -1
- package/src/build/compose.js +8 -2
- package/src/migrate/extract.js +3 -1
- package/src/migrate/source.js +6 -10
- package/src/migrate/v3.js +26 -8
- package/src/project/review.js +1 -1
- package/src/server.js +2 -2
- package/src/shops/direct.js +172 -82
- package/src/shops/tools.js +6 -2
- package/src/validate/design.js +42 -0
- package/src/version.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "story-theme-mcp",
|
|
3
|
-
"version": "1.6.
|
|
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",
|
package/src/build/compose.js
CHANGED
|
@@ -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'))
|
|
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;
|
package/src/migrate/extract.js
CHANGED
|
@@ -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
|
-
['
|
|
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 });
|
package/src/migrate/source.js
CHANGED
|
@@ -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
|
-
|
|
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': {
|
|
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
|
|
484
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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})
|
|
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.',
|
package/src/project/review.js
CHANGED
|
@@ -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
|
|
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
|
|
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'
|
|
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 });
|
package/src/shops/direct.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
168
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
...
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
//
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
-
]
|
|
436
|
+
]
|
|
437
|
+
.filter(Boolean)
|
|
438
|
+
.join('\n'),
|
|
349
439
|
{ projet: slug, repris },
|
|
350
440
|
);
|
|
351
441
|
}),
|
package/src/shops/tools.js
CHANGED
|
@@ -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
|
-
'
|
|
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
|
-
'
|
|
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.'),
|
package/src/validate/design.js
CHANGED
|
@@ -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