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