story-theme-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/format.js ADDED
@@ -0,0 +1,64 @@
1
+ // Text output helpers: tool results are read by the model, so they stay compact and explicit.
2
+
3
+ export const ok = (text) => ({ content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] });
4
+ export const fail = (text) => ({ content: [{ type: 'text', text: `⛔ ${text}` }], isError: true });
5
+
6
+ export function parseContent(content) {
7
+ if (typeof content !== 'string') return content;
8
+ try {
9
+ return JSON.parse(content.replace(/^/, '').replace(/^\s*\/\*[\s\S]*?\*\/\s*/, ''));
10
+ } catch (error) {
11
+ throw new Error(`JSON invalide : ${error.message}`);
12
+ }
13
+ }
14
+
15
+ const short = (value) => {
16
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
17
+ return text.length > 60 ? `${text.slice(0, 57)}…` : text;
18
+ };
19
+
20
+ export function formatSettings(settings) {
21
+ const lines = [];
22
+ let group = null;
23
+ for (const setting of settings) {
24
+ if (setting.group !== group) {
25
+ group = setting.group;
26
+ if (group) lines.push(` [${group}]`);
27
+ }
28
+ const parts = [`- ${setting.id} (${setting.type}) « ${setting.label} »`];
29
+ if (setting.default !== undefined) parts.push(`défaut ${short(setting.default)}`);
30
+ if (setting.options) parts.push(`options ${setting.options.map((option) => option.value).join('|')}`);
31
+ if (setting.min !== undefined) parts.push(`${setting.min}–${setting.max} pas ${setting.step ?? 1}${setting.unit ? ' ' + setting.unit : ''}`);
32
+ if (setting.limit) parts.push(`max ${setting.limit}`);
33
+ if (setting.visible_if) parts.push(`visible si ${setting.visible_if.replace(/\{\{\s*|\s*\}\}/g, '')}`);
34
+ let line = parts.join(' · ');
35
+ if (setting.info) line += ` — ${setting.info}`;
36
+ lines.push(line);
37
+ }
38
+ return lines.join('\n');
39
+ }
40
+
41
+ export function formatValidation(result, { max = 25 } = {}) {
42
+ const lines = [];
43
+ if (result.errors.length) {
44
+ lines.push(`❌ ${result.errors.length} erreur(s) bloquante(s) :`);
45
+ for (const error of result.errors.slice(0, max)) lines.push(`- ${error.path || '(racine)'} : ${error.message}`);
46
+ if (result.errors.length > max) lines.push(`- … et ${result.errors.length - max} autre(s)`);
47
+ } else lines.push('✅ Valide : Shopify acceptera ce fichier.');
48
+ if (result.warnings.length) {
49
+ lines.push(`⚠️ ${result.warnings.length} avertissement(s) :`);
50
+ for (const warning of result.warnings.slice(0, 10)) lines.push(`- ${warning.path || '(racine)'} : ${warning.message}`);
51
+ }
52
+ return lines.join('\n');
53
+ }
54
+
55
+ export function formatFindings(findings, { max = 20 } = {}) {
56
+ if (!findings.length) return '✨ Revue design : rien à signaler.';
57
+ const icon = { important: '🔴', suggestion: '🟡', info: 'ℹ️' };
58
+ const order = { important: 0, suggestion: 1, info: 2 };
59
+ const sorted = [...findings].sort((a, b) => order[a.level] - order[b.level]);
60
+ const lines = [`Revue design : ${findings.filter((f) => f.level === 'important').length} à corriger, ${findings.filter((f) => f.level === 'suggestion').length} suggestion(s), ${findings.filter((f) => f.level === 'info').length} info(s)`];
61
+ for (const finding of sorted.slice(0, max)) lines.push(`- ${icon[finding.level]} ${finding.path || '(page)'} : ${finding.message}`);
62
+ if (sorted.length > max) lines.push(`- … et ${sorted.length - max} autre(s) (review_project pour tout voir)`);
63
+ return lines.join('\n');
64
+ }
package/src/index.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { createServer } from './server.js';
4
+ import { loadConfig } from './config.js';
5
+ import { ThemeSource, LicenceError } from './theme/source.js';
6
+
7
+ // stdout porte le protocole MCP : tout le reste part sur stderr (visible dans les journaux de
8
+ // Claude, jamais dans la conversation).
9
+ const journal = (message) => console.error(`[story-theme] ${message}`);
10
+
11
+ const config = loadConfig();
12
+
13
+ // `npx story-theme-mcp --version` sert à vérifier l'installation avant même d'avoir un jeton :
14
+ // il répond sans thème, sans réseau et sans protocole MCP.
15
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
16
+ console.log(config.version);
17
+ process.exit(0);
18
+ }
19
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
20
+ console.log(
21
+ [
22
+ `MCP Story Thème ${config.version}`,
23
+ '',
24
+ "Serveur MCP à déclarer dans Claude (ou tout client MCP) : il connaît le Story Thème et",
25
+ 'construit des boutiques Shopify prêtes à importer. Il ne s’utilise pas à la main.',
26
+ '',
27
+ 'Variables d’environnement :',
28
+ ' STORY_MCP_TOKEN jeton personnel (espace membre Story Thème > MCP & Appli) — indispensable',
29
+ ' STORY_MCP_HOME dossier des projets et exports (défaut : ~/Documents/Story Theme MCP)',
30
+ ' STORY_MCP_LANGUAGE langue des textes (défaut : fr)',
31
+ ' STORY_MCP_CANAL « test » pour la version en test du thème (comptes testeurs)',
32
+ ' STORY_THEME_DIR dossier de thème local, prioritaire (développement du thème)',
33
+ ' STORY_MCP_API espace membre à interroger (défaut : https://story-theme.com)',
34
+ '',
35
+ 'Installation et mode d’emploi : https://story-theme.com/mcp',
36
+ ].join('\n'),
37
+ );
38
+ process.exit(0);
39
+ }
40
+
41
+ const source = new ThemeSource(config, { log: journal });
42
+
43
+ // Le thème est résolu AVANT d'accepter le premier appel : l'assistant ne doit jamais répondre
44
+ // « je ne connais pas cette section » parce que le téléchargement n'était pas fini. Un jeton
45
+ // refusé arrête ici, avec le message de l'espace membre — il n'y a rien à faire sans thème.
46
+ try {
47
+ const resolu = await source.ensure({ timeoutMs: 20000 });
48
+ journal(
49
+ `thème ${resolu.version || ''} (${resolu.origin}) : ${resolu.dir}${resolu.changed ? ' — téléchargé à l’instant' : ''}`,
50
+ );
51
+ if (resolu.message) journal(resolu.message);
52
+ } catch (error) {
53
+ journal(error instanceof LicenceError ? `licence : ${error.message}` : error.message);
54
+ if (error instanceof LicenceError) process.exit(1);
55
+ // Sans thème du tout, le serveur démarre quand même : les outils renvoient l'erreur avec la
56
+ // marche à suivre, ce qui vaut mieux qu'un serveur MCP « en échec » sans explication dans
57
+ // l'interface de Claude.
58
+ }
59
+
60
+ const server = createServer({ ...config, source });
61
+ await server.connect(new StdioServerTransport());
62
+ journal(`prêt (projets : ${config.homeDir})`);
@@ -0,0 +1,192 @@
1
+ // Curated notes on Story Theme 4.0 sections and blocks, for the model choosing what to build.
2
+ // `fr`: what it is. `use`: when it is the right choice. `keywords`: extra search terms.
3
+ // Anything not listed here still comes out of the catalog from its schema.
4
+
5
+ export const SECTION_NOTES = {
6
+ 'advanced-custom-section': {
7
+ fr: 'Section libre : un cadre vide (largeur, direction, alignement, espacements, palette, séparateurs) dans lequel on compose n’importe quels blocs, y compris des groupes imbriqués.',
8
+ use: 'Quand aucune section spécialisée ne convient. Préférer d’abord une section avec preset : elle part d’une composition déjà réglée.',
9
+ keywords: 'custom personnalisee libre composer groupe mise en page',
10
+ },
11
+ 'announcement-bar': {
12
+ fr: 'Barre d’annonce au-dessus de l’en-tête, en trois zones (gauche, centre, droite) : messages qui tournent, défilent ou s’écrivent, compte à rebours, liens, sélecteur de pays.',
13
+ use: 'Livraison offerte, retours gratuits, offre en cours. Un seul message court par annonce ; trois annonces maximum.',
14
+ keywords: 'bandeau annonce top bar promo livraison offerte',
15
+ },
16
+ apps: { fr: 'Section qui accueille des blocs d’applications (avis, fidélité, etc.) dans le cadre du thème.', use: 'Pour placer un bloc d’application là où l’application le demande.' },
17
+ banner: {
18
+ fr: 'Bannière héro : image, vidéo (fichier ou YouTube/Vimeo) avec média mobile distinct, en superposition ou côte à côte, hauteur écran ou ratio, voile, boîte de contenu, animations d’entrée. Trois presets : image, split (média à côté), vidéo.',
19
+ use: 'Première section de la page d’accueil ou d’une landing : une promesse, un sous-titre, un ou deux boutons.',
20
+ keywords: 'hero banniere image principale accueil haut de page video fond',
21
+ },
22
+ 'before-after': { fr: 'Comparateur avant / après avec curseur glissant.', use: 'Résultats visibles : cosmétique, nettoyage, rénovation, retouche.', keywords: 'avant apres comparaison resultat' },
23
+ 'blog-posts': { fr: 'Liste d’articles de blog : grille, carrousel, article mis en avant, journal.', use: 'Contenu éditorial et SEO en bas de page d’accueil.', keywords: 'blog articles journal actualites' },
24
+ breadcrumb: { fr: 'Fil d’Ariane avec données structurées BreadcrumbList.', use: 'Haut des pages produit, collection et pages de contenu.' },
25
+ 'bundle-builder': {
26
+ fr: 'Créateur de lot : le client choisit plusieurs produits d’une collection ou d’une liste, avec minimum, maximum, paliers de remise et récapitulatif.',
27
+ use: 'Coffrets, packs à composer, « 3 achetés = -15 % ». Les remises doivent exister comme réductions automatiques dans Shopify.',
28
+ keywords: 'lot coffret pack bundle composer box',
29
+ },
30
+ calculator: { fr: 'Calculateur d’économies : nous contre eux, abonnement contre achat unique, ou coût par utilisation.', use: 'Justifier un prix (produit durable, abonnement).', keywords: 'calculateur economies prix comparaison cout' },
31
+ 'cart-drawer': { fr: 'Tiroir panier (rendu par le layout, ses blocs vivent dans config/settings_data.json) : en-tête, lignes, récompenses, ventes additionnelles, récapitulatif, paiement.', use: 'Se règle dans les réglages du thème (cart_type = drawer).' },
32
+ 'cart-notification': { fr: 'Pop-up d’ajout au panier avec blocs de récompenses, ventes additionnelles, minuteur, livraison.', use: 'Quand cart_type = notification.' },
33
+ 'collection-list': {
34
+ fr: 'Liste de collections : grille, carrousel, catégories rondes, bannières, bento. Source : collections choisies, collections d’un menu, ou toutes.',
35
+ use: 'Aider à naviguer dans un catalogue de plusieurs familles de produits.',
36
+ keywords: 'collections categories univers rayons',
37
+ },
38
+ comparison: { fr: 'Tableau comparatif : « nous contre eux » ou comparaison de produits, cases coche / croix / texte, colonne mise en avant.', use: 'Se différencier d’une alternative (concurrent, grande surface, ancienne méthode).', keywords: 'comparatif tableau nous vs eux concurrent' },
39
+ contact: { fr: 'Contact : coordonnées et/ou formulaire à champs configurables.', use: 'Page contact.', keywords: 'contact formulaire email telephone' },
40
+ countdown: { fr: 'Compte à rebours vers une date (fuseau de la boutique), en section ou en bannière d’offre limitée.', use: 'Uniquement pour une échéance réelle (soldes, lancement). Jamais d’urgence inventée.', keywords: 'compte a rebours timer urgence offre limitee' },
41
+ 'custom-liquid': { fr: 'Code Liquid/HTML libre.', use: 'Intégrations ponctuelles. À éviter pour du contenu : non modifiable proprement par le marchand.' },
42
+ faq: { fr: 'FAQ en accordéons, avec données structurées FAQPage en option. Presets : simple, avec contact, par thème.', use: 'Lever les objections (livraison, retours, taille, composition). Une seule FAQ structurée par page.', keywords: 'faq questions reponses accordeon' },
43
+ 'featured-product': { fr: 'Produit en vedette : galerie et blocs de fiche produit (prix, variantes, achat, offres) pour un produit choisi, hors de la fiche produit.', use: 'Mettre en avant un produit phare sur l’accueil ou une landing.', keywords: 'produit vedette phare mise en avant acheter' },
44
+ 'floating-video': { fr: 'Vidéo flottante en bulle dans un coin, qui s’ouvre en lecteur ; ciblage de pages.', use: 'Vidéo de présentation ou témoignage vidéo, sans prendre de place dans la page.' },
45
+ footer: { fr: 'Pied de page composé de groupes de blocs (menus, réseaux sociaux, newsletter, coordonnées, logo, paiement, mentions). Presets : colonnes, centré, nom de marque géant.', use: 'Groupe footer uniquement.' },
46
+ gallery: { fr: 'Galerie d’images : grille, masonry, bento ou carrousel, avec visionneuse.', use: 'Lookbook, univers de marque, réalisations.', keywords: 'galerie photos lookbook images' },
47
+ header: { fr: 'En-tête composé : trois rangées (haut, principale, bas) de trois zones chacune, plus un tiroir mobile. Menu, méga menu, recherche, compte, panier, logo, sélecteur de pays placés en blocs.', use: 'Groupe header uniquement. Se modifie dans sections/header-group.json.' },
48
+ hotspots: { fr: 'Image interactive avec points cliquables liés à des produits ou des textes. Presets : « Shop the look », produit expliqué.', use: 'Mise en situation (tenue, pièce de maison) ou anatomie d’un produit.', keywords: 'hotspots points image interactive shop the look' },
49
+ 'image-with-text': {
50
+ fr: 'Image avec texte, entièrement composée de blocs. Presets : bénéfices produit, histoire de la marque, détails qui comptent.',
51
+ use: 'Raconter la marque, expliquer un produit, alterner image et texte pour rythmer la page.',
52
+ keywords: 'image texte histoire marque storytelling benefices',
53
+ },
54
+ 'landing-mode': {
55
+ fr: 'Masque en-tête, pied de page, barres d’annonce ou éléments flottants sur toutes les pages qui utilisent ce modèle.',
56
+ use: 'Page de vente ou d’atterrissage publicitaire : on retire la navigation pour ne laisser qu’un seul chemin, l’achat. Une seule par modèle, à poser dans un modèle de page dédié (jamais sur l’accueil).',
57
+ keywords: 'landing atterrissage publicite masquer entete pied de page tunnel',
58
+ },
59
+ 'logo-list': { fr: 'Liste de logos (presse, partenaires, labels) en rangée.', use: 'Preuve sociale : « Ils parlent de nous », certifications.', keywords: 'logos presse partenaires labels' },
60
+ map: { fr: 'Plan d’accès Google Maps (chargé au clic) avec coordonnées et horaires, une ou plusieurs adresses.', use: 'Boutique physique, showroom, click and collect.' },
61
+ marquee: { fr: 'Bandeau défilant : textes, logos ou images.', use: 'Réassurance rapide (livraison, retours, avis) ou rythme visuel. Un seul par page en général.', keywords: 'defilant marquee bandeau texte defile' },
62
+ multicolumn: { fr: 'Multicolonne : icônes avec texte, cartes avec images, étapes numérotées, bénéfices.', use: 'Réassurance, points forts, « comment ça marche ».', keywords: 'colonnes icones avantages benefices etapes reassurance' },
63
+ newsletter: { fr: 'Inscription newsletter (consentement, prénom, SMS en option, code promo à copier). Presets : simple, avec image, avec réduction.', use: 'Fin de page d’accueil. Donner une vraie raison de s’inscrire.', keywords: 'newsletter inscription email' },
64
+ 'order-tracking': { fr: 'Suivi de commande (17TRACK) et historique pour les clients connectés.', use: 'Page de suivi de colis.', keywords: 'suivi commande colis tracking' },
65
+ popup: { fr: 'Pop-up : newsletter, réduction, vérification d’âge ; déclencheurs (délai, défilement, intention de sortie, lien #popup-nom) et fréquence.', use: 'Une seule pop-up automatique par boutique. La vérification d’âge seulement quand la loi l’impose.', keywords: 'popup pop-up modal newsletter reduction age' },
66
+ 'product-list': {
67
+ fr: 'Liste de produits : collection, produits choisis, recommandations, vus récemment, liste de souhaits ; grille, carrousel ou bento ; carte produit composée de blocs.',
68
+ use: 'Meilleures ventes, nouveautés, produits associés. Presets : collection en vedette, carrousel, onglets de collections, produits associés, vus récemment, produits avec image, immersif, liste compacte, favoris.',
69
+ keywords: 'produits liste grille carrousel best sellers nouveautes recommandations',
70
+ },
71
+ reviews: { fr: 'Avis clients : résumé des notes, bandeau de note, widget Trustpilot.', use: 'Preuve sociale chiffrée. Les notes affichées doivent être réelles.', keywords: 'avis notes etoiles trustpilot' },
72
+ 'quick-order-list': {
73
+ fr: 'Liste de commande rapide : toutes les variantes d’un produit ou d’une collection avec une quantité par ligne, ajoutées au panier en une fois (images, références, tarifs dégressifs, stock).',
74
+ use: 'Vente en gros, réassort professionnel, gamme à décliner en tailles ou coloris — quand le client achète plusieurs variantes d’un coup plutôt qu’un produit.',
75
+ keywords: 'commande rapide gros b2b quantites variantes reassort',
76
+ },
77
+ 'rich-text': { fr: 'Texte enrichi : titre, texte, boutons. Presets : texte, citation, titre de section, grande déclaration.', use: 'Manifeste de marque, transition entre deux sections, titre de page.', keywords: 'texte titre citation manifeste' },
78
+ 'scroll-cards': { fr: 'Cartes qui s’empilent au défilement.', use: 'Présenter 3 à 5 étapes ou arguments de façon mémorable. Effet fort : une fois par page.', keywords: 'cartes empilees scroll effet' },
79
+ 'scroll-horizontal': { fr: 'Rangée épinglée qui défile horizontalement pendant le défilement vertical.', use: 'Galerie ou collection mise en scène. Effet fort : une fois par page.' },
80
+ 'scroll-story': { fr: 'Scrollytelling : média épinglé qui change avec chaque étape de texte.', use: 'Raconter la fabrication ou les ingrédients d’un produit phare.', keywords: 'scrollytelling histoire etapes defilement' },
81
+ 'scroll-zoom': { fr: 'Média qui grandit d’un cadre arrondi jusqu’au plein écran au défilement.', use: 'Moment « waouh » pour une marque premium, après le héro.' },
82
+ slideshow: { fr: 'Diaporama de slides héro (mêmes réglages que la bannière), transitions fondu, zoom, révélation, parallaxe ou glissement, autoplay avec barres de progression.', use: 'Plusieurs messages de même importance (collections, campagnes). Sinon préférer une bannière : un message fort convertit mieux.', keywords: 'diaporama slider carrousel hero slides' },
83
+ specifications: { fr: 'Caractéristiques techniques en liste (clé / valeur).', use: 'Fiche produit technique : dimensions, matière, entretien.', keywords: 'caracteristiques specifications fiche technique' },
84
+ stats: { fr: 'Statistiques et chiffres clés avec compteurs animés.', use: 'Résultats mesurés, chiffres de la marque. Uniquement des chiffres vérifiables.', keywords: 'chiffres statistiques compteurs resultats' },
85
+ stories: { fr: 'Stories photo et vidéo façon Instagram, en bulles.', use: 'Contenu social, coulisses, nouveautés.' },
86
+ testimonials: { fr: 'Témoignages : carrousel, grille, mur, témoignage avec image.', use: 'Preuve sociale qualitative. De vrais avis, avec prénom et si possible photo.', keywords: 'temoignages avis clients' },
87
+ timeline: { fr: 'Frise chronologique : notre histoire ou comment ça marche.', use: 'Page à propos, processus de fabrication ou de commande.', keywords: 'frise chronologie histoire etapes' },
88
+ ugc: { fr: 'Photos et vidéos clients (UGC), grille façon Instagram ou vidéos clients.', use: 'Preuve sociale visuelle, produits portés ou utilisés.', keywords: 'ugc instagram photos clients videos' },
89
+ video: { fr: 'Vidéo : lecteur, vidéo avec texte, vidéo collante (storytelling produit), vidéo d’arrière-plan.', use: 'Démonstration produit, film de marque.', keywords: 'video film demonstration' },
90
+ 'main-product-composed': {
91
+ fr: 'Fiche produit composée de blocs : galerie, titre, prix, variantes, stock, quantité, achat, offres, livraison, description, accordéons, barre d’achat collante. Presets : fiche produit, fiche avec offres.',
92
+ use: 'Modèle product uniquement.',
93
+ keywords: 'fiche produit page produit acheter',
94
+ },
95
+ 'main-collection': { fr: 'Page collection : bannière de collection, filtres (barre latérale, barre ou tiroir), tri, densité, grille de produits paginée, tuiles promo.', use: 'Modèle collection.' },
96
+ 'main-search': { fr: 'Page de recherche : titre, onglets de types de résultats, filtres, grille.', use: 'Modèle search.' },
97
+ 'main-cart': { fr: 'Page panier en deux zones (lignes, récapitulatif) plus panier vide.', use: 'Modèle cart.' },
98
+ 'main-page': { fr: 'Contenu d’une page Shopify (titre et texte saisis dans l’administration).', use: 'Modèles page.*.' },
99
+ 'main-404': { fr: 'Page 404.', use: 'Modèle 404.' },
100
+ 'main-article': { fr: 'Article de blog : titre, infos, image, contenu, sommaire, partage, auteur, navigation, commentaires, données BlogPosting.', use: 'Modèle article.' },
101
+ 'main-blog': { fr: 'Page de blog : liste des articles.', use: 'Modèle blog.' },
102
+ 'main-list-collections': { fr: 'Page listant toutes les collections.', use: 'Modèle list-collections.' },
103
+ 'main-password': { fr: 'Page mot de passe (boutique en préparation) : image ou vidéo, logo, titre, compte à rebours, newsletter, connexion propriétaire.', use: 'Modèle password.' },
104
+ 'main-login': { fr: 'Connexion client (anciens comptes clients) : formulaire, validation en ligne.', use: 'Modèle customers/login.' },
105
+ 'main-register': { fr: 'Création de compte client.', use: 'Modèle customers/register.' },
106
+ 'main-account': { fr: 'Espace client : informations du compte et historique des commandes.', use: 'Modèle customers/account.' },
107
+ 'main-order': { fr: 'Détail d’une commande client.', use: 'Modèle customers/order.' },
108
+ 'main-addresses': { fr: 'Carnet d’adresses du client.', use: 'Modèle customers/addresses.' },
109
+ 'main-activate-account': { fr: 'Activation d’un compte client invité.', use: 'Modèle customers/activate_account.' },
110
+ 'main-reset-password': { fr: 'Nouveau mot de passe client.', use: 'Modèle customers/reset_password.' },
111
+ };
112
+
113
+ export const BLOCK_NOTES = {
114
+ group: { fr: 'Groupe : conteneur flex (horizontal ou vertical, alignements, espacement, fond, bordure, collant) qui peut contenir n’importe quels blocs, y compris d’autres groupes.', keywords: 'groupe conteneur colonne ligne' },
115
+ heading: { fr: 'Titre (texte riche : <h1>…<h6> ou <p>), sous-titre, mots qui tournent, taille personnalisée, style de titre du thème.', keywords: 'titre h1 h2 heading' },
116
+ text: { fr: 'Paragraphe en texte riche.', keywords: 'texte paragraphe' },
117
+ button: { fr: 'Bouton ou lien, styles du thème 1 / 2 / 3 ou personnalisé.', keywords: 'bouton cta lien' },
118
+ image: { fr: 'Image avec ratio, hauteur, coins, lien, effets au défilement.', keywords: 'image photo visuel' },
119
+ video: { fr: 'Vidéo en lecteur ou en arrière-plan, avec blocs superposés.', keywords: 'video' },
120
+ icon: { fr: 'Icône de la bibliothèque du thème ou image.', keywords: 'icone pictogramme' },
121
+ checklist: { fr: 'Liste à puces avec coches ou icônes (réassurance).', keywords: 'liste puces coches avantages' },
122
+ accordion: { fr: 'Accordéon : question / réponse, avec balisage FAQ optionnel.', keywords: 'accordeon faq question' },
123
+ tabs: { fr: 'Onglets contenant chacun des blocs.', keywords: 'onglets tabs' },
124
+ carousel: { fr: 'Carrousel générique de blocs.', keywords: 'carrousel slider' },
125
+ 'content-over-image': { fr: 'Contenu superposé à une image de fond.', keywords: 'texte sur image overlay' },
126
+ spacer: { fr: 'Espace ou séparateur.', keywords: 'espace separateur ligne' },
127
+ badge: { fr: 'Badge (pastille de texte).', keywords: 'badge pastille etiquette' },
128
+ counter: { fr: 'Compteur animé ({120}K+).', keywords: 'compteur chiffre statistique' },
129
+ rating: { fr: 'Note en étoiles saisie à la main.', keywords: 'note etoiles' },
130
+ 'review-summary': { fr: 'Résumé des notes (moyenne, répartition).', keywords: 'avis resume notes' },
131
+ testimonial: { fr: 'Témoignage : texte, auteur, photo, note.', keywords: 'temoignage avis client' },
132
+ trustpilot: { fr: 'Widget Trustpilot (identifiant d’entreprise requis).', keywords: 'trustpilot avis' },
133
+ 'trust-badges': { fr: 'Badges de confiance (icône ou image de certification, titre, texte).', keywords: 'confiance paiement securise garantie labels' },
134
+ 'payment-icons': { fr: 'Icônes des moyens de paiement actifs.', keywords: 'paiement cartes' },
135
+ countdown: { fr: 'Compte à rebours vers une date.', keywords: 'compte a rebours timer' },
136
+ 'progress-bar': { fr: 'Barre de progression (objectif, quantité).', keywords: 'progression barre' },
137
+ newsletter: { fr: 'Formulaire newsletter (consentement, prénom, SMS, code promo).', keywords: 'newsletter email inscription' },
138
+ 'contact-form': { fr: 'Formulaire de contact à champs configurables.', keywords: 'formulaire contact' },
139
+ 'contact-info': { fr: 'Coordonnées : e-mail, téléphone, WhatsApp, adresse, horaires avec statut ouvert/fermé.', keywords: 'coordonnees adresse horaires telephone' },
140
+ map: { fr: 'Plan Google Maps chargé au clic.', keywords: 'carte plan adresse' },
141
+ menu: { fr: 'Liste de liens (menu Shopify), repliable sur mobile.', keywords: 'menu liens' },
142
+ 'social-links': { fr: 'Liens réseaux sociaux (depuis les réglages du thème).', keywords: 'reseaux sociaux instagram tiktok' },
143
+ logo: { fr: 'Logo de la boutique (version fond sombre possible).', keywords: 'logo' },
144
+ wordmark: { fr: 'Nom de marque géant sur toute la largeur.', keywords: 'nom marque geant' },
145
+ copyright: { fr: 'Mention de copyright.', keywords: 'copyright' },
146
+ 'policy-links': { fr: 'Liens vers les politiques (CGV, confidentialité, remboursement).', keywords: 'mentions legales cgv politiques' },
147
+ localization: { fr: 'Sélecteur de pays / langue.', keywords: 'pays langue devise' },
148
+ 'back-to-top': { fr: 'Bouton retour en haut.', keywords: 'retour haut' },
149
+ share: { fr: 'Boutons de partage.', keywords: 'partage' },
150
+ 'marquee-text': { fr: 'Texte défilant (une phrase par ligne, séparateur).', keywords: 'texte defilant' },
151
+ 'marquee-images': { fr: 'Images défilantes.', keywords: 'images defilantes' },
152
+ 'logo-list': { fr: 'Liste de logos.', keywords: 'logos' },
153
+ gallery: { fr: 'Galerie d’images (grille, masonry, bento, carrousel).', keywords: 'galerie' },
154
+ 'ugc-gallery': { fr: 'Galerie de photos et vidéos clients.', keywords: 'ugc photos clients' },
155
+ stories: { fr: 'Stories en bulles.', keywords: 'stories' },
156
+ 'image-before-after': { fr: 'Avant / après à curseur.', keywords: 'avant apres' },
157
+ 'image-hotspots': { fr: 'Image à points cliquables.', keywords: 'hotspots' },
158
+ timeline: { fr: 'Frise chronologique.', keywords: 'frise' },
159
+ 'comparison-table': { fr: 'Tableau comparatif.', keywords: 'comparatif' },
160
+ calculator: { fr: 'Calculateur d’économies.', keywords: 'calculateur' },
161
+ specs: { fr: 'Liste de caractéristiques clé / valeur.', keywords: 'caracteristiques' },
162
+ pictogram: { fr: 'Pictogramme flottant décoratif.', keywords: 'pictogramme decoratif' },
163
+ 'text-reveal': { fr: 'Texte dont les mots s’illuminent au défilement.', keywords: 'texte revele scroll' },
164
+ custom_liquid: { fr: 'Code Liquid libre.', keywords: 'liquid code' },
165
+ 'product-list': { fr: 'Liste de produits (collection, choix, recommandations, vus récemment, favoris) avec carte produit composée.', keywords: 'produits liste' },
166
+ 'collection-list': { fr: 'Liste de collections avec carte composée.', keywords: 'collections' },
167
+ 'blog-posts': { fr: 'Liste d’articles.', keywords: 'articles blog' },
168
+ 'bundle-builder': { fr: 'Créateur de lot.', keywords: 'lot bundle' },
169
+ 'product-media': { fr: 'Galerie du produit (miniatures, zoom, vidéos, 3D).', keywords: 'galerie produit photos' },
170
+ 'product-title': { fr: 'Titre du produit (niveau de titre réglable : h1 sur la fiche produit).', keywords: 'titre produit' },
171
+ 'product-price': { fr: 'Prix, prix barré, badge promo, mention taxes, paiement en plusieurs fois (Shop Pay).', keywords: 'prix promo' },
172
+ 'product-variants': { fr: 'Sélecteur de variantes (boutons, pastilles, liste).', keywords: 'variantes taille couleur' },
173
+ 'product-quantity': { fr: 'Sélecteur de quantité (règles de quantité, prix dégressifs B2B).', keywords: 'quantite' },
174
+ 'product-buy': { fr: 'Boutons d’achat : ajouter au panier, paiement accéléré, retrait en magasin, carte cadeau à offrir.', keywords: 'ajouter au panier acheter' },
175
+ 'product-offers': { fr: 'Offres : paliers de quantité, X achetés Y offert, packs, lots, cadeaux. Le thème affiche, Shopify facture : chaque offre exige une réduction automatique ou un code.', keywords: 'offres paliers quantite bogo packs remise' },
176
+ 'product-subscription': { fr: 'Options d’achat : abonnement (plans de vente d’une app d’abonnement) ou achat unique.', keywords: 'abonnement' },
177
+ 'product-addons': { fr: 'Compléments : produits ajoutés avec le produit, cadeau à choisir, souvent achetés ensemble.', keywords: 'complements cross sell cadeau achetes ensemble' },
178
+ 'product-description': { fr: 'Description du produit.', keywords: 'description' },
179
+ 'product-rating': { fr: 'Note du produit (métachamps d’avis d’une application, ou saisie).', keywords: 'note avis produit' },
180
+ 'product-stock': { fr: 'Stock : stock réel Shopify ou chiffre simulé (à éviter : l’urgence doit être réelle).', keywords: 'stock inventaire' },
181
+ 'product-delivery': { fr: 'Date de livraison estimée (jours ouvrés, heure limite, jours fermés).', keywords: 'livraison date estimee' },
182
+ 'product-shipping-bar': { fr: 'Barre de progression vers la livraison offerte.', keywords: 'livraison offerte barre' },
183
+ 'product-spin-message': { fr: 'Messages tournants sous le bouton d’achat.', keywords: 'messages reassurance' },
184
+ 'product-size-guide': { fr: 'Guide des tailles en fenêtre (tableau cm / in).', keywords: 'guide des tailles' },
185
+ 'product-custom-field': { fr: 'Champ de personnalisation (gravure, photo, emballage cadeau), envoyé en propriété de ligne.', keywords: 'personnalisation gravure' },
186
+ 'product-sticky-bar': { fr: 'Barre d’ajout au panier collante (bas, haut ou carte flottante).', keywords: 'barre collante sticky ajout panier' },
187
+ facets: { fr: 'Filtres de collection (barre latérale, barre, tiroir).', keywords: 'filtres' },
188
+ 'product-grid': { fr: 'Grille de produits paginée des pages collection et recherche.', keywords: 'grille produits' },
189
+ 'collection-header': { fr: 'Bannière de collection (titre, description, image).', keywords: 'banniere collection' },
190
+ 'page-title': { fr: 'Titre de la page Shopify.', keywords: 'titre page' },
191
+ 'page-content': { fr: 'Contenu de la page Shopify.', keywords: 'contenu page' },
192
+ };
@@ -0,0 +1,85 @@
1
+ // Art directions: the whole look of a store, not only its colours. Each one sets the theme's
2
+ // global style settings, decorates sections (shape separators, their animation) and picks the
3
+ // signature moments of the home page. Every value is an option of the theme's own schema
4
+ // (tests check them).
5
+
6
+ export const DIRECTIONS = {
7
+ editorial: {
8
+ label: 'Éditorial chic',
9
+ for: 'mode, bijoux, maroquinerie, marque premium',
10
+ fonts: 'editorial',
11
+ settings: { radius_preset: 'sharp', subheadings_uppercase: true, subheadings_letter_spacing: 3, heading_separator: false, buttons_font: 'body', hover_button_effect: 'fill', hover_card_effect: 'zoom', page_transition_style: 'curtain', button_1_shadow: false, media_container_border: 'none', content_container_border: 'none', collapsible_icon: 'plus_rotating' },
12
+ separator: null,
13
+ signatures: ['manifesto', 'showcase'],
14
+ },
15
+ minimal: {
16
+ label: 'Minimal scandinave',
17
+ for: 'maison, déco, objets, grand catalogue',
18
+ fonts: 'minimal',
19
+ settings: { radius_preset: 'soft', subheadings_uppercase: true, subheadings_letter_spacing: 2, heading_separator: false, hover_button_effect: 'lift', hover_card_effect: 'zoom', page_transition_style: 'fade', button_1_shadow: false, media_container_border: 'none', content_container_border: 'border--1' },
20
+ separator: null,
21
+ signatures: ['manifesto', 'cards'],
22
+ },
23
+ organique: {
24
+ label: 'Naturel organique',
25
+ for: 'bougies, épicerie fine, cosmétique naturelle, bio, fait main',
26
+ fonts: 'natural',
27
+ settings: { radius_preset: 'rounded', subheadings_uppercase: false, heading_separator: true, hover_button_effect: 'lift', hover_card_effect: 'lift', page_transition_style: 'dissolve', button_1_shadow: false, collapsible_icon: 'plus_minus' },
28
+ separator: { shape: 'organic', animation: 'flow' },
29
+ signatures: ['process', 'manifesto'],
30
+ },
31
+ artisan: {
32
+ label: 'Atelier artisanal',
33
+ for: 'artisanat, céramique, maroquinerie faite main, producteur',
34
+ fonts: 'heritage',
35
+ settings: { radius_preset: 'soft', subheadings_uppercase: true, subheadings_letter_spacing: 2, heading_separator: true, hover_button_effect: 'lift', hover_card_effect: 'zoom', page_transition_style: 'tiles', button_1_shadow: false },
36
+ separator: { shape: 'torn', animation: 'none' },
37
+ signatures: ['process', 'manifesto'],
38
+ },
39
+ douceur: {
40
+ label: 'Douceur beauté',
41
+ for: 'beauté, soin, bien-être, maternité',
42
+ fonts: 'soft-modern',
43
+ settings: { radius_preset: 'round', subheadings_uppercase: false, heading_separator: false, hover_button_effect: 'lift', hover_card_effect: 'lift', page_transition_style: 'blur', card_style: 'card', button_1_shadow: true },
44
+ separator: { shape: 'curve', animation: 'swell' },
45
+ signatures: ['cards', 'manifesto'],
46
+ },
47
+ pop: {
48
+ label: 'Pop coloré',
49
+ for: 'enfants, animaux, snacks, accessoires fun, marque joyeuse',
50
+ fonts: 'friendly',
51
+ settings: { radius_preset: 'round', subheadings_uppercase: true, subheadings_letter_spacing: 1, heading_separator: false, hover_button_effect: 'magnetic', hover_card_effect: 'tilt', page_transition_style: 'circle', card_style: 'card', button_1_shadow: true },
52
+ separator: { shape: 'scallop', animation: 'swell' },
53
+ signatures: ['cards', 'rotating'],
54
+ },
55
+ energie: {
56
+ label: 'Sport énergique',
57
+ for: 'sport, nutrition, outdoor, streetwear',
58
+ fonts: 'bold-sport',
59
+ settings: { radius_preset: 'sharp', headings_uppercase: true, headings_letter_spacing: 1, subheadings_uppercase: true, subheadings_letter_spacing: 2, heading_separator: false, hover_button_effect: 'shine', hover_card_effect: 'lift_zoom', page_transition_style: 'diagonal', button_1_shadow: false },
60
+ separator: { shape: 'slope', animation: 'none' },
61
+ signatures: ['cards', 'rotating'],
62
+ },
63
+ tech: {
64
+ label: 'Tech précis',
65
+ for: 'électronique, accessoires, gadgets, produit technique',
66
+ fonts: 'tech',
67
+ settings: { radius_preset: 'soft', subheadings_uppercase: true, subheadings_letter_spacing: 2, heading_separator: false, hover_button_effect: 'magnetic', hover_card_effect: 'spotlight', page_transition_style: 'blur', button_1_shadow: false, media_container_border: 'none' },
68
+ separator: null,
69
+ signatures: ['cards', 'rotating'],
70
+ },
71
+ };
72
+
73
+ // Default direction for each style of build_store.
74
+ export const STYLE_DIRECTION = {
75
+ essentiel: 'minimal',
76
+ premium: 'editorial',
77
+ mode: 'editorial',
78
+ beaute: 'douceur',
79
+ nature: 'organique',
80
+ maison: 'minimal',
81
+ tech: 'tech',
82
+ sport: 'energie',
83
+ enfant: 'pop',
84
+ 'produit-unique': 'energie',
85
+ };
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs';
2
+ import { normalizeText } from '../theme/catalog.js';
3
+
4
+ const LIBRARY = JSON.parse(fs.readFileSync(new URL('../data/shopify-fonts.json', import.meta.url), 'utf8'));
5
+
6
+ const byHandle = new Map();
7
+ for (const family of LIBRARY.available) for (const handle of family.handles) byHandle.set(handle, { family: family.family, deprecated: false, alternative: family.alternative || null });
8
+ for (const family of LIBRARY.deprecated) for (const handle of family.handles) if (!byHandle.has(handle)) byHandle.set(handle, { family: family.family, deprecated: true, replacement: family.replacement });
9
+
10
+ export const FONT_LIBRARY = LIBRARY;
11
+
12
+ export function fontInfo(handle) {
13
+ return byHandle.get(handle) || null;
14
+ }
15
+
16
+ function familyHandles(name) {
17
+ const family = LIBRARY.available.find((entry) => normalizeText(entry.family) === normalizeText(name));
18
+ return family ? family.handles : [];
19
+ }
20
+
21
+ // Checks a font_picker value. Unknown handles are refused at upload; deprecated ones still render
22
+ // but Shopify has announced their removal.
23
+ export function checkFontHandle(handle) {
24
+ if (typeof handle !== 'string' || !handle) return [{ level: 'error', message: 'police vide : choisissez un identifiant de la bibliothèque Shopify (ex. jost_n4)' }];
25
+ const info = byHandle.get(handle);
26
+ if (!info) {
27
+ const base = handle.replace(/_[nio]\d$/, '');
28
+ const sameFamily = [...byHandle.keys()].filter((candidate) => candidate.startsWith(base + '_')).slice(0, 6);
29
+ return [{ level: 'error', message: `police « ${handle} » inconnue de la bibliothèque Shopify${sameFamily.length ? ` (graisses existantes : ${sameFamily.join(', ')})` : ''}. Utilisez find_fonts.` }];
30
+ }
31
+ if (info.deprecated) {
32
+ const replacement = familyHandles(info.replacement);
33
+ const weight = handle.match(/_([nio]\d)$/)?.[1]?.replace('o', 'i');
34
+ const suggestion = replacement.find((candidate) => candidate.endsWith(`_${weight}`)) || replacement.find((candidate) => candidate.endsWith('_n4'));
35
+ return [{ level: 'warning', message: `la police ${info.family} est dépréciée par Shopify (remplaçante : ${info.replacement}${suggestion ? `, ex. ${suggestion}` : ''}). Changez-la avant qu'elle ne disparaisse.` }];
36
+ }
37
+ return [];
38
+ }
39
+
40
+ export function searchFonts(query, limit = 20) {
41
+ const target = normalizeText(query);
42
+ return LIBRARY.available
43
+ .filter((family) => normalizeText(family.family).includes(target))
44
+ .slice(0, limit)
45
+ .map((family) => ({ family: family.family, handles: family.handles, ...(family.alternative ? { licensed: true, free_alternative: family.alternative } : {}) }));
46
+ }
47
+
48
+ // Pairings verified against the library by the tests. Weights: heading, body.
49
+ export const FONT_PAIRINGS = [
50
+ { id: 'editorial', style: 'Éditorial, luxe, mode', heading: 'cormorant_n5', body: 'jost_n4', note: 'Serif à fort contraste pour les titres, sans-serif géométrique pour le texte. Titres grands et aérés, pas en majuscules.' },
51
+ { id: 'classic-serif', style: 'Élégant, bijoux, cosmétique haut de gamme', heading: 'playfair_display_n4', body: 'dm_sans_n4', note: 'Classique et lisible. Éviter le gras sur Playfair.' },
52
+ { id: 'soft-modern', style: 'Beauté, bien-être, clean beauty', heading: 'fraunces_n4', body: 'manrope_n4', note: 'Serif douce et chaleureuse, texte très lisible.' },
53
+ { id: 'minimal', style: 'Minimaliste, scandinave, maison et déco', heading: 'jost_n5', body: 'jost_n4', note: 'Une seule famille, deux graisses : cohérent et rapide à charger.' },
54
+ { id: 'tech', style: 'Tech, accessoires, électronique', heading: 'space_grotesk_n6', body: 'inter_n4', note: 'Grotesque technique pour les titres, Inter pour le texte.' },
55
+ { id: 'bold-sport', style: 'Sport, énergie, streetwear', heading: 'bebas_neue_n4', body: 'barlow_n4', note: 'Titres condensés en majuscules, texte robuste. Réserver Bebas aux titres courts.' },
56
+ { id: 'natural', style: 'Naturel, artisanal, épicerie fine', heading: 'young_serif_n4', body: 'work_sans_n4', note: 'Serif généreuse et authentique.' },
57
+ { id: 'friendly', style: 'Enfants, animaux, marque joyeuse', heading: 'fredoka_n6', body: 'nunito_n4', note: 'Formes arrondies, ton chaleureux.' },
58
+ { id: 'contemporary', style: 'Contemporain, DTC, marque produit unique', heading: 'bricolage_grotesque_n6', body: 'plus_jakarta_sans_n4', note: 'Caractère affirmé pour les titres, texte net.' },
59
+ { id: 'heritage', style: 'Maison historique, vins, savoir-faire', heading: 'libre_caslon_text_n4', body: 'libre_franklin_n4', note: 'Tradition typographique, sérieux et chaleureux.' },
60
+ { id: 'clean-sans', style: 'Neutre, polyvalent, gros catalogue', heading: 'dm_sans_n6', body: 'dm_sans_n4', note: 'Sobre et très lisible, ne vole pas la vedette aux produits.' },
61
+ { id: 'fashion-sans', style: 'Mode urbaine, prêt-à-porter', heading: 'syne_n6', body: 'inter_n4', note: 'Titres expressifs à utiliser en grand.' },
62
+ ];