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/LICENSE +34 -0
- package/README.md +113 -0
- package/package.json +46 -0
- package/src/build/compose.js +356 -0
- package/src/build/edit.js +228 -0
- package/src/build/localize.js +70 -0
- package/src/build/migrate.js +63 -0
- package/src/build/palette.js +153 -0
- package/src/build/quick-shared.js +2 -0
- package/src/build/quick.js +741 -0
- package/src/build/recipe.js +47 -0
- package/src/build/scaffold.js +130 -0
- package/src/build/texts.js +43 -0
- package/src/config.js +28 -0
- package/src/data/shopify-fonts.json +5880 -0
- package/src/format.js +64 -0
- package/src/index.js +62 -0
- package/src/knowledge/descriptions.js +192 -0
- package/src/knowledge/directions.js +85 -0
- package/src/knowledge/fonts.js +62 -0
- package/src/knowledge/guides.js +252 -0
- package/src/knowledge/recipes.js +253 -0
- package/src/project/export.js +161 -0
- package/src/project/review.js +143 -0
- package/src/project/store.js +260 -0
- package/src/server.js +1350 -0
- package/src/shops/client.js +38 -0
- package/src/shops/format.js +58 -0
- package/src/shops/tools.js +77 -0
- package/src/theme/catalog.js +415 -0
- package/src/theme/loader.js +130 -0
- package/src/theme/source.js +242 -0
- package/src/validate/color.js +176 -0
- package/src/validate/design.js +258 -0
- package/src/validate/richtext.js +103 -0
- package/src/validate/template.js +362 -0
- package/src/validate/values.js +173 -0
- package/src/version.js +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Licence d'utilisation — MCP Story Thème
|
|
2
|
+
Copyright (c) 2026 SAS NS GROUP (New Story). Tous droits réservés.
|
|
3
|
+
|
|
4
|
+
1. Objet
|
|
5
|
+
Ce logiciel (« le MCP ») accompagne le thème Shopify « Story Thème ». Il est réservé aux
|
|
6
|
+
titulaires d'une licence Story Thème en cours de validité, souscrite sur story-theme.com.
|
|
7
|
+
|
|
8
|
+
2. Ce que vous pouvez faire
|
|
9
|
+
Installer et exécuter le MCP sur vos propres machines, pour construire et maintenir les
|
|
10
|
+
thèmes des boutiques Shopify que vous exploitez ou dont vous avez la charge, y compris pour
|
|
11
|
+
le compte de vos clients.
|
|
12
|
+
|
|
13
|
+
3. Ce que vous ne pouvez pas faire
|
|
14
|
+
Revendre, redistribuer, publier ou sous-licencier le MCP ou ses sources ; en retirer les
|
|
15
|
+
vérifications de licence ; partager votre jeton d'accès avec un tiers ; utiliser le MCP,
|
|
16
|
+
les archives du thème qu'il télécharge ou les fichiers qu'il produit pour reconstituer,
|
|
17
|
+
dériver ou diffuser le Story Thème en dehors de vos propres boutiques.
|
|
18
|
+
|
|
19
|
+
4. Jeton et données
|
|
20
|
+
Le jeton personnel identifie votre compte auprès de l'espace membre Story Thème. Il vérifie
|
|
21
|
+
que votre licence est active et donne accès à la dernière version publiée du thème. Le MCP
|
|
22
|
+
n'envoie rien d'autre : il ne transmet ni le contenu de vos boutiques, ni vos fichiers.
|
|
23
|
+
|
|
24
|
+
5. Fin de licence
|
|
25
|
+
L'arrêt de votre abonnement met fin au droit d'utiliser le MCP et coupe l'accès aux versions
|
|
26
|
+
du thème. Les thèmes déjà installés sur vos boutiques restent régis par la licence du
|
|
27
|
+
Story Thème.
|
|
28
|
+
|
|
29
|
+
6. Garantie et responsabilité
|
|
30
|
+
Le logiciel est fourni « en l'état », sans garantie d'aucune sorte. Les fichiers qu'il
|
|
31
|
+
produit sont à vérifier dans l'éditeur Shopify avant publication. SAS NS GROUP ne peut être
|
|
32
|
+
tenue responsable d'un dommage résultant de son utilisation.
|
|
33
|
+
|
|
34
|
+
Contact : contact@story-theme.com
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# MCP Story Thème
|
|
2
|
+
|
|
3
|
+
Votre assistant IA connaît le [Story Thème](https://story-theme.com) par cœur — chaque section, chaque bloc, chaque réglage — et construit des boutiques Shopify propres et professionnelles, sans jamais produire un fichier que Shopify refuserait.
|
|
4
|
+
|
|
5
|
+
Réservé aux titulaires d'une licence Story Thème. Le jeton se génère dans l'espace membre : **Story Thème > MCP & Appli**.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Vous : « Crée la boutique Maison Lumen : bougies parfumées coulées à la main à Lyon. »
|
|
9
|
+
IA : couleurs, polices, accueil de 9 sections, fiche produit, collection, pages Notre
|
|
10
|
+
histoire / Comment utiliser / Contact / FAQ / Suivi, thème traduit, démos retirées,
|
|
11
|
+
zip prêt à importer — puis trois questions pour affiner.
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Node 20 ou plus ([nodejs.org](https://nodejs.org)).
|
|
17
|
+
|
|
18
|
+
**Claude (application)** — Réglages > Développeur > Modifier la configuration, puis dans `claude_desktop_config.json` :
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"mcpServers": {
|
|
23
|
+
"story-theme": {
|
|
24
|
+
"command": "npx",
|
|
25
|
+
"args": ["-y", "story-theme-mcp@latest"],
|
|
26
|
+
"env": { "STORY_MCP_TOKEN": "VOTRE_JETON" }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Claude Code** :
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
claude mcp add story-theme --scope user -e STORY_MCP_TOKEN=VOTRE_JETON -- npx -y story-theme-mcp@latest
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Redémarrez, puis demandez : *« Sur quelle version du Story Thème travailles-tu ? »*
|
|
39
|
+
|
|
40
|
+
Le mode d'emploi complet, avec les cas de dépannage : **https://story-theme.com/mcp**
|
|
41
|
+
|
|
42
|
+
## Toujours la dernière version du thème
|
|
43
|
+
|
|
44
|
+
Le MCP ne contient pas le thème : au démarrage, il demande à l'espace membre la **dernière version publiée**, la télécharge une fois et la garde en cache. Une version sort, il la prend au démarrage suivant — rien à réinstaller. Hors ligne, il travaille sur la version en cache. Les comptes testeurs reçoivent la version en test (`STORY_MCP_CANAL=test`).
|
|
45
|
+
|
|
46
|
+
`theme_source` dit d'où vient le thème et vérifie les mises à jour à la demande.
|
|
47
|
+
|
|
48
|
+
## Ce qu'il fait
|
|
49
|
+
|
|
50
|
+
| Étape | Outils |
|
|
51
|
+
|---|---|
|
|
52
|
+
| 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` |
|
|
53
|
+
| Concevoir | `recipes` (15 compositions de pages), `generate_color_schemes`, `check_contrast`, `find_fonts` |
|
|
54
|
+
| Construire vite | `build_store` : la V1 complète d'une boutique en un appel (direction artistique, pages de marque, moments signature, textes, traduction, zip) |
|
|
55
|
+
| 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…) |
|
|
56
|
+
| Construire | `create_project`, `update_brief`, `apply_recipe`, `localize_template`, `list_texts`, `edit_template`, `write_file`, `update_theme_settings`, `read_file`, `exclude_template` |
|
|
57
|
+
| Contrôler | `validate_json`, `review_project`, `undo`, `revert_file` |
|
|
58
|
+
| Livrer | `export_theme` (zip à importer + check-list), `import_theme` (auditer un thème téléchargé d'une boutique) |
|
|
59
|
+
|
|
60
|
+
Prompts prêts à l'emploi : `creer-boutique`, `refaire-une-page`, `auditer-un-theme`. Les guides sont aussi exposés en ressources (`storytheme://guides/<sujet>`).
|
|
61
|
+
|
|
62
|
+
## Garde-fous
|
|
63
|
+
|
|
64
|
+
- **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 ? »).
|
|
65
|
+
- **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.
|
|
66
|
+
- **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.
|
|
67
|
+
- **Jamais le code du thème**, jamais une boutique en ligne : seuls `templates/*.json`, `sections/<groupe>.json` et `config/settings_data.json` sont modifiables, dans un dossier de projet.
|
|
68
|
+
- **Historique** : chaque écriture est précédée d'un instantané ; `undo` revient en arrière autant de fois que nécessaire.
|
|
69
|
+
- **Export bloqué** tant qu'une erreur bloquante reste ; signalé « pas prêt à publier » tant qu'un point 🔴 reste.
|
|
70
|
+
|
|
71
|
+
## Variables
|
|
72
|
+
|
|
73
|
+
| Variable | Défaut | Rôle |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `STORY_MCP_TOKEN` | — | Jeton personnel (espace membre > MCP & Appli). Vérifie la licence et donne accès au thème publié. |
|
|
76
|
+
| `STORY_MCP_HOME` | `~/Documents/Story Theme MCP` | Projets, historique, exports, cache du thème. |
|
|
77
|
+
| `STORY_MCP_LANGUAGE` | `fr` | Langue par défaut des projets et des textes. |
|
|
78
|
+
| `STORY_MCP_CANAL` | `stable` | `test` pour la version en test du thème (comptes testeurs). |
|
|
79
|
+
| `STORY_THEME_DIR` | — | Dossier de thème local, **prioritaire** sur le thème publié. Pour développer le thème lui-même. |
|
|
80
|
+
| `STORY_MCP_API` | `https://story-theme.com` | Espace membre interrogé. |
|
|
81
|
+
|
|
82
|
+
## Déroulé type
|
|
83
|
+
|
|
84
|
+
1. **Premier message** (« Crée la boutique X, qui vend Y ») : l'IA appelle `build_store` sans poser de question. En un appel : couleurs et polices selon le style, accueil de 8 à 10 sections, fiche produit, collection, pages à propos / contact / FAQ / suivi, reste du thème traduit, démos retirées, textes clés de la marque placés, zip exporté.
|
|
85
|
+
- **Photos** : avec un connecteur Shopify, l'IA copie d'abord 3 à 6 belles photos de la boutique dans Contenu > Fichiers (`fileCreate`) et les passe à `build_store` (`images`) : héro, histoire, ambiance. Seules les photos de 1600 px de large ou plus (`images.large`) vont en plein écran : une petite photo n'est jamais étirée. Sans photo : héro typographique sur la couleur de marque, et les sections qui exigent des photos sont remplacées. Jamais de carré « image manquante ».
|
|
86
|
+
- **Preuves** : avis, note et chiffres uniquement réels (`proofs`). Sans eux, les sections d'avis, de chiffres et de presse sont remplacées par des sections factuelles ou catalogue, pas supprimées.
|
|
87
|
+
- **Direction artistique** (`direction`) : editorial, minimal, organique, artisan, douceur, pop, energie, tech. Chacune règle arrondis, boutons, survols, transitions de page, formes de séparation animées et polices.
|
|
88
|
+
- **Moments signature** (selon la direction et `copy`) : manifeste qui s'illumine au défilement, mots qui tournent dans le titre, cartes empilées, étapes de fabrication.
|
|
89
|
+
- **Pages de marque** créées d'office : Notre histoire (`page.story`) et Comment utiliser (`page.utilisation`).
|
|
90
|
+
2. Elle résume la V1, donne le zip, puis pose 3 à 5 questions (collection principale, avis réels, conditions de livraison, couleur et logo, photos).
|
|
91
|
+
3. Chaque réponse s'applique au **projet en cours**, sans le re-préciser.
|
|
92
|
+
4. `review_project`, puis `export_theme` pour un nouveau zip.
|
|
93
|
+
5. Dans Shopify : Boutique en ligne > Thèmes > Ajouter un thème > Importer un fichier zip, vérifier dans l'éditeur, suivre la check-list, publier.
|
|
94
|
+
|
|
95
|
+
## Limites
|
|
96
|
+
|
|
97
|
+
Pas de connexion à une boutique : le MCP fabrique un thème à importer, il ne publie rien et ne crée ni produits, ni collections, ni menus, ni images, ni réductions (la check-list les liste). Il ne remplace pas une vérification visuelle dans l'éditeur Shopify.
|
|
98
|
+
|
|
99
|
+
## Développement (New Story)
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm test # validateur, presets, recettes, couleurs, polices, projets, export, source, serveur MCP
|
|
103
|
+
npm run check # après une mise à jour du thème : presets, recettes et descriptions toujours valides ?
|
|
104
|
+
npm run fonts # recharge la bibliothèque de polices Shopify (shopify.dev)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Avec `STORY_THEME_DIR` sur le dépôt du thème, le MCP lit le thème en cours d'écriture et n'appelle pas l'espace membre. Les connaissances rédigées à la main vivent dans `src/knowledge/` ; tout le reste vient du thème.
|
|
108
|
+
|
|
109
|
+
Publication : `npm version <x.y.z>` (mettre à jour `src/version.js` à l'identique), `npm publish` — `prepublishOnly` vérifie l'ensemble.
|
|
110
|
+
|
|
111
|
+
## Licence
|
|
112
|
+
|
|
113
|
+
Propriétaire — voir [LICENSE](LICENSE). Réservé aux licences Story Thème actives.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "story-theme-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"story-theme-mcp": "src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node src/index.js",
|
|
15
|
+
"test": "node --test test/*.test.js",
|
|
16
|
+
"check": "node scripts/check-theme.js",
|
|
17
|
+
"fonts": "node scripts/fetch-fonts.js",
|
|
18
|
+
"prepublishOnly": "npm test && node scripts/check-release.js"
|
|
19
|
+
},
|
|
20
|
+
"author": "New Story (SAS NS GROUP) <contact@story-theme.com>",
|
|
21
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
24
|
+
"fflate": "^0.8.3",
|
|
25
|
+
"zod": "^4.6.5"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"modelcontextprotocol",
|
|
30
|
+
"shopify",
|
|
31
|
+
"shopify-theme",
|
|
32
|
+
"story-theme",
|
|
33
|
+
"ia",
|
|
34
|
+
"claude"
|
|
35
|
+
],
|
|
36
|
+
"homepage": "https://story-theme.com/mcp",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://story-theme.com/contact",
|
|
39
|
+
"email": "contact@story-theme.com"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"src",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { sectionFromPreset, blockFromPreset, createIdFactory, collectIds } from './scaffold.js';
|
|
2
|
+
import { imageRef } from './quick-shared.js';
|
|
3
|
+
import { closest } from '../theme/catalog.js';
|
|
4
|
+
|
|
5
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
6
|
+
const escapeHtml = (text) => String(text ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
7
|
+
|
|
8
|
+
// Plain text in, theme rich text out: **word** becomes bold (the theme's heading styles colour
|
|
9
|
+
// bold words), blank lines split paragraphs.
|
|
10
|
+
export const inline = (text) => escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
11
|
+
// Plain text settings (labels, accordion titles, eyebrows) cannot hold bold: the stars go.
|
|
12
|
+
export const plain = (text) => escapeHtml(String(text ?? '').replace(/\*\*(.+?)\*\*/g, '$1'));
|
|
13
|
+
export const paragraphs = (text) =>
|
|
14
|
+
String(text ?? '')
|
|
15
|
+
.split(/\n\s*\n/)
|
|
16
|
+
.map((part) => part.trim())
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.map((part) => `<p>${inline(part).replace(/\n/g, '<br>')}</p>`)
|
|
19
|
+
.join('');
|
|
20
|
+
|
|
21
|
+
// Blocks that repeat inside a section and carry one item of content each.
|
|
22
|
+
const ITEM_TYPES = ['_timeline-step', '_scroll-card', '_story-step', '_slide', 'accordion', 'testimonial', 'counter', 'progress-bar', '_spec-row', '_comparison-row', '_hotspot', '_gallery-item', '_story', '_logo', '_marquee-image', '_trust-badge', '_ugc-item'];
|
|
23
|
+
const HERO_TYPES = new Set(['banner', 'slideshow', 'scroll-zoom', 'video']);
|
|
24
|
+
|
|
25
|
+
function* walk(node, parent = null, id = null) {
|
|
26
|
+
if (id) yield { id, block: node, parent };
|
|
27
|
+
const ids = [...Object.keys(node.blocks || {}).filter((key) => node.blocks[key].static), ...(node.block_order || [])];
|
|
28
|
+
for (const key of ids) if (node.blocks?.[key]) yield* walk(node.blocks[key], node, key);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const within = (entries, roots) => entries.filter(({ block }) => !roots.some((root) => root !== block && [...walk(root)].some((entry) => entry.block === block)));
|
|
32
|
+
|
|
33
|
+
function remove(parent, id) {
|
|
34
|
+
delete parent.blocks[id];
|
|
35
|
+
parent.block_order = (parent.block_order || []).filter((item) => item !== id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A 4.0 testimonial carries its content in child blocks: a `text` block for the review
|
|
39
|
+
// (preceded, in some presets, by a second bold `text` used as a title) and a
|
|
40
|
+
// `_testimonial-author` for the name, role and photo. The old `testimonial.settings.text`
|
|
41
|
+
// no longer exists: writing it made the validator reject the WHOLE page (all or nothing),
|
|
42
|
+
// which then stayed in sample copy — fake reviews signed "Thomas R." included.
|
|
43
|
+
export function writeTestimonial(block, { text, title, name, role, image } = {}) {
|
|
44
|
+
const children = (block.block_order || [])
|
|
45
|
+
.map((id) => ({ id, child: block.blocks?.[id] }))
|
|
46
|
+
.filter((entry) => entry.child);
|
|
47
|
+
const texts = children.filter((entry) => entry.child.type === 'text');
|
|
48
|
+
const author = children.find((entry) => entry.child.type === '_testimonial-author');
|
|
49
|
+
|
|
50
|
+
// A theme older than the child-block testimonial: its settings are still on the block.
|
|
51
|
+
if (!texts.length && !author) {
|
|
52
|
+
block.settings = { ...(block.settings || {}) };
|
|
53
|
+
if (text) block.settings.text = paragraphs(text);
|
|
54
|
+
if (name) block.settings.name = plain(name);
|
|
55
|
+
if (role) block.settings.role = plain(role);
|
|
56
|
+
if (title && title !== name) block.settings.title = plain(title);
|
|
57
|
+
if (image) block.settings.image = imageRef(image);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const body = texts[texts.length - 1];
|
|
62
|
+
const heading = texts.length > 1 ? texts[texts.length - 2] : null;
|
|
63
|
+
if (body && text) body.child.settings = { ...(body.child.settings || {}), description: paragraphs(text) };
|
|
64
|
+
if (heading) {
|
|
65
|
+
// A sample title ("Au-delà de mes attentes") must not crown a real review.
|
|
66
|
+
if (title && title !== name) heading.child.settings = { ...(heading.child.settings || {}), description: `<p><strong>${inline(title)}</strong></p>` };
|
|
67
|
+
else remove(block, heading.id);
|
|
68
|
+
}
|
|
69
|
+
if (author) {
|
|
70
|
+
const settings = { ...(author.child.settings || {}) };
|
|
71
|
+
if (name) settings.name = plain(name);
|
|
72
|
+
// The default role ("Acheteuse vérifiée") is a claim: kept only when the merchant gives one.
|
|
73
|
+
settings.role = role ? plain(role) : '';
|
|
74
|
+
if (image) settings.avatar = imageRef(image);
|
|
75
|
+
author.child.settings = settings;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const holds = (block, type) => [...walk(block)].some((entry) => entry.block.type === type);
|
|
80
|
+
const leafGroup = (block) => block.type === 'group' && ![...walk(block)].some((entry) => entry.block.type === 'group');
|
|
81
|
+
const signature = (block) => (block.block_order || []).map((id) => block.blocks?.[id]?.type).join('+');
|
|
82
|
+
|
|
83
|
+
// The biggest set of sibling entries sharing the same key, when there are at least two.
|
|
84
|
+
function largestSiblings(entries, keyOf) {
|
|
85
|
+
const sets = new Map();
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const key = `${keyOf(entry.block)}`;
|
|
88
|
+
const bucket = sets.get(entry.parent) || new Map();
|
|
89
|
+
bucket.set(key, [...(bucket.get(key) || []), entry]);
|
|
90
|
+
sets.set(entry.parent, bucket);
|
|
91
|
+
}
|
|
92
|
+
const best = [...sets.values()].flatMap((bucket) => [...bucket.values()]).sort((a, b) => b.length - a.length)[0] || [];
|
|
93
|
+
return best.length > 1 ? best : [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The repeated items of a section: its most frequent item block type, else groups holding a
|
|
97
|
+
// heading (columns, benefits), else alike groups of an image and a caption (horizontal gallery).
|
|
98
|
+
function findItems(section) {
|
|
99
|
+
const entries = [...walk(section)];
|
|
100
|
+
const counts = new Map();
|
|
101
|
+
for (const { block } of entries) if (ITEM_TYPES.includes(block.type)) counts.set(block.type, (counts.get(block.type) || 0) + 1);
|
|
102
|
+
const type = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
103
|
+
if (type) return entries.filter(({ block }) => block.type === type);
|
|
104
|
+
const titled = largestSiblings(entries.filter(({ block }) => block.type === 'group' && holds(block, 'heading') && ![...walk(block)].slice(1).some((entry) => entry.block.type === 'group')), () => '');
|
|
105
|
+
if (titled.length) return titled;
|
|
106
|
+
return largestSiblings(entries.filter(({ block }) => leafGroup(block) && (holds(block, 'image') || holds(block, 'text'))), signature);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function refreshIds(block, nextId) {
|
|
110
|
+
if (!block.blocks) return block;
|
|
111
|
+
const mapping = {};
|
|
112
|
+
const blocks = {};
|
|
113
|
+
for (const [id, child] of Object.entries(block.blocks)) {
|
|
114
|
+
const fresh = child.static ? id : nextId(child.type);
|
|
115
|
+
mapping[id] = fresh;
|
|
116
|
+
blocks[fresh] = refreshIds(child, nextId);
|
|
117
|
+
}
|
|
118
|
+
block.blocks = blocks;
|
|
119
|
+
if (block.block_order) block.block_order = block.block_order.map((id) => mapping[id] || id);
|
|
120
|
+
return block;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setOption(catalog, block, key, value, notes) {
|
|
124
|
+
const setting = (catalog.block(block.type)?.schema?.settings || []).find((item) => item.id === key);
|
|
125
|
+
if (!setting || value === undefined || value === null || value === '') return;
|
|
126
|
+
const options = (setting.options || []).map((option) => option.value);
|
|
127
|
+
if (!options.length || options.includes(value)) {
|
|
128
|
+
block.settings[key] = value;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const hint = closest(String(value), options, 1)[0];
|
|
132
|
+
notes.push(`icône « ${value} » absente de la bibliothèque du thème${hint ? ` : « ${hint} » utilisée` : ' : ignorée'}`);
|
|
133
|
+
if (hint) block.settings[key] = hint;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const firstOf = (node, type) => [...walk(node)].find((entry) => entry.block.type === type);
|
|
137
|
+
|
|
138
|
+
// One item of content into one item block, whatever its kind.
|
|
139
|
+
function fillItem(catalog, entry, item, notes) {
|
|
140
|
+
const { block } = entry;
|
|
141
|
+
block.settings = block.settings || {};
|
|
142
|
+
const content = typeof item === 'string' ? { title: item } : item;
|
|
143
|
+
const title = content.title ?? content.question ?? content.name ?? content.label;
|
|
144
|
+
const text = content.text ?? content.answer;
|
|
145
|
+
switch (block.type) {
|
|
146
|
+
case '_timeline-step':
|
|
147
|
+
if (content.label !== undefined) block.settings.label = plain(content.label);
|
|
148
|
+
if (content.title) block.settings.title = inline(content.title);
|
|
149
|
+
if (text) block.settings.text = paragraphs(text);
|
|
150
|
+
if (content.image) block.settings.image = imageRef(content.image);
|
|
151
|
+
setOption(catalog, block, 'icon', content.icon, notes);
|
|
152
|
+
return;
|
|
153
|
+
case 'accordion':
|
|
154
|
+
if (title) block.settings.heading = plain(title);
|
|
155
|
+
if (text) block.settings.content = paragraphs(text);
|
|
156
|
+
setOption(catalog, block, 'icon', content.icon, notes);
|
|
157
|
+
return;
|
|
158
|
+
case 'testimonial':
|
|
159
|
+
writeTestimonial(block, { text, title: content.title, name: content.name, role: content.role, image: content.image });
|
|
160
|
+
return;
|
|
161
|
+
case 'counter':
|
|
162
|
+
if (content.value) block.settings.heading = plain(content.value);
|
|
163
|
+
if (content.label || content.title) block.settings.subheading = plain(content.label || content.title);
|
|
164
|
+
if (text) block.settings.description = inline(text);
|
|
165
|
+
setOption(catalog, block, 'icon', content.icon, notes);
|
|
166
|
+
return;
|
|
167
|
+
case 'progress-bar':
|
|
168
|
+
if (content.value !== undefined) block.settings.value = Math.max(0, Math.min(100, Math.round(Number(content.value))));
|
|
169
|
+
if (content.label || content.title) block.settings.label = inline(content.label || content.title);
|
|
170
|
+
if (text) block.settings.sublabel = inline(text);
|
|
171
|
+
return;
|
|
172
|
+
case '_spec-row':
|
|
173
|
+
if (content.label || content.title) block.settings.label = plain(content.label || content.title);
|
|
174
|
+
if (content.value || text) block.settings.value = inline(content.value || text);
|
|
175
|
+
setOption(catalog, block, 'icon', content.icon, notes);
|
|
176
|
+
return;
|
|
177
|
+
case '_comparison-row':
|
|
178
|
+
if (content.label || content.title) block.settings.label = inline(content.label || content.title);
|
|
179
|
+
(content.values || []).forEach((value, index) => {
|
|
180
|
+
const key = `value_${index + 1}`;
|
|
181
|
+
if (value === true || value === false) block.settings[`${key}_type`] = value ? 'check' : 'cross';
|
|
182
|
+
else {
|
|
183
|
+
block.settings[`${key}_type`] = 'text';
|
|
184
|
+
block.settings[key] = inline(value);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
case '_hotspot':
|
|
189
|
+
if (content.title) block.settings.title = inline(content.title);
|
|
190
|
+
if (text) block.settings.text = paragraphs(text);
|
|
191
|
+
if (content.product) block.settings.product = content.product;
|
|
192
|
+
setOption(catalog, block, 'marker_icon', content.icon, notes);
|
|
193
|
+
return;
|
|
194
|
+
case '_gallery-item':
|
|
195
|
+
case '_marquee-image':
|
|
196
|
+
case '_logo':
|
|
197
|
+
case '_ugc-item':
|
|
198
|
+
if (content.image) block.settings.image = imageRef(content.image);
|
|
199
|
+
if (content.title && block.type === '_gallery-item') block.settings.caption = inline(content.title);
|
|
200
|
+
if (content.title && block.type === '_logo') block.settings.name = plain(content.title);
|
|
201
|
+
if (content.link) block.settings.link = content.link;
|
|
202
|
+
return;
|
|
203
|
+
case '_story':
|
|
204
|
+
if (content.image) block.settings.image = imageRef(content.image);
|
|
205
|
+
if (content.title) block.settings.title = plain(content.title);
|
|
206
|
+
if (content.link) block.settings.link = content.link;
|
|
207
|
+
return;
|
|
208
|
+
default: {
|
|
209
|
+
// Containers (scroll cards, story steps, slides, columns): their heading, text, button,
|
|
210
|
+
// icon and image blocks.
|
|
211
|
+
const heading = firstOf(block, 'heading');
|
|
212
|
+
if (heading && title) heading.block.settings = { ...(heading.block.settings || {}), title: `<p>${inline(title)}</p>` };
|
|
213
|
+
const body = firstOf(block, 'text');
|
|
214
|
+
// No heading (a captioned photo): the title opens the text, in bold.
|
|
215
|
+
const caption = !heading && title ? `**${String(title).replace(/\*\*/g, '')}**${text ? `\n${text}` : ''}` : text;
|
|
216
|
+
if (body && caption) body.block.settings = { ...(body.block.settings || {}), description: paragraphs(caption) };
|
|
217
|
+
else if (body && !caption && content.title) remove(body.parent, body.id);
|
|
218
|
+
const button = firstOf(block, 'button');
|
|
219
|
+
if (button) {
|
|
220
|
+
if (content.button?.label) button.block.settings = { ...(button.block.settings || {}), button_text: inline(content.button.label), button_link: content.button.link || button.block.settings?.button_link || '' };
|
|
221
|
+
else remove(button.parent, button.id);
|
|
222
|
+
}
|
|
223
|
+
const icon = firstOf(block, 'icon');
|
|
224
|
+
if (icon && content.icon) setOption(catalog, icon.block, 'icon', content.icon, notes);
|
|
225
|
+
const image = firstOf(block, 'image');
|
|
226
|
+
if (image && content.image) image.block.settings = { ...(image.block.settings || {}), image: imageRef(content.image) };
|
|
227
|
+
else if (image && !block.settings.image) remove(image.parent, image.id);
|
|
228
|
+
if (content.image && ['_story-step', '_slide'].includes(block.type)) block.settings.image = imageRef(content.image);
|
|
229
|
+
if (content.palette && (catalog.block(block.type)?.schema?.settings || []).some((setting) => setting.id === 'color_scheme')) block.settings.color_scheme = content.palette;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function colorSettingOf(catalog, section) {
|
|
235
|
+
const settings = catalog.section(section.type)?.schema?.settings || [];
|
|
236
|
+
const ids = HERO_TYPES.has(section.type) ? ['frame_palette', 'color_palette', 'color_scheme'] : ['color_palette', 'color_scheme', 'frame_palette'];
|
|
237
|
+
return ids.find((id) => settings.some((setting) => setting.id === id && setting.type === 'color_scheme')) || null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const hasSetting = (catalog, section, id) => (catalog.section(section.type)?.schema?.settings || []).some((setting) => setting.id === id);
|
|
241
|
+
|
|
242
|
+
// Fills a section from a content spec. Returns notes about what could not be placed.
|
|
243
|
+
export function fillSection(catalog, section, spec, { nextId, h1 = false } = {}) {
|
|
244
|
+
const notes = [];
|
|
245
|
+
const content = spec.content || {};
|
|
246
|
+
section.settings = section.settings || {};
|
|
247
|
+
for (const { block } of walk(section)) block.settings = block.settings || {};
|
|
248
|
+
|
|
249
|
+
// Manifesto: a statement that lights up word by word on scroll.
|
|
250
|
+
if (content.manifesto) {
|
|
251
|
+
for (const { id, parent } of [...walk(section)].filter(({ parent: owner }) => owner === section)) remove(parent, id);
|
|
252
|
+
const { id, block } = blockFromPreset(catalog, 'text-reveal', { taken: new Set(), id: nextId('text_reveal') });
|
|
253
|
+
block.settings = { ...block.settings, text: inline(content.manifesto), alignment: 'center' };
|
|
254
|
+
section.blocks = { ...(section.blocks || {}), [id]: block };
|
|
255
|
+
section.block_order = [...(section.block_order || []), id];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const photos = [...(spec.images || [])];
|
|
259
|
+
const items = content.items?.length ? findItems(section) : [];
|
|
260
|
+
if (content.items?.length && !items.length) notes.push(`${section.type} : pas d'éléments répétables, liste ignorée`);
|
|
261
|
+
if (items.length) {
|
|
262
|
+
const last = items[items.length - 1];
|
|
263
|
+
while (items.length < content.items.length) {
|
|
264
|
+
const copy = refreshIds(clone(last.block), nextId);
|
|
265
|
+
const id = nextId(copy.type);
|
|
266
|
+
last.parent.blocks[id] = copy;
|
|
267
|
+
const order = last.parent.block_order || [];
|
|
268
|
+
order.splice(order.indexOf(items[items.length - 1].id) + 1, 0, id);
|
|
269
|
+
last.parent.block_order = order;
|
|
270
|
+
items.push({ id, block: copy, parent: last.parent });
|
|
271
|
+
}
|
|
272
|
+
items.slice(content.items.length).forEach(({ id, parent }) => remove(parent, id));
|
|
273
|
+
items.slice(0, content.items.length).forEach((entry, index) => {
|
|
274
|
+
// Items holding an image take the section photos in order when they bring none.
|
|
275
|
+
const item = typeof content.items[index] === 'string' ? { title: content.items[index] } : content.items[index];
|
|
276
|
+
const withPhoto = !item.image && photos.length && firstOf(entry.block, 'image') ? { ...item, image: photos.shift() } : item;
|
|
277
|
+
fillItem(catalog, entry, withPhoto, notes);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Texts of the section itself, outside its items.
|
|
282
|
+
const roots = items.map((entry) => entry.block);
|
|
283
|
+
const outside = within([...walk(section)], roots).filter(({ block }) => !roots.includes(block));
|
|
284
|
+
const heading = outside.find(({ block }) => block.type === 'heading');
|
|
285
|
+
if (heading) {
|
|
286
|
+
const tag = content.title_tag || (h1 ? 'h1' : 'p');
|
|
287
|
+
if (content.title) heading.block.settings.title = `<${tag}>${inline(content.title)}</${tag}>`;
|
|
288
|
+
if (content.eyebrow !== undefined) heading.block.settings.subheading = plain(content.eyebrow);
|
|
289
|
+
if (content.rotating_words?.length) heading.block.settings.rotating_words = content.rotating_words.map(plain).join('\n');
|
|
290
|
+
}
|
|
291
|
+
const text = outside.find(({ block }) => block.type === 'text');
|
|
292
|
+
if (text && content.text) text.block.settings.description = paragraphs(content.text);
|
|
293
|
+
else if (text && content.text === '') remove(text.parent, text.id);
|
|
294
|
+
if (content.buttons) {
|
|
295
|
+
const buttons = outside.filter(({ block }) => block.type === 'button');
|
|
296
|
+
buttons.forEach(({ id, block, parent }, index) => {
|
|
297
|
+
const button = content.buttons[index];
|
|
298
|
+
if (!button) return remove(parent, id);
|
|
299
|
+
block.settings.button_text = inline(button.label);
|
|
300
|
+
if (button.link) block.settings.button_link = button.link;
|
|
301
|
+
if (button.style) block.settings.button_style = String(button.style);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// Strings lists into scrolling text and check lists.
|
|
305
|
+
if (content.lines?.length) {
|
|
306
|
+
for (const { block } of outside) {
|
|
307
|
+
if (block.type === 'marquee-text') block.settings.text_items = content.lines.map(plain).join('\n');
|
|
308
|
+
if (block.type === 'checklist') block.settings.items_rich = content.lines.map((line) => `<p>${inline(line)}</p>`).join('');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Media and look of the section.
|
|
313
|
+
if (spec.image && hasSetting(catalog, section, 'image')) {
|
|
314
|
+
section.settings.image = imageRef(spec.image);
|
|
315
|
+
if (hasSetting(catalog, section, 'media_type')) section.settings.media_type = 'image';
|
|
316
|
+
}
|
|
317
|
+
if (spec.image_mobile && hasSetting(catalog, section, 'image_mobile')) section.settings.image_mobile = imageRef(spec.image_mobile);
|
|
318
|
+
if (spec.images?.length) {
|
|
319
|
+
for (const { block, id, parent } of [...walk(section)].filter(({ block }) => block.type === 'image' && !block.settings?.image)) {
|
|
320
|
+
if (photos.length) block.settings = { ...(block.settings || {}), image: imageRef(photos.shift()) };
|
|
321
|
+
else remove(parent, id);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const colorKey = colorSettingOf(catalog, section);
|
|
325
|
+
if (spec.palette && colorKey) section.settings[colorKey] = spec.palette;
|
|
326
|
+
if (spec.separator) {
|
|
327
|
+
for (const edge of ['top', 'bottom']) if (spec.separator[edge] && hasSetting(catalog, section, `separator_${edge}`)) section.settings[`separator_${edge}`] = spec.separator[edge];
|
|
328
|
+
if (spec.separator.animation && hasSetting(catalog, section, 'separator_animation')) section.settings.separator_animation = spec.separator.animation;
|
|
329
|
+
if (spec.separator.flip && hasSetting(catalog, section, 'separator_bottom_flip')) section.settings.separator_bottom_flip = true;
|
|
330
|
+
}
|
|
331
|
+
if (spec.settings) Object.assign(section.settings, spec.settings);
|
|
332
|
+
return notes;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Builds sections from specs: preset, content, media, palette, separators.
|
|
336
|
+
export function composeSections(catalog, specs, { language = 'fr', data = { sections: {}, order: [] }, h1First = true } = {}) {
|
|
337
|
+
const result = clone(data);
|
|
338
|
+
result.sections = result.sections || {};
|
|
339
|
+
result.order = result.order || [];
|
|
340
|
+
const taken = collectIds(result);
|
|
341
|
+
const nextId = createIdFactory(taken);
|
|
342
|
+
const notes = [];
|
|
343
|
+
let h1Used = JSON.stringify(result).includes('<h1');
|
|
344
|
+
for (const spec of specs) {
|
|
345
|
+
const base = spec.type.replace(/[^A-Za-z0-9]+/g, '_');
|
|
346
|
+
let wanted = spec.id || base;
|
|
347
|
+
for (let index = 2; taken.has(wanted); index++) wanted = `${spec.id || base}_${index}`;
|
|
348
|
+
const { id, section } = sectionFromPreset(catalog, spec.type, { preset: spec.preset, language, taken, id: wanted });
|
|
349
|
+
const h1 = h1First && !h1Used && Boolean(spec.content?.title) && !spec.content?.title_tag;
|
|
350
|
+
notes.push(...fillSection(catalog, section, spec, { nextId, h1 }).map((note) => `${id} : ${note}`));
|
|
351
|
+
if (h1) h1Used = true;
|
|
352
|
+
result.sections[id] = section;
|
|
353
|
+
result.order.push(id);
|
|
354
|
+
}
|
|
355
|
+
return { data: result, notes };
|
|
356
|
+
}
|