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,47 @@
|
|
|
1
|
+
import { sectionFromPreset, blockFromPreset, collectIds } from './scaffold.js';
|
|
2
|
+
|
|
3
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
4
|
+
|
|
5
|
+
function findContainer(node, type) {
|
|
6
|
+
for (const id of node.block_order || []) {
|
|
7
|
+
const block = node.blocks?.[id];
|
|
8
|
+
if (!block) continue;
|
|
9
|
+
if (block.type === type) return block;
|
|
10
|
+
const inner = findContainer(block, type);
|
|
11
|
+
if (inner) return inner;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readableId(type, taken) {
|
|
17
|
+
const base = type.replace(/[^A-Za-z0-9]+/g, '_');
|
|
18
|
+
let id = base;
|
|
19
|
+
for (let index = 2; taken.has(id); index++) id = `${base}_${index}`;
|
|
20
|
+
return id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Composes a template from a recipe. Returns the data and, per section, what its content must say.
|
|
24
|
+
export function buildFromRecipe(catalog, recipe, { language = 'fr', base = null } = {}) {
|
|
25
|
+
const data = base ? clone(base) : { sections: {}, order: [] };
|
|
26
|
+
const taken = collectIds(data);
|
|
27
|
+
const roles = [];
|
|
28
|
+
for (const step of recipe.steps) {
|
|
29
|
+
const { id, section } = sectionFromPreset(catalog, step.type, { preset: step.preset, language, taken, id: readableId(step.type, taken) });
|
|
30
|
+
if (step.settings) section.settings = { ...(section.settings || {}), ...step.settings };
|
|
31
|
+
for (const extra of step.blocks || []) {
|
|
32
|
+
const container = extra.into ? findContainer(section, extra.into) : section;
|
|
33
|
+
if (!container) throw new Error(`recette ${recipe.id} : conteneur « ${extra.into} » introuvable dans ${step.type}`);
|
|
34
|
+
const { id: blockId, block } = blockFromPreset(catalog, extra.type, { preset: extra.preset, language, taken, settings: extra.settings });
|
|
35
|
+
container.blocks = container.blocks || {};
|
|
36
|
+
container.blocks[blockId] = block;
|
|
37
|
+
const order = container.block_order || [];
|
|
38
|
+
const anchor = extra.after ? order.findIndex((candidate) => container.blocks[candidate]?.type === extra.after) : -1;
|
|
39
|
+
order.splice(anchor === -1 ? order.length : anchor + 1, 0, blockId);
|
|
40
|
+
container.block_order = order;
|
|
41
|
+
}
|
|
42
|
+
data.sections[id] = section;
|
|
43
|
+
data.order.push(id);
|
|
44
|
+
roles.push({ section: id, type: step.type, preset: step.preset, role: step.role });
|
|
45
|
+
}
|
|
46
|
+
return { data, roles };
|
|
47
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { isSafeId } from '../validate/template.js';
|
|
3
|
+
|
|
4
|
+
export function createIdFactory(taken = new Set(), random = () => crypto.randomBytes(4).toString('base64url').replace(/[^A-Za-z0-9]/g, '').slice(0, 6).padEnd(6, 'x')) {
|
|
5
|
+
return (type) => {
|
|
6
|
+
const base = String(type).replace(/^_+/, '').replace(/[^A-Za-z0-9]+/g, '_').slice(0, 24) || 'block';
|
|
7
|
+
let id;
|
|
8
|
+
do id = `${base}_${random()}`;
|
|
9
|
+
while (taken.has(id));
|
|
10
|
+
taken.add(id);
|
|
11
|
+
return id;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Every id already used in a template, so new ones never collide.
|
|
16
|
+
export function collectIds(data) {
|
|
17
|
+
const ids = new Set();
|
|
18
|
+
const walk = (node) => {
|
|
19
|
+
for (const [id, block] of Object.entries(node?.blocks || {})) {
|
|
20
|
+
ids.add(id);
|
|
21
|
+
walk(block);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
for (const [id, section] of Object.entries(data?.sections || {})) {
|
|
25
|
+
ids.add(id);
|
|
26
|
+
walk(section);
|
|
27
|
+
}
|
|
28
|
+
return ids;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Converts a preset block list (array, or object + block_order) to the template shape.
|
|
32
|
+
function convertBlocks(catalog, list, language, nextId) {
|
|
33
|
+
const entries = Array.isArray(list)
|
|
34
|
+
? list.map((block) => [block.id, block])
|
|
35
|
+
: Object.entries(list || {}).map(([id, block]) => [id, block]);
|
|
36
|
+
const blocks = {};
|
|
37
|
+
const order = [];
|
|
38
|
+
const mapping = {};
|
|
39
|
+
for (const [presetId, block] of entries) {
|
|
40
|
+
if (!block?.type) continue;
|
|
41
|
+
const id = block.static ? presetId : isSafeId(presetId) && !Object.hasOwn(blocks, presetId) ? nextId.reserve(presetId, { rename: true }) : nextId(block.type);
|
|
42
|
+
if (presetId) mapping[presetId] = id;
|
|
43
|
+
const out = { type: block.type };
|
|
44
|
+
if (block.static) out.static = true;
|
|
45
|
+
const settings = catalog.translateDeep(block.settings || {}, language);
|
|
46
|
+
out.settings = settings;
|
|
47
|
+
if (block.blocks && (Array.isArray(block.blocks) ? block.blocks.length : Object.keys(block.blocks).length)) {
|
|
48
|
+
const child = convertBlocks(catalog, block.blocks, language, nextId);
|
|
49
|
+
out.blocks = child.blocks;
|
|
50
|
+
out.block_order = orderFrom(block, child);
|
|
51
|
+
}
|
|
52
|
+
blocks[id] = out;
|
|
53
|
+
if (!block.static) order.push(id);
|
|
54
|
+
}
|
|
55
|
+
return { blocks, order, mapping };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function orderFrom(preset, converted) {
|
|
59
|
+
// An object-shaped preset may carry its own block_order, written with the preset's ids.
|
|
60
|
+
if (!Array.isArray(preset.blocks) && Array.isArray(preset.block_order)) {
|
|
61
|
+
return preset.block_order.map((id) => converted.mapping[id]).filter((id) => id && !converted.blocks[id].static);
|
|
62
|
+
}
|
|
63
|
+
return converted.order;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function idFactoryWithReserve(taken) {
|
|
67
|
+
const next = createIdFactory(taken);
|
|
68
|
+
// Preset ids may be renamed on collision; an id asked for explicitly may not.
|
|
69
|
+
next.reserve = (id, { rename = false } = {}) => {
|
|
70
|
+
if (!isSafeId(id)) throw new Error(`identifiant « ${id} » invalide (lettres, chiffres, - et _ ; pas de nom réservé)`);
|
|
71
|
+
if (taken.has(id)) {
|
|
72
|
+
if (!rename) throw new Error(`l'identifiant « ${id} » est déjà utilisé dans ce fichier`);
|
|
73
|
+
return next(id.replace(/_[A-Za-z0-9]{6}$/, ''));
|
|
74
|
+
}
|
|
75
|
+
taken.add(id);
|
|
76
|
+
return id;
|
|
77
|
+
};
|
|
78
|
+
return next;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function sectionFromPreset(catalog, type, { preset, language = 'fr', taken = new Set(), id = null } = {}) {
|
|
82
|
+
const entry = catalog.section(type);
|
|
83
|
+
if (!entry?.schema) throw new Error(`section « ${type} » inconnue`);
|
|
84
|
+
const found = catalog.findPreset(entry.schema, preset, language);
|
|
85
|
+
if (preset !== undefined && preset !== null && preset !== '' && !found.preset) {
|
|
86
|
+
const names = catalog.presetNames(entry.schema, language);
|
|
87
|
+
throw new Error(`preset « ${preset} » introuvable pour ${type}${names.length ? ` (presets : ${names.join(' ; ')})` : ' (cette section n’a pas de preset)'}`);
|
|
88
|
+
}
|
|
89
|
+
const nextId = idFactoryWithReserve(taken);
|
|
90
|
+
const sectionId = id ? nextId.reserve(id) : nextId(type);
|
|
91
|
+
const section = { type };
|
|
92
|
+
const source = found.preset;
|
|
93
|
+
if (source) {
|
|
94
|
+
const converted = convertBlocks(catalog, source.blocks, language, nextId);
|
|
95
|
+
if (Object.keys(converted.blocks).length) {
|
|
96
|
+
section.blocks = converted.blocks;
|
|
97
|
+
section.block_order = orderFrom(source, converted);
|
|
98
|
+
}
|
|
99
|
+
section.settings = catalog.translateDeep(source.settings || {}, language);
|
|
100
|
+
} else {
|
|
101
|
+
section.settings = {};
|
|
102
|
+
}
|
|
103
|
+
return { id: sectionId, section, preset: source ? catalog.t(source.name, language) : null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function blockFromPreset(catalog, type, { preset, language = 'fr', taken = new Set(), id = null, settings = null } = {}) {
|
|
107
|
+
const entry = catalog.block(type);
|
|
108
|
+
if (!entry?.schema) throw new Error(`bloc « ${type} » inconnu`);
|
|
109
|
+
const found = catalog.findPreset(entry.schema, preset, language);
|
|
110
|
+
if (preset !== undefined && preset !== null && preset !== '' && !found.preset) {
|
|
111
|
+
const names = catalog.presetNames(entry.schema, language);
|
|
112
|
+
throw new Error(`preset « ${preset} » introuvable pour le bloc ${type}${names.length ? ` (presets : ${names.join(' ; ')})` : ''}`);
|
|
113
|
+
}
|
|
114
|
+
const nextId = idFactoryWithReserve(taken);
|
|
115
|
+
const blockId = id ? nextId.reserve(id) : nextId(type);
|
|
116
|
+
const block = { type, settings: {} };
|
|
117
|
+
const source = found.preset;
|
|
118
|
+
if (source) {
|
|
119
|
+
block.settings = catalog.translateDeep(source.settings || {}, language);
|
|
120
|
+
if (source.blocks) {
|
|
121
|
+
const converted = convertBlocks(catalog, source.blocks, language, nextId);
|
|
122
|
+
if (Object.keys(converted.blocks).length) {
|
|
123
|
+
block.blocks = converted.blocks;
|
|
124
|
+
block.block_order = orderFrom(source, converted);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (settings) Object.assign(block.settings, settings);
|
|
129
|
+
return { id: blockId, block };
|
|
130
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { normalizeText } from '../theme/catalog.js';
|
|
2
|
+
import { lintTemplate, sampleTexts } from '../validate/design.js';
|
|
3
|
+
import { plainText } from '../validate/richtext.js';
|
|
4
|
+
|
|
5
|
+
const TEXT_TYPES = ['text', 'textarea', 'richtext', 'inline_richtext', 'url'];
|
|
6
|
+
|
|
7
|
+
// Every text, rich text and link of a template or section group, with its exact target
|
|
8
|
+
// ("section/block/…"), in reading order. `flag` is 'fix' when the review would flag it
|
|
9
|
+
// (sample copy, English, an unverified claim), 'sample' for short generic theme labels that
|
|
10
|
+
// may stay, null otherwise. The same list feeds list_texts and the automatic rewrite pass.
|
|
11
|
+
export function collectTexts(catalog, rel, data, { language = 'fr' } = {}) {
|
|
12
|
+
const samples = sampleTexts(catalog);
|
|
13
|
+
const flagged = new Map(
|
|
14
|
+
lintTemplate(catalog, rel, data, { language })
|
|
15
|
+
.filter((finding) => finding.level === 'important')
|
|
16
|
+
.map((finding) => [finding.path, finding.message])
|
|
17
|
+
);
|
|
18
|
+
const out = [];
|
|
19
|
+
const visit = (kind, type, settings, target) => {
|
|
20
|
+
const schema = (kind === 'section' ? catalog.section(type) : catalog.block(type))?.schema;
|
|
21
|
+
for (const setting of schema?.settings || []) {
|
|
22
|
+
if (!setting.id || !TEXT_TYPES.includes(setting.type)) continue;
|
|
23
|
+
const value = settings?.[setting.id];
|
|
24
|
+
if (value === undefined || value === '') continue;
|
|
25
|
+
const issue = flagged.get(`${target}.${setting.id}`) || null;
|
|
26
|
+
const flag = issue ? 'fix' : setting.type !== 'url' && samples.has(normalizeText(plainText(value))) ? 'sample' : null;
|
|
27
|
+
out.push({ target, blockType: type, setting: setting.id, type: setting.type, value, flag, issue });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const walk = (node, target) => {
|
|
31
|
+
for (const [id, block] of Object.entries(node.blocks || {})) {
|
|
32
|
+
visit('block', block.type, block.settings, `${target}/${id}`);
|
|
33
|
+
walk(block, `${target}/${id}`);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
for (const id of data.order || []) {
|
|
37
|
+
const section = data.sections?.[id];
|
|
38
|
+
if (!section) continue;
|
|
39
|
+
visit('section', section.type, section.settings, id);
|
|
40
|
+
walk(section, id);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { VERSION } from './version.js';
|
|
4
|
+
|
|
5
|
+
const expand = (value) => (value && value.startsWith('~') ? path.join(os.homedir(), value.slice(1)) : value);
|
|
6
|
+
|
|
7
|
+
// L'espace membre Story Thème : d'où vient la dernière version publiée du thème.
|
|
8
|
+
const DEFAULT_API = 'https://story-theme.com';
|
|
9
|
+
|
|
10
|
+
export function loadConfig(env = process.env) {
|
|
11
|
+
const themeDir = env.STORY_THEME_DIR || '';
|
|
12
|
+
return {
|
|
13
|
+
version: VERSION,
|
|
14
|
+
// Jeton personnel du client (espace membre > MCP & Appli). Sans lui, pas de thème à distance :
|
|
15
|
+
// le MCP retombe sur son cache, puis sur un dossier local.
|
|
16
|
+
token: (env.STORY_MCP_TOKEN || '').trim(),
|
|
17
|
+
apiBase: (env.STORY_MCP_API || DEFAULT_API).trim(),
|
|
18
|
+
// `test` demande la version en test du thème : accordé par le serveur aux seuls testeurs.
|
|
19
|
+
canal: env.STORY_MCP_CANAL === 'test' ? 'test' : 'stable',
|
|
20
|
+
// Dossier de thème lu quand il est imposé (développement du thème lui-même). Explicite =
|
|
21
|
+
// posé dans l'environnement : il passe alors AVANT le thème publié.
|
|
22
|
+
themeDir: path.resolve(expand(themeDir || '~/Developer/story-theme-shopify')),
|
|
23
|
+
themeDirExplicit: Boolean(themeDir),
|
|
24
|
+
// Projets, instantanés, exports, et cache des versions du thème.
|
|
25
|
+
homeDir: path.resolve(expand(env.STORY_MCP_HOME || '~/Documents/Story Theme MCP')),
|
|
26
|
+
defaultLanguage: env.STORY_MCP_LANGUAGE || 'fr',
|
|
27
|
+
};
|
|
28
|
+
}
|