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.
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const SCHEMA_RE = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/;
5
+ const COMMENT_RE = /\{%-?\s*comment\s*-?%\}[\s\S]*?\{%-?\s*endcomment\s*-?%\}/g;
6
+ const DOC_RE = /\{%-?\s*doc\s*-?%\}[\s\S]*?\{%-?\s*enddoc\s*-?%\}/g;
7
+ const STATIC_RE = /content_for\s+['"]block['"]\s*,/g;
8
+
9
+ // Theme JSON files (templates, locales, settings_data) may open with a /* ... */ notice.
10
+ export function stripJsonComment(text) {
11
+ return text.replace(/^/, '').replace(/^\s*\/\*[\s\S]*?\*\/\s*/, '');
12
+ }
13
+
14
+ export function parseThemeJson(text) {
15
+ return JSON.parse(stripJsonComment(text));
16
+ }
17
+
18
+ function readJson(file) {
19
+ return parseThemeJson(fs.readFileSync(file, 'utf8'));
20
+ }
21
+
22
+ function staticBlocks(liquid) {
23
+ const code = liquid.replace(SCHEMA_RE, '').replace(COMMENT_RE, '').replace(DOC_RE, '');
24
+ const found = [];
25
+ for (const match of code.matchAll(STATIC_RE)) {
26
+ // Inside {% liquid %} a statement ends with its line, so type and id sit before the next tag end.
27
+ const rest = code.slice(match.index + match[0].length);
28
+ const end = rest.search(/-?%\}/);
29
+ const tag = end === -1 ? rest.slice(0, 400) : rest.slice(0, end);
30
+ const type = tag.match(/\btype:\s*['"]([^'"]+)['"]/);
31
+ const id = tag.match(/\bid:\s*['"]([^'"]+)['"]/);
32
+ if (type && id && !found.some((entry) => entry.type === type[1] && entry.id === id[1])) {
33
+ found.push({ type: type[1], id: id[1] });
34
+ }
35
+ }
36
+ return found;
37
+ }
38
+
39
+ function loadSchemaFiles(dir, kind) {
40
+ const result = new Map();
41
+ if (!fs.existsSync(dir)) return result;
42
+ for (const file of fs.readdirSync(dir).sort()) {
43
+ if (!file.endsWith('.liquid')) continue;
44
+ const type = file.slice(0, -'.liquid'.length);
45
+ const source = fs.readFileSync(path.join(dir, file), 'utf8');
46
+ const match = source.match(SCHEMA_RE);
47
+ const entry = { type, kind, file: `${kind === 'section' ? 'sections' : 'blocks'}/${file}`, schema: null, statics: staticBlocks(source), error: null };
48
+ if (match) {
49
+ try {
50
+ entry.schema = JSON.parse(match[1]);
51
+ } catch (error) {
52
+ entry.error = `schema JSON: ${error.message}`;
53
+ }
54
+ }
55
+ result.set(type, entry);
56
+ }
57
+ return result;
58
+ }
59
+
60
+ function loadTemplates(dir) {
61
+ const templates = new Map();
62
+ const walk = (folder, prefix) => {
63
+ if (!fs.existsSync(folder)) return;
64
+ for (const file of fs.readdirSync(folder).sort()) {
65
+ const full = path.join(folder, file);
66
+ if (fs.statSync(full).isDirectory()) walk(full, `${prefix}${file}/`);
67
+ else if (file.endsWith('.json')) {
68
+ try {
69
+ templates.set(`${prefix}${file}`, readJson(full));
70
+ } catch (error) {
71
+ templates.set(`${prefix}${file}`, { __error: error.message });
72
+ }
73
+ }
74
+ }
75
+ };
76
+ walk(dir, 'templates/');
77
+ return templates;
78
+ }
79
+
80
+ function loadGroups(dir) {
81
+ const groups = new Map();
82
+ if (!fs.existsSync(dir)) return groups;
83
+ for (const file of fs.readdirSync(dir).sort()) {
84
+ if (!file.endsWith('.json')) continue;
85
+ try {
86
+ groups.set(`sections/${file}`, readJson(path.join(dir, file)));
87
+ } catch (error) {
88
+ groups.set(`sections/${file}`, { __error: error.message });
89
+ }
90
+ }
91
+ return groups;
92
+ }
93
+
94
+ export function loadTheme(themeDir) {
95
+ if (!fs.existsSync(path.join(themeDir, 'config', 'settings_schema.json'))) {
96
+ throw new Error(`No Shopify theme at ${themeDir} (config/settings_schema.json is missing). Set STORY_THEME_DIR.`);
97
+ }
98
+ const settingsSchema = readJson(path.join(themeDir, 'config', 'settings_schema.json'));
99
+ const info = settingsSchema.find((group) => group.name === 'theme_info') || {};
100
+ const localesDir = path.join(themeDir, 'locales');
101
+ const localeFiles = fs.existsSync(localesDir) ? fs.readdirSync(localesDir) : [];
102
+ const schemaLanguages = localeFiles
103
+ .filter((file) => file.endsWith('.schema.json'))
104
+ .map((file) => file.replace('.schema.json', '').replace('.default', ''));
105
+ const defaultLanguage = (localeFiles.find((file) => /\.default\.schema\.json$/.test(file)) || 'en.default.schema.json').split('.')[0];
106
+
107
+ return {
108
+ dir: themeDir,
109
+ name: info.theme_name || 'Theme',
110
+ version: info.theme_version || 'unknown',
111
+ sections: loadSchemaFiles(path.join(themeDir, 'sections'), 'section'),
112
+ blocks: loadSchemaFiles(path.join(themeDir, 'blocks'), 'block'),
113
+ settingsSchema,
114
+ settingsData: fs.existsSync(path.join(themeDir, 'config', 'settings_data.json'))
115
+ ? readJson(path.join(themeDir, 'config', 'settings_data.json'))
116
+ : { current: {} },
117
+ templates: loadTemplates(path.join(themeDir, 'templates')),
118
+ groups: loadGroups(path.join(themeDir, 'sections')),
119
+ schemaLanguages,
120
+ defaultLanguage,
121
+ loadSchemaLocale(language) {
122
+ const file = path.join(localesDir, language === defaultLanguage ? `${language}.default.schema.json` : `${language}.schema.json`);
123
+ return fs.existsSync(file) ? readJson(file) : null;
124
+ },
125
+ loadStorefrontLocale(language) {
126
+ const file = path.join(localesDir, language === defaultLanguage ? `${language}.default.json` : `${language}.json`);
127
+ return fs.existsSync(file) ? readJson(file) : null;
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,242 @@
1
+ // =====================================================================
2
+ // D'où vient le thème que le MCP connaît
3
+ // =====================================================================
4
+ // Trois origines possibles, dans cet ordre :
5
+ //
6
+ // dashboard le compte a un jeton (STORY_MCP_TOKEN) : le MCP demande à l'espace membre la
7
+ // DERNIÈRE VERSION PUBLIÉE, la télécharge une fois, et travaille dessus. C'est le
8
+ // mode des clients : ils n'ont rien à mettre à jour, le thème suit les sorties.
9
+ // cache même chose, mais hors ligne ou l'espace membre injoignable : on repart sur la
10
+ // dernière version téléchargée. Un avion ne doit pas rendre l'assistant muet.
11
+ // local STORY_THEME_DIR pointe sur un dossier de thème : mode développement (le thème en
12
+ // cours d'écriture), prioritaire sur tout le reste quand il est posé explicitement.
13
+ //
14
+ // Embarquer une copie du thème dans le paquet npm aurait été plus simple, et faux : le MCP
15
+ // aurait connu le thème du jour de sa publication, pas celui que le client vient d'installer.
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { unzipSync } from 'fflate';
20
+
21
+ const AIDE_JETON = `Aucun thème disponible. Deux façons de brancher le MCP :
22
+ • un jeton : générez-le dans votre espace membre Story Thème (menu « MCP & Appli »), puis ajoutez STORY_MCP_TOKEN à la configuration du MCP ;
23
+ • un dossier : pointez STORY_THEME_DIR sur un thème Story déjà décompressé.`;
24
+
25
+ /** Erreur de licence : le jeton est refusé ou l'abonnement n'est plus actif. Jamais un repli. */
26
+ export class LicenceError extends Error {}
27
+
28
+ const safeVersion = (version) => String(version || 'inconnue').replace(/[^0-9A-Za-z._-]/g, '_');
29
+
30
+ const rmrf = (dir) => fs.rmSync(dir, { recursive: true, force: true });
31
+
32
+ /** Un dossier de thème utilisable ? Le fichier que le chargeur exige de toute façon. */
33
+ export const isThemeDir = (dir) => Boolean(dir) && fs.existsSync(path.join(dir, 'config', 'settings_schema.json'));
34
+
35
+ /**
36
+ * Écrit l'archive du thème dans `dir`. Le zip du dashboard a ses fichiers à la racine, celui
37
+ * d'une boutique Shopify peut tout ranger sous un dossier : on repère la racine au fichier qui
38
+ * est toujours là, puis on ne garde que ce qui est dessous.
39
+ */
40
+ export function extractTheme(buffer, dir) {
41
+ const archive = unzipSync(new Uint8Array(buffer));
42
+ const names = Object.keys(archive);
43
+ const marker = names.find((name) => name.endsWith('config/settings_schema.json') && !name.includes('__MACOSX'));
44
+ if (!marker) throw new Error("L'archive reçue n'est pas un thème Shopify (config/settings_schema.json absent).");
45
+ const root = marker.slice(0, marker.length - 'config/settings_schema.json'.length);
46
+
47
+ let written = 0;
48
+ for (const name of names) {
49
+ if (!name.startsWith(root) || name.endsWith('/') || name.includes('__MACOSX')) continue;
50
+ const rel = name.slice(root.length);
51
+ // Une entrée de zip est une chaîne fournie par un tiers : `a/../../.ssh/id_rsa` écrirait
52
+ // hors du dossier. On refuse tout ce qui ne reste pas dedans plutôt que de l'assainir.
53
+ const full = path.resolve(dir, rel);
54
+ if (rel.startsWith('/') || !full.startsWith(path.resolve(dir) + path.sep)) continue;
55
+ fs.mkdirSync(path.dirname(full), { recursive: true });
56
+ fs.writeFileSync(full, Buffer.from(archive[name]));
57
+ written += 1;
58
+ }
59
+ if (!written) throw new Error("L'archive reçue est vide.");
60
+ return written;
61
+ }
62
+
63
+ export class ThemeSource {
64
+ /**
65
+ * @param {object} config — voir src/config.js (apiBase, token, canal, themeDir, homeDir)
66
+ * @param {{ fetch?: Function, log?: Function }} [deps] — injectés par les tests
67
+ */
68
+ constructor(config, deps = {}) {
69
+ this.config = config;
70
+ this.fetch = deps.fetch || globalThis.fetch;
71
+ this.log = deps.log || (() => {});
72
+ this.cacheRoot = path.join(config.homeDir, 'themes');
73
+ this.statePath = path.join(this.cacheRoot, 'state.json');
74
+ this.info = { origin: null, version: null, canal: null, dir: null, checkedAt: 0, message: null };
75
+ }
76
+
77
+ get dir() {
78
+ return this.info.dir;
79
+ }
80
+
81
+ readState() {
82
+ try {
83
+ return JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ writeState(state) {
90
+ fs.mkdirSync(this.cacheRoot, { recursive: true });
91
+ fs.writeFileSync(this.statePath, JSON.stringify(state, null, 2));
92
+ }
93
+
94
+ /** Dossier de travail d'une version en cache (existe ou non). */
95
+ versionDir(version) {
96
+ return path.join(this.cacheRoot, safeVersion(version));
97
+ }
98
+
99
+ async call(chemin, { timeoutMs = 10000, ...options } = {}) {
100
+ const url = `${this.config.apiBase.replace(/\/+$/, '')}/api/v1/mcp${chemin}`;
101
+ const reponse = await this.fetch(url, {
102
+ ...options,
103
+ headers: {
104
+ Authorization: `Bearer ${this.config.token}`,
105
+ Accept: 'application/json',
106
+ 'User-Agent': `story-theme-mcp/${this.config.version || ''}`,
107
+ ...(options.headers || {}),
108
+ },
109
+ signal: AbortSignal.timeout(timeoutMs),
110
+ });
111
+
112
+ if (reponse.status === 401 || reponse.status === 403) {
113
+ const corps = await reponse.json().catch(() => null);
114
+ throw new LicenceError(corps?.message || 'Jeton MCP refusé par l’espace membre Story Thème.');
115
+ }
116
+ return reponse;
117
+ }
118
+
119
+ /**
120
+ * Le thème est prêt à être lu : rend `{ dir, origin, version, canal, changed }`.
121
+ *
122
+ * Appelé une fois au démarrage (sans bloquer plus que `timeoutMs`), puis à la demande par
123
+ * l'outil `theme_source`. Une panne réseau ne fait jamais échouer si un cache existe ; un
124
+ * jeton refusé, si : c'est une licence, pas un incident.
125
+ */
126
+ async ensure({ force = false, timeoutMs = 10000 } = {}) {
127
+ // 1. Dossier local explicite : le mode développement gagne, sans appel réseau.
128
+ if (this.config.themeDirExplicit) {
129
+ if (!isThemeDir(this.config.themeDir)) {
130
+ throw new Error(`STORY_THEME_DIR ne pointe pas sur un thème Shopify : ${this.config.themeDir} (config/settings_schema.json absent)`);
131
+ }
132
+ this.info = { origin: 'local', version: null, canal: null, dir: this.config.themeDir, checkedAt: Date.now(), message: null };
133
+ return { ...this.info, changed: false };
134
+ }
135
+
136
+ if (this.config.token) {
137
+ try {
138
+ return await this.sync({ force, timeoutMs });
139
+ } catch (error) {
140
+ if (error instanceof LicenceError) throw error;
141
+ // Réseau, DNS, 500, timeout : on continue sur ce qu'on a déjà. « fetch failed » ne
142
+ // dit rien à un marchand : on traduit les pannes de liaison en une seule phrase.
143
+ const panne = /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|timed out|aborted|network/i.test(error.message)
144
+ ? 'espace membre injoignable'
145
+ : error.message;
146
+ const cache = this.fromCache(panne);
147
+ if (cache) return cache;
148
+ throw new Error(`Espace membre Story Thème injoignable (${error.message}) et aucun thème en cache. Réessayez connecté.`);
149
+ }
150
+ }
151
+
152
+ // 2. Pas de jeton : un cache déjà téléchargé reste exploitable (le jeton a pu être retiré
153
+ // de la configuration), sinon le dossier par défaut, sinon on explique quoi faire.
154
+ const cache = this.fromCache('aucun jeton configuré');
155
+ if (cache) return cache;
156
+ if (isThemeDir(this.config.themeDir)) {
157
+ this.info = { origin: 'local', version: null, canal: null, dir: this.config.themeDir, checkedAt: Date.now(), message: null };
158
+ return { ...this.info, changed: false };
159
+ }
160
+ throw new Error(AIDE_JETON);
161
+ }
162
+
163
+ fromCache(raison) {
164
+ const state = this.readState();
165
+ if (!state?.version) return null;
166
+ const dir = this.versionDir(state.version);
167
+ if (!isThemeDir(dir)) return null;
168
+ this.info = {
169
+ origin: 'cache',
170
+ version: state.version,
171
+ canal: state.canal || 'stable',
172
+ dir,
173
+ checkedAt: Date.now(),
174
+ message: `Thème ${state.version} lu depuis le cache local (${raison}).`,
175
+ };
176
+ return { ...this.info, changed: false };
177
+ }
178
+
179
+ /** Interroge l'espace membre et télécharge la version publiée si elle a changé. */
180
+ async sync({ force = false, timeoutMs = 10000 } = {}) {
181
+ const canal = this.config.canal === 'test' ? 'test' : 'stable';
182
+ const reponse = await this.call(`/theme?canal=${canal}`, { timeoutMs });
183
+ if (!reponse.ok) throw new Error(`réponse ${reponse.status} de l’espace membre`);
184
+ const corps = await reponse.json();
185
+ const theme = corps?.data?.theme;
186
+ if (!theme?.version) throw new Error('aucune version publiée annoncée par l’espace membre');
187
+
188
+ const dir = this.versionDir(theme.version);
189
+ const state = this.readState();
190
+ const dejaLa = isThemeDir(dir) && state?.version === theme.version && state?.canal === theme.canal;
191
+ if (dejaLa && !force) {
192
+ this.info = { origin: 'dashboard', version: theme.version, canal: theme.canal, dir, checkedAt: Date.now(), message: null };
193
+ return { ...this.info, changed: false };
194
+ }
195
+
196
+ await this.download(theme, dir);
197
+ this.writeState({ version: theme.version, canal: theme.canal, downloadedAt: new Date().toISOString(), api: this.config.apiBase });
198
+ this.prune(theme.version);
199
+ this.info = { origin: 'dashboard', version: theme.version, canal: theme.canal, dir, checkedAt: Date.now(), message: null };
200
+ return { ...this.info, changed: true, publieLe: theme.publieLe || null };
201
+ }
202
+
203
+ async download(theme, dir) {
204
+ // Une archive de 1,3 Mo : le délai doit couvrir une connexion lente, pas celui d'un appel JSON.
205
+ const reponse = await this.call(`/theme/archive?canal=${theme.canal}`, { timeoutMs: 120000 });
206
+ if (!reponse.ok) throw new Error(`téléchargement refusé (${reponse.status})`);
207
+ const buffer = Buffer.from(await reponse.arrayBuffer());
208
+
209
+ // Extraction dans un dossier temporaire : un téléchargement coupé en route ne doit pas
210
+ // laisser un thème à moitié écrit que le chargeur prendrait pour valide.
211
+ const temp = `${dir}.partiel`;
212
+ rmrf(temp);
213
+ fs.mkdirSync(temp, { recursive: true });
214
+ try {
215
+ extractTheme(buffer, temp);
216
+ if (!isThemeDir(temp)) throw new Error("l'archive reçue ne contient pas config/settings_schema.json");
217
+ rmrf(dir);
218
+ fs.renameSync(temp, dir);
219
+ } finally {
220
+ rmrf(temp);
221
+ }
222
+ this.log(`Thème ${theme.version} téléchargé (${Math.round(buffer.length / 1024)} Ko).`);
223
+ }
224
+
225
+ /** Garde la version courante et la précédente : de quoi comparer, sans accumuler. */
226
+ prune(garder) {
227
+ if (!fs.existsSync(this.cacheRoot)) return;
228
+ const versions = fs
229
+ .readdirSync(this.cacheRoot)
230
+ .filter((name) => name !== 'state.json' && name !== safeVersion(garder) && isThemeDir(path.join(this.cacheRoot, name)))
231
+ .map((name) => ({ name, at: fs.statSync(path.join(this.cacheRoot, name)).mtimeMs }))
232
+ .sort((a, b) => b.at - a.at);
233
+ for (const vieille of versions.slice(1)) rmrf(path.join(this.cacheRoot, vieille.name));
234
+ }
235
+ }
236
+
237
+ /** Source figée sur un dossier : utilisée par les tests et les scripts (npm run check). */
238
+ export const localSource = (dir) => ({
239
+ info: { origin: 'local', version: null, canal: null, dir, checkedAt: Date.now(), message: null },
240
+ dir,
241
+ ensure: async () => ({ origin: 'local', version: null, canal: null, dir, changed: false }),
242
+ });
@@ -0,0 +1,176 @@
1
+ // Colour math: WCAG contrast, OKLCH for building harmonious palettes.
2
+
3
+ export function parseColor(value) {
4
+ if (typeof value !== 'string') return null;
5
+ const text = value.trim();
6
+ let match = text.match(/^#([0-9a-f]{3,8})$/i);
7
+ if (match) {
8
+ let hex = match[1];
9
+ if (hex.length === 3 || hex.length === 4) hex = [...hex].map((char) => char + char).join('');
10
+ if (hex.length !== 6 && hex.length !== 8) return null;
11
+ return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1 };
12
+ }
13
+ match = text.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i);
14
+ if (match) return { r: +match[1], g: +match[2], b: +match[3], a: match[4] === undefined ? 1 : +match[4] };
15
+ return null;
16
+ }
17
+
18
+ const clamp255 = (value) => Math.max(0, Math.min(255, Math.round(value)));
19
+
20
+ export function toHex({ r, g, b }) {
21
+ return '#' + [r, g, b].map((channel) => clamp255(channel).toString(16).padStart(2, '0')).join('').toUpperCase();
22
+ }
23
+
24
+ function channel(value) {
25
+ const c = value / 255;
26
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
27
+ }
28
+
29
+ export function luminance(color) {
30
+ const c = typeof color === 'string' ? parseColor(color) : color;
31
+ return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b);
32
+ }
33
+
34
+ export function blend(foreground, background, alpha) {
35
+ const f = typeof foreground === 'string' ? parseColor(foreground) : foreground;
36
+ const b = typeof background === 'string' ? parseColor(background) : background;
37
+ return { r: f.r * alpha + b.r * (1 - alpha), g: f.g * alpha + b.g * (1 - alpha), b: f.b * alpha + b.b * (1 - alpha), a: 1 };
38
+ }
39
+
40
+ // Translucent colours are measured blended over what is behind them (white behind the background).
41
+ export function contrast(a, b) {
42
+ let back = typeof b === 'string' ? parseColor(b) : b;
43
+ if (back.a !== undefined && back.a < 1) back = blend(back, { r: 255, g: 255, b: 255 }, back.a);
44
+ let front = typeof a === 'string' ? parseColor(a) : a;
45
+ if (front.a !== undefined && front.a < 1) front = blend(front, back, front.a);
46
+ const la = luminance(front);
47
+ const lb = luminance(back);
48
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
49
+ }
50
+
51
+ export const round2 = (value) => Math.round(value * 100) / 100;
52
+
53
+ // ---- OKLCH (Björn Ottosson)
54
+
55
+ const toLinear = (value) => channel(value);
56
+ const fromLinear = (value) => 255 * (value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055);
57
+
58
+ export function toOklch(color) {
59
+ const c = typeof color === 'string' ? parseColor(color) : color;
60
+ const r = toLinear(c.r);
61
+ const g = toLinear(c.g);
62
+ const b = toLinear(c.b);
63
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
64
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
65
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
66
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
67
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
68
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
69
+ const C = Math.sqrt(A * A + B * B);
70
+ let H = (Math.atan2(B, A) * 180) / Math.PI;
71
+ if (H < 0) H += 360;
72
+ return { l: L, c: C, h: H };
73
+ }
74
+
75
+ function oklchToLinear({ l: L, c: C, h: H }) {
76
+ const A = C * Math.cos((H * Math.PI) / 180);
77
+ const B = C * Math.sin((H * Math.PI) / 180);
78
+ const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
79
+ const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
80
+ const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
81
+ return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
82
+ }
83
+
84
+ // Converts to sRGB, lowering chroma until the colour fits the gamut.
85
+ export function fromOklch(color) {
86
+ let { l, c, h } = color;
87
+ l = Math.max(0, Math.min(1, l));
88
+ for (let i = 0; i < 40; i++) {
89
+ const rgb = oklchToLinear({ l, c, h });
90
+ if (rgb.every((value) => value >= -0.0005 && value <= 1.0005)) {
91
+ return { r: fromLinear(Math.max(0, rgb[0])), g: fromLinear(Math.max(0, rgb[1])), b: fromLinear(Math.max(0, rgb[2])), a: 1 };
92
+ }
93
+ c *= 0.9;
94
+ }
95
+ const rgb = oklchToLinear({ l, c: 0, h });
96
+ return { r: fromLinear(Math.max(0, Math.min(1, rgb[0]))), g: fromLinear(Math.max(0, Math.min(1, rgb[1]))), b: fromLinear(Math.max(0, Math.min(1, rgb[2]))), a: 1 };
97
+ }
98
+
99
+ export const oklchHex = (l, c, h) => toHex(fromOklch({ l, c, h }));
100
+
101
+ // Moves a colour's lightness (keeping hue and as much chroma as fits) until it reaches `target`
102
+ // contrast against `against`. Returns the original when it already passes.
103
+ export function ensureContrast(hex, against, target, direction = null) {
104
+ if (contrast(hex, against) >= target) return { hex: toHex(parseColor(hex)), changed: false };
105
+ const base = toOklch(hex);
106
+ const dir = direction || (luminance(against) > 0.18 ? -1 : 1);
107
+ for (let step = 1; step <= 100; step++) {
108
+ const candidate = toHex(fromOklch({ ...base, l: base.l + dir * step * 0.01 }));
109
+ if (contrast(candidate, against) >= target) return { hex: candidate, changed: true };
110
+ }
111
+ return { hex: dir < 0 ? '#000000' : '#FFFFFF', changed: true };
112
+ }
113
+
114
+ // Which colours a theme button uses, from its palette choice (snippets/css-variables.liquid).
115
+ export function buttonColors(palette, scheme) {
116
+ switch (palette) {
117
+ case 'accent-1':
118
+ return { background: scheme['accent-1'], label: scheme.background };
119
+ case 'accent-2':
120
+ return { background: scheme['accent-2'], label: scheme.background };
121
+ case 'text':
122
+ return { background: scheme.text, label: scheme.background };
123
+ case 'bg_fade':
124
+ return { background: scheme.background_secondary, label: scheme.text };
125
+ case 'accent-1-outline':
126
+ return { background: scheme.background, label: scheme['accent-1'], outline: true };
127
+ case 'accent-2-outline':
128
+ return { background: scheme.background, label: scheme['accent-2'], outline: true };
129
+ case 'text-outline':
130
+ return { background: scheme.background, label: scheme.text, outline: true };
131
+ default:
132
+ return null;
133
+ }
134
+ }
135
+
136
+ function grade(ratio, pass, floor) {
137
+ if (ratio >= pass) return 'ok';
138
+ if (ratio >= floor) return 'warning';
139
+ return 'error';
140
+ }
141
+
142
+ // Audits one colour scheme the way the theme paints it: headings use `text`, body copy
143
+ // `text_secondary`, muted copy `text_secondary` at 75 %, buttons follow the button palettes.
144
+ export function auditScheme(id, settings, buttonPalettes = {}) {
145
+ const findings = [];
146
+ const bg = parseColor(settings.background || '');
147
+ if (!bg) return [{ scheme: id, check: 'fond', level: 'error', message: 'couleur de fond manquante ou invalide' }];
148
+ const push = (check, fg, background, pass, floor, extra = '') => {
149
+ const fc = parseColor(fg || '');
150
+ if (!fc) return;
151
+ const ratio = contrast(fc, background);
152
+ const level = grade(ratio, pass, floor);
153
+ findings.push({ scheme: id, check, ratio: round2(ratio), level, message: `${check} : ${round2(ratio)}:1${level === 'ok' ? '' : ` (${pass}:1 attendu)`}${extra}` });
154
+ };
155
+ const gradientNote = settings.background_gradient ? ' — fond en dégradé : mesuré sur la couleur de fond unie' : '';
156
+ push('titres sur fond', settings.text, bg, 4.5, 3, gradientNote);
157
+ push('texte sur fond', settings.text_secondary, bg, 4.5, 3, gradientNote);
158
+ const secondary = parseColor(settings.text_secondary || '');
159
+ if (secondary) {
160
+ const muted = blend(secondary, bg, 0.75);
161
+ const ratio = contrast(muted, bg);
162
+ findings.push({ scheme: id, check: 'texte discret (75 %)', ratio: round2(ratio), level: ratio >= 4.5 ? 'ok' : 'warning', message: `texte discret (75 %) : ${round2(ratio)}:1${ratio >= 4.5 ? '' : ' (4.5:1 conseillé pour les petits textes)'}` });
163
+ }
164
+ for (const [style, palette] of Object.entries(buttonPalettes)) {
165
+ const colors = buttonColors(palette, settings);
166
+ if (!colors) continue;
167
+ const background = parseColor(colors.background || '');
168
+ if (!background) continue;
169
+ push(`bouton ${style} (${palette})`, colors.label, background, 4.5, 3);
170
+ }
171
+ return findings;
172
+ }
173
+
174
+ export function auditSchemes(schemes, buttonPalettes) {
175
+ return Object.entries(schemes || {}).flatMap(([id, scheme]) => auditScheme(id, scheme?.settings || {}, buttonPalettes));
176
+ }