story-theme-mcp 1.2.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +1 -2
- package/src/build/quick.js +91 -12
- package/src/knowledge/demos.js +81 -0
- package/src/knowledge/guides.js +39 -2
- package/src/server.js +53 -8
- package/src/shops/push.js +198 -0
- package/src/shops/tools.js +5 -1
- package/src/validate/design.js +9 -1
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -52,12 +52,14 @@ Le MCP ne contient pas le thème : au démarrage, il demande à l'espace membre
|
|
|
52
52
|
| Étape | Outils |
|
|
53
53
|
|---|---|
|
|
54
54
|
| Connaître le thème | `get_started`, `theme_source`, `search_theme`, `list_sections`, `get_section`, `list_blocks`, `get_block`, `get_theme_settings`, `get_theme_template`, `read_guide` |
|
|
55
|
+
| S'inspirer | `inspiration` : les 11 boutiques démo composées par le designer — l'ossature de leurs pages, à regarder avant de construire |
|
|
55
56
|
| Concevoir | `recipes` (15 compositions de pages), `generate_color_schemes`, `check_contrast`, `find_fonts` |
|
|
56
57
|
| Construire vite | `build_store` : la V1 complète d'une boutique en un appel (direction artistique, pages de marque, moments signature, textes, traduction, zip) |
|
|
57
58
|
| Composer librement | `compose_page` : n'importe quelle page avec toutes les sections du thème, en donnant seulement le contenu (titre, texte, boutons, étapes, cartes, questions, chiffres…) |
|
|
58
59
|
| Construire | `create_project`, `update_brief`, `apply_recipe`, `localize_template`, `list_texts`, `edit_template`, `write_file`, `update_theme_settings`, `read_file`, `exclude_template` |
|
|
59
60
|
| Contrôler | `validate_json`, `review_project`, `undo`, `revert_file` |
|
|
60
61
|
| Livrer | `export_theme` (zip à importer + check-list), `import_theme` (auditer un thème téléchargé d'une boutique) |
|
|
62
|
+
| Vos boutiques | `list_shops`, `shops_overview`, `shop_graphql` (Admin GraphQL sur une, plusieurs ou toutes les boutiques), `push_to_shop` (écrire le projet dans un thème non publié, sans zip) — via l'appli Shopify Story Apps, sans aucune clé Shopify |
|
|
61
63
|
|
|
62
64
|
Prompts prêts à l'emploi : `creer-boutique`, `refaire-une-page`, `auditer-un-theme`. Les guides sont aussi exposés en ressources (`storytheme://guides/<sujet>`).
|
|
63
65
|
|
|
@@ -66,7 +68,8 @@ Prompts prêts à l'emploi : `creer-boutique`, `refaire-une-page`, `auditer-un-t
|
|
|
66
68
|
- **Aucun fichier invalide n'est enregistré.** Chaque modèle, groupe de sections et `settings_data.json` est validé contre les vrais schémas du thème : types de sections et de blocs, blocs privés, blocs statiques et leurs identifiants, réglages (identifiant, type, options, bornes et pas des curseurs), texte riche, liens, images, palettes, polices de la bibliothèque Shopify, limites Shopify (25 sections, 50 blocs, 8 niveaux). Les erreurs proposent la bonne orthographe (« vouliez-vous dire banner ? »).
|
|
67
69
|
- **Plus strict que Shopify Theme Check** : sur un même fichier piégé, Theme Check ne voit que le bloc interdit ; le MCP refuse aussi le réglage inconnu et la valeur hors limites.
|
|
68
70
|
- **Revue design** : textes d'exemple ou en anglais laissés sur une boutique française, faux avis et faux chiffres des presets, bouton sans lien, plusieurs `<h1>`, contrastes insuffisants (WCAG AA, boutons compris), polices dépréciées par Shopify (Futura → Jost…), compte à rebours qui redémarre et stock simulé (pratiques commerciales trompeuses), pop-ups multiples, rythme des palettes.
|
|
69
|
-
- **Jamais le code du thème
|
|
71
|
+
- **Jamais le code du thème** : seuls `templates/*.json`, `sections/<groupe>.json` et `config/settings_data.json` sont modifiables, dans un dossier de projet. Le Liquid, le CSS et le JS du thème ne sont jamais touchés, ni en local ni à distance.
|
|
72
|
+
- **Écrire dans une boutique reste encadré** : `push_to_shop` n'envoie que les fichiers du projet, refuse une boutique qui ne tourne pas sur un Story Thème de la même version majeure, refuse le thème publié sans confirmation explicite, et ne supprime jamais rien à distance. Les accès passent par l'appli Story Apps (OAuth par boutique, révocable par le marchand) et sont journalisés.
|
|
70
73
|
- **Historique** : chaque écriture est précédée d'un instantané ; `undo` revient en arrière autant de fois que nécessaire.
|
|
71
74
|
- **Export bloqué** tant qu'une erreur bloquante reste ; signalé « pas prêt à publier » tant qu'un point 🔴 reste.
|
|
72
75
|
|
|
@@ -96,7 +99,7 @@ Prompts prêts à l'emploi : `creer-boutique`, `refaire-une-page`, `auditer-un-t
|
|
|
96
99
|
|
|
97
100
|
## Limites
|
|
98
101
|
|
|
99
|
-
|
|
102
|
+
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.
|
|
100
103
|
|
|
101
104
|
## Développement (New Story)
|
|
102
105
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "story-theme-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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",
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
25
25
|
"fflate": "^0.8.3",
|
|
26
|
-
"story-theme-mcp": "^1.2.1",
|
|
27
26
|
"zod": "^4.6.5"
|
|
28
27
|
},
|
|
29
28
|
"keywords": [
|
package/src/build/quick.js
CHANGED
|
@@ -16,16 +16,16 @@ const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
|
16
16
|
// A style picks sensible defaults when the merchant has not said more: brand colour, neutrals,
|
|
17
17
|
// font pairing and home page recipe.
|
|
18
18
|
export const STYLES = {
|
|
19
|
-
essentiel: { label: 'polyvalent', color: '#2F4B3A', neutral: 'tinted', fonts: 'clean-sans', home: 'accueil-essentiel' },
|
|
20
|
-
premium: { label: 'premium, luxe', color: '#2B2622', neutral: 'warm', fonts: 'editorial', home: 'accueil-marque-premium' },
|
|
21
|
-
mode: { label: 'mode, prêt-à-porter', color: '#1A1A1A', neutral: 'pure', fonts: 'fashion-sans', home: 'accueil-marque-premium' },
|
|
22
|
-
beaute: { label: 'beauté, soin, bien-être', color: '#A45C68', neutral: 'warm', fonts: 'soft-modern', home: 'accueil-beaute' },
|
|
23
|
-
nature: { label: 'naturel, artisanal, épicerie', color: '#3F5A3C', neutral: 'warm', fonts: 'natural', home: 'accueil-artisan' },
|
|
24
|
-
maison: { label: 'maison, déco, grand catalogue', color: '#7A5C44', neutral: 'warm', fonts: 'minimal', home: 'accueil-catalogue' },
|
|
25
|
-
tech: { label: 'tech, accessoires', color: '#2F5BEA', neutral: 'cool', fonts: 'tech', home: 'accueil-produit-phare' },
|
|
26
|
-
sport: { label: 'sport, énergie', color: '#D8432B', neutral: 'pure', fonts: 'bold-sport', home: 'accueil-essentiel' },
|
|
27
|
-
enfant: { label: 'enfants, animaux, ludique', color: '#E08A2E', neutral: 'warm', fonts: 'friendly', home: 'accueil-essentiel' },
|
|
28
|
-
'produit-unique': { label: 'marque d’un produit phare (DTC)', color: '#1F3A5F', neutral: 'cool', fonts: 'contemporary', home: 'accueil-produit-phare' },
|
|
19
|
+
essentiel: { label: 'polyvalent', color: '#2F4B3A', neutral: 'tinted', fonts: 'clean-sans', home: 'accueil-essentiel', homes: ['accueil-essentiel', 'accueil-produit-phare', 'accueil-catalogue'] },
|
|
20
|
+
premium: { label: 'premium, luxe', color: '#2B2622', neutral: 'warm', fonts: 'editorial', home: 'accueil-marque-premium', homes: ['accueil-marque-premium', 'accueil-essentiel', 'accueil-produit-phare'] },
|
|
21
|
+
mode: { label: 'mode, prêt-à-porter', color: '#1A1A1A', neutral: 'pure', fonts: 'fashion-sans', home: 'accueil-marque-premium', homes: ['accueil-marque-premium', 'accueil-catalogue', 'accueil-essentiel'] },
|
|
22
|
+
beaute: { label: 'beauté, soin, bien-être', color: '#A45C68', neutral: 'warm', fonts: 'soft-modern', home: 'accueil-beaute', homes: ['accueil-beaute', 'accueil-produit-phare', 'accueil-essentiel'] },
|
|
23
|
+
nature: { label: 'naturel, artisanal, épicerie', color: '#3F5A3C', neutral: 'warm', fonts: 'natural', home: 'accueil-artisan', homes: ['accueil-artisan', 'accueil-essentiel', 'accueil-catalogue'] },
|
|
24
|
+
maison: { label: 'maison, déco, grand catalogue', color: '#7A5C44', neutral: 'warm', fonts: 'minimal', home: 'accueil-catalogue', homes: ['accueil-catalogue', 'accueil-marque-premium', 'accueil-essentiel'] },
|
|
25
|
+
tech: { label: 'tech, accessoires', color: '#2F5BEA', neutral: 'cool', fonts: 'tech', home: 'accueil-produit-phare', homes: ['accueil-produit-phare', 'accueil-essentiel', 'accueil-catalogue'] },
|
|
26
|
+
sport: { label: 'sport, énergie', color: '#D8432B', neutral: 'pure', fonts: 'bold-sport', home: 'accueil-essentiel', homes: ['accueil-essentiel', 'accueil-produit-phare', 'accueil-beaute'] },
|
|
27
|
+
enfant: { label: 'enfants, animaux, ludique', color: '#E08A2E', neutral: 'warm', fonts: 'friendly', home: 'accueil-essentiel', homes: ['accueil-essentiel', 'accueil-catalogue', 'accueil-artisan'] },
|
|
28
|
+
'produit-unique': { label: 'marque d’un produit phare (DTC)', color: '#1F3A5F', neutral: 'cool', fonts: 'contemporary', home: 'accueil-produit-phare', homes: ['accueil-produit-phare', 'accueil-beaute', 'accueil-essentiel'] },
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const KEYWORDS = {
|
|
@@ -411,6 +411,81 @@ export function enrichir(steps, { direction = 'editorial', graine = 0, hasImages
|
|
|
411
411
|
return sortie;
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
// ---- variété des structures
|
|
415
|
+
//
|
|
416
|
+
// Le reproche était juste : deux boutiques du même style sortaient avec le même enchaînement de
|
|
417
|
+
// sections, parce que la recette imposait l'ordre et que l'enrichissement tombait toujours au même
|
|
418
|
+
// endroit (juste avant la FAQ). On garde la tête (le héros, le bandeau qui le suit) et la queue
|
|
419
|
+
// (FAQ, newsletter) — leur place est justifiée — et on rebat le MILIEU avec la graine de la marque.
|
|
420
|
+
//
|
|
421
|
+
// Rebattre ne veut pas dire mélanger au hasard : une page doit rester vendeuse. Les sections sont
|
|
422
|
+
// donc réparties en deux familles (commerce et récit) et réassemblées selon l'un des quatre rythmes
|
|
423
|
+
// ci-dessous. Deux sections de même type ne se retrouvent jamais collées.
|
|
424
|
+
|
|
425
|
+
const COMMERCE = new Set(['product-list', 'collection-list', 'featured-product', 'bundle-builder', 'image-hotspots', 'hotspots']);
|
|
426
|
+
|
|
427
|
+
// Chaque rythme est une intention de page, pas un tirage : C = commerce, R = récit.
|
|
428
|
+
const RYTHMES = [
|
|
429
|
+
'CRRCRRC', // le produit d'abord, la marque autour
|
|
430
|
+
'RCRRCRC', // une respiration de marque, puis le produit
|
|
431
|
+
'CCRRCRR', // vitrine groupée en haut
|
|
432
|
+
'CRCRRRC', // alternance serrée
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
const alea = (graine) => {
|
|
436
|
+
let x = (graine % 2147483647) || 1;
|
|
437
|
+
return () => (x = (x * 48271) % 2147483647) / 2147483647;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const melanger = (liste, tirage) => {
|
|
441
|
+
const out = [...liste];
|
|
442
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
443
|
+
const j = Math.floor(tirage() * (i + 1));
|
|
444
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/** La queue d'une page : ce qui se lit en dernier et ne se déplace pas. */
|
|
450
|
+
const FIN = new Set(['faq', 'newsletter', 'contact-form', 'map', 'blog-posts']);
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Rebat le milieu de l'accueil pour que deux marques du même style n'aient pas la même page.
|
|
454
|
+
* @param {object[]} steps sections déjà adaptées et enrichies
|
|
455
|
+
* @param {{graine: number}} options
|
|
456
|
+
*/
|
|
457
|
+
export function varier(steps, { graine = 0 } = {}) {
|
|
458
|
+
// La tête : le héros, plus le bandeau défilant ou la barre de réassurance qui le suit.
|
|
459
|
+
let debut = 1;
|
|
460
|
+
while (debut < steps.length && ['marquee', 'marquee-text', 'trust-badges', 'announcement'].includes(steps[debut]?.type)) debut += 1;
|
|
461
|
+
let fin = steps.length;
|
|
462
|
+
while (fin > debut && FIN.has(steps[fin - 1]?.type)) fin -= 1;
|
|
463
|
+
|
|
464
|
+
const milieu = steps.slice(debut, fin);
|
|
465
|
+
// En dessous de quatre sections mobiles, permuter ne produit pas de variété, seulement du bruit.
|
|
466
|
+
if (milieu.length < 4) return steps;
|
|
467
|
+
|
|
468
|
+
const tirage = alea(graine + 7);
|
|
469
|
+
const commerce = melanger(milieu.filter((etape) => COMMERCE.has(etape.type)), tirage);
|
|
470
|
+
const recit = melanger(milieu.filter((etape) => !COMMERCE.has(etape.type)), tirage);
|
|
471
|
+
const rythme = RYTHMES[graine % RYTHMES.length];
|
|
472
|
+
|
|
473
|
+
const sortie = [];
|
|
474
|
+
for (let i = 0; sortie.length < milieu.length; i++) {
|
|
475
|
+
const veutCommerce = rythme[i % rythme.length] === 'C';
|
|
476
|
+
let premier = veutCommerce ? commerce : recit;
|
|
477
|
+
let second = veutCommerce ? recit : commerce;
|
|
478
|
+
// Deux sections du même type à la suite : on prend dans l'autre famille, sinon on décale.
|
|
479
|
+
const dernier = sortie.at(-1)?.type;
|
|
480
|
+
if (premier.length && premier[0].type === dernier && second.length) [premier, second] = [second, premier];
|
|
481
|
+
const choisi = (premier.length ? premier : second).shift();
|
|
482
|
+
if (!choisi) break;
|
|
483
|
+
sortie.push(choisi);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return [...steps.slice(0, debut), ...sortie, ...steps.slice(fin)];
|
|
487
|
+
}
|
|
488
|
+
|
|
414
489
|
export function adaptSteps(steps, { templateType, hasImages, hasLarge = hasImages, proofs, collections }) {
|
|
415
490
|
const out = [];
|
|
416
491
|
const planned = steps.map((step) => `${step.type}|${step.preset}`);
|
|
@@ -753,7 +828,10 @@ export function buildStore({ catalog, store }, input) {
|
|
|
753
828
|
|
|
754
829
|
// Pages
|
|
755
830
|
const recipes = [];
|
|
756
|
-
|
|
831
|
+
// Trois accueils par style, et la graine de la marque tranche : deux boutiques « beauté » ne
|
|
832
|
+
// partent plus de la même recette. input.home reste prioritaire si l'assistant en impose une.
|
|
833
|
+
const famille = (preset.homes || [preset.home]).filter((id) => findRecipe(id));
|
|
834
|
+
const home = findRecipe(input.home) ? input.home : famille[graineDe(input.variation || input.name) % famille.length] || preset.home;
|
|
757
835
|
recipes.push([home, 'templates/index.json']);
|
|
758
836
|
const avecOffres = input.offers !== false;
|
|
759
837
|
recipes.push([avecOffres ? 'produit-offres' : 'produit-complet', 'templates/product.json']);
|
|
@@ -777,9 +855,10 @@ export function buildStore({ catalog, store }, input) {
|
|
|
777
855
|
const adaptes = adaptSteps(recipe.steps, { templateType, hasImages, hasLarge, proofs, collections });
|
|
778
856
|
// Enrichissement APRÈS l'adaptation : c'est la page finale qu'on complète, sinon les
|
|
779
857
|
// remplissages d'adaptSteps repoussaient les sections tirées derrière la FAQ.
|
|
858
|
+
const graine = graineDe(input.variation || input.name);
|
|
780
859
|
const steps =
|
|
781
860
|
templateType === 'index'
|
|
782
|
-
? enrichir(adaptes, { direction: directionId, graine
|
|
861
|
+
? varier(enrichir(adaptes, { direction: directionId, graine, hasImages, proofs }), { graine })
|
|
783
862
|
: adaptes;
|
|
784
863
|
const { data } = buildFromRecipe(catalog, { ...recipe, steps }, { language: lang, base: recipe.base ? catalog.theme.templates.get(recipe.base) : null });
|
|
785
864
|
const baseCount = recipe.base ? catalog.theme.templates.get(recipe.base).order.length : 0;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// =====================================================================
|
|
2
|
+
// Les boutiques démo, vues par l'assistant
|
|
3
|
+
// =====================================================================
|
|
4
|
+
// Onze boutiques composées par le designer du thème dorment dans l'espace membre. L'assistant
|
|
5
|
+
// ne les voyait pas : il repartait des recettes, et deux marques du même style se ressemblaient.
|
|
6
|
+
//
|
|
7
|
+
// Il lit maintenant leur OSSATURE — quelles sections, dans quel ordre, avec quels blocs — pour
|
|
8
|
+
// s'en inspirer comme un designer regarde le travail d'un confrère. Jamais leurs textes : dix
|
|
9
|
+
// boutiques clientes qui racontent la même histoire seraient pires que dix boutiques identiques.
|
|
10
|
+
//
|
|
11
|
+
// Deux chemins d'accès, selon l'endroit où tourne le MCP :
|
|
12
|
+
// connecteur `config.demos` : l'espace membre sert les démos sans aller-retour réseau ;
|
|
13
|
+
// local l'API `/api/v1/mcp/demos`, avec le jeton du client.
|
|
14
|
+
|
|
15
|
+
const CHEMIN = '/api/v1/mcp/demos';
|
|
16
|
+
|
|
17
|
+
/** Rend les démos accessibles, quelle que soit la façon dont le MCP est servi. */
|
|
18
|
+
export function creerDemos(config) {
|
|
19
|
+
// Connecteur : l'espace membre a déjà tout sous la main.
|
|
20
|
+
if (config.demos) return config.demos;
|
|
21
|
+
|
|
22
|
+
const appel = async (chemin) => {
|
|
23
|
+
if (!config.token) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'Les boutiques démo demandent un jeton : ajoutez STORY_MCP_TOKEN (espace membre Story Thème > MCP & Appli).',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const reponse = await fetch(`${config.apiBase.replace(/\/+$/, '')}${chemin}`, {
|
|
29
|
+
headers: { Authorization: `Bearer ${config.token}`, Accept: 'application/json' },
|
|
30
|
+
signal: AbortSignal.timeout(15000),
|
|
31
|
+
});
|
|
32
|
+
if (!reponse.ok) throw new Error(`réponse ${reponse.status} de l’espace membre`);
|
|
33
|
+
return (await reponse.json()).data;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
lister: async () => (await appel(CHEMIN)).demos,
|
|
38
|
+
composition: async (slug) => appel(`${CHEMIN}/${encodeURIComponent(slug)}`),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Le catalogue, en une liste lisible. */
|
|
43
|
+
export function formatDemos(demos) {
|
|
44
|
+
if (!demos?.length) return 'Aucune boutique démo disponible.';
|
|
45
|
+
return [
|
|
46
|
+
`${demos.length} boutiques démo, composées par le designer du thème. Regardez celle dont l’activité ressemble le plus à celle du marchand, puis inspirez-vous de sa COMPOSITION — jamais de ses textes.`,
|
|
47
|
+
'',
|
|
48
|
+
...demos.map((demo) => `- **${demo.slug}** — ${demo.nom} · ${demo.niche}${demo.type ? ` · ${demo.type}` : ''}\n ${demo.pitch}`),
|
|
49
|
+
'',
|
|
50
|
+
'inspiration({ demo: "<slug>" }) donne la composition de ses pages.',
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** La composition d'une démo : une ligne par section, dans l'ordre de la page. */
|
|
55
|
+
export function formatComposition(composition, pageDemandee) {
|
|
56
|
+
const pages = composition.pages || {};
|
|
57
|
+
const noms = Object.keys(pages);
|
|
58
|
+
if (!noms.length) return `La démo « ${composition.slug} » n’expose aucune page.`;
|
|
59
|
+
|
|
60
|
+
const choisies = pageDemandee
|
|
61
|
+
? noms.filter((nom) => nom === pageDemandee || nom === `page.${pageDemandee}`)
|
|
62
|
+
: ['index', 'product', 'collection'].filter((nom) => pages[nom]);
|
|
63
|
+
|
|
64
|
+
if (!choisies.length) return `Page « ${pageDemandee} » absente. Pages disponibles : ${noms.join(', ')}.`;
|
|
65
|
+
|
|
66
|
+
const lignes = [`# ${composition.nom} (${composition.slug}) — ${composition.niche}`, composition.pitch, ''];
|
|
67
|
+
for (const nom of choisies) {
|
|
68
|
+
lignes.push(`## ${nom} — ${pages[nom].length} sections`);
|
|
69
|
+
pages[nom].forEach((section, index) => {
|
|
70
|
+
const blocs = section.blocs?.length ? ` — blocs : ${section.blocs.join(', ')}` : '';
|
|
71
|
+
const palette = section.palette ? ` [${section.palette}]` : '';
|
|
72
|
+
lignes.push(`${index + 1}. ${section.type}${palette}${blocs}`);
|
|
73
|
+
});
|
|
74
|
+
lignes.push('');
|
|
75
|
+
}
|
|
76
|
+
lignes.push(`Pages disponibles : ${noms.join(', ')}.`);
|
|
77
|
+
lignes.push(
|
|
78
|
+
'Inspirez-vous de l’enchaînement et du rythme des palettes ; écrivez vos propres textes, et n’imposez pas cette structure si la marque appelle autre chose.',
|
|
79
|
+
);
|
|
80
|
+
return lignes.join('\n');
|
|
81
|
+
}
|
package/src/knowledge/guides.js
CHANGED
|
@@ -206,8 +206,9 @@ Formats conseillés :
|
|
|
206
206
|
|
|
207
207
|
limites: {
|
|
208
208
|
title: 'Ce que le MCP (V1) ne fait pas',
|
|
209
|
-
body: `- Il ne
|
|
210
|
-
-
|
|
209
|
+
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.
|
|
210
|
+
- 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.
|
|
211
|
+
- 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.
|
|
211
212
|
- 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.
|
|
212
213
|
- Il ne garantit pas le rendu visuel : prévisualisez toujours dans l'éditeur Shopify avant de publier.`,
|
|
213
214
|
},
|
|
@@ -249,4 +250,40 @@ GUIDES.signatures = {
|
|
|
249
250
|
**Règles** : 7 à 10 sections par page, alterner visuel et texte, une section colorée sur trois, un seul effet fort par écran. Les avis et chiffres d'une maquette sont des exemples à valider : écrivez-les, puis déclarez-les.`,
|
|
250
251
|
};
|
|
251
252
|
|
|
253
|
+
|
|
254
|
+
GUIDES.reproduire = {
|
|
255
|
+
title: 'Reproduire une section vue ailleurs (capture d’écran, site concurrent)',
|
|
256
|
+
body: `Le marchand montre une capture et dit « refais-moi ça ». La tentation est d'écrire du Liquid : c'est
|
|
257
|
+
le plus rapide pour VOUS, et le plus coûteux pour LUI. Une section en code sort de l'éditeur Shopify —
|
|
258
|
+
plus un mot modifiable, plus de palette, plus d'animation, rien à traduire — et aucune mise à jour du
|
|
259
|
+
thème ne la corrigera. Le thème est fait pour éviter ça : 109 blocs publics, acceptés presque partout,
|
|
260
|
+
imbriqués jusqu'à 8 niveaux.
|
|
261
|
+
|
|
262
|
+
**L'ordre, sans exception**
|
|
263
|
+
1. **Une section du thème qui fait déjà ça.** search_theme sur ce que vous voyez (« comparatif », « chiffres », « étapes »), puis get_section pour les presets. 60 sections, 200 presets : la plupart des maquettes y sont déjà.
|
|
264
|
+
2. **advanced-custom-section** (« Section personnalisée », preset « Advanced custom section »). C'est le cadre vide qui accepte tous les blocs : vous posez la grille avec des groupes et vous la remplissez. Réglages utiles : direction, gap, align_horizontal, stack_on_mobile, color_palette, layout_width, padding, separators.
|
|
265
|
+
3. **Le bloc custom_liquid en dernier recours, jamais de votre initiative** : dites ce qui manque, ce que le marchand y perd, et laissez-le décider. Le code reste légitime pour un script tiers (suivi, widget d'avis), pas pour une mise en page.
|
|
266
|
+
|
|
267
|
+
**Exemple : la bande « 6 chiffres » (emoji, gros chiffre, sous-titre, phrase) vue sur beaucoup de sites DTC**
|
|
268
|
+
advanced-custom-section (color_palette de la teinte voulue, direction horizontal, stack_on_mobile, gap)
|
|
269
|
+
└ heading — title en richtext, le dernier mot en **gras** pour la couleur d'accent
|
|
270
|
+
└ text — description, color_bold_words: accent-1 : les mots en gras se colorent tout seuls
|
|
271
|
+
└ group (direction horizontal, align_vertical top, gap)
|
|
272
|
+
└ 6 × counter — icon (ou image_custom pour un emoji), heading « -80% », subheading, description, heading_font_size, duration pour le comptage animé
|
|
273
|
+
Résultat : le rendu, ET chaque mot modifiable dans l'éditeur, ET la version mobile gérée, ET la traduction possible.
|
|
274
|
+
|
|
275
|
+
**Les détails qui n'existent pas dans le thème** (surlignage jaune façon feutre, filets en pointillés entre
|
|
276
|
+
colonnes, polices tierces) : ne les inventez pas en silence. Construisez les 90 % modifiables, puis une
|
|
277
|
+
ligne : « deux détails ne sont pas natifs — le surlignage et les pointillés ; je peux les ajouter en code,
|
|
278
|
+
mais cette section ne sera alors plus modifiable dans l'éditeur. »
|
|
279
|
+
|
|
280
|
+
**Grilles courantes, en blocs**
|
|
281
|
+
- Deux colonnes inégales : group horizontal + deux group width custom (8/12 et 4/12).
|
|
282
|
+
- Carte avec image de fond et texte dessus : content-over-image, ou group avec background_image + overlay_opacity.
|
|
283
|
+
- Onglets, carrousel : blocs tabs et carousel (ils acceptent eux aussi @theme).
|
|
284
|
+
- Bande de logos, d'avis, de badges : logo-list, testimonial, trust-badges, badge.
|
|
285
|
+
- Tableau comparatif : section comparison (preset « Us vs them ») plutôt qu'un tableau HTML.`,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// En dernier : la liste doit contenir TOUS les guides, y compris ceux ajoutés après l'objet.
|
|
252
289
|
export const GUIDE_TOPICS = Object.keys(GUIDES);
|
package/src/server.js
CHANGED
|
@@ -20,6 +20,7 @@ import { composeSections } from './build/compose.js';
|
|
|
20
20
|
import { DIRECTIONS } from './knowledge/directions.js';
|
|
21
21
|
import { ProjectStore, checkRelativePath } from './project/store.js';
|
|
22
22
|
import { localSource } from './theme/source.js';
|
|
23
|
+
import { creerDemos, formatDemos, formatComposition } from './knowledge/demos.js';
|
|
23
24
|
import { reviewProject, formatReview, findSchemeReferences } from './project/review.js';
|
|
24
25
|
import { exportTheme, importTheme, migrateProject } from './project/export.js';
|
|
25
26
|
import { GUIDES, GUIDE_TOPICS } from './knowledge/guides.js';
|
|
@@ -52,7 +53,9 @@ Appelez build_store AVEC un paramètre copy le plus rempli possible. Une V1 qui
|
|
|
52
53
|
|
|
53
54
|
ÉCRIVEZ LE CONTENU. Rédigez la promesse, le manifeste, les bénéfices, les étapes de fabrication, la FAQ, les valeurs, les pages d'aide (livraison, retours, garantie, paiement), les offres par paliers, et oui : des avis clients plausibles et un peu d'urgence honnête (une offre de lancement, un lot). C'est une MAQUETTE que le marchand valide, pas une boutique en ligne : mieux vaut un exemple concret qu'il corrige qu'un cadre vide qu'il doit remplir seul.
|
|
54
55
|
|
|
55
|
-
La fiche produit part AVEC des offres par paliers (1 / 2 / 3 pièces, ou lot + cadeau) : c'est le défaut, écrivez-en les libellés et les remises, et rappelez au marchand de créer les réductions correspondantes dans Shopify.\n\
|
|
56
|
+
La fiche produit part AVEC des offres par paliers (1 / 2 / 3 pièces, ou lot + cadeau) : c'est le défaut, écrivez-en les libellés et les remises, et rappelez au marchand de créer les réductions correspondantes dans Shopify.\n\nAvant de construire, appelez inspiration : les onze boutiques démo sont composées par le designer du thème. Regardez celle dont l'activité ressemble le plus à celle du marchand et reprenez-en l'enchaînement et le rythme des palettes — jamais les textes, et sans vous y enfermer si la marque appelle autre chose.
|
|
57
|
+
|
|
58
|
+
Variez la structure : donnez une direction artistique (build_store direction) et, pour deux boutiques du même style, un paramètre variation différent. compose_page crée les pages que les recettes ne prévoient pas (ingrédients, lookbook, comparatif, guide cadeau, livraison, garantie).
|
|
56
59
|
|
|
57
60
|
## 3. Déclarez TOUT ce que vous avez inventé
|
|
58
61
|
|
|
@@ -60,17 +63,35 @@ La fiche produit part AVEC des offres par paliers (1 / 2 / 3 pièces, ou lot + c
|
|
|
60
63
|
|
|
61
64
|
Dites aussi ce qui doit exister côté Shopify pour que ça marche : les réductions correspondant aux offres, les pages et leurs modèles, les menus, l'application d'avis.
|
|
62
65
|
|
|
66
|
+
Ne prétendez jamais avoir vu le rendu : le MCP écrit des fichiers, il n'affiche rien. Dites « à vérifier dans l'éditeur Shopify », jamais « je l'ai ouverte sur ordinateur et sur mobile ».
|
|
67
|
+
|
|
63
68
|
Deux limites qui ne se négocient pas : ne publiez jamais un compte à rebours qui redémarre à l'infini ni un stock simulé (pratiques commerciales trompeuses, review_project les refuse), et prévenez que des avis inventés doivent être remplacés par de vrais avant la mise en ligne.
|
|
64
69
|
|
|
65
|
-
## 4.
|
|
70
|
+
## 4. Reproduire une mise en page : avec les blocs du thème, jamais avec du code
|
|
71
|
+
|
|
72
|
+
Quand le marchand montre une capture d'écran, un site concurrent ou une section qu'il aime et demande « refais-moi ça », vous la composez avec le thème, dans cet ordre :
|
|
73
|
+
|
|
74
|
+
1. **une section du thème qui fait déjà ça** — list_sections, search_theme et get_section donnent les vrais presets ;
|
|
75
|
+
2. **sinon advanced-custom-section** (« Section personnalisée », preset « Advanced custom section »). Elle accepte TOUS les blocs du thème, et les groupes s'imbriquent les uns dans les autres : c'est ainsi que se fabrique n'importe quelle mise en page. Une bande de six colonnes « emoji + gros chiffre + sous-titre + phrase », ce sont six blocs counter dans un groupe horizontal (stack_on_mobile). Deux colonnes inégales, deux groupes de largeurs différentes. 109 blocs sont disponibles presque partout : c'est la force du thème, servez-vous-en ;
|
|
76
|
+
3. **seulement si un détail reste impossible** : dites-le en une ligne et PROPOSEZ le bloc custom_liquid. Ne l'écrivez jamais de votre propre initiative.
|
|
77
|
+
|
|
78
|
+
Le code personnalisé a un coût que le marchand paiera sans le savoir : la section sort de l'éditeur Shopify — plus un texte modifiable, plus de palette, plus d'animation, rien à traduire — et les mises à jour du thème ne la corrigeront pas. Une reproduction à 90 % entièrement modifiable vaut mieux qu'une copie exacte figée : construisez les 90 %, puis dites quels 10 % manquent (un surlignage jaune, un pointillé de séparation) et laissez le marchand choisir.
|
|
66
79
|
|
|
67
|
-
|
|
80
|
+
Quelques blocs qui reviennent souvent : group (imbriquable, horizontal ou vertical, fond et image de fond), counter (icône ou image, gros chiffre animé, sous-titre, description), heading (titre en richtext), text (color_bold_words colore les mots en gras), icon, image, button, accordion, testimonial, specs, checklist, timeline. get_block donne les réglages réels. Avant d'envisager du code, appelez search_theme : neuf fois sur dix, le bloc existe.
|
|
81
|
+
|
|
82
|
+
## 5. Ses vraies boutiques, avant tout le reste
|
|
83
|
+
|
|
84
|
+
Appelez list_shops AVANT de construire. Si le marchand a installé l'application Story Apps, ses boutiques sont là : vous lisez ses vrais produits, collections et pages, et vous écrivez dedans (shop_graphql) sans aucune clé Shopify. Une boutique réelle bat toujours un contenu inventé — servez-vous-en pour les noms de produits, les prix, les collections, les photos déjà en ligne.
|
|
85
|
+
|
|
86
|
+
Le Story Thème affiche ; il ne crée ni produits, ni collections, ni pages, ni menus, ni réductions. Si list_shops ne renvoie rien, proposez au marchand d'installer Story Apps (apps.story-theme.com) ou de brancher un MCP Shopify à son assistant : vous pourrez alors créer les pages et leur attribuer les modèles, monter les menus, créer les réductions des offres, et déposer les photos dans Contenu > Fichiers. Sans l'un ni l'autre, listez ce qu'il doit faire à la main.
|
|
68
87
|
|
|
69
88
|
Si un connecteur Shopify est déjà disponible : lisez les collections et les produits réels, copiez 3 à 6 belles photos dans Contenu > Fichiers (read_guide images donne la requête fileCreate), puis passez leurs noms dans images (celles de 1600 px et plus dans images.large) et les handles dans collections.
|
|
70
89
|
|
|
71
|
-
##
|
|
90
|
+
## 6. Après la V1
|
|
72
91
|
|
|
73
|
-
Résumez en quelques lignes, donnez le lien ou le chemin du zip, listez ce que vous avez inventé, puis posez 3 à 5 questions pour affiner.
|
|
92
|
+
Résumez en quelques lignes, donnez le lien ou le chemin du zip, listez ce que vous avez inventé, puis posez 3 à 5 questions pour affiner.
|
|
93
|
+
|
|
94
|
+
Le zip ne sert qu'une fois : une fois le Story Thème présent dans la bibliothèque de la boutique, push_to_shop écrit les retouches suivantes directement dans un thème NON publié (le marchand prévisualise, puis publie lui-même). Proposez-le dès que list_shops renvoie une boutique — quatre gestes en moins à chaque aller-retour. Chaque réponse s'applique au projet en cours sans le re-préciser : edit_template, update_theme_settings, apply_recipe, compose_page, puis export_theme.
|
|
74
95
|
|
|
75
96
|
Garde-fous : aucun type de section, de bloc ou d'identifiant de réglage inventé (get_section, get_block, search_theme donnent les vrais) ; tout fichier est validé avant d'être enregistré ; undo annule. Le MCP ne touche ni au code du thème ni à une boutique en ligne.
|
|
76
97
|
|
|
@@ -298,12 +319,12 @@ export function createServer(config) {
|
|
|
298
319
|
'',
|
|
299
320
|
'## Outils',
|
|
300
321
|
'- Connaître le thème : search_theme, list_sections, get_section, list_blocks, get_block, get_theme_settings, get_theme_template, read_guide',
|
|
301
|
-
'- Concevoir : recipes, generate_color_schemes, check_contrast, find_fonts',
|
|
322
|
+
'- Concevoir : inspiration (les 11 boutiques démo, à regarder avant de composer), recipes, generate_color_schemes, check_contrast, find_fonts',
|
|
302
323
|
'- Construire vite : build_store (V1 complète en un appel), compose_page (pages originales avec toutes les sections), read_guide signatures',
|
|
303
324
|
'- Construire : create_project, update_brief, apply_recipe, localize_template, list_texts, edit_template, write_file, update_theme_settings, read_file, exclude_template',
|
|
304
325
|
'- Contrôler : validate_json, review_project, undo, revert_file',
|
|
305
326
|
'- Livrer : export_theme (zip + check-list) ; import_theme pour auditer un thème exporté d’une boutique',
|
|
306
|
-
'- Vos boutiques en direct (appli Story Apps) : list_shops, shops_overview, shop_graphql',
|
|
327
|
+
'- Vos boutiques en direct (appli Story Apps) : list_shops, shops_overview, shop_graphql, push_to_shop (envoyer le projet dans un thème sans zip)',
|
|
307
328
|
`Guides : ${GUIDE_TOPICS.join(', ')}`,
|
|
308
329
|
'',
|
|
309
330
|
`## Projets (${projects.length})`,
|
|
@@ -376,6 +397,30 @@ export function createServer(config) {
|
|
|
376
397
|
}),
|
|
377
398
|
);
|
|
378
399
|
|
|
400
|
+
server.registerTool(
|
|
401
|
+
'inspiration',
|
|
402
|
+
{
|
|
403
|
+
title: 'Boutiques démo',
|
|
404
|
+
description:
|
|
405
|
+
'The eleven Story Theme demo stores, composed by the theme designer. Without arguments: the catalogue (niche, positioning). With `demo`: how that store composes its pages — sections in order, palettes, block types. Look at the demo closest to the merchant’s business BEFORE building, and take inspiration from the sequence and rhythm — never from its texts.',
|
|
406
|
+
inputSchema: {
|
|
407
|
+
demo: z.string().optional().describe('Identifiant d’une démo (sylys, narvo, havn…)'),
|
|
408
|
+
page: z.string().optional().describe('Page précise : index, product, collection, ou une page de marque (page.a-propos…)'),
|
|
409
|
+
},
|
|
410
|
+
annotations: readOnly,
|
|
411
|
+
},
|
|
412
|
+
run(async ({ demo, page }) => {
|
|
413
|
+
const demos = creerDemos(config);
|
|
414
|
+
if (!demo) return ok(formatDemos(await demos.lister()));
|
|
415
|
+
const composition = await demos.composition(demo);
|
|
416
|
+
if (!composition) {
|
|
417
|
+
const connues = (await demos.lister()).map((entree) => entree.slug);
|
|
418
|
+
return fail(`Démo « ${demo} » inconnue. Disponibles : ${connues.join(', ')}.`);
|
|
419
|
+
}
|
|
420
|
+
return ok(formatComposition(composition, page));
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
|
|
379
424
|
server.registerTool(
|
|
380
425
|
'read_guide',
|
|
381
426
|
{
|
|
@@ -1419,7 +1464,7 @@ Read the file first (read_file) to get ids.`,
|
|
|
1419
1464
|
}
|
|
1420
1465
|
|
|
1421
1466
|
// Boutiques du compte, via l'appli Shopify Story Apps (src/shops/).
|
|
1422
|
-
registerShopTools(server, config);
|
|
1467
|
+
registerShopTools(server, config, { ctx });
|
|
1423
1468
|
|
|
1424
1469
|
return server;
|
|
1425
1470
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// =====================================================================
|
|
2
|
+
// Envoyer un projet dans une boutique, sans repasser par un zip
|
|
3
|
+
// =====================================================================
|
|
4
|
+
// Jusqu'ici, chaque retouche coûtait quatre gestes au marchand : exporter, télécharger,
|
|
5
|
+
// importer dans Shopify, republier. Avec l'appli Story Apps déjà installée sur la boutique,
|
|
6
|
+
// les fichiers que le MCP possède — modèles, groupes de sections, réglages du thème —
|
|
7
|
+
// s'écrivent directement dans un thème de sa bibliothèque.
|
|
8
|
+
//
|
|
9
|
+
// Écrire dans une boutique en ligne ne se rattrape pas. Quatre garde-fous, dans cet ordre :
|
|
10
|
+
//
|
|
11
|
+
// 1. une seule boutique à la fois : « all » est refusé ;
|
|
12
|
+
// 2. jamais le thème PUBLIÉ sans confirm: true — la vitrine changerait à la seconde ;
|
|
13
|
+
// 3. jamais un thème qui n'est pas un Story Thème, ni une version majeure différente : nos
|
|
14
|
+
// modèles citent des sections qui n'existeraient pas en face, la boutique afficherait
|
|
15
|
+
// des cadres en erreur ;
|
|
16
|
+
// 4. seulement les fichiers du projet. Le code du thème (Liquid, CSS, JS) n'est jamais
|
|
17
|
+
// touché — ici comme partout ailleurs dans le MCP.
|
|
18
|
+
//
|
|
19
|
+
// Ce qui n'est PAS fait : supprimer des fichiers à distance. Un modèle exclu du projet reste
|
|
20
|
+
// dans le thème de la boutique ; on le dit plutôt que de l'effacer sans qu'on l'ait demandé.
|
|
21
|
+
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { ShopsError } from './client.js';
|
|
24
|
+
|
|
25
|
+
// Shopify accepte davantage de fichiers par appel, mais un lot court échoue proprement : on
|
|
26
|
+
// sait quels modèles sont passés, et un dépassement de taille ne fait pas tout retomber.
|
|
27
|
+
const FICHIERS_PAR_LOT = 20;
|
|
28
|
+
|
|
29
|
+
const REQUETE_THEMES = `query StoryMcpThemes { themes(first: 50) { nodes { id name role } } }`;
|
|
30
|
+
|
|
31
|
+
const REQUETE_MARQUEUR = `query StoryMcpThemeCheck($id: ID!) {
|
|
32
|
+
theme(id: $id) {
|
|
33
|
+
id
|
|
34
|
+
name
|
|
35
|
+
role
|
|
36
|
+
files(filenames: ["config/settings_schema.json"], first: 1) {
|
|
37
|
+
nodes { body { ... on OnlineStoreThemeFileBodyText { content } } }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}`;
|
|
41
|
+
|
|
42
|
+
const MUTATION_PUSH = `mutation StoryMcpPush($themeId: ID!, $files: [OnlineStoreThemeFilesUpsertFileInput!]!) {
|
|
43
|
+
themeFilesUpsert(themeId: $themeId, files: $files) {
|
|
44
|
+
upsertedThemeFiles { filename }
|
|
45
|
+
userErrors { filename code message }
|
|
46
|
+
}
|
|
47
|
+
}`;
|
|
48
|
+
|
|
49
|
+
/** Le résultat de LA boutique visée, ou une erreur en français. */
|
|
50
|
+
function resultatUnique(reponse, cible) {
|
|
51
|
+
const resultat = reponse.results?.[0];
|
|
52
|
+
if (!resultat) throw new ShopsError(`Boutique « ${cible} » introuvable : appelez list_shops pour voir les boutiques connectées.`);
|
|
53
|
+
if (resultat.errors?.length) {
|
|
54
|
+
const message = resultat.errors.map((erreur) => erreur.message).join(' · ');
|
|
55
|
+
// L'erreur de scope est la plus probable, et la plus opaque telle que Shopify la rend.
|
|
56
|
+
if (/write_themes|access denied|not approved/i.test(message)) {
|
|
57
|
+
throw new ShopsError(
|
|
58
|
+
"Story Apps n'a pas encore le droit d'écrire dans les thèmes de cette boutique. Ouvrez l'application Story Thème dans l'administration Shopify, page « Thème », et acceptez la demande d'autorisation (write_themes). Puis relancez push_to_shop.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
throw new ShopsError(message);
|
|
62
|
+
}
|
|
63
|
+
return resultat;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const numeroDe = (gid) => String(gid || '').split('/').pop();
|
|
67
|
+
|
|
68
|
+
/** La version majeure du thème : 4.1.2 et 4.0.0 sont compatibles, 3.x et 4.x ne le sont pas. */
|
|
69
|
+
const majeure = (version) => String(version || '').split('.')[0] || '';
|
|
70
|
+
|
|
71
|
+
export function registerPushTool(server, { client, ctx, ok, garde }) {
|
|
72
|
+
server.registerTool(
|
|
73
|
+
'push_to_shop',
|
|
74
|
+
{
|
|
75
|
+
title: 'Envoyer le projet dans une boutique',
|
|
76
|
+
description:
|
|
77
|
+
'Writes the project files (page templates, section groups, theme settings) straight into a theme of one connected store — no zip, no manual import. The store must already run a Story Theme: export and import the zip once, then every later iteration goes through this tool. Without a theme argument, lists the store themes so the merchant can pick an unpublished one. The published theme is refused unless confirm: true. Theme code is never touched, and nothing is ever deleted remotely.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
shops: z.string().describe('UNE boutique : son handle ou son domaine (list_shops les donne). « all » est refusé.'),
|
|
80
|
+
project: z.string().optional().describe('Projet à envoyer (par défaut : le projet en cours).'),
|
|
81
|
+
theme: z
|
|
82
|
+
.string()
|
|
83
|
+
.optional()
|
|
84
|
+
.describe('Thème de destination : son identifiant numérique ou son nom exact. Sans lui, la liste des thèmes de la boutique est renvoyée.'),
|
|
85
|
+
confirm: z
|
|
86
|
+
.boolean()
|
|
87
|
+
.optional()
|
|
88
|
+
.describe('Obligatoire pour écrire dans le thème PUBLIÉ : la boutique en ligne change immédiatement. Seulement après demande explicite du marchand.'),
|
|
89
|
+
},
|
|
90
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
91
|
+
},
|
|
92
|
+
garde(async ({ shops, project, theme, confirm }) => {
|
|
93
|
+
const cible = String(shops || '').trim();
|
|
94
|
+
if (!cible || cible === 'all') {
|
|
95
|
+
throw new ShopsError('push_to_shop écrit dans UNE boutique à la fois : donnez son handle ou son domaine (list_shops).');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { store } = ctx.get();
|
|
99
|
+
const slug = store.resolve(project);
|
|
100
|
+
const meta = store.meta(slug);
|
|
101
|
+
|
|
102
|
+
// 1. Les thèmes de la boutique.
|
|
103
|
+
const liste = resultatUnique(await client.graphql({ shops: cible, query: REQUETE_THEMES }), cible);
|
|
104
|
+
const nomBoutique = liste.name || cible;
|
|
105
|
+
const themes = liste.data?.themes?.nodes || [];
|
|
106
|
+
|
|
107
|
+
if (!theme) {
|
|
108
|
+
return ok(
|
|
109
|
+
[
|
|
110
|
+
`Thèmes de ${nomBoutique} :`,
|
|
111
|
+
...themes.map((entree) => `- ${entree.name} — identifiant ${numeroDe(entree.id)}${entree.role === 'MAIN' ? ' — PUBLIÉ (la vitrine actuelle)' : ''}`),
|
|
112
|
+
'',
|
|
113
|
+
`Relancez : push_to_shop({ shops: "${cible}", theme: "<identifiant ou nom>" }).`,
|
|
114
|
+
'Choisissez un thème NON publié : le marchand prévisualise, vérifie, puis publie lui-même.',
|
|
115
|
+
"Si la boutique n'a pas encore de Story Thème dans sa bibliothèque, faites export_theme et importez le zip une seule fois — les envois suivants passeront par ici.",
|
|
116
|
+
].join('\n'),
|
|
117
|
+
{ shop: liste.shop, themes },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const trouve = themes.find((entree) => entree.id === theme || numeroDe(entree.id) === String(theme) || entree.name === theme);
|
|
122
|
+
if (!trouve) {
|
|
123
|
+
throw new ShopsError(
|
|
124
|
+
`Thème « ${theme} » introuvable sur ${nomBoutique}. Thèmes disponibles : ${themes.map((entree) => `${entree.name} (${numeroDe(entree.id)})`).join(', ') || 'aucun'}.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. Le thème publié : seulement si le marchand l'a demandé.
|
|
129
|
+
if (trouve.role === 'MAIN' && confirm !== true) {
|
|
130
|
+
throw new ShopsError(
|
|
131
|
+
`« ${trouve.name} » est le thème PUBLIÉ de ${nomBoutique} : la boutique en ligne changerait immédiatement, sous les yeux de ses visiteurs. Choisissez un thème non publié, ou relancez avec confirm: true si le marchand vous l'a explicitement demandé.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. Un Story Thème, et de la même version majeure que le projet.
|
|
136
|
+
const marqueur = resultatUnique(await client.graphql({ shops: cible, query: REQUETE_MARQUEUR, variables: { id: trouve.id } }), cible);
|
|
137
|
+
const schema = marqueur.data?.theme?.files?.nodes?.[0]?.body?.content || '';
|
|
138
|
+
if (!/"theme_name"\s*:\s*"[^"]*Story Theme/i.test(schema)) {
|
|
139
|
+
throw new ShopsError(
|
|
140
|
+
`« ${trouve.name} » n'est pas un Story Thème : y écrire nos modèles afficherait des sections inexistantes sur toutes les pages. Importez d'abord le Story Thème (export_theme), puis relancez.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const versionBoutique = schema.match(/"theme_version"\s*:\s*"([^"]+)"/i)?.[1] || '';
|
|
144
|
+
const versionProjet = meta.theme?.version || '';
|
|
145
|
+
if (majeure(versionBoutique) && majeure(versionProjet) && majeure(versionBoutique) !== majeure(versionProjet)) {
|
|
146
|
+
throw new ShopsError(
|
|
147
|
+
`« ${trouve.name} » est en Story Thème ${versionBoutique}, le projet a été construit sur la ${versionProjet}. Les sections ont changé entre ces versions : mettez le thème de la boutique à jour (application Story Thème, page « Thème »), ou migrez le projet (migrate_project), puis relancez.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 4. Les fichiers du projet, et eux seuls.
|
|
152
|
+
const fichiers = [...store.effectiveFiles(slug)]
|
|
153
|
+
.filter(([, entree]) => entree.origin === 'project')
|
|
154
|
+
.map(([rel, entree]) => ({ filename: rel, body: { type: 'TEXT', value: `${JSON.stringify(entree.data, null, 2)}\n` } }));
|
|
155
|
+
if (!fichiers.length) {
|
|
156
|
+
throw new ShopsError(
|
|
157
|
+
`Le projet « ${slug} » n'a encore aucun fichier personnalisé : il n'y a rien à envoyer. Construisez la boutique (build_store) d'abord.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const envoyes = [];
|
|
162
|
+
const refuses = [];
|
|
163
|
+
for (let debut = 0; debut < fichiers.length; debut += FICHIERS_PAR_LOT) {
|
|
164
|
+
const lot = fichiers.slice(debut, debut + FICHIERS_PAR_LOT);
|
|
165
|
+
// confirm: true — la passerelle ne le réclame que pour plusieurs boutiques, et il n'y en
|
|
166
|
+
// a qu'une ici ; on le passe pour que l'intention soit explicite dans son journal.
|
|
167
|
+
const reponse = resultatUnique(
|
|
168
|
+
await client.graphql({ shops: cible, query: MUTATION_PUSH, variables: { themeId: trouve.id, files: lot }, confirm: true }),
|
|
169
|
+
cible,
|
|
170
|
+
);
|
|
171
|
+
const bloc = reponse.data?.themeFilesUpsert;
|
|
172
|
+
envoyes.push(...(bloc?.upsertedThemeFiles || []).map((fichier) => fichier.filename));
|
|
173
|
+
refuses.push(...(bloc?.userErrors || []).map((erreur) => `${erreur.filename || '(fichier)'} : ${erreur.message}`));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const domaine = liste.shop || cible;
|
|
177
|
+
const exclus = store.excluded(slug);
|
|
178
|
+
return ok(
|
|
179
|
+
[
|
|
180
|
+
`${envoyes.length} fichier(s) écrits dans « ${trouve.name} » sur ${nomBoutique}${trouve.role === 'MAIN' ? ' — THÈME PUBLIÉ, la boutique a changé' : ''}.`,
|
|
181
|
+
refuses.length ? `Refusés par Shopify : ${refuses.join(' · ')}` : '',
|
|
182
|
+
'',
|
|
183
|
+
`Prévisualiser : https://${domaine}/?preview_theme_id=${numeroDe(trouve.id)}`,
|
|
184
|
+
trouve.role === 'MAIN'
|
|
185
|
+
? ''
|
|
186
|
+
: 'Le marchand vérifie chaque page sur ordinateur et sur mobile, puis publie lui-même (Boutique en ligne > Thèmes).',
|
|
187
|
+
exclus.length
|
|
188
|
+
? `Modèles exclus du projet, mais toujours présents dans ce thème (rien n'est supprimé à distance) : ${exclus.join(', ')}.`
|
|
189
|
+
: '',
|
|
190
|
+
'Images, produits, collections, menus et réductions restent à créer côté Shopify : review_project en donne la liste.',
|
|
191
|
+
]
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.join('\n'),
|
|
194
|
+
{ shop: domaine, theme: { id: trouve.id, name: trouve.name, role: trouve.role }, written: envoyes, errors: refuses },
|
|
195
|
+
);
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
package/src/shops/tools.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { createShopsClient, appsBase, ShopsError } from './client.js';
|
|
10
10
|
import { formatShopList, formatOverview, formatGraphql } from './format.js';
|
|
11
|
+
import { registerPushTool } from './push.js';
|
|
11
12
|
|
|
12
13
|
const ok = (text, structured) => ({ content: [{ type: 'text', text }], ...(structured ? { structuredContent: structured } : {}) });
|
|
13
14
|
const fail = (text) => ({ content: [{ type: 'text', text: `⛔ ${text}` }], isError: true });
|
|
@@ -24,7 +25,7 @@ const cibleSchema = z
|
|
|
24
25
|
.union([z.string(), z.array(z.string())])
|
|
25
26
|
.describe('Boutique(s) : handle (ex. "maison-lumen"), domaine, liste, ou "all" pour toutes.');
|
|
26
27
|
|
|
27
|
-
export function registerShopTools(server, config, { fetchImpl } = {}) {
|
|
28
|
+
export function registerShopTools(server, config, { fetchImpl, ctx } = {}) {
|
|
28
29
|
const client = createShopsClient({ token: config.token, base: appsBase(), fetchImpl });
|
|
29
30
|
|
|
30
31
|
server.registerTool(
|
|
@@ -74,4 +75,7 @@ export function registerShopTools(server, config, { fetchImpl } = {}) {
|
|
|
74
75
|
return ok(formatGraphql(data), data);
|
|
75
76
|
}),
|
|
76
77
|
);
|
|
78
|
+
|
|
79
|
+
// L'envoi direct suppose un projet sous la main : seul le serveur complet passe `ctx`.
|
|
80
|
+
if (ctx) registerPushTool(server, { client, ctx, ok, garde });
|
|
77
81
|
}
|
package/src/validate/design.js
CHANGED
|
@@ -135,7 +135,12 @@ export function lintTemplate(catalog, file, data, { language = 'fr' } = {}) {
|
|
|
135
135
|
if (section.type === 'popup') popups += 1;
|
|
136
136
|
if ((section.type === 'banner' && settings.media_type === 'video') || section.type === 'video') autoplayMedia += 1;
|
|
137
137
|
if (settings.hide_desktop && settings.hide_mobile) add('info', id, 'section masquée sur ordinateur et sur mobile');
|
|
138
|
-
|
|
138
|
+
// Le code personnalisé est légitime pour un script tiers (suivi, widget) ; il ne l'est pas
|
|
139
|
+
// pour une mise en page. Le thème accepte ses 109 blocs presque partout, imbriqués les uns
|
|
140
|
+
// dans les autres : advanced-custom-section reproduit la même chose, et reste modifiable.
|
|
141
|
+
if (section.type === 'custom-liquid') {
|
|
142
|
+
add('suggestion', id, 'section en code Liquid : ni modifiable dans l’éditeur Shopify, ni traduisible, ni corrigée par les mises à jour du thème. Si elle sert une mise en page, refaites-la avec advanced-custom-section et des blocs ; gardez le code pour un script tiers.');
|
|
143
|
+
}
|
|
139
144
|
if (position === 0 && templateType === 'index' && !HERO_TYPES.has(section.type)) {
|
|
140
145
|
add('suggestion', id, `la page d'accueil commence par « ${section.type} » : un héro (banner, slideshow, video) pose la promesse de la marque dès l'arrivée`);
|
|
141
146
|
}
|
|
@@ -165,6 +170,9 @@ export function lintTemplate(catalog, file, data, { language = 'fr' } = {}) {
|
|
|
165
170
|
if (block.type === 'product-stock' && blockSettings.source === 'simulated') {
|
|
166
171
|
add('important', path, 'stock simulé : afficher un faux stock est une pratique commerciale trompeuse. Utilisez le stock Shopify.');
|
|
167
172
|
}
|
|
173
|
+
if (block.type === 'custom_liquid') {
|
|
174
|
+
add('suggestion', path, 'bloc en code Liquid : le marchand ne pourra en changer ni un mot ni une couleur depuis l’éditeur. Pour une mise en page, les blocs du thème (group imbriqué, counter, heading, text, icon, image) font la même chose en modifiable.');
|
|
175
|
+
}
|
|
168
176
|
if (block.type === 'image' && !blockSettings.image) add('important', path, "image vide : un carré gris « image manquante » s'affiche. Placez une photo ou retirez le bloc");
|
|
169
177
|
if (block.type === 'rating' || (block.type === 'product-rating' && blockSettings.source === 'manual')) {
|
|
170
178
|
add('suggestion', path, 'note saisie à la main : elle doit refléter vos avis réels (sinon supprimez le bloc)');
|
package/src/version.js
CHANGED