swatch-token 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +169 -2
- package/dist/cli.js +660 -0
- package/dist/client.js +81 -0
- package/dist/server/files.d.ts +16 -0
- package/dist/server/guard.d.ts +13 -0
- package/dist/server/project.d.ts +71 -0
- package/dist/server/requests.d.ts +42 -0
- package/dist/server/stylesheet.d.ts +51 -0
- package/dist/vite.d.ts +4 -0
- package/dist/vite.js +629 -0
- package/package.json +63 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
5
|
+
|
|
6
|
+
// src/mcp.ts
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
|
|
9
|
+
// src/server/project.ts
|
|
10
|
+
import { existsSync, readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "node:fs";
|
|
11
|
+
import { join, relative, resolve } from "node:path";
|
|
12
|
+
import postcss2 from "postcss";
|
|
13
|
+
|
|
14
|
+
// src/server/files.ts
|
|
15
|
+
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
// src/server/stylesheet.ts
|
|
18
|
+
import postcss from "postcss";
|
|
19
|
+
var THEMES = ["light", "dark"];
|
|
20
|
+
var LIGHT_SEL = /^(:root|html|:root,\s*html|html,\s*:root)$/;
|
|
21
|
+
var DARK_FIRST = /^(:root|html)?(\.dark|\[data-(theme|mode|color-scheme)=["']?dark["']?\])$/;
|
|
22
|
+
var DARK_MEDIA = /^\(\s*prefers-color-scheme\s*:\s*dark\s*\)$/;
|
|
23
|
+
function enclosing(block) {
|
|
24
|
+
let media = null;
|
|
25
|
+
for (let p = block.parent; p && p.type !== "root"; p = p.parent) {
|
|
26
|
+
if (p.type !== "atrule") return { media, ok: false };
|
|
27
|
+
const at = p;
|
|
28
|
+
if (at.name === "layer") continue;
|
|
29
|
+
if (at.name === "media" && DARK_MEDIA.test(at.params.trim()) && !media) {
|
|
30
|
+
media = at.params;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
return { media, ok: false };
|
|
34
|
+
}
|
|
35
|
+
return { media, ok: true };
|
|
36
|
+
}
|
|
37
|
+
function contextOf(block) {
|
|
38
|
+
const { media, ok } = enclosing(block);
|
|
39
|
+
if (!ok) return { theme: null, media };
|
|
40
|
+
if (block.type === "atrule") {
|
|
41
|
+
const declares = block.name === "theme" && !/\b(inline|reference)\b/.test(block.params);
|
|
42
|
+
return { theme: declares && !media ? "light" : null, media };
|
|
43
|
+
}
|
|
44
|
+
const sel = block.selector.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();
|
|
45
|
+
if (media) return { theme: LIGHT_SEL.test(sel) ? "dark" : null, media };
|
|
46
|
+
if (LIGHT_SEL.test(sel)) return { theme: "light", media };
|
|
47
|
+
const first = sel.split(",")[0].trim();
|
|
48
|
+
return { theme: DARK_FIRST.test(first) ? "dark" : null, media };
|
|
49
|
+
}
|
|
50
|
+
function parse(css, from) {
|
|
51
|
+
const root = postcss.parse(css, { from });
|
|
52
|
+
const decls = [];
|
|
53
|
+
const blocks = { light: [], dark: [] };
|
|
54
|
+
let dark = null;
|
|
55
|
+
const take = (block) => {
|
|
56
|
+
const { theme, media } = contextOf(block);
|
|
57
|
+
if (!theme) return;
|
|
58
|
+
blocks[theme].push(block);
|
|
59
|
+
if (theme === "dark" && !dark && block.type === "rule") dark = { selector: block.selector.replace(/\s+/g, " ").trim(), media };
|
|
60
|
+
for (const node of block.nodes ?? []) if (node.type === "decl" && node.prop.startsWith("--")) decls.push({ decl: node, theme });
|
|
61
|
+
};
|
|
62
|
+
root.walk((node) => {
|
|
63
|
+
if (node.type === "rule") take(node);
|
|
64
|
+
else if (node.type === "atrule" && node.name === "theme") take(node);
|
|
65
|
+
});
|
|
66
|
+
return { root, decls, blocks, dark };
|
|
67
|
+
}
|
|
68
|
+
var shown = (d) => d ? d.value.trim() + (d.important ? " !important" : "") : null;
|
|
69
|
+
function readText(css, from = "stylesheet.css") {
|
|
70
|
+
const { decls, dark } = parse(css, from);
|
|
71
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
72
|
+
for (const { decl, theme } of decls) {
|
|
73
|
+
const t = tokens.get(decl.prop) ?? {};
|
|
74
|
+
t[theme] = shown(decl);
|
|
75
|
+
tokens.set(decl.prop, t);
|
|
76
|
+
}
|
|
77
|
+
return { tokens, dark };
|
|
78
|
+
}
|
|
79
|
+
function check(e) {
|
|
80
|
+
if (!e || typeof e !== "object") throw new Error("bad edit");
|
|
81
|
+
if (!THEMES.includes(e.theme)) throw new Error(`unknown theme "${e.theme}"`);
|
|
82
|
+
if (typeof e.name !== "string" || !/^--[a-zA-Z0-9_-]+$/.test(e.name)) throw new Error(`bad token name "${e.name}"`);
|
|
83
|
+
if (typeof e.value !== "string" || /[;{}\n\r\\]/.test(e.value) || e.value.length > 2e3) throw new Error(`bad value for ${e.name}`);
|
|
84
|
+
if (e.value.trim() === "") return;
|
|
85
|
+
try {
|
|
86
|
+
const rule = postcss.parse(`a{--x:${e.value}}`).first;
|
|
87
|
+
if (rule?.type !== "rule" || rule.nodes.length !== 1 || rule.first?.type !== "decl") throw new Error();
|
|
88
|
+
if (/\/\*/.test(e.value)) throw new Error();
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`bad value for ${e.name}: ${e.value}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function writeText(css, edits, from = "stylesheet.css") {
|
|
94
|
+
edits.forEach(check);
|
|
95
|
+
const parsed2 = parse(css, from);
|
|
96
|
+
const changes = [];
|
|
97
|
+
const lastPerBlock = (theme, name) => {
|
|
98
|
+
const m = /* @__PURE__ */ new Map();
|
|
99
|
+
for (const d of parsed2.decls) if (d.theme === theme && d.decl.prop === name) m.set(d.decl.parent, d);
|
|
100
|
+
return [...m.values()];
|
|
101
|
+
};
|
|
102
|
+
const drop = (d) => {
|
|
103
|
+
const decl = d.decl, block = decl.parent;
|
|
104
|
+
const next = decl.next();
|
|
105
|
+
if (next?.type === "comment" && !/\n/.test(next.raws.before ?? "")) next.remove();
|
|
106
|
+
if (block.last === decl) {
|
|
107
|
+
const tail = decl.value.match(/\s*$/)?.[0] ?? "";
|
|
108
|
+
if (tail && !block.raws.after) {
|
|
109
|
+
block.raws.after = tail;
|
|
110
|
+
block.raws.semicolon = true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
decl.remove();
|
|
114
|
+
parsed2.decls.splice(parsed2.decls.indexOf(d), 1);
|
|
115
|
+
};
|
|
116
|
+
for (const { theme, name, value: raw } of edits) {
|
|
117
|
+
const important = /\s*!important\s*$/i.test(raw);
|
|
118
|
+
const value = raw.replace(/\s*!important\s*$/i, "").trim();
|
|
119
|
+
const all = parsed2.decls.filter((d) => d.theme === theme && d.decl.prop === name);
|
|
120
|
+
const from2 = shown(all.at(-1)?.decl);
|
|
121
|
+
const to = value === "" ? null : value + (important ? " !important" : "");
|
|
122
|
+
if (from2 !== to) changes.push({ name, theme, from: from2, to });
|
|
123
|
+
if (value === "") {
|
|
124
|
+
for (const d of all) drop(d);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (all.length) {
|
|
128
|
+
for (const { decl } of lastPerBlock(theme, name)) {
|
|
129
|
+
const kept = decl.raws.value?.raw.match(/\/\*[\s\S]*?\*\//g);
|
|
130
|
+
const next = value + (decl.value.match(/\s*$/)?.[0] ?? "");
|
|
131
|
+
decl.value = next;
|
|
132
|
+
if (kept) decl.raws.value = { value: next, raw: `${value} ${kept.join(" ")}` };
|
|
133
|
+
decl.important = important;
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!parsed2.blocks[theme].length) {
|
|
138
|
+
const rule = postcss.rule({ selector: theme === "dark" ? ".dark" : ":root" });
|
|
139
|
+
parsed2.root.append(rule);
|
|
140
|
+
parsed2.blocks[theme].push(rule);
|
|
141
|
+
}
|
|
142
|
+
const firsts = /* @__PURE__ */ new Map();
|
|
143
|
+
for (const b of parsed2.blocks[theme]) {
|
|
144
|
+
const k = enclosing(b).media ?? "";
|
|
145
|
+
if (!firsts.has(k)) firsts.set(k, b);
|
|
146
|
+
}
|
|
147
|
+
for (const block of firsts.values()) {
|
|
148
|
+
const prev = [...block.nodes ?? []].reverse().find((n) => n.type === "decl");
|
|
149
|
+
const decl = postcss.decl({ prop: name, value, important });
|
|
150
|
+
if (prev) {
|
|
151
|
+
const tail = prev.value.match(/\s*$/)?.[0] ?? "";
|
|
152
|
+
if (tail) {
|
|
153
|
+
prev.value = prev.value.slice(0, -tail.length);
|
|
154
|
+
if (!block.raws.after) block.raws.after = tail;
|
|
155
|
+
}
|
|
156
|
+
decl.raws.before = prev.raws.before;
|
|
157
|
+
decl.raws.between = prev.raws.between;
|
|
158
|
+
}
|
|
159
|
+
block.raws.semicolon = true;
|
|
160
|
+
block.append(decl);
|
|
161
|
+
parsed2.decls.push({ decl, theme });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const out = parsed2.root.toString();
|
|
165
|
+
parse(out, from);
|
|
166
|
+
return { css: out, changes };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/server/files.ts
|
|
170
|
+
var parsed = /* @__PURE__ */ new Map();
|
|
171
|
+
function readSheet(file) {
|
|
172
|
+
const { mtimeMs, size } = statSync(file);
|
|
173
|
+
const hit = parsed.get(file);
|
|
174
|
+
if (hit && hit.mtime === mtimeMs && hit.size === size) return hit.sheet;
|
|
175
|
+
const sheet = readText(readFileSync(file, "utf8"), file);
|
|
176
|
+
parsed.set(file, { mtime: mtimeMs, size, sheet });
|
|
177
|
+
return sheet;
|
|
178
|
+
}
|
|
179
|
+
function writeAll(batches) {
|
|
180
|
+
const planned = [...batches].map(([file, edits]) => {
|
|
181
|
+
const before = readFileSync(file, "utf8");
|
|
182
|
+
return { file, before, ...writeText(before, edits, file) };
|
|
183
|
+
});
|
|
184
|
+
const done = [];
|
|
185
|
+
try {
|
|
186
|
+
for (const p of planned) {
|
|
187
|
+
writeFileSync(p.file, p.css);
|
|
188
|
+
done.push(p);
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
for (const p of done) {
|
|
192
|
+
try {
|
|
193
|
+
writeFileSync(p.file, p.before);
|
|
194
|
+
} catch {
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`could not write ${planned[done.length]?.file ?? "a stylesheet"}, so nothing was saved: ${err instanceof Error ? err.message : err}`);
|
|
198
|
+
}
|
|
199
|
+
return planned.flatMap((p) => p.changes);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/server/project.ts
|
|
203
|
+
var SOURCE = /\.(tsx?|jsx?|mjs|cjs|css|scss|vue|svelte|astro|html|mdx?)$/;
|
|
204
|
+
function walk(dir, test, skip, acc = []) {
|
|
205
|
+
for (const f of readdirSync(dir)) {
|
|
206
|
+
if (f === "node_modules" || f.startsWith(".")) continue;
|
|
207
|
+
const p = join(dir, f);
|
|
208
|
+
if (skip.has(p)) continue;
|
|
209
|
+
let s;
|
|
210
|
+
try {
|
|
211
|
+
s = statSync2(p);
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (s.isDirectory()) walk(p, test, skip, acc);
|
|
216
|
+
else if (test.test(f)) acc.push(p);
|
|
217
|
+
}
|
|
218
|
+
return acc;
|
|
219
|
+
}
|
|
220
|
+
var KINDS = "bg|text|border-[trblxyse]|border|ring-offset|ring|fill|stroke|from|via|to|outline|shadow|divide|placeholder|caret|decoration|accent";
|
|
221
|
+
var PREFIX = String.raw`(?<![\w-])(?:[\w-]+(?:\[[^\]\s]*\])?:)*`;
|
|
222
|
+
var UTIL = new RegExp(`${PREFIX}(?:${KINDS})-([a-z][\\w-]*?)(?:\\/[\\w.[\\]]+)?(?![\\w-])(?!\\s*:)`, "g");
|
|
223
|
+
var UTIL_VAR = new RegExp(`${PREFIX}(?:${KINDS})-[([](--[\\w-]+)[)\\]]`, "g");
|
|
224
|
+
var VAR = /var\(\s*(--[\w-]+)/g;
|
|
225
|
+
var counted = /* @__PURE__ */ new Map();
|
|
226
|
+
function countIn(path, trusted) {
|
|
227
|
+
const hit = counted.get(path);
|
|
228
|
+
if (trusted && hit) return hit.counts;
|
|
229
|
+
const mtime = statSync2(path).mtimeMs;
|
|
230
|
+
if (hit && hit.mtime === mtime) return hit.counts;
|
|
231
|
+
const text = readFileSync2(path, "utf8");
|
|
232
|
+
const counts = /* @__PURE__ */ new Map();
|
|
233
|
+
const add = (name) => counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
234
|
+
const util = (name) => {
|
|
235
|
+
add(`--${name}`);
|
|
236
|
+
add(`--color-${name}`);
|
|
237
|
+
};
|
|
238
|
+
if (/\.s?css$/.test(path)) {
|
|
239
|
+
try {
|
|
240
|
+
postcss2.parse(text, { from: path }).walk((node) => {
|
|
241
|
+
if (node.type === "decl") {
|
|
242
|
+
let p = node.parent;
|
|
243
|
+
while (p && p.type !== "root") {
|
|
244
|
+
if (p.type === "atrule" && p.name === "theme") return;
|
|
245
|
+
p = p.parent;
|
|
246
|
+
}
|
|
247
|
+
for (const m of node.value.matchAll(VAR)) add(m[1]);
|
|
248
|
+
} else if (node.type === "atrule" && node.name === "apply") {
|
|
249
|
+
for (const m of node.params.matchAll(UTIL)) util(m[1]);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.warn(`[swatch] could not count uses in ${path}: ${err}`);
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
for (const m of text.matchAll(VAR)) add(m[1]);
|
|
257
|
+
for (const m of text.matchAll(UTIL)) util(m[1]);
|
|
258
|
+
for (const m of text.matchAll(UTIL_VAR)) add(m[1]);
|
|
259
|
+
}
|
|
260
|
+
counted.set(path, { mtime, counts });
|
|
261
|
+
return counts;
|
|
262
|
+
}
|
|
263
|
+
var Project = class {
|
|
264
|
+
root;
|
|
265
|
+
options;
|
|
266
|
+
/** folders left out of every search, absolute */
|
|
267
|
+
skip;
|
|
268
|
+
// Under a dev server its file watcher says what changed, so a read walks
|
|
269
|
+
// no directories: the lists of stylesheets and sources are kept until a
|
|
270
|
+
// file is added or removed (or a stylesheet changes, which can give it
|
|
271
|
+
// tokens or take them away), and a file's use counts until it changes.
|
|
272
|
+
// Without a watcher (a test, the MCP server) every read looks at the disk.
|
|
273
|
+
watching = false;
|
|
274
|
+
sheets = null;
|
|
275
|
+
sources = null;
|
|
276
|
+
constructor(root, options = {}, skip = []) {
|
|
277
|
+
this.root = resolve(root);
|
|
278
|
+
this.options = options;
|
|
279
|
+
this.skip = new Set([...skip, "dist", "coverage", ...options.ignore ?? []].map((d) => resolve(this.root, d)));
|
|
280
|
+
}
|
|
281
|
+
get src() {
|
|
282
|
+
return join(this.root, this.options.src ?? "");
|
|
283
|
+
}
|
|
284
|
+
watch() {
|
|
285
|
+
this.watching = true;
|
|
286
|
+
}
|
|
287
|
+
/** A file changed (or was added or removed: `listChanged`). */
|
|
288
|
+
forget(path, listChanged) {
|
|
289
|
+
counted.delete(path);
|
|
290
|
+
if (listChanged) this.sources = null;
|
|
291
|
+
if (listChanged || path.endsWith(".css")) this.sheets = null;
|
|
292
|
+
}
|
|
293
|
+
/** The stylesheets that hold tokens: the ones named, or every one in the
|
|
294
|
+
* project that declares a custom property on a theme. */
|
|
295
|
+
stylesheets() {
|
|
296
|
+
const named = this.options.css;
|
|
297
|
+
if (named) return [named].flat().map((f) => join(this.root, f)).filter((f) => existsSync(f));
|
|
298
|
+
if (this.watching && this.sheets) return this.sheets;
|
|
299
|
+
const found = walk(this.root, /\.css$/, this.skip).filter((f) => {
|
|
300
|
+
try {
|
|
301
|
+
return readSheet(f).tokens.size > 0;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
if (this.watching) this.sheets = found;
|
|
307
|
+
return found;
|
|
308
|
+
}
|
|
309
|
+
read() {
|
|
310
|
+
const files = this.stylesheets();
|
|
311
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
312
|
+
const key = (d) => d ? `${d.media ?? ""}|${d.selector}` : "";
|
|
313
|
+
const darkOf = /* @__PURE__ */ new Map();
|
|
314
|
+
const votes = /* @__PURE__ */ new Map();
|
|
315
|
+
for (const file of files) {
|
|
316
|
+
let sheet;
|
|
317
|
+
try {
|
|
318
|
+
sheet = readSheet(file);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.warn(`[swatch] could not read ${file}: ${err}`);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
for (const [name, v] of sheet.tokens) {
|
|
324
|
+
const t = tokens.get(name) ?? { name, file: relative(this.root, file), uses: [] };
|
|
325
|
+
if (v.light !== void 0) {
|
|
326
|
+
t.light = v.light;
|
|
327
|
+
t.file = relative(this.root, file);
|
|
328
|
+
}
|
|
329
|
+
if (v.dark !== void 0) {
|
|
330
|
+
t.dark = v.dark;
|
|
331
|
+
darkOf.set(name, sheet.dark);
|
|
332
|
+
if (sheet.dark) {
|
|
333
|
+
const k = key(sheet.dark);
|
|
334
|
+
votes.set(k, { dark: sheet.dark, n: (votes.get(k)?.n ?? 0) + 1 });
|
|
335
|
+
}
|
|
336
|
+
} else if (!darkOf.has(name)) darkOf.set(name, sheet.dark);
|
|
337
|
+
tokens.set(name, t);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const dark = [...votes.values()].sort((a, b) => b.n - a.n)[0]?.dark ?? [...darkOf.values()].find(Boolean) ?? null;
|
|
341
|
+
for (const t of tokens.values()) {
|
|
342
|
+
const own = darkOf.get(t.name);
|
|
343
|
+
if (own && key(own) !== key(dark)) t.darkAt = own;
|
|
344
|
+
}
|
|
345
|
+
const sources = this.watching && this.sources || walk(this.src, SOURCE, this.skip);
|
|
346
|
+
if (this.watching) this.sources = sources;
|
|
347
|
+
for (const path of sources) {
|
|
348
|
+
for (const [name, n] of countIn(path, this.watching)) tokens.get(name)?.uses.push({ file: relative(this.root, path), n });
|
|
349
|
+
}
|
|
350
|
+
const groups = this.options.groups ? Object.entries(this.options.groups).map(([title, re]) => [title, typeof re === "string" ? re : re.source]) : null;
|
|
351
|
+
return { files: files.map((f) => relative(this.root, f)), tokens: [...tokens.values()], dark, groups, agent: !!this.options.agent };
|
|
352
|
+
}
|
|
353
|
+
/** Sends each edit to the stylesheet that holds the token in that theme;
|
|
354
|
+
* failing that, the one that holds it in the other theme; failing that
|
|
355
|
+
* (a new token), the one with the most tokens. Every edit is checked,
|
|
356
|
+
* and every file's new text worked out, before any file is touched. */
|
|
357
|
+
write(edits) {
|
|
358
|
+
if (!Array.isArray(edits)) throw new Error("expected a list of edits");
|
|
359
|
+
edits.forEach(check);
|
|
360
|
+
const files = this.stylesheets();
|
|
361
|
+
if (!files.length) throw new Error("no stylesheet with tokens was found: name one with the `css` option");
|
|
362
|
+
const sheets = files.map((file) => ({ file, tokens: readSheet(file).tokens }));
|
|
363
|
+
const biggest = [...sheets].sort((a, b) => b.tokens.size - a.tokens.size)[0].file;
|
|
364
|
+
const holds = (name, theme) => sheets.filter((s) => {
|
|
365
|
+
const t = s.tokens.get(name);
|
|
366
|
+
return t && (theme ? t[theme] !== void 0 : true);
|
|
367
|
+
}).map((s) => s.file);
|
|
368
|
+
const batches = /* @__PURE__ */ new Map();
|
|
369
|
+
for (const e of edits) {
|
|
370
|
+
const here = holds(e.name, e.theme);
|
|
371
|
+
const targets = here.length ? here : e.value.trim() === "" ? [] : [holds(e.name).at(-1) ?? biggest];
|
|
372
|
+
for (const f of targets) batches.set(f, [...batches.get(f) ?? [], e]);
|
|
373
|
+
}
|
|
374
|
+
const changes = writeAll(batches);
|
|
375
|
+
const seen = /* @__PURE__ */ new Set();
|
|
376
|
+
return changes.filter((c) => {
|
|
377
|
+
const k = `${c.name}|${c.theme}`;
|
|
378
|
+
if (seen.has(k)) return false;
|
|
379
|
+
seen.add(k);
|
|
380
|
+
return true;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// src/server/requests.ts
|
|
386
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, renameSync, rmSync, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
387
|
+
import { join as join2 } from "node:path";
|
|
388
|
+
var Queue = class {
|
|
389
|
+
dir;
|
|
390
|
+
incoming;
|
|
391
|
+
constructor(root) {
|
|
392
|
+
this.dir = join2(root, ".swatch");
|
|
393
|
+
this.incoming = join2(this.dir, "incoming.json");
|
|
394
|
+
}
|
|
395
|
+
/** The queue holds only what was checked field by field: nothing a
|
|
396
|
+
* caller adds to the body, and no field of ours a caller can replace. */
|
|
397
|
+
add(r, files) {
|
|
398
|
+
let body;
|
|
399
|
+
if (r?.kind === "commit") {
|
|
400
|
+
if (!Array.isArray(r.changes) || !r.changes.length || r.changes.length > 2e3) throw new Error("nothing to commit");
|
|
401
|
+
const text = (v) => v === null || typeof v === "string" && v.length <= 2e3 && !/[\n\r]/.test(v);
|
|
402
|
+
body = { kind: "commit", changes: r.changes.map((c) => {
|
|
403
|
+
if (typeof c?.name !== "string" || !/^--[a-zA-Z0-9_-]+$/.test(c.name) || !THEMES.includes(c.theme) || !text(c.from) || !text(c.to)) throw new Error("bad change");
|
|
404
|
+
return { name: c.name, theme: c.theme, from: c.from, to: c.to };
|
|
405
|
+
}) };
|
|
406
|
+
} else if (r?.kind === "figma-sync") {
|
|
407
|
+
let host = "";
|
|
408
|
+
try {
|
|
409
|
+
if (typeof r.figma === "string" && r.figma.length <= 500 && !/\s/.test(r.figma)) {
|
|
410
|
+
const u = new URL(r.figma);
|
|
411
|
+
if (u.protocol === "https:") host = u.hostname;
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
}
|
|
415
|
+
if (host !== "figma.com" && host !== "www.figma.com") throw new Error("not a figma.com link");
|
|
416
|
+
body = { kind: "figma-sync", figma: r.figma };
|
|
417
|
+
} else throw new Error("unknown request");
|
|
418
|
+
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
419
|
+
const line = { ...body, id, at: (/* @__PURE__ */ new Date()).toISOString(), files };
|
|
420
|
+
mkdirSync(this.dir, { recursive: true });
|
|
421
|
+
appendFileSync(join2(this.dir, "requests.jsonl"), JSON.stringify(line) + "\n");
|
|
422
|
+
return { id: line.id };
|
|
423
|
+
}
|
|
424
|
+
/** What the agent left, or null (also while it is half written). */
|
|
425
|
+
take() {
|
|
426
|
+
if (!existsSync2(this.incoming)) return null;
|
|
427
|
+
try {
|
|
428
|
+
return JSON.parse(readFileSync3(this.incoming, "utf8"));
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
clear() {
|
|
434
|
+
rmSync(this.incoming, { force: true });
|
|
435
|
+
}
|
|
436
|
+
get file() {
|
|
437
|
+
return join2(this.dir, "requests.jsonl");
|
|
438
|
+
}
|
|
439
|
+
// the parsed queue, kept while the file is unchanged: an agent waiting
|
|
440
|
+
// for a request asks twice a second, and almost always nothing is new
|
|
441
|
+
seen = null;
|
|
442
|
+
/** The requests no agent has dealt with yet, oldest first. A request that
|
|
443
|
+
* has been dealt with is taken out of the file (below), so the file only
|
|
444
|
+
* ever holds what is pending, and stays small. */
|
|
445
|
+
pending() {
|
|
446
|
+
if (!existsSync2(this.file)) return [];
|
|
447
|
+
const { mtimeMs, size } = statSync3(this.file);
|
|
448
|
+
if (this.seen && this.seen.mtime === mtimeMs && this.seen.size === size) return this.seen.requests;
|
|
449
|
+
const requests = readFileSync3(this.file, "utf8").split("\n").filter(Boolean).flatMap((l) => {
|
|
450
|
+
try {
|
|
451
|
+
return [JSON.parse(l)];
|
|
452
|
+
} catch {
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
}).filter((r) => r && typeof r.id === "string");
|
|
456
|
+
this.seen = { mtime: mtimeMs, size, requests };
|
|
457
|
+
return requests;
|
|
458
|
+
}
|
|
459
|
+
/** Takes a request out of the queue, as dealt with, and notes it (with
|
|
460
|
+
* its note) in done.jsonl, which keeps the last hundred. The panel may
|
|
461
|
+
* append a request while this rewrites the file: the file is read again
|
|
462
|
+
* and the rewrite retried if it grew in between, so no request is lost. */
|
|
463
|
+
resolve(id, note) {
|
|
464
|
+
if (!this.pending().some((r) => r.id === id)) throw new Error(`no pending request "${id}"`);
|
|
465
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
466
|
+
const text = readFileSync3(this.file, "utf8");
|
|
467
|
+
const kept = text.split("\n").filter((l) => {
|
|
468
|
+
if (!l.trim()) return false;
|
|
469
|
+
try {
|
|
470
|
+
return JSON.parse(l).id !== id;
|
|
471
|
+
} catch {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
const tmp = `${this.file}.tmp`;
|
|
476
|
+
writeFileSync2(tmp, kept.length ? kept.join("\n") + "\n" : "");
|
|
477
|
+
if (statSync3(this.file).size !== Buffer.byteLength(text)) continue;
|
|
478
|
+
renameSync(tmp, this.file);
|
|
479
|
+
this.seen = null;
|
|
480
|
+
const done = join2(this.dir, "done.jsonl");
|
|
481
|
+
const log = existsSync2(done) ? readFileSync3(done, "utf8").split("\n").filter(Boolean) : [];
|
|
482
|
+
log.push(JSON.stringify({ id, at: (/* @__PURE__ */ new Date()).toISOString(), ...note ? { note: String(note).slice(0, 500) } : {} }));
|
|
483
|
+
writeFileSync2(done, log.slice(-100).join("\n") + "\n");
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
throw new Error("the queue kept changing while it was being updated; try again");
|
|
487
|
+
}
|
|
488
|
+
/** Leaves values for the panel to load as unsaved edits: every one
|
|
489
|
+
* checked as a write would be, and the file put in place whole, so the
|
|
490
|
+
* panel never reads half of it. Returns how many values. */
|
|
491
|
+
propose(edits, note) {
|
|
492
|
+
if (!edits || typeof edits !== "object" || Array.isArray(edits)) throw new Error("edits must be an object of token name to { light?, dark? }");
|
|
493
|
+
const clean = {};
|
|
494
|
+
let n = 0;
|
|
495
|
+
for (const [name, values] of Object.entries(edits)) {
|
|
496
|
+
if (!values || typeof values !== "object") throw new Error(`bad values for ${name}`);
|
|
497
|
+
for (const theme of THEMES) {
|
|
498
|
+
const value = values[theme];
|
|
499
|
+
if (value === void 0) continue;
|
|
500
|
+
check({ name, theme, value });
|
|
501
|
+
clean[name] = { ...clean[name], [theme]: value.trim() };
|
|
502
|
+
n++;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (!n) throw new Error("no values to propose");
|
|
506
|
+
mkdirSync(this.dir, { recursive: true });
|
|
507
|
+
const tmp = `${this.incoming}.tmp`;
|
|
508
|
+
writeFileSync2(tmp, JSON.stringify({ edits: clean, ...note ? { note: String(note).slice(0, 300) } : {} }));
|
|
509
|
+
renameSync(tmp, this.incoming);
|
|
510
|
+
return n;
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
// src/mcp.ts
|
|
515
|
+
var VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
516
|
+
var edit = {
|
|
517
|
+
type: "object",
|
|
518
|
+
properties: {
|
|
519
|
+
name: { type: "string", description: "The custom property, e.g. --card" },
|
|
520
|
+
theme: { type: "string", enum: ["light", "dark"] },
|
|
521
|
+
value: { type: "string", description: 'The CSS value, e.g. "#f4f4f6" or "var(--foreground)". An empty string removes the declaration (a dark value removed falls back to the light one).' }
|
|
522
|
+
},
|
|
523
|
+
required: ["name", "theme", "value"]
|
|
524
|
+
};
|
|
525
|
+
function tools(project, queue) {
|
|
526
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
527
|
+
return [
|
|
528
|
+
{
|
|
529
|
+
name: "swatch_list_tokens",
|
|
530
|
+
description: "List the project's CSS custom properties in its light and dark themes: each token's value per theme, the stylesheet it is declared in, and how many times it is used. Use this to see the current colours before proposing or writing changes.",
|
|
531
|
+
inputSchema: { type: "object", properties: {} },
|
|
532
|
+
run: () => {
|
|
533
|
+
const sheet = project.read();
|
|
534
|
+
return {
|
|
535
|
+
stylesheets: sheet.files,
|
|
536
|
+
dark: sheet.dark,
|
|
537
|
+
tokens: sheet.tokens.map((t) => ({ name: t.name, light: t.light ?? null, dark: t.dark ?? null, file: t.file, uses: t.uses.reduce((n, u) => n + u.n, 0) }))
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
name: "swatch_pending_requests",
|
|
543
|
+
description: 'List what the person using the Swatch panel has asked for and no agent has dealt with yet. A "commit" request lists stylesheet changes they saved and want committed (each with its token, theme, old and new value). A "figma-sync" request names a Figma file whose colour variables they want pulled in: read them, then call swatch_propose_edits. Call swatch_resolve_request once each is dealt with.',
|
|
544
|
+
inputSchema: { type: "object", properties: {} },
|
|
545
|
+
run: () => ({ requests: queue.pending() })
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
name: "swatch_wait_for_request",
|
|
549
|
+
description: "Wait until the person using the Swatch panel asks for something, then return the pending requests. Returns an empty list if nothing arrives within the timeout; call it again to keep waiting.",
|
|
550
|
+
inputSchema: { type: "object", properties: { timeout_seconds: { type: "number", description: "How long to wait, 1 to 300. Default 60." } } },
|
|
551
|
+
run: async (args2) => {
|
|
552
|
+
const limit = Math.min(Math.max(Number(args2.timeout_seconds) || 60, 1), 300) * 1e3;
|
|
553
|
+
const until = Date.now() + limit;
|
|
554
|
+
for (; ; ) {
|
|
555
|
+
const requests = queue.pending();
|
|
556
|
+
if (requests.length || Date.now() >= until) return { requests };
|
|
557
|
+
await sleep(500);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: "swatch_resolve_request",
|
|
563
|
+
description: "Mark a request from swatch_pending_requests as dealt with, so it is not listed again.",
|
|
564
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, note: { type: "string", description: "What was done, in a few words." } }, required: ["id"] },
|
|
565
|
+
run: (args2) => {
|
|
566
|
+
queue.resolve(String(args2.id), args2.note === void 0 ? void 0 : String(args2.note));
|
|
567
|
+
return { resolved: args2.id };
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "swatch_propose_edits",
|
|
572
|
+
description: "Show colour values in the Swatch panel as unsaved edits: the person sees them previewed live on the page and chooses whether to save. Nothing is written to a stylesheet. Use this to answer a figma-sync request, or to suggest colours. Prefer this over swatch_write_tokens unless you were asked to change the files directly. The dev server must be running for the panel to pick them up.",
|
|
573
|
+
inputSchema: {
|
|
574
|
+
type: "object",
|
|
575
|
+
properties: {
|
|
576
|
+
edits: { type: "object", description: 'Token name to its values, e.g. { "--card": { "light": "#eeeeee", "dark": "#1d1d22" } }. Give only the themes that change.', additionalProperties: { type: "object", properties: { light: { type: "string" }, dark: { type: "string" } } } },
|
|
577
|
+
note: { type: "string", description: 'One line the panel shows with them, e.g. "12 values from Figma".' }
|
|
578
|
+
},
|
|
579
|
+
required: ["edits"]
|
|
580
|
+
},
|
|
581
|
+
run: (args2) => ({ proposed: queue.propose(args2.edits, args2.note === void 0 ? void 0 : String(args2.note)) })
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
name: "swatch_write_tokens",
|
|
585
|
+
description: "Write colour token values straight into the project's stylesheets, leaving every other byte of each file as it was. A value that would break the stylesheet is refused and nothing is written. Returns each change with the old and new value.",
|
|
586
|
+
inputSchema: { type: "object", properties: { edits: { type: "array", items: edit } }, required: ["edits"] },
|
|
587
|
+
run: (args2) => ({ changes: project.write(args2.edits) })
|
|
588
|
+
}
|
|
589
|
+
];
|
|
590
|
+
}
|
|
591
|
+
async function handle(msg, all, version2) {
|
|
592
|
+
const reply = (result) => ({ jsonrpc: "2.0", id: msg.id, result });
|
|
593
|
+
const fail = (code, message) => ({ jsonrpc: "2.0", id: msg.id ?? null, error: { code, message } });
|
|
594
|
+
if (msg.id === void 0) return void 0;
|
|
595
|
+
switch (msg.method) {
|
|
596
|
+
case "initialize": {
|
|
597
|
+
const asked = String(msg.params?.protocolVersion ?? "");
|
|
598
|
+
return reply({
|
|
599
|
+
protocolVersion: VERSIONS.includes(asked) ? asked : VERSIONS[0],
|
|
600
|
+
capabilities: { tools: {} },
|
|
601
|
+
serverInfo: { name: "swatch", version: version2 },
|
|
602
|
+
instructions: "Swatch is a live colour-token editor in the project's dev server. Call swatch_pending_requests (or swatch_wait_for_request) to see what the person using its panel has asked for."
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
case "ping":
|
|
606
|
+
return reply({});
|
|
607
|
+
case "tools/list":
|
|
608
|
+
return reply({ tools: all.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
609
|
+
case "tools/call": {
|
|
610
|
+
const tool = all.find((t) => t.name === msg.params?.name);
|
|
611
|
+
if (!tool) return fail(-32602, `unknown tool "${msg.params?.name}"`);
|
|
612
|
+
try {
|
|
613
|
+
const out = await tool.run(msg.params?.arguments ?? {});
|
|
614
|
+
return reply({ content: [{ type: "text", text: JSON.stringify(out, null, 2) }] });
|
|
615
|
+
} catch (err) {
|
|
616
|
+
return reply({ content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], isError: true });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
default:
|
|
620
|
+
return fail(-32601, `method not found: ${msg.method}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function serve(root, options, version2) {
|
|
624
|
+
const all = tools(new Project(root, options), new Queue(root));
|
|
625
|
+
const out = (m) => process.stdout.write(JSON.stringify(m) + "\n");
|
|
626
|
+
createInterface({ input: process.stdin }).on("line", async (line) => {
|
|
627
|
+
if (!line.trim()) return;
|
|
628
|
+
let msg;
|
|
629
|
+
try {
|
|
630
|
+
msg = JSON.parse(line);
|
|
631
|
+
} catch {
|
|
632
|
+
return out({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
633
|
+
}
|
|
634
|
+
const answer = await handle(msg, all, version2);
|
|
635
|
+
if (answer) out(answer);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/cli.ts
|
|
640
|
+
var args = process.argv.slice(2);
|
|
641
|
+
var flag = (name) => {
|
|
642
|
+
const i = args.indexOf(`--${name}`);
|
|
643
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
644
|
+
};
|
|
645
|
+
var version = JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
646
|
+
if (args[0] === "mcp") {
|
|
647
|
+
const css = flag("css")?.split(",").filter(Boolean);
|
|
648
|
+
serve(flag("root") ?? process.cwd(), { css, src: flag("src") }, version);
|
|
649
|
+
} else if (args[0] === "--version" || args[0] === "-v") {
|
|
650
|
+
console.log(version);
|
|
651
|
+
} else {
|
|
652
|
+
console.error(`swatch ${version}
|
|
653
|
+
|
|
654
|
+
Usage:
|
|
655
|
+
npx swatch-token mcp [--root <dir>] [--css <file>[,<file>]] [--src <dir>]
|
|
656
|
+
Run the MCP server for a coding agent, from the project root.
|
|
657
|
+
|
|
658
|
+
The panel itself is a Vite plugin: import swatch from "swatch-token/vite".`);
|
|
659
|
+
process.exit(args.length ? 1 : 0);
|
|
660
|
+
}
|