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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Client de la passerelle multi-boutiques de Story Apps (l'appli Shopify du Story Thème).
|
|
2
|
+
// Le jeton est celui du MCP (STORY_MCP_TOKEN) : aucune clé Shopify n'est jamais stockée ici.
|
|
3
|
+
|
|
4
|
+
const DEFAULT_APPS = 'https://apps.story-theme.com';
|
|
5
|
+
|
|
6
|
+
export function appsBase(env = process.env) {
|
|
7
|
+
return (env.STORY_APPS_URL || DEFAULT_APPS).trim().replace(/\/$/, '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ShopsError extends Error {}
|
|
11
|
+
|
|
12
|
+
export function createShopsClient({ token, base = appsBase(), fetchImpl = globalThis.fetch }) {
|
|
13
|
+
const appel = async (chemin, options = {}) => {
|
|
14
|
+
if (!token) {
|
|
15
|
+
throw new ShopsError(
|
|
16
|
+
"Aucun jeton : générez-le dans votre espace Story Thème (Story Thème > MCP), puis ajoutez STORY_MCP_TOKEN à la configuration de Claude.",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
let res;
|
|
20
|
+
try {
|
|
21
|
+
res = await fetchImpl(`${base}${chemin}`, {
|
|
22
|
+
...options,
|
|
23
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...(options.headers || {}) },
|
|
24
|
+
signal: AbortSignal.timeout(30000),
|
|
25
|
+
});
|
|
26
|
+
} catch {
|
|
27
|
+
throw new ShopsError('Story Apps injoignable. Vérifiez votre connexion, puis réessayez.');
|
|
28
|
+
}
|
|
29
|
+
const data = await res.json().catch(() => ({}));
|
|
30
|
+
if (!res.ok) throw new ShopsError(data.error || `Erreur ${res.status}`);
|
|
31
|
+
return data;
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
list: ({ overview = false } = {}) => appel(`/mcp/shops${overview ? '?overview=1' : ''}`),
|
|
35
|
+
graphql: ({ shops, query, variables, confirm }) =>
|
|
36
|
+
appel('/mcp/graphql', { method: 'POST', body: JSON.stringify({ shops, query, variables, confirm }) }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Mise en forme « Story Thème » des résultats multi-boutiques : lisible par le modèle comme par
|
|
2
|
+
// l'utilisateur quand il la recopie.
|
|
3
|
+
|
|
4
|
+
const BANDEAU = '◆ Story Thème · Vos boutiques';
|
|
5
|
+
const n = (v) => (v === null || v === undefined ? '—' : new Intl.NumberFormat('fr-FR').format(v));
|
|
6
|
+
|
|
7
|
+
const etatTheme = (t = {}) => {
|
|
8
|
+
if (!t.version) return 'thème inconnu';
|
|
9
|
+
const base = `${t.compatible ? 'Story Thème' : t.name || 'autre thème'} ${t.version}`;
|
|
10
|
+
return t.updateAvailable ? `${base} (mise à jour dispo)` : base;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const etatLicence = (l) => ({ active: 'licence active', inactive: '⚠️ licence inactive', unknown: '⚠️ boutique non rattachée' })[l] || l || '';
|
|
14
|
+
|
|
15
|
+
export function formatShopList(data) {
|
|
16
|
+
const shops = data.shops || [];
|
|
17
|
+
const lignes = [BANDEAU, `Compte : ${data.account?.email || '?'} · ${shops.length} boutique${shops.length > 1 ? 's' : ''} connectée${shops.length > 1 ? 's' : ''}`, ''];
|
|
18
|
+
if (!shops.length) {
|
|
19
|
+
lignes.push(
|
|
20
|
+
"Aucune boutique connectée. Pour en connecter une : installez l'appli Story Apps sur la boutique (depuis l'App Store Shopify ou l'espace Story Thème), et vérifiez qu'elle est déclarée dans votre espace membre (Boutiques).",
|
|
21
|
+
);
|
|
22
|
+
return lignes.join('\n');
|
|
23
|
+
}
|
|
24
|
+
for (const s of shops) {
|
|
25
|
+
lignes.push(`● ${s.name} — ${s.domain || s.shop}`);
|
|
26
|
+
lignes.push(` handle : ${s.handle || s.shop} · ${etatTheme(s.theme)} · ${etatLicence(s.licence)}`);
|
|
27
|
+
}
|
|
28
|
+
lignes.push('', 'shop_graphql accepte un handle, un domaine, une liste ou "all".');
|
|
29
|
+
return lignes.join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatOverview(data) {
|
|
33
|
+
const shops = data.shops || [];
|
|
34
|
+
const lignes = [BANDEAU, `Vue d'ensemble · ${shops.length} boutique${shops.length > 1 ? 's' : ''}`, ''];
|
|
35
|
+
for (const s of shops) {
|
|
36
|
+
lignes.push(`● ${s.name}${s.url ? ` — ${s.url}` : ''}`);
|
|
37
|
+
lignes.push(` ${etatTheme(s.theme)} · ${etatLicence(s.licence)}${s.plan ? ` · Shopify ${s.plan}` : ''}`);
|
|
38
|
+
lignes.push(` ${n(s.products)} produits · ${n(s.collections)} collections`);
|
|
39
|
+
const m = s.modules || {};
|
|
40
|
+
lignes.push(
|
|
41
|
+
` Story Apps : ${n(m.reviews)} avis${m.reviewsPending ? ` (${m.reviewsPending} à modérer)` : ''} · ${n(m.abTests)} test${m.abTests > 1 ? 's' : ''} A/B · ${n(m.postPurchaseOffers)} offre${m.postPurchaseOffers > 1 ? 's' : ''} après paiement`,
|
|
42
|
+
);
|
|
43
|
+
if (s.errors?.length) lignes.push(` ⚠️ ${s.errors.map((e) => e.message).join(' · ')}`);
|
|
44
|
+
}
|
|
45
|
+
return lignes.join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatGraphql(data) {
|
|
49
|
+
const res = data.results || [];
|
|
50
|
+
const ok = res.filter((r) => !r.errors?.length).length;
|
|
51
|
+
const lignes = [`${BANDEAU} · ${data.operation === 'mutation' ? 'écriture' : 'lecture'} sur ${res.length} boutique${res.length > 1 ? 's' : ''} (${ok} OK)`];
|
|
52
|
+
for (const r of res) {
|
|
53
|
+
lignes.push('', `## ${r.name} (${r.shop})`);
|
|
54
|
+
if (r.errors?.length) lignes.push(`⛔ ${r.errors.map((e) => e.message).join(' · ')}`);
|
|
55
|
+
if (r.data) lignes.push(JSON.stringify(r.data, null, 2));
|
|
56
|
+
}
|
|
57
|
+
return lignes.join('\n');
|
|
58
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Outils « multi-boutiques » du MCP Story Thème : lire et modifier TOUTES les boutiques d'un
|
|
2
|
+
// client en même temps, sans se déconnecter, via l'appli Shopify Story Apps.
|
|
3
|
+
//
|
|
4
|
+
// Branchement : dans src/server.js, juste avant `return server;` de createServer :
|
|
5
|
+
// registerShopTools(server, config);
|
|
6
|
+
// (import { registerShopTools } from './shops/tools.js';)
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { createShopsClient, appsBase, ShopsError } from './client.js';
|
|
10
|
+
import { formatShopList, formatOverview, formatGraphql } from './format.js';
|
|
11
|
+
|
|
12
|
+
const ok = (text, structured) => ({ content: [{ type: 'text', text }], ...(structured ? { structuredContent: structured } : {}) });
|
|
13
|
+
const fail = (text) => ({ content: [{ type: 'text', text: `⛔ ${text}` }], isError: true });
|
|
14
|
+
|
|
15
|
+
const garde = (fn) => async (args) => {
|
|
16
|
+
try {
|
|
17
|
+
return await fn(args || {});
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return fail(e instanceof ShopsError ? e.message : `Erreur inattendue : ${e.message}`);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const cibleSchema = z
|
|
24
|
+
.union([z.string(), z.array(z.string())])
|
|
25
|
+
.describe('Boutique(s) : handle (ex. "maison-lumen"), domaine, liste, ou "all" pour toutes.');
|
|
26
|
+
|
|
27
|
+
export function registerShopTools(server, config, { fetchImpl } = {}) {
|
|
28
|
+
const client = createShopsClient({ token: config.token, base: appsBase(), fetchImpl });
|
|
29
|
+
|
|
30
|
+
server.registerTool(
|
|
31
|
+
'list_shops',
|
|
32
|
+
{
|
|
33
|
+
title: 'Vos boutiques',
|
|
34
|
+
description:
|
|
35
|
+
"Lists every Shopify store of this Story Theme account that has the Story Apps app installed: name, domain, handle, theme version, licence. Call it first before any multi-store work. Stores are reached through Story Apps: no Shopify key needed.",
|
|
36
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
37
|
+
},
|
|
38
|
+
garde(async () => {
|
|
39
|
+
const data = await client.list();
|
|
40
|
+
return ok(formatShopList(data), data);
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
server.registerTool(
|
|
45
|
+
'shops_overview',
|
|
46
|
+
{
|
|
47
|
+
title: "Vue d'ensemble des boutiques",
|
|
48
|
+
description:
|
|
49
|
+
'Key figures of all connected stores in one call: products, collections, Shopify plan, theme version and pending update, licence, and Story Apps modules (reviews, pending reviews, A/B tests, post-purchase offers).',
|
|
50
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
51
|
+
},
|
|
52
|
+
garde(async () => {
|
|
53
|
+
const data = await client.list({ overview: true });
|
|
54
|
+
return ok(formatOverview(data), data);
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
server.registerTool(
|
|
59
|
+
'shop_graphql',
|
|
60
|
+
{
|
|
61
|
+
title: 'Requête sur une ou plusieurs boutiques',
|
|
62
|
+
description:
|
|
63
|
+
'Runs one Shopify Admin GraphQL operation (API 2025-10) on one, several or all connected stores, in parallel. Queries and mutations within the Story Apps scopes (products, collections, pages, menus, files, discounts, themes read). A mutation on several stores needs confirm: true, only after the user explicitly agreed. Every access is logged.',
|
|
64
|
+
inputSchema: {
|
|
65
|
+
shops: cibleSchema,
|
|
66
|
+
query: z.string().describe('Requête ou mutation GraphQL Admin.'),
|
|
67
|
+
variables: z.record(z.string(), z.any()).optional(),
|
|
68
|
+
confirm: z.boolean().optional().describe("Obligatoire pour une écriture sur plusieurs boutiques, après accord de l'utilisateur."),
|
|
69
|
+
},
|
|
70
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
71
|
+
},
|
|
72
|
+
garde(async ({ shops, query, variables, confirm }) => {
|
|
73
|
+
const data = await client.graphql({ shops: shops ?? 'all', query, variables, confirm });
|
|
74
|
+
return ok(formatGraphql(data), data);
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { SECTION_NOTES, BLOCK_NOTES } from '../knowledge/descriptions.js';
|
|
2
|
+
|
|
3
|
+
export const NON_VALUE_TYPES = new Set(['header', 'paragraph']);
|
|
4
|
+
|
|
5
|
+
export function normalizeText(value) {
|
|
6
|
+
return String(value ?? '')
|
|
7
|
+
.normalize('NFD')
|
|
8
|
+
.replace(/[̀-ͯ]/g, '')
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/<[^>]+>/g, ' ')
|
|
11
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// "templates/page.contact.json" -> "page", "templates/customers/account.json" -> "customers/account".
|
|
16
|
+
export function templateTypeOf(file) {
|
|
17
|
+
const name = file.replace(/^templates\//, '').replace(/\.json$/, '');
|
|
18
|
+
if (name.startsWith('customers/')) return name;
|
|
19
|
+
return name.split('.')[0];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isInternalTemplate(file) {
|
|
23
|
+
return /\.qa[\w-]*\.json$|\/[a-z]+\.qa[\w-]*\.json$|hero-test|\/product\.v4\.json$/.test(file);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function levenshtein(a, b) {
|
|
27
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
28
|
+
for (let i = 1; i <= a.length; i++) {
|
|
29
|
+
let previous = row[0];
|
|
30
|
+
row[0] = i;
|
|
31
|
+
for (let j = 1; j <= b.length; j++) {
|
|
32
|
+
const current = row[j];
|
|
33
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
34
|
+
previous = current;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return row[b.length];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function closest(value, candidates, max = 3) {
|
|
41
|
+
const target = String(value).toLowerCase();
|
|
42
|
+
return [...candidates]
|
|
43
|
+
.map((candidate) => ({ candidate, score: levenshtein(target, String(candidate).toLowerCase()) - (String(candidate).includes(target) || target.includes(candidate) ? 2 : 0) }))
|
|
44
|
+
.filter(({ score, candidate }) => score <= Math.max(3, Math.floor(String(candidate).length / 2)))
|
|
45
|
+
.sort((a, b) => a.score - b.score)
|
|
46
|
+
.slice(0, max)
|
|
47
|
+
.map(({ candidate }) => candidate);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class Catalog {
|
|
51
|
+
constructor(theme) {
|
|
52
|
+
this.theme = theme;
|
|
53
|
+
this.version = theme.version;
|
|
54
|
+
this.name = theme.name;
|
|
55
|
+
this.locales = new Map();
|
|
56
|
+
this.defaultLanguage = theme.defaultLanguage;
|
|
57
|
+
this.globalSettings = this.#buildGlobalSettings();
|
|
58
|
+
this.schemeDefinition = this.globalSettings.find((setting) => setting.type === 'color_scheme_group') || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- translations
|
|
62
|
+
|
|
63
|
+
locale(language) {
|
|
64
|
+
if (!this.locales.has(language)) this.locales.set(language, this.theme.loadSchemaLocale(language));
|
|
65
|
+
return this.locales.get(language);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get languages() {
|
|
69
|
+
return this.theme.schemaLanguages;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
resolveKey(key, language) {
|
|
73
|
+
for (const lang of [language, this.defaultLanguage]) {
|
|
74
|
+
const tree = this.locale(lang);
|
|
75
|
+
let node = tree;
|
|
76
|
+
for (const part of key.split('.')) {
|
|
77
|
+
if (!node || typeof node !== 'object' || !(part in node)) {
|
|
78
|
+
node = undefined;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
node = node[part];
|
|
82
|
+
}
|
|
83
|
+
if (typeof node === 'string') return node;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
t(value, language = 'fr') {
|
|
89
|
+
if (typeof value === 'string' && value.startsWith('t:')) {
|
|
90
|
+
const resolved = this.resolveKey(value.slice(2), language);
|
|
91
|
+
return resolved ?? value;
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// English text -> the same text in `language`, from the theme's editor and storefront
|
|
97
|
+
// translations. Used to localise the English sample content of the shipped templates.
|
|
98
|
+
translationPairs(language) {
|
|
99
|
+
this._pairs ||= new Map();
|
|
100
|
+
if (this._pairs.has(language)) return this._pairs.get(language);
|
|
101
|
+
const pairs = new Map();
|
|
102
|
+
const add = (source, target) => {
|
|
103
|
+
const walk = (node, other) => {
|
|
104
|
+
for (const [key, value] of Object.entries(node || {})) {
|
|
105
|
+
if (typeof value === 'string' && typeof other?.[key] === 'string') {
|
|
106
|
+
if (!pairs.has(value) && value.trim()) pairs.set(value, other[key]);
|
|
107
|
+
} else if (value && typeof value === 'object') walk(value, other?.[key]);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
walk(source, target);
|
|
111
|
+
};
|
|
112
|
+
if (language !== this.defaultLanguage) {
|
|
113
|
+
add(this.locale(this.defaultLanguage), this.locale(language));
|
|
114
|
+
add(this.theme.loadStorefrontLocale(this.defaultLanguage), this.theme.loadStorefrontLocale(language));
|
|
115
|
+
}
|
|
116
|
+
this._pairs.set(language, pairs);
|
|
117
|
+
return pairs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resolves every t: string in a preset tree (sample content) for one language.
|
|
121
|
+
translateDeep(value, language) {
|
|
122
|
+
if (typeof value === 'string') return value.startsWith('t:') ? this.resolveKey(value.slice(2), language) ?? '' : value;
|
|
123
|
+
if (Array.isArray(value)) return value.map((item) => this.translateDeep(item, language));
|
|
124
|
+
if (value && typeof value === 'object') {
|
|
125
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.translateDeep(item, language)]));
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- definitions
|
|
131
|
+
|
|
132
|
+
section(type) {
|
|
133
|
+
return this.theme.sections.get(type) || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
block(type) {
|
|
137
|
+
return this.theme.blocks.get(type) || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get sectionTypes() {
|
|
141
|
+
return [...this.theme.sections.keys()];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get blockTypes() {
|
|
145
|
+
return [...this.theme.blocks.keys()];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
isPrivate(type) {
|
|
149
|
+
return type.startsWith('_');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
isAppBlock(type) {
|
|
153
|
+
return typeof type === 'string' && type.startsWith('shopify://apps/');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// What a section or block schema accepts as children.
|
|
157
|
+
childPolicy(schema) {
|
|
158
|
+
const declared = schema?.blocks || [];
|
|
159
|
+
return {
|
|
160
|
+
theme: declared.some((entry) => entry.type === '@theme'),
|
|
161
|
+
app: declared.some((entry) => entry.type === '@app'),
|
|
162
|
+
explicit: declared.filter((entry) => !entry.type.startsWith('@')).map((entry) => entry.type),
|
|
163
|
+
local: Object.fromEntries(declared.filter((entry) => !entry.type.startsWith('@') && (entry.settings || entry.name)).map((entry) => [entry.type, entry])),
|
|
164
|
+
maxBlocks: schema?.max_blocks ?? 50,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Returns the definition a child block is validated against, or a reason it is refused.
|
|
169
|
+
resolveChild(parentSchema, type) {
|
|
170
|
+
const policy = this.childPolicy(parentSchema);
|
|
171
|
+
if (this.isAppBlock(type)) {
|
|
172
|
+
return policy.app ? { ok: true, app: true } : { ok: false, reason: "n'accepte pas les blocs d'application (@app)" };
|
|
173
|
+
}
|
|
174
|
+
if (Object.hasOwn(policy.local, type)) return { ok: true, schema: policy.local[type], local: true };
|
|
175
|
+
const entry = this.block(type);
|
|
176
|
+
if (!entry) return { ok: false, reason: `le bloc « ${type} » n'existe pas dans le thème`, unknown: true };
|
|
177
|
+
if (!entry.schema) return { ok: false, reason: `blocks/${type}.liquid n'a pas de schéma lisible` };
|
|
178
|
+
if (policy.explicit.includes(type)) return { ok: true, schema: entry.schema };
|
|
179
|
+
if (policy.theme && !this.isPrivate(type)) return { ok: true, schema: entry.schema };
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: this.isPrivate(type)
|
|
183
|
+
? `« ${type} » est un bloc privé : seul un parent qui le déclare explicitement peut le contenir`
|
|
184
|
+
: "ce parent n'accepte pas les blocs du thème (@theme) et ne déclare pas ce type",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
acceptedChildTypes(schema) {
|
|
189
|
+
const policy = this.childPolicy(schema);
|
|
190
|
+
const types = new Set(policy.explicit);
|
|
191
|
+
if (policy.theme) for (const type of this.blockTypes) if (!this.isPrivate(type)) types.add(type);
|
|
192
|
+
return [...types];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Every section or block that can hold a block type directly.
|
|
196
|
+
parentsOf(type) {
|
|
197
|
+
const parents = [];
|
|
198
|
+
for (const [kind, map] of [['section', this.theme.sections], ['block', this.theme.blocks]]) {
|
|
199
|
+
for (const [parentType, entry] of map) {
|
|
200
|
+
if (!entry.schema?.blocks) continue;
|
|
201
|
+
const policy = this.childPolicy(entry.schema);
|
|
202
|
+
if (policy.explicit.includes(type)) parents.push({ kind, type: parentType, via: 'explicit' });
|
|
203
|
+
else if (policy.theme && !this.isPrivate(type)) parents.push({ kind, type: parentType, via: '@theme' });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return parents;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
staticChildren(kind, type) {
|
|
210
|
+
const entry = kind === 'section' ? this.section(type) : this.block(type);
|
|
211
|
+
return entry?.statics || [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- placement rules
|
|
215
|
+
|
|
216
|
+
sectionAllowedIn(schema, context) {
|
|
217
|
+
const enabled = schema?.enabled_on;
|
|
218
|
+
const disabled = schema?.disabled_on;
|
|
219
|
+
const match = (rule) => {
|
|
220
|
+
if (!rule) return false;
|
|
221
|
+
if (context.template) {
|
|
222
|
+
const list = rule.templates || [];
|
|
223
|
+
return list.includes('*') || list.includes(context.template);
|
|
224
|
+
}
|
|
225
|
+
if (context.group) {
|
|
226
|
+
const list = rule.groups || [];
|
|
227
|
+
return list.includes('*') || list.includes(context.group) || (context.group.startsWith('custom.') && list.includes('custom.*'));
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
};
|
|
231
|
+
if (enabled && !match(enabled)) return false;
|
|
232
|
+
if (disabled && match(disabled)) return false;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
describePlacement(schema, language = 'fr') {
|
|
237
|
+
const parts = [];
|
|
238
|
+
const describe = (rule) => [...(rule.templates || []).map((t) => `modèle ${t}`), ...(rule.groups || []).map((g) => `groupe ${g}`)].join(', ');
|
|
239
|
+
if (schema?.enabled_on) parts.push(`${language === 'fr' ? 'uniquement' : 'only'} : ${describe(schema.enabled_on)}`);
|
|
240
|
+
if (schema?.disabled_on) parts.push(`${language === 'fr' ? 'interdit' : 'not in'} : ${describe(schema.disabled_on)}`);
|
|
241
|
+
return parts.join(' ; ') || (language === 'fr' ? 'partout' : 'anywhere');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- settings
|
|
245
|
+
|
|
246
|
+
normalizeSettings(settings, language = 'fr') {
|
|
247
|
+
const out = [];
|
|
248
|
+
let group = null;
|
|
249
|
+
for (const setting of settings || []) {
|
|
250
|
+
if (setting.type === 'header') {
|
|
251
|
+
group = this.t(setting.content, language);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (setting.type === 'paragraph') continue;
|
|
255
|
+
const item = { id: setting.id, type: setting.type, label: this.t(setting.label, language) ?? setting.id };
|
|
256
|
+
if (group) item.group = group;
|
|
257
|
+
if ('default' in setting) item.default = typeof setting.default === 'string' ? this.t(setting.default, language) : setting.default;
|
|
258
|
+
if (setting.options) item.options = setting.options.map((option) => ({ value: option.value, label: this.t(option.label, language) }));
|
|
259
|
+
for (const key of ['min', 'max', 'step', 'unit', 'visible_if', 'limit', 'accept']) if (key in setting) item[key] = setting[key];
|
|
260
|
+
if (setting.info) item.info = this.t(setting.info, language);
|
|
261
|
+
if (setting.placeholder) item.placeholder = this.t(setting.placeholder, language);
|
|
262
|
+
if (setting.definition) item.definition = this.normalizeSettings(setting.definition, language);
|
|
263
|
+
out.push(item);
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
settingIndex(schema) {
|
|
269
|
+
return new Map((schema?.settings || []).filter((setting) => setting.id).map((setting) => [setting.id, setting]));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#buildGlobalSettings() {
|
|
273
|
+
const list = [];
|
|
274
|
+
for (const group of this.theme.settingsSchema) {
|
|
275
|
+
if (group.name === 'theme_info') continue;
|
|
276
|
+
for (const setting of group.settings || []) if (setting.id) list.push({ ...setting, __group: group.name });
|
|
277
|
+
}
|
|
278
|
+
return list;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
globalSettingIndex() {
|
|
282
|
+
return new Map(this.globalSettings.map((setting) => [setting.id, setting]));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
defaultSchemeIds() {
|
|
286
|
+
return Object.keys(this.theme.settingsData?.current?.color_schemes || {});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- presets
|
|
290
|
+
|
|
291
|
+
presetsOf(schema) {
|
|
292
|
+
return schema?.presets || [];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
findPreset(schema, name, language = 'fr') {
|
|
296
|
+
const presets = this.presetsOf(schema);
|
|
297
|
+
if (name === undefined || name === null || name === '') return { preset: presets[0] || null, index: 0 };
|
|
298
|
+
if (typeof name === 'number') return { preset: presets[name] || null, index: name };
|
|
299
|
+
const target = normalizeText(name);
|
|
300
|
+
const index = presets.findIndex((preset) =>
|
|
301
|
+
[this.t(preset.name, 'en'), this.t(preset.name, language), this.t(preset.name, 'fr'), preset.name].some((label) => normalizeText(label) === target),
|
|
302
|
+
);
|
|
303
|
+
return { preset: index === -1 ? null : presets[index], index };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
presetNames(schema, language = 'fr') {
|
|
307
|
+
return this.presetsOf(schema).map((preset) => this.t(preset.name, language));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---- summaries for the model
|
|
311
|
+
|
|
312
|
+
notes(kind, type) {
|
|
313
|
+
return (kind === 'section' ? SECTION_NOTES : BLOCK_NOTES)[type] || null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
sectionSummary(type, language = 'fr') {
|
|
317
|
+
const entry = this.section(type);
|
|
318
|
+
if (!entry?.schema) return null;
|
|
319
|
+
const schema = entry.schema;
|
|
320
|
+
const policy = this.childPolicy(schema);
|
|
321
|
+
const notes = this.notes('section', type);
|
|
322
|
+
return {
|
|
323
|
+
type,
|
|
324
|
+
name: this.t(schema.name, language),
|
|
325
|
+
name_en: this.t(schema.name, 'en'),
|
|
326
|
+
description: notes?.fr || null,
|
|
327
|
+
use_for: notes?.use || null,
|
|
328
|
+
placement: this.describePlacement(schema, language),
|
|
329
|
+
presets: this.presetsOf(schema).map((preset) => ({ name: this.t(preset.name, language), name_en: this.t(preset.name, 'en'), category: this.t(preset.category, language) })),
|
|
330
|
+
children: policy.theme ? `tous les blocs publics du thème${policy.explicit.length ? ` + ${policy.explicit.join(', ')}` : ''}${policy.app ? ' + blocs d’applications' : ''}` : policy.explicit.join(', ') || 'aucun',
|
|
331
|
+
static_blocks: entry.statics,
|
|
332
|
+
limit: schema.limit || null,
|
|
333
|
+
settings_count: (schema.settings || []).filter((setting) => setting.id).length,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
blockSummary(type, language = 'fr') {
|
|
338
|
+
const entry = this.block(type);
|
|
339
|
+
if (!entry?.schema) return null;
|
|
340
|
+
const schema = entry.schema;
|
|
341
|
+
const policy = this.childPolicy(schema);
|
|
342
|
+
const notes = this.notes('block', type);
|
|
343
|
+
return {
|
|
344
|
+
type,
|
|
345
|
+
name: this.t(schema.name, language),
|
|
346
|
+
name_en: this.t(schema.name, 'en'),
|
|
347
|
+
private: this.isPrivate(type),
|
|
348
|
+
description: notes?.fr || null,
|
|
349
|
+
presets: this.presetsOf(schema).map((preset) => this.t(preset.name, language)),
|
|
350
|
+
category: this.presetsOf(schema)[0] ? this.t(this.presetsOf(schema)[0].category, language) : null,
|
|
351
|
+
children: policy.theme ? 'tous les blocs publics' + (policy.explicit.length ? ` + ${policy.explicit.join(', ')}` : '') : policy.explicit.join(', ') || null,
|
|
352
|
+
static_blocks: entry.statics,
|
|
353
|
+
settings_count: (schema.settings || []).filter((setting) => setting.id).length,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- search
|
|
358
|
+
|
|
359
|
+
searchIndex() {
|
|
360
|
+
if (this._index) return this._index;
|
|
361
|
+
const docs = [];
|
|
362
|
+
const push = (kind, key, title, texts) => docs.push({ kind, key, title, text: normalizeText(texts.filter(Boolean).join(' ')), titleText: normalizeText(title) });
|
|
363
|
+
for (const [type, entry] of this.theme.sections) {
|
|
364
|
+
if (!entry.schema) continue;
|
|
365
|
+
const notes = this.notes('section', type);
|
|
366
|
+
const presetNames = this.presetsOf(entry.schema).flatMap((preset) => [this.t(preset.name, 'fr'), this.t(preset.name, 'en')]);
|
|
367
|
+
push('section', type, `${this.t(entry.schema.name, 'fr')} (${type})`, [type, this.t(entry.schema.name, 'fr'), this.t(entry.schema.name, 'en'), notes?.fr, notes?.use, notes?.keywords, ...presetNames]);
|
|
368
|
+
for (const setting of entry.schema.settings || []) {
|
|
369
|
+
if (!setting.id) continue;
|
|
370
|
+
push('setting', `${type}.${setting.id}`, `section ${type} › ${setting.id}`, [setting.id, this.t(setting.label, 'fr'), this.t(setting.label, 'en'), this.t(setting.info, 'fr')]);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
for (const [type, entry] of this.theme.blocks) {
|
|
374
|
+
if (!entry.schema) continue;
|
|
375
|
+
const notes = this.notes('block', type);
|
|
376
|
+
const presetNames = this.presetsOf(entry.schema).flatMap((preset) => [this.t(preset.name, 'fr'), this.t(preset.name, 'en')]);
|
|
377
|
+
push('block', type, `${this.t(entry.schema.name, 'fr')} (${type})`, [type, this.t(entry.schema.name, 'fr'), this.t(entry.schema.name, 'en'), notes?.fr, notes?.keywords, ...presetNames]);
|
|
378
|
+
for (const setting of entry.schema.settings || []) {
|
|
379
|
+
if (!setting.id) continue;
|
|
380
|
+
push('setting', `${type}.${setting.id}`, `bloc ${type} › ${setting.id}`, [setting.id, this.t(setting.label, 'fr'), this.t(setting.label, 'en'), this.t(setting.info, 'fr')]);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const setting of this.globalSettings) {
|
|
384
|
+
push('global_setting', setting.id, `réglage du thème › ${setting.id}`, [setting.id, this.t(setting.label, 'fr'), this.t(setting.label, 'en'), this.t(setting.info, 'fr'), this.t(setting.__group, 'fr')]);
|
|
385
|
+
}
|
|
386
|
+
this._index = docs;
|
|
387
|
+
return docs;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
search(query, { kinds = null, limit = 25, extra = [] } = {}) {
|
|
391
|
+
const terms = normalizeText(query).split(' ').filter((term) => term.length > 1);
|
|
392
|
+
if (!terms.length) return [];
|
|
393
|
+
const docs = [...this.searchIndex(), ...extra];
|
|
394
|
+
const scored = [];
|
|
395
|
+
// Short queries need every word; longer ones tolerate one miss.
|
|
396
|
+
const needed = terms.length <= 2 ? terms.length : terms.length - 1;
|
|
397
|
+
for (const doc of docs) {
|
|
398
|
+
if (kinds && !kinds.includes(doc.kind)) continue;
|
|
399
|
+
let score = 0;
|
|
400
|
+
let matched = 0;
|
|
401
|
+
for (const term of terms) {
|
|
402
|
+
const stem = term.length > 4 ? term.slice(0, term.length - 1) : term;
|
|
403
|
+
if (doc.titleText.includes(stem)) score += 4;
|
|
404
|
+
if (doc.text.includes(stem)) {
|
|
405
|
+
score += 1;
|
|
406
|
+
matched += 1;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (matched < needed) continue;
|
|
410
|
+
if (doc.kind === 'setting' || doc.kind === 'global_setting') score -= 1;
|
|
411
|
+
if (score > 0) scored.push({ ...doc, score });
|
|
412
|
+
}
|
|
413
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit).map(({ kind, key, title, score }) => ({ kind, key, title, score: Math.round(score * 10) / 10 }));
|
|
414
|
+
}
|
|
415
|
+
}
|