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,228 @@
|
|
|
1
|
+
import { sectionFromPreset, blockFromPreset, collectIds, createIdFactory } from './scaffold.js';
|
|
2
|
+
import { plainText } from '../validate/richtext.js';
|
|
3
|
+
import { isSafeId } from '../validate/template.js';
|
|
4
|
+
|
|
5
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
6
|
+
|
|
7
|
+
// "hero/group_ab12/button_x" -> { section: 'hero', blocks: ['group_ab12', 'button_x'] }
|
|
8
|
+
export function parseTarget(target) {
|
|
9
|
+
const parts = String(target || '').split('/').map((part) => part.trim()).filter(Boolean);
|
|
10
|
+
if (!parts.length) throw new Error('cible vide : indiquez « idSection » ou « idSection/idBloc/… »');
|
|
11
|
+
const bad = parts.find((part) => !isSafeId(part));
|
|
12
|
+
if (bad) throw new Error(`identifiant « ${bad} » invalide`);
|
|
13
|
+
return { section: parts[0], blocks: parts.slice(1) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveNode(data, target) {
|
|
17
|
+
const { section, blocks } = parseTarget(target);
|
|
18
|
+
const node = Object.hasOwn(data.sections || {}, section) ? data.sections[section] : null;
|
|
19
|
+
if (!node) throw new Error(`section « ${section} » introuvable (sections : ${Object.keys(data.sections || {}).join(', ') || 'aucune'})`);
|
|
20
|
+
let current = node;
|
|
21
|
+
let parent = null;
|
|
22
|
+
let id = section;
|
|
23
|
+
for (const blockId of blocks) {
|
|
24
|
+
const next = current.blocks && Object.hasOwn(current.blocks, blockId) ? current.blocks[blockId] : null;
|
|
25
|
+
if (!next) throw new Error(`bloc « ${blockId} » introuvable dans « ${id} » (blocs : ${Object.keys(current.blocks || {}).join(', ') || 'aucun'})`);
|
|
26
|
+
parent = current;
|
|
27
|
+
current = next;
|
|
28
|
+
id = blockId;
|
|
29
|
+
}
|
|
30
|
+
return { node: current, parent, id, isSection: blocks.length === 0, sectionId: section };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function insertAt(list, id, position) {
|
|
34
|
+
const without = list.filter((item) => item !== id);
|
|
35
|
+
let index = without.length;
|
|
36
|
+
if (position === 'start' || position === 0) index = 0;
|
|
37
|
+
else if (typeof position === 'number') index = Math.max(0, Math.min(without.length, position));
|
|
38
|
+
else if (position && typeof position === 'object') {
|
|
39
|
+
const anchor = position.before ?? position.after;
|
|
40
|
+
const at = without.indexOf(anchor);
|
|
41
|
+
if (at === -1) throw new Error(`position : « ${anchor} » introuvable (ordre actuel : ${without.join(', ')})`);
|
|
42
|
+
index = position.before !== undefined ? at : at + 1;
|
|
43
|
+
}
|
|
44
|
+
without.splice(index, 0, id);
|
|
45
|
+
return without;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function applySettings(node, settings, mode) {
|
|
49
|
+
if (!settings || typeof settings !== 'object') throw new Error('"settings" doit être un objet');
|
|
50
|
+
if (mode === 'replace') node.settings = {};
|
|
51
|
+
node.settings = node.settings || {};
|
|
52
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
53
|
+
if (value === null) delete node.settings[key];
|
|
54
|
+
else node.settings[key] = value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function refreshIds(catalog, section, nextId) {
|
|
59
|
+
const walk = (node) => {
|
|
60
|
+
if (!node.blocks) return;
|
|
61
|
+
const blocks = {};
|
|
62
|
+
const mapping = {};
|
|
63
|
+
for (const [id, block] of Object.entries(node.blocks)) {
|
|
64
|
+
const newId = block.static ? id : nextId(block.type);
|
|
65
|
+
mapping[id] = newId;
|
|
66
|
+
walk(block);
|
|
67
|
+
blocks[newId] = block;
|
|
68
|
+
}
|
|
69
|
+
node.blocks = blocks;
|
|
70
|
+
if (node.block_order) node.block_order = node.block_order.map((id) => mapping[id] || id);
|
|
71
|
+
};
|
|
72
|
+
walk(section);
|
|
73
|
+
return section;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Applies edit operations to a template or section group. Throws on the first invalid operation;
|
|
77
|
+
// the caller validates the result before saving anything.
|
|
78
|
+
export function applyOperations(catalog, input, operations, { language = 'fr' } = {}) {
|
|
79
|
+
const data = clone(input || { sections: {}, order: [] });
|
|
80
|
+
data.sections = data.sections || {};
|
|
81
|
+
data.order = data.order || [];
|
|
82
|
+
const taken = collectIds(data);
|
|
83
|
+
const nextId = createIdFactory(taken);
|
|
84
|
+
const log = [];
|
|
85
|
+
|
|
86
|
+
operations.forEach((operation, index) => {
|
|
87
|
+
const where = `opération ${index + 1} (${operation?.op})`;
|
|
88
|
+
try {
|
|
89
|
+
switch (operation?.op) {
|
|
90
|
+
case 'add_section': {
|
|
91
|
+
const { id, section, preset } = sectionFromPreset(catalog, operation.type, { preset: operation.preset, language, taken, id: operation.id });
|
|
92
|
+
if (operation.settings) applySettings(section, operation.settings, 'merge');
|
|
93
|
+
if (operation.disabled) section.disabled = true;
|
|
94
|
+
data.sections[id] = section;
|
|
95
|
+
data.order = insertAt(data.order, id, operation.position ?? 'end');
|
|
96
|
+
log.push(`section ajoutée : ${id} (${operation.type}${preset ? `, preset « ${preset} »` : ''})`);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case 'remove_section': {
|
|
100
|
+
const { section } = parseTarget(operation.section);
|
|
101
|
+
if (!Object.hasOwn(data.sections, section)) throw new Error(`section « ${section} » introuvable`);
|
|
102
|
+
delete data.sections[section];
|
|
103
|
+
data.order = data.order.filter((id) => id !== section);
|
|
104
|
+
log.push(`section supprimée : ${section}`);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'move_section': {
|
|
108
|
+
const { section } = parseTarget(operation.section);
|
|
109
|
+
if (!Object.hasOwn(data.sections, section)) throw new Error(`section « ${section} » introuvable`);
|
|
110
|
+
data.order = insertAt(data.order, section, operation.position);
|
|
111
|
+
log.push(`section déplacée : ${section}`);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case 'duplicate_section': {
|
|
115
|
+
const { section } = parseTarget(operation.section);
|
|
116
|
+
if (!Object.hasOwn(data.sections, section)) throw new Error(`section « ${section} » introuvable`);
|
|
117
|
+
const copy = refreshIds(catalog, clone(data.sections[section]), nextId);
|
|
118
|
+
const id = nextId(copy.type);
|
|
119
|
+
data.sections[id] = copy;
|
|
120
|
+
data.order = insertAt(data.order, id, operation.position ?? { after: section });
|
|
121
|
+
log.push(`section dupliquée : ${section} → ${id}`);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'replace_section': {
|
|
125
|
+
const { section } = parseTarget(operation.section);
|
|
126
|
+
if (!Object.hasOwn(data.sections, section)) throw new Error(`section « ${section} » introuvable`);
|
|
127
|
+
if (!operation.data?.type) throw new Error('"data" doit être une section complète { type, settings, blocks, block_order }');
|
|
128
|
+
const replacement = clone(operation.data);
|
|
129
|
+
for (const id of collectIds({ sections: { [section]: replacement } })) {
|
|
130
|
+
if (id !== section && taken.has(id)) throw new Error(`le bloc « ${id} » existe déjà ailleurs dans le fichier : donnez-lui un autre identifiant`);
|
|
131
|
+
taken.add(id);
|
|
132
|
+
}
|
|
133
|
+
data.sections[section] = replacement;
|
|
134
|
+
log.push(`section remplacée : ${section}`);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'update_settings': {
|
|
138
|
+
const { node, id } = resolveNode(data, operation.target);
|
|
139
|
+
applySettings(node, operation.settings, operation.mode);
|
|
140
|
+
log.push(`réglages modifiés : ${id} (${Object.keys(operation.settings).join(', ')})`);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case 'add_block': {
|
|
144
|
+
const { node, id: parentId } = resolveNode(data, operation.parent);
|
|
145
|
+
const { id, block } = blockFromPreset(catalog, operation.type, { preset: operation.preset, language, taken, id: operation.id, settings: operation.settings });
|
|
146
|
+
node.blocks = node.blocks || {};
|
|
147
|
+
node.blocks[id] = block;
|
|
148
|
+
node.block_order = insertAt(node.block_order || [], id, operation.position ?? 'end');
|
|
149
|
+
log.push(`bloc ajouté : ${id} (${operation.type}) dans ${parentId}`);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case 'remove_block': {
|
|
153
|
+
const { parent, id, isSection, node } = resolveNode(data, operation.target);
|
|
154
|
+
if (isSection) throw new Error('la cible est une section : utilisez remove_section');
|
|
155
|
+
if (node.static) throw new Error(`« ${id} » est un bloc statique du thème : il ne se supprime pas (masquez-le avec set_disabled ou ses réglages)`);
|
|
156
|
+
delete parent.blocks[id];
|
|
157
|
+
parent.block_order = (parent.block_order || []).filter((item) => item !== id);
|
|
158
|
+
log.push(`bloc supprimé : ${id}`);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'move_block': {
|
|
162
|
+
const { parent, id, isSection, node } = resolveNode(data, operation.target);
|
|
163
|
+
if (isSection) throw new Error('la cible est une section : utilisez move_section');
|
|
164
|
+
if (node.static) throw new Error(`« ${id} » est statique : sa place est fixée par le thème`);
|
|
165
|
+
parent.block_order = insertAt(parent.block_order || [], id, operation.position);
|
|
166
|
+
log.push(`bloc déplacé : ${id}`);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case 'set_disabled': {
|
|
170
|
+
const { node, id } = resolveNode(data, operation.target);
|
|
171
|
+
if (operation.disabled) node.disabled = true;
|
|
172
|
+
else delete node.disabled;
|
|
173
|
+
log.push(`${operation.disabled ? 'masqué' : 'réactivé'} : ${id}`);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
default:
|
|
177
|
+
throw new Error('opération inconnue (add_section, remove_section, move_section, duplicate_section, replace_section, update_settings, add_block, remove_block, move_block, set_disabled)');
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw new Error(`${where} : ${error.message}`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return { data, log };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const TEXT_KEYS = ['title', 'heading', 'button_text', 'text', 'description', 'content', 'label', 'subheading', 'text_items', 'items_rich', 'caption', 'question', 'name', 'quote'];
|
|
187
|
+
|
|
188
|
+
function summary(block) {
|
|
189
|
+
const settings = block.settings || {};
|
|
190
|
+
for (const key of TEXT_KEYS) {
|
|
191
|
+
const value = settings[key];
|
|
192
|
+
if (typeof value === 'string' && plainText(value)) {
|
|
193
|
+
const text = plainText(value);
|
|
194
|
+
return `« ${text.length > 70 ? text.slice(0, 67) + '…' : text} »`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return '';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A compact tree of a template, readable by the model and the merchant.
|
|
201
|
+
export function outline(catalog, data, { language = 'fr', file = '' } = {}) {
|
|
202
|
+
const lines = [];
|
|
203
|
+
const order = data?.order || [];
|
|
204
|
+
lines.push(`${file || 'modèle'} — ${order.length} section(s)`);
|
|
205
|
+
const walk = (node, depth) => {
|
|
206
|
+
const ids = [...Object.entries(node.blocks || {}).filter(([, block]) => block.static).map(([id]) => id), ...(node.block_order || [])];
|
|
207
|
+
for (const id of ids) {
|
|
208
|
+
const block = node.blocks?.[id];
|
|
209
|
+
if (!block) continue;
|
|
210
|
+
const name = catalog.block(block.type) ? catalog.t(catalog.block(block.type).schema?.name, language) : block.type;
|
|
211
|
+
const flags = [block.static ? 'statique' : null, block.disabled ? 'masqué' : null].filter(Boolean).join(', ');
|
|
212
|
+
const link = block.settings?.button_link ? ` → ${block.settings.button_link}` : '';
|
|
213
|
+
lines.push(`${' '.repeat(depth)}- ${id} · ${block.type} (${name})${flags ? ` [${flags}]` : ''} ${summary(block)}${link}`.trimEnd());
|
|
214
|
+
walk(block, depth + 1);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
order.forEach((id, index) => {
|
|
218
|
+
const section = data.sections?.[id];
|
|
219
|
+
if (!section) return;
|
|
220
|
+
const entry = catalog.section(section.type);
|
|
221
|
+
const name = entry?.schema ? catalog.t(entry.schema.name, language) : section.type;
|
|
222
|
+
const palette = section.settings?.frame_palette || section.settings?.color_palette || section.settings?.color_scheme;
|
|
223
|
+
const flags = [palette ? `palette ${palette}` : null, section.disabled ? 'masquée' : null].filter(Boolean).join(', ');
|
|
224
|
+
lines.push(`${index + 1}. ${id} · ${section.type} (${name})${flags ? ` [${flags}]` : ''}`);
|
|
225
|
+
walk(section, 1);
|
|
226
|
+
});
|
|
227
|
+
return lines.join('\n');
|
|
228
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { plainText } from '../validate/richtext.js';
|
|
2
|
+
|
|
3
|
+
const TEXT_TYPES = new Set(['text', 'textarea', 'richtext', 'inline_richtext']);
|
|
4
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
5
|
+
|
|
6
|
+
const OUTER_TAG = /^<(p|h[1-6])>([\s\S]*)<\/\1>$/;
|
|
7
|
+
|
|
8
|
+
const unwrap = (value) => {
|
|
9
|
+
const match = value.match(OUTER_TAG);
|
|
10
|
+
return match ? { tag: match[1], inner: match[2] } : { tag: null, inner: value };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Correspondance de dernier recours : la même phrase, habillée autrement. Le thème range par
|
|
14
|
+
// exemple « Made in small batches. » dans un `<h2>` côté traductions et dans un `<p>` côté
|
|
15
|
+
// modèle ; sans cette passe, 36 textes de l'accueil restaient en anglais alors que leur
|
|
16
|
+
// traduction existait. Réservée aux phrases (au moins 4 caractères et une lettre) : sur « 10 »
|
|
17
|
+
// ou « % », deux réglages sans rapport se ressembleraient.
|
|
18
|
+
const PHRASE = /[a-zà-ÿ]/i;
|
|
19
|
+
function plainPairsOf(pairs) {
|
|
20
|
+
const map = new Map();
|
|
21
|
+
for (const [source, cible] of pairs) {
|
|
22
|
+
const key = plainText(source).trim();
|
|
23
|
+
if (key.length >= 4 && PHRASE.test(key) && !map.has(key)) map.set(key, cible);
|
|
24
|
+
}
|
|
25
|
+
return map;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function translate(pairs, plainPairs, value) {
|
|
29
|
+
if (pairs.has(value)) return pairs.get(value);
|
|
30
|
+
// "<p>Text</p>" or "<h1>Text</h1>" around a string the locales hold bare, or as "<p>…</p>".
|
|
31
|
+
const { tag, inner } = unwrap(value);
|
|
32
|
+
if (tag) {
|
|
33
|
+
if (pairs.has(inner)) return `<${tag}>${pairs.get(inner)}</${tag}>`;
|
|
34
|
+
if (pairs.has(`<p>${inner}</p>`)) {
|
|
35
|
+
const translated = pairs.get(`<p>${inner}</p>`);
|
|
36
|
+
return tag === 'p' ? translated : `<${tag}>${translated.replace(/^<p>|<\/p>$/g, '')}</${tag}>`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const found = plainPairs.get(plainText(value).trim());
|
|
40
|
+
if (found === undefined) return null;
|
|
41
|
+
// Le réglage impose la forme : un titre en `<p>` reste en `<p>`, un réglage `text` ne reçoit
|
|
42
|
+
// jamais de balise, même si la traduction en porte une.
|
|
43
|
+
return tag ? `<${tag}>${unwrap(found).inner}</${tag}>` : plainText(found).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Replaces the theme's English sample texts by the same texts in `language`, taken from the
|
|
47
|
+
// theme's own translations. Returns what could not be translated, for the model to write.
|
|
48
|
+
export function localizeData(catalog, input, language) {
|
|
49
|
+
const data = clone(input);
|
|
50
|
+
const pairs = catalog.translationPairs(language);
|
|
51
|
+
const plainPairs = plainPairsOf(pairs);
|
|
52
|
+
let translated = 0;
|
|
53
|
+
const remaining = [];
|
|
54
|
+
const visit = (kind, node, target) => {
|
|
55
|
+
const schema = (kind === 'section' ? catalog.section(node.type) : catalog.block(node.type))?.schema;
|
|
56
|
+
for (const setting of schema?.settings || []) {
|
|
57
|
+
if (!setting.id || !TEXT_TYPES.has(setting.type)) continue;
|
|
58
|
+
const value = node.settings?.[setting.id];
|
|
59
|
+
if (typeof value !== 'string' || !plainText(value)) continue;
|
|
60
|
+
const result = translate(pairs, plainPairs, value);
|
|
61
|
+
if (result !== null && result !== value) {
|
|
62
|
+
node.settings[setting.id] = result;
|
|
63
|
+
translated += 1;
|
|
64
|
+
} else if (result === null) remaining.push({ target, key: setting.id, type: setting.type, value });
|
|
65
|
+
}
|
|
66
|
+
for (const [id, block] of Object.entries(node.blocks || {})) visit('block', block, `${target}/${id}`);
|
|
67
|
+
};
|
|
68
|
+
for (const [id, section] of Object.entries(data.sections || {})) visit('section', section, id);
|
|
69
|
+
return { data, translated, remaining };
|
|
70
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { checkValue } from '../validate/values.js';
|
|
2
|
+
import { NON_VALUE_TYPES } from '../theme/catalog.js';
|
|
3
|
+
|
|
4
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
5
|
+
|
|
6
|
+
// Brings a project file up to the theme it is exported with, without changing what renders:
|
|
7
|
+
// settings the theme no longer has do nothing and are dropped; a value the theme no longer
|
|
8
|
+
// accepts falls back to the default (or the nearest valid step for a range). Unknown section
|
|
9
|
+
// and block types are left for the validator: removing them would lose content.
|
|
10
|
+
export function migrateData(catalog, input, { schemeIds = [] } = {}) {
|
|
11
|
+
const data = clone(input);
|
|
12
|
+
const changes = [];
|
|
13
|
+
|
|
14
|
+
const fixSettings = (node, settingsList, path) => {
|
|
15
|
+
if (!node.settings || typeof node.settings !== 'object') return;
|
|
16
|
+
const index = new Map((settingsList || []).filter((setting) => setting.id && !NON_VALUE_TYPES.has(setting.type)).map((setting) => [setting.id, setting]));
|
|
17
|
+
for (const [key, value] of Object.entries(node.settings)) {
|
|
18
|
+
const setting = index.get(key);
|
|
19
|
+
if (!setting) {
|
|
20
|
+
delete node.settings[key];
|
|
21
|
+
changes.push(`${path}.${key} : réglage retiré du thème, supprimé`);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (!checkValue(setting, value, { schemeIds }).some((problem) => problem.level === 'error')) continue;
|
|
25
|
+
if (setting.type === 'range' && typeof value === 'number') {
|
|
26
|
+
const step = setting.step ?? 1;
|
|
27
|
+
const clamped = Math.min(setting.max, Math.max(setting.min, setting.min + Math.round((value - setting.min) / step) * step));
|
|
28
|
+
node.settings[key] = Math.round(clamped * 1000) / 1000;
|
|
29
|
+
changes.push(`${path}.${key} : ${value} ramené à ${node.settings[key]}`);
|
|
30
|
+
} else if (setting.type === 'checkbox' && (value === 'true' || value === 'false')) {
|
|
31
|
+
node.settings[key] = value === 'true';
|
|
32
|
+
changes.push(`${path}.${key} : « ${value} » converti en booléen`);
|
|
33
|
+
} else if (['select', 'radio', 'color_scheme', 'text_alignment'].includes(setting.type)) {
|
|
34
|
+
delete node.settings[key];
|
|
35
|
+
changes.push(`${path}.${key} : « ${value} » n'existe plus, valeur par défaut du thème`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const walk = (node, schema, path) => {
|
|
41
|
+
fixSettings(node, schema?.settings, path);
|
|
42
|
+
for (const [id, block] of Object.entries(node.blocks || {})) {
|
|
43
|
+
if (!block || typeof block !== 'object') continue;
|
|
44
|
+
const resolved = catalog.resolveChild(schema, block.type);
|
|
45
|
+
const childSchema = resolved.ok ? resolved.schema : catalog.block(block.type)?.schema;
|
|
46
|
+
if (resolved.app || !childSchema) continue;
|
|
47
|
+
walk(block, childSchema, `${path}/${id}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
for (const [id, section] of Object.entries(data.sections || {})) {
|
|
52
|
+
const schema = catalog.section(section?.type)?.schema;
|
|
53
|
+
if (schema) walk(section, schema, id);
|
|
54
|
+
}
|
|
55
|
+
const current = typeof data.current === 'object' ? data.current : null;
|
|
56
|
+
if (current) {
|
|
57
|
+
for (const [id, section] of Object.entries(current.sections || {})) {
|
|
58
|
+
const schema = catalog.section(section?.type)?.schema;
|
|
59
|
+
if (schema) walk(section, schema, `current.sections.${id}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { data, changes };
|
|
63
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { parseColor, toHex, toOklch, oklchHex, ensureContrast, contrast, blend, round2, auditSchemes } from '../validate/color.js';
|
|
2
|
+
|
|
3
|
+
const NEUTRALS = {
|
|
4
|
+
// hue, chroma of the neutrals (backgrounds, text)
|
|
5
|
+
tinted: null,
|
|
6
|
+
warm: { h: 75, c: 0.014 },
|
|
7
|
+
cool: { h: 250, c: 0.012 },
|
|
8
|
+
pure: { h: 0, c: 0 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const BUTTON_PALETTES = { button_1_palette: 'accent-1', button_2_palette: 'text-outline', button_3_palette: 'bg_fade' };
|
|
12
|
+
|
|
13
|
+
// Builds the four colour schemes the theme's templates reference (background-1, background-2,
|
|
14
|
+
// inverse, scheme), plus the global palette, from one or two brand colours. Every text and button
|
|
15
|
+
// pair is pushed to WCAG AA; each adjustment of a brand colour is reported.
|
|
16
|
+
export function generateColorSchemes({ primary, secondary = null, background = null, neutral = 'tinted', mood = 'light' }) {
|
|
17
|
+
const brand = parseColor(primary);
|
|
18
|
+
if (!brand) throw new Error(`couleur principale « ${primary} » invalide : utilisez #RRGGBB`);
|
|
19
|
+
const second = secondary ? parseColor(secondary) : null;
|
|
20
|
+
if (secondary && !second) throw new Error(`couleur secondaire « ${secondary} » invalide`);
|
|
21
|
+
const P = toOklch(brand);
|
|
22
|
+
const tone = NEUTRALS[neutral] || { h: P.h, c: Math.min(0.012, P.c * 0.15) };
|
|
23
|
+
const adjustments = [];
|
|
24
|
+
const note = (what, from, to) => {
|
|
25
|
+
if (from.toUpperCase() !== to.toUpperCase()) adjustments.push(`${what} : ${from.toUpperCase()} → ${to} pour atteindre le contraste AA`);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Light base
|
|
29
|
+
let base = background ? toHex(parseColor(background)) : oklchHex(neutral === 'warm' ? 0.975 : 0.993, neutral === 'warm' ? 0.012 : tone.c * 0.4, tone.h);
|
|
30
|
+
if (background && contrast(base, '#000000') < 10) {
|
|
31
|
+
adjustments.push('fond clair demandé trop sombre pour du texte lisible : fond éclairci');
|
|
32
|
+
base = oklchHex(0.97, toOklch(base).c, toOklch(base).h);
|
|
33
|
+
}
|
|
34
|
+
const heading = ensureContrast(oklchHex(0.2, tone.c * 1.6, tone.h), base, 12).hex;
|
|
35
|
+
const body = ensureContrastBlended(oklchHex(0.33, tone.c * 1.4, tone.h), base, 4.5, 0.75);
|
|
36
|
+
const surface = oklchHex(toOklch(base).l - 0.035, Math.max(tone.c, 0.004), tone.h);
|
|
37
|
+
|
|
38
|
+
const accent1 = ensureContrast(toHex(brand), base, 4.5);
|
|
39
|
+
note('couleur principale sur fond clair', primary, accent1.hex);
|
|
40
|
+
const accent2Source = second ? toHex(second) : oklchHex(Math.max(0.2, toOklch(accent1.hex).l - 0.12), P.c, P.h);
|
|
41
|
+
const accent2 = ensureContrast(accent2Source, base, 4.5);
|
|
42
|
+
if (second) note('couleur secondaire sur fond clair', secondary, accent2.hex);
|
|
43
|
+
|
|
44
|
+
const light = { text: heading, text_secondary: body, background_gradient_text: '', background: base, background_secondary: surface, background_gradient: '', 'accent-1': accent1.hex, 'accent-2': accent2.hex, background_gradient_accent: '' };
|
|
45
|
+
|
|
46
|
+
// Soft brand tint
|
|
47
|
+
const softBg = oklchHex(0.955, Math.min(P.c * 0.22, 0.03), P.h);
|
|
48
|
+
const soft = {
|
|
49
|
+
...light,
|
|
50
|
+
background: softBg,
|
|
51
|
+
background_secondary: base,
|
|
52
|
+
text: ensureContrast(heading, softBg, 12).hex,
|
|
53
|
+
text_secondary: ensureContrastBlended(body, softBg, 4.5, 0.75),
|
|
54
|
+
'accent-1': ensureContrast(accent1.hex, softBg, 4.5).hex,
|
|
55
|
+
'accent-2': ensureContrast(accent2.hex, softBg, 4.5).hex,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Dark
|
|
59
|
+
const darkBg = oklchHex(0.22, Math.min(P.c * 0.35, 0.045), P.h);
|
|
60
|
+
const darkText = oklchHex(0.97, tone.c * 0.5, tone.h);
|
|
61
|
+
const darkAccent = ensureContrast(toHex(brand), darkBg, 4.5, 1);
|
|
62
|
+
note('couleur principale sur fond sombre', primary, darkAccent.hex);
|
|
63
|
+
const dark = {
|
|
64
|
+
text: darkText,
|
|
65
|
+
text_secondary: ensureContrastBlended(oklchHex(0.9, tone.c * 0.6, tone.h), darkBg, 4.5, 0.75),
|
|
66
|
+
background_gradient_text: '',
|
|
67
|
+
background: darkBg,
|
|
68
|
+
background_secondary: oklchHex(0.28, Math.min(P.c * 0.35, 0.045), P.h),
|
|
69
|
+
background_gradient: '',
|
|
70
|
+
'accent-1': darkAccent.hex,
|
|
71
|
+
'accent-2': darkText,
|
|
72
|
+
background_gradient_accent: '',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Brand block: the brand colour as background, nudged until its text reads at AA.
|
|
76
|
+
let brandHex = toHex(brand);
|
|
77
|
+
const onBrand = contrast('#FFFFFF', brandHex) >= contrast(heading, brandHex) ? '#FFFFFF' : heading;
|
|
78
|
+
if (contrast(onBrand, brandHex) < 4.5) {
|
|
79
|
+
const nudged = ensureContrast(brandHex, onBrand, 4.5, onBrand === '#FFFFFF' ? -1 : 1).hex;
|
|
80
|
+
if (Math.abs(toOklch(nudged).l - P.l) <= 0.08) {
|
|
81
|
+
note('couleur de marque en fond (palette scheme)', brandHex, nudged);
|
|
82
|
+
brandHex = nudged;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const brandBlock = {
|
|
86
|
+
text: onBrand,
|
|
87
|
+
text_secondary: onBrand,
|
|
88
|
+
background_gradient_text: '',
|
|
89
|
+
background: brandHex,
|
|
90
|
+
background_secondary: oklchHex(P.l + (onBrand === '#FFFFFF' ? -0.06 : 0.06), P.c, P.h),
|
|
91
|
+
background_gradient: '',
|
|
92
|
+
'accent-1': onBrand,
|
|
93
|
+
'accent-2': onBrand === '#FFFFFF' ? base : heading,
|
|
94
|
+
background_gradient_accent: '',
|
|
95
|
+
};
|
|
96
|
+
const brandContrast = contrast(onBrand, brandHex);
|
|
97
|
+
if (brandContrast < 4.5) {
|
|
98
|
+
adjustments.push(`palette « scheme » : le texte sur la couleur de marque n'atteint que ${round2(brandContrast)}:1 ; réservez-la aux grands titres ou utilisez une teinte plus foncée`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Dark-first store: the dark scheme is the default, a softer dark rhythms the page, the light
|
|
102
|
+
// scheme becomes the contrast section.
|
|
103
|
+
const darkSoftBg = oklchHex(0.28, Math.min(P.c * 0.4, 0.05), P.h);
|
|
104
|
+
const darkSoft = {
|
|
105
|
+
...dark,
|
|
106
|
+
background: darkSoftBg,
|
|
107
|
+
background_secondary: darkBg,
|
|
108
|
+
text_secondary: ensureContrastBlended(dark.text_secondary, darkSoftBg, 4.5, 0.75),
|
|
109
|
+
'accent-1': ensureContrast(darkAccent.hex, darkSoftBg, 4.5, 1).hex,
|
|
110
|
+
};
|
|
111
|
+
const schemes = mood === 'dark'
|
|
112
|
+
? { 'background-1': { settings: dark }, 'background-2': { settings: darkSoft }, inverse: { settings: light }, scheme: { settings: brandBlock } }
|
|
113
|
+
: { 'background-1': { settings: light }, 'background-2': { settings: soft }, inverse: { settings: dark }, scheme: { settings: brandBlock } };
|
|
114
|
+
const main = schemes['background-1'].settings;
|
|
115
|
+
const palette = {
|
|
116
|
+
background: main.background,
|
|
117
|
+
surface: main.background_secondary,
|
|
118
|
+
text: main.text,
|
|
119
|
+
text_muted: ensureContrast(oklchHex(mood === 'dark' ? 0.75 : 0.5, tone.c, tone.h), main.background, 4.5).hex,
|
|
120
|
+
accent_1: main['accent-1'],
|
|
121
|
+
accent_2: main['accent-2'],
|
|
122
|
+
border: oklchHex(toOklch(main.background).l + (mood === 'dark' ? 0.1 : -0.08), tone.c, tone.h),
|
|
123
|
+
shadow: '#000000',
|
|
124
|
+
stars: '#E8B50C',
|
|
125
|
+
};
|
|
126
|
+
const buttonPalettes = { 1: BUTTON_PALETTES.button_1_palette, 2: BUTTON_PALETTES.button_2_palette, 3: BUTTON_PALETTES.button_3_palette };
|
|
127
|
+
const audit = auditSchemes(schemes, buttonPalettes);
|
|
128
|
+
return {
|
|
129
|
+
color_schemes: schemes,
|
|
130
|
+
palette,
|
|
131
|
+
button_palettes: BUTTON_PALETTES,
|
|
132
|
+
adjustments,
|
|
133
|
+
audit: audit.filter((finding) => finding.level !== 'ok'),
|
|
134
|
+
usage: {
|
|
135
|
+
'background-1': 'fond principal, la majorité des sections',
|
|
136
|
+
'background-2': mood === 'dark' ? 'fond sombre adouci, pour rythmer' : 'fond doux teinté de la marque, pour rythmer (réassurance, FAQ, avis)',
|
|
137
|
+
inverse: mood === 'dark' ? 'fond clair, pour 1 ou 2 sections de contraste' : 'fond sombre, pour 1 ou 2 sections fortes (bandeau défilant, héro, newsletter, pied de page)',
|
|
138
|
+
scheme: 'couleur de marque en fond, avec parcimonie (bandeau d’annonce, offre, appel à l’action)',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function ensureContrastBlended(hex, background, target, alpha) {
|
|
144
|
+
let candidate = toHex(parseColor(hex));
|
|
145
|
+
const bg = parseColor(background);
|
|
146
|
+
const dark = contrast('#000000', background) > contrast('#FFFFFF', background);
|
|
147
|
+
const base = toOklch(candidate);
|
|
148
|
+
for (let step = 0; step <= 100; step++) {
|
|
149
|
+
candidate = oklchHex(base.l + (dark ? -1 : 1) * step * 0.01, base.c, base.h);
|
|
150
|
+
if (contrast(blend(parseColor(candidate), bg, alpha), bg) >= target) return candidate;
|
|
151
|
+
}
|
|
152
|
+
return dark ? '#000000' : '#FFFFFF';
|
|
153
|
+
}
|