@open-slide/core 1.4.0 → 1.6.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.
Files changed (45) hide show
  1. package/dist/{build-1Rqivz0d.js → build-tLrkKUHr.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-s0YUbmUe.d.ts → config-CfMThYN9.d.ts} +1 -1
  4. package/dist/{config-XZJnC_fu.js → config-PwUHqZ_X.js} +2312 -1654
  5. package/dist/{dev-0W8gYiSa.js → dev-DpCIRbhT.js} +1 -1
  6. package/dist/{en-7GU-DHbJ.js → en-BDnM5zKJ.js} +18 -1
  7. package/dist/index.d.ts +12 -3
  8. package/dist/index.js +20 -4
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +55 -4
  11. package/dist/{preview-DT9hJvzM.js → preview-BSGlM6Se.js} +1 -1
  12. package/dist/{types-QCpkHkiS.d.ts → types-B-KrjgX8.d.ts} +21 -0
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/create-theme/SKILL.md +30 -22
  17. package/skills/slide-authoring/SKILL.md +26 -2
  18. package/src/app/components/asset-view.tsx +83 -10
  19. package/src/app/components/inspector/inspector-panel.tsx +16 -1
  20. package/src/app/components/panel/panel-shell.tsx +5 -3
  21. package/src/app/components/player.tsx +6 -1
  22. package/src/app/components/present/laser-pointer.tsx +3 -4
  23. package/src/app/components/present/overview-grid.tsx +4 -1
  24. package/src/app/components/present/progress-bar.tsx +4 -4
  25. package/src/app/components/themes/theme-detail.tsx +7 -2
  26. package/src/app/components/themes/themes-gallery.tsx +4 -1
  27. package/src/app/components/thumbnail-rail.tsx +10 -2
  28. package/src/app/lib/assets.ts +23 -0
  29. package/src/app/lib/export-html.ts +7 -2
  30. package/src/app/lib/export-pdf.ts +34 -2
  31. package/src/app/lib/folders.ts +35 -1
  32. package/src/app/lib/page-context.tsx +38 -0
  33. package/src/app/lib/sdk.ts +2 -0
  34. package/src/app/lib/slides.ts +2 -0
  35. package/src/app/lib/use-wheel-page-navigation.ts +7 -0
  36. package/src/app/routes/home-shell.tsx +13 -2
  37. package/src/app/routes/home.tsx +129 -5
  38. package/src/app/routes/presenter.tsx +7 -2
  39. package/src/app/routes/slide.tsx +49 -1
  40. package/src/app/virtual.d.ts +1 -0
  41. package/src/locale/en.ts +18 -1
  42. package/src/locale/ja.ts +18 -1
  43. package/src/locale/types.ts +21 -0
  44. package/src/locale/zh-cn.ts +18 -1
  45. package/src/locale/zh-tw.ts +18 -1
@@ -6,15 +6,17 @@ import { randomUUID } from "node:crypto";
6
6
  import { existsSync } from "node:fs";
7
7
  import tailwindcss from "@tailwindcss/vite";
8
8
  import react from "@vitejs/plugin-react";
9
- import { parse } from "@babel/parser";
9
+ import * as t$4 from "@babel/types";
10
+ import * as t$3 from "@babel/types";
10
11
  import * as t$2 from "@babel/types";
11
12
  import * as t$1 from "@babel/types";
12
13
  import * as t from "@babel/types";
13
14
  import { isJSXElement, isJSXFragment } from "@babel/types";
15
+ import { parse } from "@babel/parser";
14
16
  import fg from "fast-glob";
15
17
  import { loadConfigFromFile, normalizePath } from "vite";
16
18
 
17
- //#region src/vite/babel-walk.ts
19
+ //#region src/editing/babel-walk.ts
18
20
  const SKIP_KEYS = new Set([
19
21
  "loc",
20
22
  "start",
@@ -54,89 +56,106 @@ function walkJsx(ast, visit) {
54
56
  function walkAll(ast, visit) {
55
57
  walk(ast, visit, acceptAll);
56
58
  }
59
+ function parseSource$2(source) {
60
+ try {
61
+ const ast = parse(source, {
62
+ sourceType: "module",
63
+ plugins: ["typescript", "jsx"],
64
+ errorRecovery: true
65
+ });
66
+ return ast.errors && ast.errors.length > 0 ? null : ast;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
57
71
 
58
72
  //#endregion
59
- //#region src/vite/comments-plugin.ts
60
- const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
61
- const SLIDE_ID_RE$4 = /^[a-z0-9_-]+$/i;
62
- function b64urlEncode(s) {
63
- return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
73
+ //#region src/editing/edit-ops.ts
74
+ function jsString$1(s) {
75
+ return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
64
76
  }
65
- function b64urlDecode(s) {
66
- const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
67
- return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
77
+ function spliceRange(node, text) {
78
+ return {
79
+ from: node.start ?? 0,
80
+ to: node.end ?? 0,
81
+ text
82
+ };
68
83
  }
69
- async function readBody$3(req) {
70
- return await new Promise((resolve, reject) => {
71
- const chunks = [];
72
- req.on("data", (c) => chunks.push(c));
73
- req.on("end", () => {
74
- const raw = Buffer.concat(chunks).toString("utf8");
75
- if (!raw) return resolve({});
76
- try {
77
- resolve(JSON.parse(raw));
78
- } catch (e) {
79
- reject(e);
80
- }
81
- });
82
- req.on("error", reject);
83
- });
84
+ function formatJsxAttrValue(value) {
85
+ if (/^[^"\\<>&{}\n\r]*$/.test(value)) return `"${value}"`;
86
+ return `{${jsString$1(value)}}`;
84
87
  }
85
- function json$3(res, status, body) {
86
- res.statusCode = status;
87
- res.setHeader("content-type", "application/json");
88
- res.end(JSON.stringify(body));
88
+ function jsxAttrName(attr) {
89
+ return t$4.isJSXIdentifier(attr.name) ? attr.name.name : null;
89
90
  }
90
- function resolveSlidePath$2(userCwd, slidesDir, slideId) {
91
- if (!SLIDE_ID_RE$4.test(slideId)) return null;
92
- const slidesRoot = path.resolve(userCwd, slidesDir);
93
- const full = path.resolve(slidesRoot, slideId, "index.tsx");
94
- if (!full.startsWith(slidesRoot + path.sep)) return null;
95
- return full;
91
+ function findJsxAttr(opening, name) {
92
+ for (const attr of opening.attributes) if (t$4.isJSXAttribute(attr) && jsxAttrName(attr) === name) return attr;
93
+ return null;
96
94
  }
97
- function parseMarkers(source) {
98
- const comments = [];
99
- const lines = source.split("\n");
100
- for (let i = 0; i < lines.length; i++) {
101
- const line = lines[i];
102
- MARKER_RE.lastIndex = 0;
103
- const m = MARKER_RE.exec(line);
104
- if (!m) continue;
105
- const [, id, ts, textB64] = m;
106
- try {
107
- const payload = JSON.parse(b64urlDecode(textB64));
108
- comments.push({
109
- id,
110
- line: i + 1,
111
- ts,
112
- note: payload.note,
113
- hint: payload.hint
114
- });
115
- } catch {}
116
- }
117
- return comments;
95
+ function readJsxStringAttr(opening, name) {
96
+ const attr = findJsxAttr(opening, name);
97
+ const v = attr?.value;
98
+ if (!v) return null;
99
+ if (t$4.isStringLiteral(v)) return v.value;
100
+ if (t$4.isJSXExpressionContainer(v) && t$4.isStringLiteral(v.expression)) return v.expression.value;
101
+ return null;
118
102
  }
119
- function newId() {
120
- return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
103
+ function readJsxNumberAttr(opening, name) {
104
+ const attr = findJsxAttr(opening, name);
105
+ const v = attr?.value;
106
+ if (!v || !t$4.isJSXExpressionContainer(v)) return null;
107
+ if (!t$4.isNumericLiteral(v.expression)) return null;
108
+ const n = v.expression.value;
109
+ return Number.isFinite(n) ? n : null;
121
110
  }
122
- function lineToOffset(source, line) {
123
- let off = 0;
124
- for (let l = 1; l < line; l++) {
125
- const nl = source.indexOf("\n", off);
126
- if (nl === -1) return source.length;
127
- off = nl + 1;
111
+ function findImports$1(ast) {
112
+ const out = [];
113
+ for (const node of ast.program.body) {
114
+ if (!t$4.isImportDeclaration(node)) continue;
115
+ let def = null;
116
+ for (const spec of node.specifiers) if (t$4.isImportDefaultSpecifier(spec)) {
117
+ def = spec.local.name;
118
+ break;
119
+ }
120
+ out.push({
121
+ node,
122
+ source: node.source.value,
123
+ defaultIdent: def
124
+ });
128
125
  }
129
- return off;
126
+ return out;
130
127
  }
131
- function lineIndent(source, lineNumber) {
132
- const start = lineToOffset(source, lineNumber);
133
- const m = source.slice(start, start + 200).match(/^[ \t]*/);
134
- return m?.[0] ?? "";
128
+ function collectTopLevelIdentifiers(ast) {
129
+ const names = new Set();
130
+ for (const imp of findImports$1(ast)) {
131
+ if (imp.defaultIdent) names.add(imp.defaultIdent);
132
+ for (const spec of imp.node.specifiers) if (!t$4.isImportDefaultSpecifier(spec)) names.add(spec.local.name);
133
+ }
134
+ return names;
135
135
  }
136
- function findJsxAncestors(ast, line, column) {
136
+ function safeAssetIdentifier(filename, taken) {
137
+ const stem = filename.replace(/\.[^.]+$/, "");
138
+ let camel = "";
139
+ let upper = false;
140
+ for (const ch of stem) if (/[A-Za-z0-9]/.test(ch)) {
141
+ camel += upper ? ch.toUpperCase() : ch;
142
+ upper = false;
143
+ } else upper = camel.length > 0;
144
+ let base = camel;
145
+ if (!base || !/^[A-Za-z_$]/.test(base)) base = `asset${base.charAt(0).toUpperCase()}${base.slice(1)}` || "asset";
146
+ base = base.charAt(0).toLowerCase() + base.slice(1);
147
+ let candidate = base;
148
+ let i = 2;
149
+ while (taken.has(candidate)) {
150
+ candidate = `${base}${i}`;
151
+ i += 1;
152
+ }
153
+ return candidate;
154
+ }
155
+ function findJsxAncestors$1(ast, line, column) {
137
156
  const hits = [];
138
157
  walkJsx(ast, (n) => {
139
- if (!n.loc || !t$2.isJSXElement(n) && !t$2.isJSXFragment(n)) return;
158
+ if (!n.loc || !t$4.isJSXElement(n) && !t$4.isJSXFragment(n)) return;
140
159
  const s = n.loc.start;
141
160
  const e = n.loc.end;
142
161
  const afterStart = line > s.line || line === s.line && column >= s.column;
@@ -149,64 +168,28 @@ function findJsxAncestors(ast, line, column) {
149
168
  hits.sort((a, b) => a.size - b.size);
150
169
  return hits.map((h) => h.node);
151
170
  }
152
- function planInsertion(source, target) {
153
- if (t$2.isJSXFragment(target)) {
154
- const opening = target.openingFragment;
155
- const startLine = target.loc?.start.line ?? 1;
156
- return {
157
- offset: opening.end ?? 0,
158
- indent: `${lineIndent(source, startLine)} `
159
- };
160
- }
161
- if (t$2.isJSXElement(target)) {
162
- const opening = target.openingElement;
163
- if (opening.selfClosing) return null;
164
- const startLine = target.loc?.start.line ?? 1;
165
- return {
166
- offset: opening.end ?? 0,
167
- indent: `${lineIndent(source, startLine)} `
168
- };
169
- }
170
- return null;
171
- }
172
- function findInsertion(source, line, column) {
173
- const ast = parseSource$2(source);
174
- if (!ast) return null;
175
- const col = column ?? 0;
176
- const ancestors = findJsxAncestors(ast, line, col);
177
- for (const node of ancestors) {
178
- const plan = planInsertion(source, node);
179
- if (plan) return plan;
180
- }
181
- return null;
182
- }
183
- function offsetToLine(source, offset) {
184
- let line = 1;
185
- for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
186
- return line;
187
- }
188
- function parseSource$2(source) {
189
- try {
190
- const ast = parse(source, {
191
- sourceType: "module",
192
- plugins: ["typescript", "jsx"],
193
- errorRecovery: true
194
- });
195
- return ast.errors && ast.errors.length > 0 ? null : ast;
196
- } catch {
197
- return null;
198
- }
171
+ function findJsxByStart(ast, line, column) {
172
+ let hit = null;
173
+ walkJsx(ast, (n) => {
174
+ if (!t$4.isJSXElement(n) || !n.loc) return;
175
+ const s = n.loc.start;
176
+ if (s.line === line && s.column === column) {
177
+ hit = n;
178
+ return "stop";
179
+ }
180
+ });
181
+ return hit;
199
182
  }
200
183
  function findInnermostJsxElement(ast, line, column) {
201
184
  const exact = findJsxByStart(ast, line, column);
202
185
  if (exact) return exact;
203
- for (const n of findJsxAncestors(ast, line, column)) if (t$2.isJSXElement(n)) return n;
186
+ for (const n of findJsxAncestors$1(ast, line, column)) if (t$4.isJSXElement(n)) return n;
204
187
  return null;
205
188
  }
206
189
  function findUniqueElementByText(ast, prevText) {
207
190
  const hits = [];
208
191
  walkJsx(ast, (n) => {
209
- if (!t$2.isJSXElement(n)) return;
192
+ if (!t$4.isJSXElement(n)) return;
210
193
  const parts = [];
211
194
  collectTextRangeParts(n, parts);
212
195
  if (textRangeContent(parts) !== prevText) return;
@@ -248,28 +231,6 @@ function findElementForEdit(ast, line, column, ops) {
248
231
  if (element && elementTextMatches(element, prevText)) return textMatch ?? element;
249
232
  return textMatch ?? element;
250
233
  }
251
- function findJsxByStart(ast, line, column) {
252
- let hit = null;
253
- walkJsx(ast, (n) => {
254
- if (!t$2.isJSXElement(n) || !n.loc) return;
255
- const s = n.loc.start;
256
- if (s.line === line && s.column === column) {
257
- hit = n;
258
- return "stop";
259
- }
260
- });
261
- return hit;
262
- }
263
- function jsString$1(s) {
264
- return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
265
- }
266
- function jsxAttrName(attr) {
267
- return t$2.isJSXIdentifier(attr.name) ? attr.name.name : null;
268
- }
269
- function findJsxAttr(opening, name) {
270
- for (const attr of opening.attributes) if (t$2.isJSXAttribute(attr) && jsxAttrName(attr) === name) return attr;
271
- return null;
272
- }
273
234
  function buildStyleSplice(source, element, ops) {
274
235
  const opening = element.openingElement;
275
236
  const existing = findJsxAttr(opening, "style");
@@ -277,19 +238,19 @@ function buildStyleSplice(source, element, ops) {
277
238
  let hasRawEntry = false;
278
239
  if (existing) {
279
240
  const value = existing.value;
280
- if (!value || !t$2.isJSXExpressionContainer(value)) return { error: "style attribute has unsupported form" };
241
+ if (!value || !t$4.isJSXExpressionContainer(value)) return { error: "style attribute has unsupported form" };
281
242
  const expr = value.expression;
282
- if (!t$2.isObjectExpression(expr)) {
243
+ if (!t$4.isObjectExpression(expr)) {
283
244
  if (typeof expr.start !== "number" || typeof expr.end !== "number") return { error: "style value missing source range" };
284
245
  entries.push({
285
246
  kind: "raw",
286
247
  text: `...(${source.slice(expr.start, expr.end)})`
287
248
  });
288
249
  hasRawEntry = true;
289
- } else for (const prop of expr.properties) if (t$2.isObjectProperty(prop) && !prop.computed) {
250
+ } else for (const prop of expr.properties) if (t$4.isObjectProperty(prop) && !prop.computed) {
290
251
  let keyName = null;
291
- if (t$2.isIdentifier(prop.key)) keyName = prop.key.name;
292
- else if (t$2.isStringLiteral(prop.key)) keyName = prop.key.value;
252
+ if (t$4.isIdentifier(prop.key)) keyName = prop.key.name;
253
+ else if (t$4.isStringLiteral(prop.key)) keyName = prop.key.value;
293
254
  if (!keyName) return { error: "style has unsupported key" };
294
255
  const v = prop.value;
295
256
  if (typeof prop.key.start !== "number" || typeof prop.key.end !== "number" || typeof v.start !== "number" || typeof v.end !== "number") return { error: "style value missing source range" };
@@ -368,7 +329,7 @@ function formatJsxText(value) {
368
329
  }
369
330
  function meaningfulChildren(parent) {
370
331
  return parent.children.filter((c) => {
371
- if (t$2.isJSXText(c)) return c.value.trim() !== "";
332
+ if (t$4.isJSXText(c)) return c.value.trim() !== "";
372
333
  return true;
373
334
  });
374
335
  }
@@ -435,14 +396,14 @@ function cleanJsxTextWithOffsets(value) {
435
396
  };
436
397
  }
437
398
  function isJsxBrElement(node) {
438
- if (!t$2.isJSXElement(node)) return false;
399
+ if (!t$4.isJSXElement(node)) return false;
439
400
  const name = node.openingElement.name;
440
- return t$2.isJSXIdentifier(name) && name.name.toLowerCase() === "br";
401
+ return t$4.isJSXIdentifier(name) && name.name.toLowerCase() === "br";
441
402
  }
442
403
  function collectTextCandidates(element, out) {
443
404
  const meaningful = meaningfulChildren(element);
444
405
  const isSole = meaningful.length === 1;
445
- for (const child of meaningful) if (t$2.isJSXText(child)) {
406
+ for (const child of meaningful) if (t$4.isJSXText(child)) {
446
407
  const current = child.value.trim();
447
408
  if (!current) continue;
448
409
  out.push({
@@ -453,9 +414,9 @@ function collectTextCandidates(element, out) {
453
414
  text: formatJsxText(v)
454
415
  }
455
416
  });
456
- } else if (t$2.isJSXExpressionContainer(child)) {
417
+ } else if (t$4.isJSXExpressionContainer(child)) {
457
418
  const expr = child.expression;
458
- if (t$2.isStringLiteral(expr) || t$2.isNumericLiteral(expr)) {
419
+ if (t$4.isStringLiteral(expr) || t$4.isNumericLiteral(expr)) {
459
420
  const current = String(expr.value);
460
421
  out.push({
461
422
  current,
@@ -466,7 +427,7 @@ function collectTextCandidates(element, out) {
466
427
  }
467
428
  });
468
429
  }
469
- } else if (t$2.isJSXElement(child) || t$2.isJSXFragment(child)) collectTextCandidates(child, out);
430
+ } else if (t$4.isJSXElement(child) || t$4.isJSXFragment(child)) collectTextCandidates(child, out);
470
431
  }
471
432
  function collectTextRangeParts(element, out) {
472
433
  const parts = [];
@@ -474,7 +435,7 @@ function collectTextRangeParts(element, out) {
474
435
  out.push(...normalizeTextRangeParts(parts));
475
436
  }
476
437
  function collectTextRangePartsRaw(element, out) {
477
- for (const child of element.children) if (t$2.isJSXText(child)) {
438
+ for (const child of element.children) if (t$4.isJSXText(child)) {
478
439
  const { text: current, offsets } = cleanJsxTextWithOffsets(child.value);
479
440
  if (current) out.push({
480
441
  node: child,
@@ -484,9 +445,9 @@ function collectTextRangePartsRaw(element, out) {
484
445
  text: formatJsxText,
485
446
  offsets
486
447
  });
487
- } else if (t$2.isJSXExpressionContainer(child)) {
448
+ } else if (t$4.isJSXExpressionContainer(child)) {
488
449
  const expression = child.expression;
489
- if (t$2.isStringLiteral(expression) || t$2.isNumericLiteral(expression)) {
450
+ if (t$4.isStringLiteral(expression) || t$4.isNumericLiteral(expression)) {
490
451
  const raw = String(expression.value);
491
452
  const current = raw;
492
453
  if (current) out.push({
@@ -502,7 +463,7 @@ function collectTextRangePartsRaw(element, out) {
502
463
  node: child,
503
464
  current: "\n"
504
465
  });
505
- else if (t$2.isJSXElement(child) || t$2.isJSXFragment(child)) collectTextRangePartsRaw(child, out);
466
+ else if (t$4.isJSXElement(child) || t$4.isJSXFragment(child)) collectTextRangePartsRaw(child, out);
506
467
  }
507
468
  function normalizeTextRangeParts(parts) {
508
469
  return parts.flatMap((part, index) => {
@@ -665,7 +626,7 @@ function buildTextRangeStyleSplices(ast, source, element, start, end, op, prevTe
665
626
  leafStart = leafEnd;
666
627
  continue;
667
628
  }
668
- if (selectedStart === leafStart && selectedEnd === leafEnd && t$2.isJSXElement(leaf.parent) && leaf.parent !== element && isOnlyMeaningfulChild(leaf.parent, leaf.node)) {
629
+ if (selectedStart === leafStart && selectedEnd === leafEnd && t$4.isJSXElement(leaf.parent) && leaf.parent !== element && isOnlyMeaningfulChild(leaf.parent, leaf.node)) {
669
630
  const result = buildStyleSplice(source, leaf.parent, [op]);
670
631
  if (result && "error" in result) return result;
671
632
  if (result) splices.push(result);
@@ -681,8 +642,8 @@ function buildTextRangeStyleSplices(ast, source, element, start, end, op, prevTe
681
642
  const before = raw.slice(0, rawStart);
682
643
  const selected = leaf.current.slice(localStart, localEnd);
683
644
  const after = raw.slice(rawEnd);
684
- const beforeText = t$2.isJSXText(leaf.node) ? before : formatOptionalText(before, leaf.text);
685
- const afterText = t$2.isJSXText(leaf.node) ? after : formatOptionalText(after, leaf.text);
645
+ const beforeText = t$4.isJSXText(leaf.node) ? before : formatOptionalText(before, leaf.text);
646
+ const afterText = t$4.isJSXText(leaf.node) ? after : formatOptionalText(after, leaf.text);
686
647
  splices.push(spliceRange(leaf.node, `${beforeText}${styleSpanForText(selected, op.key, op.value)}${afterText}`));
687
648
  leafStart = leafEnd;
688
649
  }
@@ -692,8 +653,8 @@ function propPassthroughName(element) {
692
653
  const meaningful = meaningfulChildren(element);
693
654
  if (meaningful.length !== 1) return null;
694
655
  const child = meaningful[0];
695
- if (!t$2.isJSXExpressionContainer(child)) return null;
696
- return t$2.isIdentifier(child.expression) ? child.expression.name : null;
656
+ if (!t$4.isJSXExpressionContainer(child)) return null;
657
+ return t$4.isIdentifier(child.expression) ? child.expression.name : null;
697
658
  }
698
659
  function findEnclosingComponent(ast, target) {
699
660
  let best = null;
@@ -715,17 +676,17 @@ function findEnclosingComponent(ast, target) {
715
676
  }
716
677
  };
717
678
  const visitDecl = (decl) => {
718
- if (t$2.isFunctionDeclaration(decl) && decl.id) consider(decl.id.name, decl);
719
- else if (t$2.isVariableDeclaration(decl)) for (const d of decl.declarations) {
720
- if (!t$2.isVariableDeclarator(d) || !t$2.isIdentifier(d.id) || !d.init) continue;
721
- if (t$2.isArrowFunctionExpression(d.init) || t$2.isFunctionExpression(d.init)) consider(d.id.name, d.init);
679
+ if (t$4.isFunctionDeclaration(decl) && decl.id) consider(decl.id.name, decl);
680
+ else if (t$4.isVariableDeclaration(decl)) for (const d of decl.declarations) {
681
+ if (!t$4.isVariableDeclarator(d) || !t$4.isIdentifier(d.id) || !d.init) continue;
682
+ if (t$4.isArrowFunctionExpression(d.init) || t$4.isFunctionExpression(d.init)) consider(d.id.name, d.init);
722
683
  }
723
684
  };
724
685
  for (const decl of ast.program.body) {
725
686
  visitDecl(decl);
726
- if (t$2.isExportNamedDeclaration(decl) || t$2.isExportDefaultDeclaration(decl)) {
687
+ if (t$4.isExportNamedDeclaration(decl) || t$4.isExportDefaultDeclaration(decl)) {
727
688
  const inner = decl.declaration;
728
- if (inner && (t$2.isStatement(inner) || t$2.isFunctionDeclaration(inner))) visitDecl(inner);
689
+ if (inner && (t$4.isStatement(inner) || t$4.isFunctionDeclaration(inner))) visitDecl(inner);
729
690
  }
730
691
  }
731
692
  return best;
@@ -733,51 +694,40 @@ function findEnclosingComponent(ast, target) {
733
694
  function componentDestructuresProp(fn, propName) {
734
695
  if (fn.params.length === 0) return false;
735
696
  let first = fn.params[0];
736
- if (t$2.isAssignmentPattern(first)) first = first.left;
737
- if (!t$2.isObjectPattern(first)) return false;
697
+ if (t$4.isAssignmentPattern(first)) first = first.left;
698
+ if (!t$4.isObjectPattern(first)) return false;
738
699
  for (const prop of first.properties) {
739
- if (!t$2.isObjectProperty(prop)) continue;
740
- if (t$2.isIdentifier(prop.key) && prop.key.name === propName) return true;
741
- if (t$2.isStringLiteral(prop.key) && prop.key.value === propName) return true;
700
+ if (!t$4.isObjectProperty(prop)) continue;
701
+ if (t$4.isIdentifier(prop.key) && prop.key.name === propName) return true;
702
+ if (t$4.isStringLiteral(prop.key) && prop.key.value === propName) return true;
742
703
  }
743
704
  return false;
744
705
  }
745
706
  function collectCallSiteCandidates(ast, componentName) {
746
707
  const out = [];
747
708
  walkJsx(ast, (n) => {
748
- if (!t$2.isJSXElement(n)) return;
709
+ if (!t$4.isJSXElement(n)) return;
749
710
  const elName = n.openingElement.name;
750
- if (t$2.isJSXIdentifier(elName) && elName.name === componentName) collectTextCandidates(n, out);
711
+ if (t$4.isJSXIdentifier(elName) && elName.name === componentName) collectTextCandidates(n, out);
751
712
  });
752
713
  return out;
753
714
  }
754
- function formatJsxAttrValue(value) {
755
- if (/^[^"\\<>&{}\n\r]*$/.test(value)) return `"${value}"`;
756
- return `{${jsString$1(value)}}`;
757
- }
758
- function spliceRange(node, text) {
759
- return {
760
- from: node.start ?? 0,
761
- to: node.end ?? 0,
762
- text
763
- };
764
- }
765
715
  function collectPropCallSiteCandidates(ast, componentName, propName) {
766
716
  const out = [];
767
717
  walkJsx(ast, (n) => {
768
- if (!t$2.isJSXElement(n)) return;
718
+ if (!t$4.isJSXElement(n)) return;
769
719
  const elName = n.openingElement.name;
770
- if (!t$2.isJSXIdentifier(elName) || elName.name !== componentName) return;
720
+ if (!t$4.isJSXIdentifier(elName) || elName.name !== componentName) return;
771
721
  const attr = findJsxAttr(n.openingElement, propName);
772
722
  if (!attr?.value) return;
773
723
  const v = attr.value;
774
- if (t$2.isStringLiteral(v)) out.push({
724
+ if (t$4.isStringLiteral(v)) out.push({
775
725
  current: v.value,
776
726
  splice: (s) => spliceRange(v, formatJsxAttrValue(s))
777
727
  });
778
- else if (t$2.isJSXExpressionContainer(v)) {
728
+ else if (t$4.isJSXExpressionContainer(v)) {
779
729
  const expr = v.expression;
780
- if (t$2.isStringLiteral(expr) || t$2.isNumericLiteral(expr)) out.push({
730
+ if (t$4.isStringLiteral(expr) || t$4.isNumericLiteral(expr)) out.push({
781
731
  current: String(expr.value),
782
732
  splice: (s) => spliceRange(v, formatJsxAttrValue(s))
783
733
  });
@@ -790,17 +740,17 @@ function findEnclosingMapCallback(ast, target) {
790
740
  const targetStart = target.start ?? 0;
791
741
  const targetEnd = target.end ?? 0;
792
742
  walkAll(ast, (node) => {
793
- if (!t$2.isCallExpression(node)) return;
743
+ if (!t$4.isCallExpression(node)) return;
794
744
  const callee = node.callee;
795
- if (!t$2.isMemberExpression(callee) || callee.computed) return;
796
- if (!t$2.isIdentifier(callee.property)) return;
745
+ if (!t$4.isMemberExpression(callee) || callee.computed) return;
746
+ if (!t$4.isIdentifier(callee.property)) return;
797
747
  if (callee.property.name !== "map" && callee.property.name !== "flatMap") return;
798
748
  const fn = node.arguments[0];
799
- if (!fn || !t$2.isArrowFunctionExpression(fn) && !t$2.isFunctionExpression(fn)) return;
749
+ if (!fn || !t$4.isArrowFunctionExpression(fn) && !t$4.isFunctionExpression(fn)) return;
800
750
  const fnStart = fn.start ?? 0;
801
751
  const fnEnd = fn.end ?? 0;
802
752
  if (fnStart > targetStart || fnEnd < targetEnd) return;
803
- if (!t$2.isExpression(callee.object)) return;
753
+ if (!t$4.isExpression(callee.object)) return;
804
754
  const size = fnEnd - fnStart;
805
755
  if (!best || size < best.size) best = {
806
756
  fn,
@@ -817,26 +767,26 @@ function findEnclosingMapCallback(ast, target) {
817
767
  }
818
768
  function resolveArrayLiteralElements(ast, expr) {
819
769
  const dropHoles = (arr) => arr.elements.filter((e) => e != null);
820
- if (t$2.isArrayExpression(expr)) return dropHoles(expr);
821
- if (!t$2.isIdentifier(expr)) return null;
770
+ if (t$4.isArrayExpression(expr)) return dropHoles(expr);
771
+ if (!t$4.isIdentifier(expr)) return null;
822
772
  const name = expr.name;
823
773
  const useStart = expr.start ?? 0;
824
774
  let init = null;
825
775
  walkAll(ast, (node) => {
826
- if (!t$2.isVariableDeclarator(node)) return;
827
- if (!t$2.isIdentifier(node.id) || node.id.name !== name) return;
828
- if (!node.init || !t$2.isArrayExpression(node.init)) return;
776
+ if (!t$4.isVariableDeclarator(node)) return;
777
+ if (!t$4.isIdentifier(node.id) || node.id.name !== name) return;
778
+ if (!node.init || !t$4.isArrayExpression(node.init)) return;
829
779
  if ((node.init.start ?? 0) > useStart) return;
830
780
  init = node.init;
831
781
  });
832
782
  return init ? dropHoles(init) : null;
833
783
  }
834
784
  function findObjectProperty(obj, name) {
835
- if (!t$2.isObjectExpression(obj)) return null;
785
+ if (!t$4.isObjectExpression(obj)) return null;
836
786
  for (const prop of obj.properties) {
837
- if (!t$2.isObjectProperty(prop) || prop.computed) continue;
838
- if (t$2.isIdentifier(prop.key) && prop.key.name === name) return prop;
839
- if (t$2.isStringLiteral(prop.key) && prop.key.value === name) return prop;
787
+ if (!t$4.isObjectProperty(prop) || prop.computed) continue;
788
+ if (t$4.isIdentifier(prop.key) && prop.key.name === name) return prop;
789
+ if (t$4.isStringLiteral(prop.key) && prop.key.value === name) return prop;
840
790
  }
841
791
  return null;
842
792
  }
@@ -844,22 +794,22 @@ function decodeMapPassthrough(element, callbackParam) {
844
794
  const meaningful = meaningfulChildren(element);
845
795
  if (meaningful.length !== 1) return null;
846
796
  const child = meaningful[0];
847
- if (!t$2.isJSXExpressionContainer(child)) return null;
797
+ if (!t$4.isJSXExpressionContainer(child)) return null;
848
798
  const expr = child.expression;
849
- if (t$2.isMemberExpression(expr)) {
799
+ if (t$4.isMemberExpression(expr)) {
850
800
  if (expr.computed) return null;
851
- if (!t$2.isIdentifier(expr.object) || !t$2.isIdentifier(expr.property)) return null;
852
- if (!callbackParam || !t$2.isIdentifier(callbackParam)) return null;
801
+ if (!t$4.isIdentifier(expr.object) || !t$4.isIdentifier(expr.property)) return null;
802
+ if (!callbackParam || !t$4.isIdentifier(callbackParam)) return null;
853
803
  if (callbackParam.name !== expr.object.name) return null;
854
804
  return expr.property.name;
855
805
  }
856
- if (t$2.isIdentifier(expr)) {
806
+ if (t$4.isIdentifier(expr)) {
857
807
  const fieldName = expr.name;
858
- if (!callbackParam || !t$2.isObjectPattern(callbackParam)) return null;
808
+ if (!callbackParam || !t$4.isObjectPattern(callbackParam)) return null;
859
809
  for (const prop of callbackParam.properties) {
860
- if (!t$2.isObjectProperty(prop) || prop.computed) continue;
861
- if (!t$2.isIdentifier(prop.key) || prop.key.name !== fieldName) continue;
862
- return t$2.isIdentifier(prop.value) && prop.value.name === fieldName ? fieldName : null;
810
+ if (!t$4.isObjectProperty(prop) || prop.computed) continue;
811
+ if (!t$4.isIdentifier(prop.key) || prop.key.name !== fieldName) continue;
812
+ return t$4.isIdentifier(prop.value) && prop.value.name === fieldName ? fieldName : null;
863
813
  }
864
814
  }
865
815
  return null;
@@ -876,11 +826,11 @@ function collectArrayMapCandidates(ast, element) {
876
826
  const prop = findObjectProperty(obj, fieldName);
877
827
  if (!prop) continue;
878
828
  const v = prop.value;
879
- if (t$2.isStringLiteral(v)) out.push({
829
+ if (t$4.isStringLiteral(v)) out.push({
880
830
  current: v.value,
881
831
  splice: (s) => spliceRange(v, jsString$1(s))
882
832
  });
883
- else if (t$2.isNumericLiteral(v)) out.push({
833
+ else if (t$4.isNumericLiteral(v)) out.push({
884
834
  current: String(v.value),
885
835
  splice: (s) => spliceRange(v, jsString$1(s))
886
836
  });
@@ -914,68 +864,24 @@ function buildTextSplice(ast, element, value, prevText) {
914
864
  if (matches.length > 1) return { error: "multiple text candidates share the same value; cannot disambiguate" };
915
865
  return matches[0].splice(value);
916
866
  }
917
- function findImports$1(ast) {
918
- const out = [];
919
- for (const node of ast.program.body) {
920
- if (!t$2.isImportDeclaration(node)) continue;
921
- let def = null;
922
- for (const spec of node.specifiers) if (t$2.isImportDefaultSpecifier(spec)) {
923
- def = spec.local.name;
924
- break;
925
- }
926
- out.push({
927
- node,
928
- source: node.source.value,
929
- defaultIdent: def
930
- });
931
- }
932
- return out;
933
- }
934
- function collectTopLevelIdentifiers(ast) {
935
- const names = new Set();
936
- for (const imp of findImports$1(ast)) {
937
- if (imp.defaultIdent) names.add(imp.defaultIdent);
938
- for (const spec of imp.node.specifiers) if (!t$2.isImportDefaultSpecifier(spec)) names.add(spec.local.name);
939
- }
940
- return names;
941
- }
942
- function safeAssetIdentifier(filename, taken) {
943
- const stem = filename.replace(/\.[^.]+$/, "");
944
- let camel = "";
945
- let upper = false;
946
- for (const ch of stem) if (/[A-Za-z0-9]/.test(ch)) {
947
- camel += upper ? ch.toUpperCase() : ch;
948
- upper = false;
949
- } else upper = camel.length > 0;
950
- let base = camel;
951
- if (!base || !/^[A-Za-z_$]/.test(base)) base = `asset${base.charAt(0).toUpperCase()}${base.slice(1)}` || "asset";
952
- base = base.charAt(0).toLowerCase() + base.slice(1);
953
- let candidate = base;
954
- let i = 2;
955
- while (taken.has(candidate)) {
956
- candidate = `${base}${i}`;
957
- i += 1;
958
- }
959
- return candidate;
960
- }
961
- function planAssetImport(ast, assetPath) {
962
- const imports = findImports$1(ast);
963
- for (const imp of imports) if (imp.source === assetPath && imp.defaultIdent) return {
964
- identifier: imp.defaultIdent,
965
- importSplice: null
966
- };
967
- const filename = assetPath.slice(assetPath.lastIndexOf("/") + 1);
968
- const identifier = safeAssetIdentifier(filename, collectTopLevelIdentifiers(ast));
969
- const importStmt = `import ${identifier} from '${assetPath.replace(/'/g, "\\'")}';\n`;
970
- const last = imports[imports.length - 1];
971
- const insertAt = last ? last.node.end ?? 0 : 0;
972
- const prefix = last ? "\n" : "";
973
- return {
974
- identifier,
975
- importSplice: {
976
- from: insertAt,
977
- to: insertAt,
978
- text: prefix + importStmt
867
+ function planAssetImport(ast, assetPath) {
868
+ const imports = findImports$1(ast);
869
+ for (const imp of imports) if (imp.source === assetPath && imp.defaultIdent) return {
870
+ identifier: imp.defaultIdent,
871
+ importSplice: null
872
+ };
873
+ const filename = assetPath.slice(assetPath.lastIndexOf("/") + 1);
874
+ const identifier = safeAssetIdentifier(filename, collectTopLevelIdentifiers(ast));
875
+ const importStmt = `import ${identifier} from '${assetPath.replace(/'/g, "\\'")}';\n`;
876
+ const last = imports[imports.length - 1];
877
+ const insertAt = last ? last.node.end ?? 0 : 0;
878
+ const prefix = last ? "\n" : "";
879
+ return {
880
+ identifier,
881
+ importSplice: {
882
+ from: insertAt,
883
+ to: insertAt,
884
+ text: prefix + importStmt
979
885
  }
980
886
  };
981
887
  }
@@ -1000,25 +906,9 @@ function planAssetAttr(ast, element, attr, assetPath) {
1000
906
  attrSplice
1001
907
  };
1002
908
  }
1003
- function readJsxStringAttr(opening, name) {
1004
- const attr = findJsxAttr(opening, name);
1005
- const v = attr?.value;
1006
- if (!v) return null;
1007
- if (t$2.isStringLiteral(v)) return v.value;
1008
- if (t$2.isJSXExpressionContainer(v) && t$2.isStringLiteral(v.expression)) return v.expression.value;
1009
- return null;
1010
- }
1011
- function readJsxNumberAttr(opening, name) {
1012
- const attr = findJsxAttr(opening, name);
1013
- const v = attr?.value;
1014
- if (!v || !t$2.isJSXExpressionContainer(v)) return null;
1015
- if (!t$2.isNumericLiteral(v.expression)) return null;
1016
- const n = v.expression.value;
1017
- return Number.isFinite(n) ? n : null;
1018
- }
1019
909
  function planReplacePlaceholder(ast, element, assetPath) {
1020
910
  const opening = element.openingElement;
1021
- if (!t$2.isJSXIdentifier(opening.name) || opening.name.name !== "ImagePlaceholder") return { error: "not a placeholder" };
911
+ if (!t$4.isJSXIdentifier(opening.name) || opening.name.name !== "ImagePlaceholder") return { error: "not a placeholder" };
1022
912
  if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
1023
913
  const hint = readJsxStringAttr(opening, "hint") ?? "";
1024
914
  const width = readJsxNumberAttr(opening, "width");
@@ -1158,706 +1048,1825 @@ function applyEdit(source, line, column, ops) {
1158
1048
  source: next
1159
1049
  };
1160
1050
  }
1161
- function commentsPlugin(opts) {
1162
- const userCwd = opts.userCwd;
1163
- const slidesDir = opts.slidesDir ?? "slides";
1164
- return {
1165
- name: "open-slide:comments",
1166
- apply: "serve",
1167
- configureServer(server) {
1168
- server.middlewares.use("/__edit", async (req, res, next) => {
1169
- const url = new URL(req.url ?? "/", "http://local");
1170
- const method = req.method ?? "GET";
1171
- if (method !== "POST") return next();
1172
- try {
1173
- if (url.pathname === "/") {
1174
- const body = await readBody$3(req);
1175
- const slideId = body.slideId ?? "";
1176
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
1177
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
1178
- if (!body.line || body.line < 1) return json$3(res, 400, { error: "invalid line" });
1179
- if (!Array.isArray(body.ops)) return json$3(res, 400, { error: "missing ops" });
1180
- let source;
1181
- try {
1182
- source = await fs.readFile(file, "utf8");
1183
- } catch {
1184
- return json$3(res, 404, { error: "slide not found" });
1185
- }
1186
- const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
1187
- if (!result.ok) return json$3(res, result.status, { error: result.error });
1188
- const changed = result.source !== source;
1189
- if (changed) await fs.writeFile(file, result.source, "utf8");
1190
- return json$3(res, 200, {
1191
- ok: true,
1192
- changed
1193
- });
1194
- }
1195
- if (url.pathname === "/batch") {
1196
- const body = await readBody$3(req);
1197
- const slideId = body.slideId ?? "";
1198
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
1199
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
1200
- if (!Array.isArray(body.edits)) return json$3(res, 400, { error: "missing edits" });
1201
- let source;
1202
- try {
1203
- source = await fs.readFile(file, "utf8");
1204
- } catch {
1205
- return json$3(res, 404, { error: "slide not found" });
1206
- }
1207
- const original = source;
1208
- const results = [];
1209
- for (const edit of body.edits) {
1210
- if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
1211
- results.push({
1212
- ok: false,
1213
- error: "invalid edit"
1214
- });
1215
- continue;
1216
- }
1217
- const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
1218
- if (r.ok) {
1219
- source = r.source;
1220
- results.push({ ok: true });
1221
- } else results.push({
1222
- ok: false,
1223
- error: r.error
1224
- });
1225
- }
1226
- const changed = source !== original;
1227
- if (changed) await fs.writeFile(file, source, "utf8");
1228
- return json$3(res, 200, {
1229
- ok: true,
1230
- changed,
1231
- results
1232
- });
1233
- }
1234
- return next();
1235
- } catch (err) {
1236
- json$3(res, 500, { error: String(err.message ?? err) });
1237
- }
1238
- });
1239
- server.middlewares.use("/__comments", async (req, res, next) => {
1240
- const url = new URL(req.url ?? "/", "http://local");
1241
- const method = req.method ?? "GET";
1242
- try {
1243
- if (method === "GET" && url.pathname === "/") {
1244
- const slideId = url.searchParams.get("slideId") ?? "";
1245
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
1246
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
1247
- let source;
1248
- try {
1249
- source = await fs.readFile(file, "utf8");
1250
- } catch {
1251
- return json$3(res, 404, { error: "slide not found" });
1252
- }
1253
- return json$3(res, 200, { comments: parseMarkers(source) });
1254
- }
1255
- if (method === "POST" && url.pathname === "/add") {
1256
- const body = await readBody$3(req);
1257
- const slideId = body.slideId ?? "";
1258
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
1259
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
1260
- if (!body.line || body.line < 1) return json$3(res, 400, { error: "invalid line" });
1261
- if (!body.text || typeof body.text !== "string") return json$3(res, 400, { error: "missing text" });
1262
- let source;
1263
- try {
1264
- source = await fs.readFile(file, "utf8");
1265
- } catch {
1266
- return json$3(res, 404, { error: "slide not found" });
1267
- }
1268
- const plan = findInsertion(source, body.line, body.column);
1269
- if (!plan) return json$3(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
1270
- const id = newId();
1271
- const ts = new Date().toISOString();
1272
- const payload = b64urlEncode(JSON.stringify({
1273
- note: body.text,
1274
- hint: body.hint
1275
- }));
1276
- const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
1277
- const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
1278
- await fs.writeFile(file, next$1, "utf8");
1279
- const markerLine = offsetToLine(next$1, plan.offset + 1);
1280
- return json$3(res, 200, {
1281
- id,
1282
- line: markerLine
1283
- });
1284
- }
1285
- if (method === "DELETE" && url.pathname.startsWith("/")) {
1286
- const id = url.pathname.slice(1);
1287
- if (!/^c-[a-f0-9]+$/.test(id)) return json$3(res, 400, { error: "invalid id" });
1288
- const slideId = url.searchParams.get("slideId") ?? "";
1289
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
1290
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
1291
- let source;
1292
- try {
1293
- source = await fs.readFile(file, "utf8");
1294
- } catch {
1295
- return json$3(res, 404, { error: "slide not found" });
1296
- }
1297
- const lines = source.split("\n");
1298
- const idRe = new RegExp(`\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`);
1299
- const hit = lines.findIndex((l) => idRe.test(l));
1300
- if (hit === -1) return json$3(res, 404, { error: "marker not found" });
1301
- lines.splice(hit, 1);
1302
- await fs.writeFile(file, lines.join("\n"), "utf8");
1303
- return json$3(res, 200, { ok: true });
1304
- }
1305
- next();
1306
- } catch (err) {
1307
- json$3(res, 500, { error: String(err.message ?? err) });
1308
- }
1309
- });
1310
- }
1311
- };
1312
- }
1313
1051
 
1314
1052
  //#endregion
1315
- //#region src/vite/current-plugin.ts
1316
- const SLIDE_ID_RE$3 = /^[a-z0-9_-]+$/i;
1317
- const TEXT_SNIPPET_MAX = 120;
1318
- function parseSelection(raw) {
1319
- if (raw == null || typeof raw !== "object") return null;
1320
- const sel = raw;
1321
- if (typeof sel.line !== "number" || !Number.isFinite(sel.line)) return null;
1322
- if (typeof sel.column !== "number" || !Number.isFinite(sel.column)) return null;
1323
- const tagName = typeof sel.tagName === "string" ? sel.tagName.toLowerCase().slice(0, 32) : "unknown";
1324
- const text = typeof sel.text === "string" ? sel.text.replace(/\s+/g, " ").trim().slice(0, TEXT_SNIPPET_MAX) : "";
1053
+ //#region src/editing/revert-asset.ts
1054
+ function collectImgSrcUses(ast, identifier) {
1055
+ const uses = [];
1056
+ walkJsx(ast, (n) => {
1057
+ if (!t$3.isJSXElement(n)) return;
1058
+ const opening = n.openingElement;
1059
+ if (!t$3.isJSXIdentifier(opening.name) || opening.name.name !== "img") return;
1060
+ const src = findJsxAttr(opening, "src");
1061
+ if (!src?.value) return;
1062
+ if (!t$3.isJSXExpressionContainer(src.value)) return;
1063
+ const expr = src.value.expression;
1064
+ if (!t$3.isIdentifier(expr) || expr.name !== identifier) return;
1065
+ uses.push({
1066
+ element: n,
1067
+ identNode: expr
1068
+ });
1069
+ });
1070
+ return uses;
1071
+ }
1072
+ function readStyleNumericDim(opening, key) {
1073
+ const style = findJsxAttr(opening, "style");
1074
+ if (!style?.value) return null;
1075
+ if (!t$3.isJSXExpressionContainer(style.value)) return null;
1076
+ const obj = style.value.expression;
1077
+ if (!t$3.isObjectExpression(obj)) return null;
1078
+ for (const prop of obj.properties) {
1079
+ if (!t$3.isObjectProperty(prop)) continue;
1080
+ if (prop.computed) continue;
1081
+ const k = prop.key;
1082
+ const keyName = t$3.isIdentifier(k) ? k.name : t$3.isStringLiteral(k) ? k.value : null;
1083
+ if (keyName !== key) continue;
1084
+ const v = prop.value;
1085
+ if (t$3.isNumericLiteral(v) && Number.isFinite(v.value)) return v.value;
1086
+ return null;
1087
+ }
1088
+ return null;
1089
+ }
1090
+ function buildPlaceholderReplacement(hint, width, height) {
1091
+ const parts = [`hint=${formatJsxAttrValue(hint)}`];
1092
+ if (width != null) parts.push(`width={${width}}`);
1093
+ if (height != null) parts.push(`height={${height}}`);
1094
+ return `<ImagePlaceholder ${parts.join(" ")} />`;
1095
+ }
1096
+ function planEnsureImagePlaceholderImport(ast) {
1097
+ const readKind = (n) => n.importKind === "type";
1098
+ const imports = findImports$1(ast);
1099
+ let valueImport = null;
1100
+ for (const imp of imports) {
1101
+ if (imp.source !== "@open-slide/core") continue;
1102
+ const declIsTypeOnly = readKind(imp.node);
1103
+ for (const spec of imp.node.specifiers) {
1104
+ if (!t$3.isImportSpecifier(spec)) continue;
1105
+ const imported = spec.imported;
1106
+ const name = t$3.isIdentifier(imported) ? imported.name : imported.value;
1107
+ if (name !== "ImagePlaceholder") continue;
1108
+ const specIsTypeOnly = readKind(spec) || declIsTypeOnly;
1109
+ if (!specIsTypeOnly) return null;
1110
+ }
1111
+ if (!declIsTypeOnly && !valueImport) valueImport = imp;
1112
+ }
1113
+ if (valueImport) {
1114
+ const node = valueImport.node;
1115
+ const lastSpec = node.specifiers[node.specifiers.length - 1];
1116
+ if (lastSpec && t$3.isImportSpecifier(lastSpec)) {
1117
+ const insertAt$1 = lastSpec.end ?? 0;
1118
+ return {
1119
+ from: insertAt$1,
1120
+ to: insertAt$1,
1121
+ text: ", ImagePlaceholder"
1122
+ };
1123
+ }
1124
+ if (lastSpec && t$3.isImportDefaultSpecifier(lastSpec)) {
1125
+ const insertAt$1 = lastSpec.end ?? 0;
1126
+ return {
1127
+ from: insertAt$1,
1128
+ to: insertAt$1,
1129
+ text: ", { ImagePlaceholder }"
1130
+ };
1131
+ }
1132
+ const insertAt = (node.source.start ?? 0) - 5;
1133
+ return {
1134
+ from: insertAt,
1135
+ to: insertAt,
1136
+ text: "{ ImagePlaceholder } "
1137
+ };
1138
+ }
1325
1139
  return {
1326
- line: Math.max(1, Math.floor(sel.line)),
1327
- column: Math.max(0, Math.floor(sel.column)),
1328
- tagName,
1329
- text
1140
+ from: 0,
1141
+ to: 0,
1142
+ text: "import { ImagePlaceholder } from '@open-slide/core';\n"
1330
1143
  };
1331
1144
  }
1332
- function currentPlugin(opts) {
1333
- const userCwd = opts.userCwd;
1334
- const slidesDir = opts.slidesDir ?? "slides";
1335
- const outDir = path.join(userCwd, "node_modules", ".open-slide");
1336
- const outFile = path.join(outDir, "current.json");
1337
- const tmpFile = `${outFile}.tmp`;
1338
- let cached = null;
1145
+ function findAssetUsages(source, assetPath) {
1146
+ const ast = parseSource$2(source);
1147
+ if (!ast) return 0;
1148
+ const imports = findImports$1(ast);
1149
+ const target = imports.find((imp) => imp.source === assetPath && imp.defaultIdent);
1150
+ if (!target?.defaultIdent) return 0;
1151
+ return collectImgSrcUses(ast, target.defaultIdent).length;
1152
+ }
1153
+ function findReferencedAssets(source, assetPaths) {
1154
+ const referenced = new Set();
1155
+ if (assetPaths.length === 0) return referenced;
1156
+ const ast = parseSource$2(source);
1157
+ if (!ast) return referenced;
1158
+ const wanted = new Set(assetPaths);
1159
+ const identToPath = new Map();
1160
+ for (const imp of findImports$1(ast)) {
1161
+ if (!imp.defaultIdent) continue;
1162
+ if (wanted.has(imp.source)) identToPath.set(imp.defaultIdent, imp.source);
1163
+ }
1164
+ if (identToPath.size === 0) return referenced;
1165
+ walkJsx(ast, (n) => {
1166
+ if (!t$3.isJSXElement(n)) return;
1167
+ const opening = n.openingElement;
1168
+ if (!t$3.isJSXIdentifier(opening.name) || opening.name.name !== "img") return;
1169
+ const src = findJsxAttr(opening, "src");
1170
+ if (!src?.value || !t$3.isJSXExpressionContainer(src.value)) return;
1171
+ const expr = src.value.expression;
1172
+ if (!t$3.isIdentifier(expr)) return;
1173
+ const p = identToPath.get(expr.name);
1174
+ if (p) referenced.add(p);
1175
+ });
1176
+ return referenced;
1177
+ }
1178
+ function applyRevertAsset(source, assetPath) {
1179
+ const ast = parseSource$2(source);
1180
+ if (!ast) return {
1181
+ ok: false,
1182
+ status: 422,
1183
+ error: "could not parse source"
1184
+ };
1185
+ const imports = findImports$1(ast);
1186
+ const target = imports.find((imp) => imp.source === assetPath && imp.defaultIdent);
1187
+ if (!target?.defaultIdent) return {
1188
+ ok: true,
1189
+ source
1190
+ };
1191
+ const identifier = target.defaultIdent;
1192
+ const importLocal = (() => {
1193
+ for (const spec of target.node.specifiers) if (t$3.isImportDefaultSpecifier(spec) && spec.local.name === identifier) return spec.local;
1194
+ return null;
1195
+ })();
1196
+ const imgUses = collectImgSrcUses(ast, identifier);
1197
+ const allowed = new Set(imgUses.map((u) => u.identNode));
1198
+ if (importLocal) allowed.add(importLocal);
1199
+ let foreign = false;
1200
+ walkAll(ast, (n) => {
1201
+ if (!t$3.isIdentifier(n) || n.name !== identifier) return;
1202
+ if (!allowed.has(n)) foreign = true;
1203
+ });
1204
+ if (foreign) return {
1205
+ ok: false,
1206
+ status: 422,
1207
+ error: `cannot revert: '${identifier}' is referenced outside <img src={${identifier}}>`
1208
+ };
1209
+ const splices = [];
1210
+ for (const use of imgUses) {
1211
+ const opening = use.element.openingElement;
1212
+ const hint = readJsxStringAttr(opening, "alt") ?? "";
1213
+ const width = readStyleNumericDim(opening, "width");
1214
+ const height = readStyleNumericDim(opening, "height");
1215
+ splices.push(spliceRange(use.element, buildPlaceholderReplacement(hint, width, height)));
1216
+ }
1217
+ const importNode = target.node;
1218
+ const importFrom = importNode.start ?? 0;
1219
+ let importTo = importNode.end ?? 0;
1220
+ if (source[importTo] === "\n") importTo += 1;
1221
+ splices.push({
1222
+ from: importFrom,
1223
+ to: importTo,
1224
+ text: ""
1225
+ });
1226
+ const ensureSplice = planEnsureImagePlaceholderImport(ast);
1227
+ if (ensureSplice) splices.push(ensureSplice);
1228
+ if (splices.length === 0) return {
1229
+ ok: true,
1230
+ source
1231
+ };
1232
+ splices.sort((a, b) => b.from - a.from);
1233
+ let next = source;
1234
+ for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
1235
+ if (!parseSource$2(next)) return {
1236
+ ok: false,
1237
+ status: 422,
1238
+ error: "edit would produce invalid source"
1239
+ };
1339
1240
  return {
1340
- name: "open-slide:current",
1341
- apply: "serve",
1342
- configureServer(server) {
1343
- server.ws.on("open-slide:current", async (raw) => {
1344
- const next = cached ? { ...cached } : {
1345
- slideId: "",
1346
- pageIndex: 0,
1347
- pageNumber: 1,
1348
- totalPages: 1,
1349
- slideTitle: "",
1350
- view: "slides",
1351
- pagePath: "",
1352
- selection: null
1353
- };
1354
- if (typeof raw?.slideId === "string") {
1355
- if (!SLIDE_ID_RE$3.test(raw.slideId)) return;
1356
- const totalPages = typeof raw.totalPages === "number" && Number.isFinite(raw.totalPages) && raw.totalPages > 0 ? Math.floor(raw.totalPages) : 1;
1357
- const rawIndex = typeof raw.pageIndex === "number" && Number.isFinite(raw.pageIndex) ? Math.floor(raw.pageIndex) : 0;
1358
- const pageIndex = Math.max(0, Math.min(totalPages - 1, rawIndex));
1359
- const slideTitle = typeof raw.slideTitle === "string" ? raw.slideTitle : raw.slideId;
1360
- const view = raw.view === "assets" ? "assets" : "slides";
1361
- const pagePath = path.join(slidesDir, raw.slideId, "index.tsx").split(path.sep).join("/");
1362
- if (cached?.slideId !== raw.slideId || cached?.pageIndex !== pageIndex) next.selection = null;
1363
- next.slideId = raw.slideId;
1364
- next.pageIndex = pageIndex;
1365
- next.pageNumber = pageIndex + 1;
1366
- next.totalPages = totalPages;
1367
- next.slideTitle = slideTitle;
1368
- next.view = view;
1369
- next.pagePath = pagePath;
1370
- }
1371
- if ("selection" in raw) next.selection = parseSelection(raw.selection);
1372
- if (!next.slideId) return;
1373
- cached = next;
1374
- const body = {
1375
- ...next,
1376
- updatedAt: new Date().toISOString()
1377
- };
1378
- try {
1379
- await fs.mkdir(outDir, { recursive: true });
1380
- await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
1381
- await fs.rename(tmpFile, outFile);
1382
- } catch {}
1383
- });
1384
- }
1241
+ ok: true,
1242
+ source: next
1385
1243
  };
1386
1244
  }
1387
1245
 
1388
1246
  //#endregion
1389
- //#region src/vite/design-plugin.ts
1390
- const SLIDE_ID_RE$2 = /^[a-z0-9_-]+$/i;
1391
- async function readBody$2(req) {
1392
- return await new Promise((resolve, reject) => {
1393
- const chunks = [];
1394
- req.on("data", (c) => chunks.push(c));
1395
- req.on("end", () => {
1396
- const raw = Buffer.concat(chunks).toString("utf8");
1397
- if (!raw) return resolve({});
1398
- try {
1399
- resolve(JSON.parse(raw));
1400
- } catch (e) {
1401
- reject(e);
1402
- }
1403
- });
1404
- req.on("error", reject);
1405
- });
1406
- }
1407
- function json$2(res, status, body) {
1408
- res.statusCode = status;
1409
- res.setHeader("content-type", "application/json");
1410
- res.end(JSON.stringify(body));
1247
+ //#region src/editing/slide-ops.ts
1248
+ const SLIDE_ID_RE$3 = /^[a-z0-9_-]+$/i;
1249
+ function validateSlideName(v) {
1250
+ if (typeof v !== "string") return null;
1251
+ const trimmed = v.trim();
1252
+ if (trimmed.length < 1 || trimmed.length > 80) return null;
1253
+ return trimmed;
1411
1254
  }
1412
- function resolveSlidePath$1(userCwd, slidesDir, slideId) {
1413
- if (!SLIDE_ID_RE$2.test(slideId)) return null;
1414
- const slidesRoot = path.resolve(userCwd, slidesDir);
1415
- const full = path.resolve(slidesRoot, slideId, "index.tsx");
1416
- if (!full.startsWith(`${slidesRoot}${path.sep}`)) return null;
1417
- return full;
1255
+ function unwrapExpression(node) {
1256
+ let current = node;
1257
+ while (current && (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression")) current = current.expression;
1258
+ return current;
1418
1259
  }
1419
- function parseSource$1(source) {
1260
+ function readMetaTitleInSource(source) {
1261
+ let ast;
1420
1262
  try {
1421
- return parse(source, {
1263
+ ast = parse(source, {
1422
1264
  sourceType: "module",
1423
1265
  plugins: ["typescript", "jsx"],
1424
1266
  errorRecovery: true
1425
1267
  });
1426
1268
  } catch {
1427
- return null;
1269
+ return { kind: "unsupported" };
1428
1270
  }
1429
- }
1430
- function findDesignDecl(ast) {
1431
1271
  const body = ast.program?.body ?? [];
1432
- for (const node of body) {
1433
- let varDecl = null;
1434
- if (node.type === "VariableDeclaration") varDecl = node;
1435
- else if (node.type === "ExportNamedDeclaration") {
1436
- const decl = node.declaration;
1437
- if (decl?.type === "VariableDeclaration") varDecl = decl;
1438
- }
1439
- if (!varDecl) continue;
1440
- const declarations = varDecl.declarations ?? [];
1272
+ for (const stmt of body) {
1273
+ if (stmt.type !== "ExportNamedDeclaration") continue;
1274
+ const decl = stmt.declaration;
1275
+ if (!decl || decl.type !== "VariableDeclaration") continue;
1276
+ const declarations = decl.declarations ?? [];
1441
1277
  for (const d of declarations) {
1442
1278
  const id = d.id;
1443
- if (!id || id.type !== "Identifier" || id.name !== "design") continue;
1444
- const init = d.init;
1445
- if (!init) return null;
1446
- let inner = init;
1447
- if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
1448
- const expr = inner.expression;
1449
- if (expr) inner = expr;
1450
- }
1451
- if (inner.type !== "ObjectExpression") return null;
1452
- return {
1453
- declStart: node.start,
1454
- declEnd: node.end,
1455
- objectStart: inner.start,
1456
- objectEnd: inner.end
1457
- };
1458
- }
1459
- }
1460
- return null;
1461
- }
1462
- function literalToValue(node) {
1463
- switch (node.type) {
1464
- case "StringLiteral": return node.value;
1465
- case "NumericLiteral": return node.value;
1466
- case "BooleanLiteral": return node.value;
1467
- case "NullLiteral": return null;
1468
- case "UnaryExpression": {
1469
- const op = node.operator;
1470
- const arg = node.argument;
1471
- const v = literalToValue(arg);
1472
- if (op === "-" && typeof v === "number") return -v;
1473
- if (op === "+" && typeof v === "number") return v;
1474
- throw new Error(`unsupported unary operator ${op}`);
1475
- }
1476
- case "TemplateLiteral": {
1477
- const quasis = node.quasis;
1478
- const expressions = node.expressions;
1479
- if (expressions.length > 0) throw new Error("template literal has expressions");
1480
- return quasis[0].value.cooked ?? quasis[0].value.raw;
1481
- }
1482
- case "ArrayExpression": {
1483
- const elements = node.elements;
1484
- return elements.map((el) => {
1485
- if (!el) throw new Error("array has hole");
1486
- return literalToValue(el);
1487
- });
1488
- }
1489
- case "ObjectExpression": {
1490
- const properties = node.properties;
1491
- const out = {};
1492
- for (const prop of properties) {
1493
- if (prop.type !== "ObjectProperty") throw new Error("object has spread or method");
1494
- const p = prop;
1495
- if (p.computed) throw new Error("object has computed key");
1496
- let key;
1497
- if (p.key.type === "Identifier" && typeof p.key.name === "string") key = p.key.name;
1498
- else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") key = p.key.value;
1499
- else throw new Error("unsupported object key");
1500
- out[key] = literalToValue(p.value);
1279
+ if (!id || id.type !== "Identifier" || id.name !== "meta") continue;
1280
+ const init = unwrapExpression(d.init);
1281
+ if (!init || init.type !== "ObjectExpression") return { kind: "unsupported" };
1282
+ const properties = init.properties ?? [];
1283
+ for (const property of properties) {
1284
+ if (property.type !== "ObjectProperty" || property.computed) continue;
1285
+ const key = property.key;
1286
+ const keyName = key?.type === "Identifier" ? key.name : key?.type === "StringLiteral" ? key.value : void 0;
1287
+ if (keyName !== "title") continue;
1288
+ const value = property.value;
1289
+ if (value?.type === "StringLiteral" && typeof value.value === "string") return {
1290
+ kind: "found",
1291
+ title: value.value
1292
+ };
1293
+ if (value?.type === "TemplateLiteral") {
1294
+ const expressions = value.expressions ?? [];
1295
+ const quasis = value.quasis ?? [];
1296
+ const firstValue = quasis[0]?.value;
1297
+ const cooked = firstValue?.cooked;
1298
+ const raw = firstValue?.raw;
1299
+ if (expressions.length === 0 && typeof (cooked ?? raw) === "string") return {
1300
+ kind: "found",
1301
+ title: cooked ?? raw
1302
+ };
1303
+ }
1304
+ return { kind: "unsupported" };
1501
1305
  }
1502
- return out;
1306
+ return { kind: "missing" };
1503
1307
  }
1504
- default: throw new Error(`unsupported node type ${node.type}`);
1505
1308
  }
1309
+ return { kind: "missing" };
1506
1310
  }
1507
- function isPlainObject(v) {
1508
- return typeof v === "object" && v !== null && !Array.isArray(v);
1509
- }
1510
- function mergeDesign(base, patch) {
1511
- const out = JSON.parse(JSON.stringify(base));
1512
- const apply = (target, src) => {
1513
- for (const [k, v] of Object.entries(src)) if (isPlainObject(v) && isPlainObject(target[k])) apply(target[k], v);
1514
- else target[k] = v;
1515
- };
1516
- if (isPlainObject(patch)) apply(out, patch);
1517
- return out;
1518
- }
1519
- function indent(level) {
1520
- return " ".repeat(level);
1521
- }
1522
- function jsString(s) {
1523
- return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
1524
- }
1525
- function isValidIdentifier(name) {
1526
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
1527
- }
1528
- function serializeValue(value, level) {
1529
- if (value === null) return "null";
1530
- if (typeof value === "string") return jsString(value);
1531
- if (typeof value === "number") {
1532
- if (!Number.isFinite(value)) throw new Error("non-finite number");
1533
- return String(value);
1534
- }
1535
- if (typeof value === "boolean") return value ? "true" : "false";
1536
- if (Array.isArray(value)) {
1537
- if (value.length === 0) return "[]";
1538
- const inner = value.map((el) => serializeValue(el, level + 1)).join(", ");
1539
- return `[${inner}]`;
1540
- }
1541
- if (isPlainObject(value)) {
1542
- const entries = Object.entries(value);
1543
- if (entries.length === 0) return "{}";
1544
- const childIndent = indent(level + 1);
1545
- const lines = entries.map(([k, v]) => {
1546
- const key = isValidIdentifier(k) ? k : jsString(k);
1547
- return `${childIndent}${key}: ${serializeValue(v, level + 1)},`;
1311
+ async function rmSlideDir(slidesRoot, slideId) {
1312
+ if (!SLIDE_ID_RE$3.test(slideId)) return false;
1313
+ const dir = path.resolve(slidesRoot, slideId);
1314
+ if (!dir.startsWith(slidesRoot + path.sep)) return false;
1315
+ try {
1316
+ await fs.rm(dir, {
1317
+ recursive: true,
1318
+ force: true
1548
1319
  });
1549
- return `{\n${lines.join("\n")}\n${indent(level)}}`;
1320
+ return true;
1321
+ } catch {
1322
+ return false;
1550
1323
  }
1551
- throw new Error(`unsupported value type ${typeof value}`);
1552
1324
  }
1553
- function serializeDesign(design) {
1554
- return serializeValue(design, 0);
1555
- }
1556
- function parseSlideDesign(source) {
1557
- const ast = parseSource$1(source);
1558
- if (!ast) return {
1559
- ok: false,
1560
- exists: true,
1561
- error: "could not parse slide source"
1562
- };
1563
- const loc = findDesignDecl(ast);
1564
- if (!loc) return {
1325
+ async function duplicateSlideDir(slidesRoot, slideId, desiredId) {
1326
+ if (!SLIDE_ID_RE$3.test(slideId)) return {
1565
1327
  ok: false,
1566
- exists: false
1328
+ status: 400,
1329
+ error: "invalid slideId"
1567
1330
  };
1568
- const objectNode = findDesignObjectNode(ast);
1569
- if (!objectNode) return {
1331
+ const root = path.resolve(slidesRoot);
1332
+ const srcDir = path.resolve(root, slideId);
1333
+ if (!srcDir.startsWith(root + path.sep)) return {
1570
1334
  ok: false,
1571
- exists: true,
1572
- error: "design has unsupported initializer"
1335
+ status: 400,
1336
+ error: "invalid slideId"
1573
1337
  };
1574
- let value;
1575
1338
  try {
1576
- value = literalToValue(objectNode);
1577
- } catch (err) {
1339
+ await fs.access(path.join(srcDir, "index.tsx"));
1340
+ } catch {
1578
1341
  return {
1579
1342
  ok: false,
1580
- exists: true,
1581
- error: err.message
1343
+ status: 404,
1344
+ error: "slide not found"
1582
1345
  };
1583
1346
  }
1584
- const merged = mergeDesign(defaultDesign, value);
1585
- return {
1586
- ok: true,
1587
- design: merged,
1588
- loc
1589
- };
1590
- }
1591
- function findDesignObjectNode(ast) {
1592
- const body = ast.program?.body ?? [];
1593
- for (const node of body) {
1594
- let varDecl = null;
1595
- if (node.type === "VariableDeclaration") varDecl = node;
1596
- else if (node.type === "ExportNamedDeclaration") {
1597
- const decl = node.declaration;
1598
- if (decl?.type === "VariableDeclaration") varDecl = decl;
1599
- }
1600
- if (!varDecl) continue;
1601
- const declarations = varDecl.declarations ?? [];
1602
- for (const d of declarations) {
1603
- const id = d.id;
1604
- if (!id || id.type !== "Identifier" || id.name !== "design") continue;
1605
- const init = d.init;
1606
- if (!init) return null;
1607
- let inner = init;
1608
- if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
1609
- const expr = inner.expression;
1610
- if (expr) inner = expr;
1347
+ let newId;
1348
+ if (desiredId !== void 0) {
1349
+ if (!SLIDE_ID_RE$3.test(desiredId)) return {
1350
+ ok: false,
1351
+ status: 400,
1352
+ error: "invalid newId"
1353
+ };
1354
+ newId = desiredId;
1355
+ const dstDir$1 = path.resolve(root, newId);
1356
+ if (!dstDir$1.startsWith(root + path.sep)) return {
1357
+ ok: false,
1358
+ status: 400,
1359
+ error: "invalid newId"
1360
+ };
1361
+ try {
1362
+ await fs.access(dstDir$1);
1363
+ return {
1364
+ ok: false,
1365
+ status: 409,
1366
+ error: "slide already exists"
1367
+ };
1368
+ } catch {}
1369
+ } else {
1370
+ let suffix = 1;
1371
+ while (true) {
1372
+ newId = suffix === 1 ? `${slideId}-copy` : `${slideId}-copy-${suffix}`;
1373
+ try {
1374
+ await fs.access(path.resolve(root, newId));
1375
+ suffix++;
1376
+ } catch {
1377
+ break;
1611
1378
  }
1612
- if (inner.type !== "ObjectExpression") return null;
1613
- return inner;
1614
1379
  }
1615
1380
  }
1616
- return null;
1617
- }
1618
- function findImports(ast) {
1619
- const body = ast.program?.body ?? [];
1620
- const out = [];
1621
- for (const node of body) {
1622
- if (node.type !== "ImportDeclaration") continue;
1623
- const src = node.source?.value;
1624
- if (typeof src !== "string") continue;
1625
- const specs = node.specifiers ?? [];
1626
- out.push({
1627
- node,
1628
- source: src,
1629
- specifiers: specs
1630
- });
1381
+ const dstDir = path.resolve(root, newId);
1382
+ if (!dstDir.startsWith(root + path.sep)) return {
1383
+ ok: false,
1384
+ status: 400,
1385
+ error: "invalid newId"
1386
+ };
1387
+ const srcEntry = path.join(srcDir, "index.tsx");
1388
+ let copiedEntrySource;
1389
+ try {
1390
+ const source = await fs.readFile(srcEntry, "utf8");
1391
+ const metaTitle = readMetaTitleInSource(source);
1392
+ if (metaTitle.kind === "unsupported") return {
1393
+ ok: false,
1394
+ status: 422,
1395
+ error: "could not update copied slide title"
1396
+ };
1397
+ const title = metaTitle.kind === "found" ? metaTitle.title : slideId;
1398
+ const updated = updateMetaTitleInSource(source, `${title} (copy)`);
1399
+ if (updated === null) return {
1400
+ ok: false,
1401
+ status: 422,
1402
+ error: "could not update copied slide title"
1403
+ };
1404
+ copiedEntrySource = updated;
1405
+ } catch {
1406
+ return {
1407
+ ok: false,
1408
+ status: 404,
1409
+ error: "slide not found"
1410
+ };
1631
1411
  }
1632
- return out;
1633
- }
1634
- function ensureDesignSystemImport(source, ast) {
1635
- const imports = findImports(ast);
1636
- const coreImport = imports.find((imp) => imp.source === "@open-slide/core");
1637
- if (coreImport) {
1638
- const hasDesignSystem = coreImport.specifiers.some((spec) => {
1639
- if (spec.type !== "ImportSpecifier") return false;
1640
- const imported = spec.imported;
1641
- return imported?.name === "DesignSystem";
1412
+ try {
1413
+ await fs.cp(srcDir, dstDir, {
1414
+ recursive: true,
1415
+ errorOnExist: true,
1416
+ force: false
1417
+ });
1418
+ await fs.writeFile(path.join(dstDir, "index.tsx"), copiedEntrySource, "utf8");
1419
+ return {
1420
+ ok: true,
1421
+ slideId: newId
1422
+ };
1423
+ } catch (err) {
1424
+ if (err.code === "EEXIST") return {
1425
+ ok: false,
1426
+ status: 409,
1427
+ error: "slide already exists"
1428
+ };
1429
+ return {
1430
+ ok: false,
1431
+ status: 500,
1432
+ error: String(err.message ?? err)
1433
+ };
1434
+ }
1435
+ }
1436
+ function resolveSlideEntry(slidesRoot, slideId) {
1437
+ if (!SLIDE_ID_RE$3.test(slideId)) return null;
1438
+ const dir = path.resolve(slidesRoot, slideId);
1439
+ if (!dir.startsWith(slidesRoot + path.sep)) return null;
1440
+ return path.join(dir, "index.tsx");
1441
+ }
1442
+ function escapeSingleQuoted(s) {
1443
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1444
+ }
1445
+ /**
1446
+ * Rewrite (or insert) the `title` field in the slide module's `export const meta`.
1447
+ *
1448
+ * Strategy:
1449
+ * 1. Find `export const meta` and brace-match its object literal.
1450
+ * 2. If the object already has a `title: '...'` entry, replace the literal.
1451
+ * 3. If the object exists but has no title, inject a new `title: '...'` line
1452
+ * as the first property (preserving the author's surrounding indentation).
1453
+ * 4. If there is no `meta` export at all, insert a fresh one right before
1454
+ * `export default`.
1455
+ *
1456
+ * Returns the rewritten source, or `null` if the file shape was too surprising
1457
+ * to touch safely (e.g. `export default` missing when we'd need to inject meta).
1458
+ */
1459
+ function updateMetaTitleInSource(source, title) {
1460
+ const newLiteral = `'${escapeSingleQuoted(title)}'`;
1461
+ const metaStart = source.search(/export\s+const\s+meta\b/);
1462
+ if (metaStart !== -1) {
1463
+ const eqIdx = source.indexOf("=", metaStart);
1464
+ if (eqIdx === -1) return null;
1465
+ const openBrace = source.indexOf("{", eqIdx);
1466
+ if (openBrace === -1) return null;
1467
+ let depth = 0;
1468
+ let closeBrace = -1;
1469
+ for (let i = openBrace; i < source.length; i++) {
1470
+ const ch = source[i];
1471
+ if (ch === "{") depth++;
1472
+ else if (ch === "}") {
1473
+ depth--;
1474
+ if (depth === 0) {
1475
+ closeBrace = i;
1476
+ break;
1477
+ }
1478
+ }
1479
+ }
1480
+ if (closeBrace === -1) return null;
1481
+ const body = source.slice(openBrace + 1, closeBrace);
1482
+ const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
1483
+ const match = body.match(titleRe);
1484
+ if (match) {
1485
+ const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
1486
+ return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
1487
+ }
1488
+ const firstIndentMatch = body.match(/\n([ \t]+)\S/);
1489
+ const indent$1 = firstIndentMatch ? firstIndentMatch[1] : " ";
1490
+ const trimmedBody = body.replace(/^\s*\n?/, "");
1491
+ const needsSeparator = trimmedBody.trim().length > 0;
1492
+ const insertion$1 = `\n${indent$1}title: ${newLiteral}${needsSeparator ? "," : ""}`;
1493
+ return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
1494
+ }
1495
+ const exportDefaultIdx = source.search(/export\s+default\b/);
1496
+ if (exportDefaultIdx === -1) return null;
1497
+ const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
1498
+ return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
1499
+ }
1500
+ function findDefaultExportArray(source) {
1501
+ let ast;
1502
+ try {
1503
+ ast = parse(source, {
1504
+ sourceType: "module",
1505
+ plugins: ["typescript", "jsx"],
1506
+ errorRecovery: true
1507
+ });
1508
+ } catch {
1509
+ return null;
1510
+ }
1511
+ const body = ast.program?.body ?? [];
1512
+ for (const node of body) {
1513
+ if (node.type !== "ExportDefaultDeclaration") continue;
1514
+ let inner = node.declaration;
1515
+ while (inner && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression")) inner = inner.expression;
1516
+ if (!inner || inner.type !== "ArrayExpression") return null;
1517
+ const arrayStart = inner.start;
1518
+ const arrayEnd = inner.end;
1519
+ const rawElements = inner.elements ?? [];
1520
+ const elements = [];
1521
+ for (const el of rawElements) {
1522
+ if (!el || typeof el.start !== "number" || typeof el.end !== "number") return null;
1523
+ elements.push({
1524
+ start: el.start,
1525
+ end: el.end
1526
+ });
1527
+ }
1528
+ return {
1529
+ elements,
1530
+ arrayStart,
1531
+ arrayEnd
1532
+ };
1533
+ }
1534
+ return null;
1535
+ }
1536
+ /**
1537
+ * Rewrite `export default [...]` so its elements appear in the requested order.
1538
+ *
1539
+ * `order[i]` is the original index that should land at new position `i`. The
1540
+ * function preserves each element's exact source slice (including any inline
1541
+ * comments that hug an identifier) and keeps the inter-element separator slots
1542
+ * in their original positions, so a 3-page array `[A, B, C]` reordered to
1543
+ * `[2, 0, 1]` becomes `[C, A, B]` with the same indentation and trailing
1544
+ * commas the author wrote.
1545
+ *
1546
+ * Returns `null` when the file's default export isn't an array literal, or the
1547
+ * order is not a valid permutation of `[0, n-1]`.
1548
+ */
1549
+ function reorderDefaultExportPagesInSource(source, order) {
1550
+ const found = findDefaultExportArray(source);
1551
+ if (!found) return null;
1552
+ const { elements, arrayStart, arrayEnd } = found;
1553
+ const n = elements.length;
1554
+ if (order.length !== n) return null;
1555
+ const seen = new Set();
1556
+ for (const idx of order) {
1557
+ if (!Number.isInteger(idx) || idx < 0 || idx >= n) return null;
1558
+ if (seen.has(idx)) return null;
1559
+ seen.add(idx);
1560
+ }
1561
+ if (n === 0) return source;
1562
+ let identity = true;
1563
+ for (let i = 0; i < n; i++) if (order[i] !== i) {
1564
+ identity = false;
1565
+ break;
1566
+ }
1567
+ if (identity) return source;
1568
+ const prefix = source.slice(arrayStart, elements[0].start);
1569
+ const suffix = source.slice(elements[n - 1].end, arrayEnd);
1570
+ const separators = [];
1571
+ for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1572
+ const elementText = elements.map((el) => source.slice(el.start, el.end));
1573
+ let rebuilt = prefix + elementText[order[0]];
1574
+ for (let i = 1; i < n; i++) rebuilt += separators[i - 1] + elementText[order[i]];
1575
+ rebuilt += suffix;
1576
+ return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
1577
+ }
1578
+ function findNotesArray(source) {
1579
+ let ast;
1580
+ try {
1581
+ ast = parse(source, {
1582
+ sourceType: "module",
1583
+ plugins: ["typescript", "jsx"],
1584
+ errorRecovery: true
1585
+ });
1586
+ } catch {
1587
+ return "invalid";
1588
+ }
1589
+ const body = ast.program?.body ?? [];
1590
+ for (const stmt of body) {
1591
+ if (stmt.type !== "ExportNamedDeclaration") continue;
1592
+ const decl = stmt.declaration;
1593
+ if (!decl || decl.type !== "VariableDeclaration") continue;
1594
+ const declarations = decl.declarations ?? [];
1595
+ for (const d of declarations) {
1596
+ const id = d.id;
1597
+ if (!id || id.type !== "Identifier" || id.name !== "notes") continue;
1598
+ const init = d.init;
1599
+ if (!init || init.type !== "ArrayExpression") return "invalid";
1600
+ const arrayStart = init.start;
1601
+ const arrayEnd = init.end;
1602
+ if (typeof arrayStart !== "number" || typeof arrayEnd !== "number") return "invalid";
1603
+ const rawElements = init.elements ?? [];
1604
+ const elementTexts = [];
1605
+ for (const el of rawElements) {
1606
+ if (el === null) {
1607
+ elementTexts.push("undefined");
1608
+ continue;
1609
+ }
1610
+ if (el.type === "SpreadElement") return "invalid";
1611
+ const start = el.start;
1612
+ const end = el.end;
1613
+ if (typeof start !== "number" || typeof end !== "number") return "invalid";
1614
+ elementTexts.push(source.slice(start, end));
1615
+ }
1616
+ return {
1617
+ arrayStart,
1618
+ arrayEnd,
1619
+ elementTexts
1620
+ };
1621
+ }
1622
+ }
1623
+ return null;
1624
+ }
1625
+ /**
1626
+ * Reorder `export const notes = [...]` to follow the page-array reorder.
1627
+ *
1628
+ * `order[i]` is the original page index that should land at new position `i`.
1629
+ * The notes array is index-aligned with the pages array but may be shorter
1630
+ * (trailing `undefined` slots are routinely trimmed). Missing elements are
1631
+ * treated as `undefined`, and trailing `undefined` is trimmed again after
1632
+ * reordering to keep the file tidy.
1633
+ *
1634
+ * Returns the rewritten source, the original source if no `notes` export
1635
+ * exists or the reorder is a no-op, or `null` if the `notes` export's shape
1636
+ * is too surprising to touch safely.
1637
+ */
1638
+ function reorderNotesArrayInSource(source, order) {
1639
+ for (const idx of order) if (!Number.isInteger(idx) || idx < 0) return null;
1640
+ const found = findNotesArray(source);
1641
+ if (found === "invalid") return null;
1642
+ if (found === null) return source;
1643
+ const { arrayStart, arrayEnd, elementTexts } = found;
1644
+ const pick = (i) => i >= 0 && i < elementTexts.length ? elementTexts[i] : "undefined";
1645
+ const reordered = order.map(pick);
1646
+ while (reordered.length > 0 && reordered[reordered.length - 1] === "undefined") reordered.pop();
1647
+ const replacement = reordered.length === 0 ? "[]" : `[\n${reordered.map((s) => ` ${s},`).join("\n")}\n]`;
1648
+ if (replacement === source.slice(arrayStart, arrayEnd)) return source;
1649
+ return source.slice(0, arrayStart) + replacement + source.slice(arrayEnd);
1650
+ }
1651
+ /**
1652
+ * Remove the element at `index` from `export default [...]`.
1653
+ *
1654
+ * Preserves the source slice of every other element, dropping the separator
1655
+ * immediately following the removed element (or the preceding one when the
1656
+ * removed element is the last). Returns `null` when the default export isn't
1657
+ * an array literal or `index` is out of range.
1658
+ */
1659
+ function removePageFromDefaultExportInSource(source, index) {
1660
+ const found = findDefaultExportArray(source);
1661
+ if (!found) return null;
1662
+ const { elements, arrayStart, arrayEnd } = found;
1663
+ const n = elements.length;
1664
+ if (!Number.isInteger(index) || index < 0 || index >= n) return null;
1665
+ if (n === 1) return `${source.slice(0, arrayStart)}[]${source.slice(arrayEnd)}`;
1666
+ const prefix = source.slice(arrayStart, elements[0].start);
1667
+ const suffix = source.slice(elements[n - 1].end, arrayEnd);
1668
+ const separators = [];
1669
+ for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1670
+ const elementText = elements.map((el) => source.slice(el.start, el.end));
1671
+ const keptElements = [];
1672
+ const keptSeparators = [];
1673
+ for (let i = 0; i < n; i++) {
1674
+ if (i === index) continue;
1675
+ keptElements.push(elementText[i]);
1676
+ }
1677
+ for (let i = 0; i < n - 1; i++) {
1678
+ if (index === n - 1 ? i === n - 2 : i === index) continue;
1679
+ keptSeparators.push(separators[i]);
1680
+ }
1681
+ let rebuilt = prefix + keptElements[0];
1682
+ for (let i = 1; i < keptElements.length; i++) rebuilt += keptSeparators[i - 1] + keptElements[i];
1683
+ rebuilt += suffix;
1684
+ return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
1685
+ }
1686
+ function chooseInsertSeparator(prefix, existingSeparators) {
1687
+ const sample = existingSeparators.find((s) => s.includes(","));
1688
+ if (sample) return sample;
1689
+ if (prefix.includes("\n")) {
1690
+ const m = prefix.match(/\n([ \t]*)$/);
1691
+ const indent$1 = m ? m[1] : " ";
1692
+ return `,\n${indent$1}`;
1693
+ }
1694
+ return ", ";
1695
+ }
1696
+ /**
1697
+ * Duplicate the element at `index` in `export default [...]`, inserting the
1698
+ * copy immediately after the original. Reuses an existing inter-element
1699
+ * separator when one is available so the cloned entry matches the surrounding
1700
+ * indentation. Returns `null` when the default export isn't an array literal
1701
+ * or `index` is out of range.
1702
+ */
1703
+ function duplicatePageInDefaultExportInSource(source, index) {
1704
+ const found = findDefaultExportArray(source);
1705
+ if (!found) return null;
1706
+ const { elements, arrayStart, arrayEnd } = found;
1707
+ const n = elements.length;
1708
+ if (!Number.isInteger(index) || index < 0 || index >= n) return null;
1709
+ const prefix = source.slice(arrayStart, elements[0].start);
1710
+ const suffix = source.slice(elements[n - 1].end, arrayEnd);
1711
+ const separators = [];
1712
+ for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1713
+ const elementText = elements.map((el) => source.slice(el.start, el.end));
1714
+ const insertSep = chooseInsertSeparator(prefix, separators);
1715
+ const newElements = [];
1716
+ const newSeparators = [];
1717
+ for (let i = 0; i < n; i++) {
1718
+ newElements.push(elementText[i]);
1719
+ if (i === index) {
1720
+ newElements.push(elementText[i]);
1721
+ newSeparators.push(insertSep);
1722
+ }
1723
+ if (i < n - 1) newSeparators.push(separators[i]);
1724
+ }
1725
+ let rebuilt = prefix + newElements[0];
1726
+ for (let i = 1; i < newElements.length; i++) rebuilt += newSeparators[i - 1] + newElements[i];
1727
+ rebuilt += suffix;
1728
+ return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
1729
+ }
1730
+
1731
+ //#endregion
1732
+ //#region src/files/assets.ts
1733
+ const GLOBAL_SCOPE = "@global";
1734
+ const ASSET_MAX_BYTES = 25 * 1024 * 1024;
1735
+ const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
1736
+ const MIME_BY_EXT = {
1737
+ png: "image/png",
1738
+ jpg: "image/jpeg",
1739
+ jpeg: "image/jpeg",
1740
+ gif: "image/gif",
1741
+ svg: "image/svg+xml",
1742
+ webp: "image/webp",
1743
+ avif: "image/avif",
1744
+ ico: "image/x-icon",
1745
+ mp4: "video/mp4",
1746
+ webm: "video/webm",
1747
+ mov: "video/quicktime",
1748
+ woff: "font/woff",
1749
+ woff2: "font/woff2",
1750
+ ttf: "font/ttf",
1751
+ otf: "font/otf",
1752
+ json: "application/json",
1753
+ txt: "text/plain; charset=utf-8",
1754
+ md: "text/markdown; charset=utf-8"
1755
+ };
1756
+ function mimeForFilename(name) {
1757
+ const dot = name.lastIndexOf(".");
1758
+ if (dot < 0) return "application/octet-stream";
1759
+ const ext = name.slice(dot + 1).toLowerCase();
1760
+ return MIME_BY_EXT[ext] ?? "application/octet-stream";
1761
+ }
1762
+ function validateAssetName(v) {
1763
+ if (typeof v !== "string") return null;
1764
+ const trimmed = v.trim();
1765
+ if (trimmed.length < 1 || trimmed.length > 120) return null;
1766
+ if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
1767
+ if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
1768
+ if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
1769
+ const dot = trimmed.lastIndexOf(".");
1770
+ if (dot <= 0 || dot === trimmed.length - 1) return null;
1771
+ return trimmed;
1772
+ }
1773
+ function resolveAssetsDir(slidesRoot, slideId) {
1774
+ if (!SLIDE_ID_RE$3.test(slideId)) return null;
1775
+ const slideDir = path.resolve(slidesRoot, slideId);
1776
+ if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
1777
+ const assetsDir = path.resolve(slideDir, "assets");
1778
+ if (assetsDir !== path.join(slideDir, "assets")) return null;
1779
+ return assetsDir;
1780
+ }
1781
+ function resolveAssetFile(slidesRoot, slideId, filename) {
1782
+ const assetsDir = resolveAssetsDir(slidesRoot, slideId);
1783
+ if (!assetsDir) return null;
1784
+ if (!validateAssetName(filename)) return null;
1785
+ const file = path.resolve(assetsDir, filename);
1786
+ if (!file.startsWith(assetsDir + path.sep)) return null;
1787
+ return file;
1788
+ }
1789
+ function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
1790
+ if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
1791
+ return resolveAssetsDir(slidesRoot, scope);
1792
+ }
1793
+ function resolveScopedAssetFile(slidesRoot, globalAssetsRoot, scope, filename) {
1794
+ if (scope === GLOBAL_SCOPE) {
1795
+ if (!validateAssetName(filename)) return null;
1796
+ const file = path.resolve(globalAssetsRoot, filename);
1797
+ if (!file.startsWith(globalAssetsRoot + path.sep)) return null;
1798
+ return file;
1799
+ }
1800
+ return resolveAssetFile(slidesRoot, scope, filename);
1801
+ }
1802
+
1803
+ //#endregion
1804
+ //#region src/http/request-guard.ts
1805
+ function firstHeaderValue(value) {
1806
+ if (Array.isArray(value)) return value[0] ?? null;
1807
+ return value ?? null;
1808
+ }
1809
+ function headerValue(req, name) {
1810
+ return firstHeaderValue(req.headers[name.toLowerCase()])?.trim() ?? null;
1811
+ }
1812
+ function firstCommaToken(value) {
1813
+ if (!value) return null;
1814
+ const [first] = value.split(",", 1);
1815
+ return first?.trim() || null;
1816
+ }
1817
+ function requestProto(req) {
1818
+ const forwarded = firstCommaToken(headerValue(req, "x-forwarded-proto"))?.toLowerCase();
1819
+ if (forwarded === "http" || forwarded === "https") return forwarded;
1820
+ return "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
1821
+ }
1822
+ function normalizedOrigin(origin) {
1823
+ try {
1824
+ const url = new URL(origin);
1825
+ return `${url.protocol}//${url.host}`.toLowerCase();
1826
+ } catch {
1827
+ return null;
1828
+ }
1829
+ }
1830
+ function validateMutationRequest(req, opts = {}) {
1831
+ if (opts.requireJsonBody) {
1832
+ const contentType = headerValue(req, "content-type")?.toLowerCase();
1833
+ if (!contentType || !contentType.startsWith("application/json")) return {
1834
+ ok: false,
1835
+ status: 415,
1836
+ error: "content-type must be application/json"
1837
+ };
1838
+ }
1839
+ const fetchSite = firstCommaToken(headerValue(req, "sec-fetch-site"))?.toLowerCase();
1840
+ if (fetchSite === "cross-site") return {
1841
+ ok: false,
1842
+ status: 403,
1843
+ error: "cross-site request blocked"
1844
+ };
1845
+ const originRaw = headerValue(req, "origin");
1846
+ if (!originRaw) return { ok: true };
1847
+ if (originRaw.toLowerCase() === "null") return {
1848
+ ok: false,
1849
+ status: 403,
1850
+ error: "opaque origin is not allowed"
1851
+ };
1852
+ const actualOrigin = normalizedOrigin(originRaw);
1853
+ if (!actualOrigin) return {
1854
+ ok: false,
1855
+ status: 403,
1856
+ error: "invalid origin header"
1857
+ };
1858
+ const host = firstCommaToken(headerValue(req, "x-forwarded-host")) ?? headerValue(req, "host");
1859
+ if (!host) return {
1860
+ ok: false,
1861
+ status: 400,
1862
+ error: "missing host header"
1863
+ };
1864
+ const expectedOrigin = `${requestProto(req)}://${host}`.toLowerCase();
1865
+ if (actualOrigin !== expectedOrigin) return {
1866
+ ok: false,
1867
+ status: 403,
1868
+ error: "origin mismatch"
1869
+ };
1870
+ return { ok: true };
1871
+ }
1872
+
1873
+ //#endregion
1874
+ //#region src/vite/routes/context.ts
1875
+ function makeContext(opts) {
1876
+ const userCwd = opts.userCwd;
1877
+ const slidesDir = opts.slidesDir ?? "slides";
1878
+ const assetsDir = opts.assetsDir ?? "assets";
1879
+ const slidesRoot = path.resolve(userCwd, slidesDir);
1880
+ const globalAssetsRoot = path.resolve(userCwd, assetsDir);
1881
+ const manifestPath = path.join(slidesRoot, ".folders.json");
1882
+ return {
1883
+ userCwd,
1884
+ slidesDir,
1885
+ slidesRoot,
1886
+ globalAssetsRoot,
1887
+ manifestPath
1888
+ };
1889
+ }
1890
+ async function readBody$2(req) {
1891
+ return await new Promise((resolve, reject) => {
1892
+ const chunks = [];
1893
+ req.on("data", (c) => chunks.push(c));
1894
+ req.on("end", () => {
1895
+ const raw = Buffer.concat(chunks).toString("utf8");
1896
+ if (!raw) return resolve({});
1897
+ try {
1898
+ resolve(JSON.parse(raw));
1899
+ } catch (e) {
1900
+ reject(e);
1901
+ }
1902
+ });
1903
+ req.on("error", reject);
1904
+ });
1905
+ }
1906
+ function json$2(res, status, body) {
1907
+ res.statusCode = status;
1908
+ res.setHeader("content-type", "application/json");
1909
+ res.end(JSON.stringify(body));
1910
+ }
1911
+ function resolveSlideEntryPath(ctx, slideId) {
1912
+ if (!SLIDE_ID_RE$3.test(slideId)) return null;
1913
+ const full = path.resolve(ctx.slidesRoot, slideId, "index.tsx");
1914
+ if (!full.startsWith(ctx.slidesRoot + path.sep)) return null;
1915
+ return full;
1916
+ }
1917
+
1918
+ //#endregion
1919
+ //#region src/vite/routes/assets.ts
1920
+ function registerAssetRoutes(server, ctx) {
1921
+ server.middlewares.use("/__assets", async (req, res, next) => {
1922
+ const url = new URL(req.url ?? "/", "http://local");
1923
+ const method = req.method ?? "GET";
1924
+ try {
1925
+ const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
1926
+ const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
1927
+ const usagesMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)\/usages$/);
1928
+ if (usagesMatch && method === "GET") {
1929
+ const scope = usagesMatch[1];
1930
+ const filename = decodeURIComponent(usagesMatch[2]);
1931
+ if (!validateAssetName(filename)) return json$2(res, 400, { error: "invalid path" });
1932
+ const isGlobal = scope === GLOBAL_SCOPE;
1933
+ const assetPath = isGlobal ? `@assets/${filename}` : `./assets/${filename}`;
1934
+ let slideIds;
1935
+ if (isGlobal) try {
1936
+ const entries = await fs.readdir(ctx.slidesRoot, { withFileTypes: true });
1937
+ slideIds = entries.filter((e) => e.isDirectory() && SLIDE_ID_RE$3.test(e.name)).map((e) => e.name);
1938
+ } catch {
1939
+ slideIds = [];
1940
+ }
1941
+ else {
1942
+ if (!SLIDE_ID_RE$3.test(scope)) return json$2(res, 400, { error: "invalid slideId" });
1943
+ slideIds = [scope];
1944
+ }
1945
+ const usages = [];
1946
+ let totalCount = 0;
1947
+ for (const sid of slideIds) {
1948
+ const entry = resolveSlideEntry(ctx.slidesRoot, sid);
1949
+ if (!entry) continue;
1950
+ let source;
1951
+ try {
1952
+ source = await fs.readFile(entry, "utf8");
1953
+ } catch {
1954
+ continue;
1955
+ }
1956
+ const count = findAssetUsages(source, assetPath);
1957
+ if (count > 0) {
1958
+ usages.push({
1959
+ slideId: sid,
1960
+ count
1961
+ });
1962
+ totalCount += count;
1963
+ }
1964
+ }
1965
+ return json$2(res, 200, {
1966
+ usages,
1967
+ totalCount
1968
+ });
1969
+ }
1970
+ if (listMatch && method === "GET") {
1971
+ const slideId = listMatch[1];
1972
+ const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
1973
+ if (!scopedDir) return json$2(res, 400, { error: "invalid slideId" });
1974
+ let entries;
1975
+ try {
1976
+ entries = await fs.readdir(scopedDir);
1977
+ } catch (err) {
1978
+ if (err.code === "ENOENT") return json$2(res, 200, { assets: [] });
1979
+ throw err;
1980
+ }
1981
+ const assets = [];
1982
+ for (const name of entries) {
1983
+ if (!validateAssetName(name)) continue;
1984
+ const stat = await fs.stat(path.join(scopedDir, name));
1985
+ if (!stat.isFile()) continue;
1986
+ assets.push({
1987
+ name,
1988
+ size: stat.size,
1989
+ mtime: stat.mtimeMs,
1990
+ mime: mimeForFilename(name),
1991
+ url: `/__assets/${slideId}/${encodeURIComponent(name)}`,
1992
+ unused: true
1993
+ });
1994
+ }
1995
+ assets.sort((a, b) => a.name.localeCompare(b.name));
1996
+ if (assets.length > 0) {
1997
+ const isGlobal = slideId === GLOBAL_SCOPE;
1998
+ let scanIds;
1999
+ if (isGlobal) try {
2000
+ const dirs = await fs.readdir(ctx.slidesRoot, { withFileTypes: true });
2001
+ scanIds = dirs.filter((e) => e.isDirectory() && SLIDE_ID_RE$3.test(e.name)).map((e) => e.name);
2002
+ } catch {
2003
+ scanIds = [];
2004
+ }
2005
+ else scanIds = SLIDE_ID_RE$3.test(slideId) ? [slideId] : [];
2006
+ const paths = assets.map((a) => isGlobal ? `@assets/${a.name}` : `./assets/${a.name}`);
2007
+ const pathToAsset = new Map(paths.map((p, i) => [p, assets[i]]));
2008
+ for (const sid of scanIds) {
2009
+ const entry = resolveSlideEntry(ctx.slidesRoot, sid);
2010
+ if (!entry) continue;
2011
+ let source;
2012
+ try {
2013
+ source = await fs.readFile(entry, "utf8");
2014
+ } catch {
2015
+ continue;
2016
+ }
2017
+ for (const p of findReferencedAssets(source, paths)) {
2018
+ const a = pathToAsset.get(p);
2019
+ if (a) a.unused = false;
2020
+ }
2021
+ }
2022
+ }
2023
+ return json$2(res, 200, { assets });
2024
+ }
2025
+ if (fileMatch) {
2026
+ const slideId = fileMatch[1];
2027
+ const filename = decodeURIComponent(fileMatch[2]);
2028
+ const file = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, filename);
2029
+ if (!file) return json$2(res, 400, { error: "invalid path" });
2030
+ if (method === "GET") try {
2031
+ const buf = await fs.readFile(file);
2032
+ res.statusCode = 200;
2033
+ res.setHeader("content-type", mimeForFilename(filename));
2034
+ res.setHeader("cache-control", "no-store");
2035
+ res.end(buf);
2036
+ return;
2037
+ } catch (err) {
2038
+ if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
2039
+ throw err;
2040
+ }
2041
+ if (method === "POST") {
2042
+ const requestCheck = validateMutationRequest(req);
2043
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2044
+ const overwrite = url.searchParams.get("overwrite") === "1";
2045
+ const lenHeader = req.headers["content-length"];
2046
+ const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
2047
+ if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json$2(res, 413, { error: "file too large" });
2048
+ if (!overwrite) try {
2049
+ await fs.access(file);
2050
+ return json$2(res, 409, { error: "asset exists" });
2051
+ } catch {}
2052
+ const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
2053
+ if (!scopedDir) return json$2(res, 400, { error: "invalid slideId" });
2054
+ await fs.mkdir(scopedDir, { recursive: true });
2055
+ const chunks = [];
2056
+ let total = 0;
2057
+ let oversized = false;
2058
+ await new Promise((resolve, reject) => {
2059
+ req.on("data", (c) => {
2060
+ total += c.length;
2061
+ if (total > ASSET_MAX_BYTES) {
2062
+ oversized = true;
2063
+ req.destroy();
2064
+ return;
2065
+ }
2066
+ chunks.push(c);
2067
+ });
2068
+ req.on("end", () => resolve());
2069
+ req.on("error", reject);
2070
+ });
2071
+ if (oversized) return json$2(res, 413, { error: "file too large" });
2072
+ await fs.writeFile(file, Buffer.concat(chunks));
2073
+ return json$2(res, 200, {
2074
+ ok: true,
2075
+ name: filename,
2076
+ size: total,
2077
+ mime: mimeForFilename(filename),
2078
+ url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
2079
+ });
2080
+ }
2081
+ if (method === "PATCH") {
2082
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2083
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2084
+ const body = await readBody$2(req);
2085
+ const target = validateAssetName(body.name);
2086
+ if (!target) return json$2(res, 400, { error: "invalid name" });
2087
+ if (target === filename) return json$2(res, 200, {
2088
+ ok: true,
2089
+ name: filename
2090
+ });
2091
+ const dest = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, target);
2092
+ if (!dest) return json$2(res, 400, { error: "invalid name" });
2093
+ try {
2094
+ await fs.access(dest);
2095
+ return json$2(res, 409, { error: "target exists" });
2096
+ } catch {}
2097
+ try {
2098
+ await fs.rename(file, dest);
2099
+ } catch (err) {
2100
+ if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
2101
+ throw err;
2102
+ }
2103
+ return json$2(res, 200, {
2104
+ ok: true,
2105
+ name: target
2106
+ });
2107
+ }
2108
+ if (method === "DELETE") {
2109
+ const requestCheck = validateMutationRequest(req);
2110
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2111
+ try {
2112
+ await fs.unlink(file);
2113
+ } catch (err) {
2114
+ if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
2115
+ throw err;
2116
+ }
2117
+ return json$2(res, 200, { ok: true });
2118
+ }
2119
+ }
2120
+ return next();
2121
+ } catch (err) {
2122
+ json$2(res, 500, { error: String(err.message ?? err) });
2123
+ }
2124
+ });
2125
+ }
2126
+
2127
+ //#endregion
2128
+ //#region src/editing/comments.ts
2129
+ const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
2130
+ function b64urlEncode(s) {
2131
+ return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2132
+ }
2133
+ function b64urlDecode(s) {
2134
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
2135
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
2136
+ }
2137
+ function parseMarkers(source) {
2138
+ const comments = [];
2139
+ const lines = source.split("\n");
2140
+ for (let i = 0; i < lines.length; i++) {
2141
+ const line = lines[i];
2142
+ MARKER_RE.lastIndex = 0;
2143
+ const m = MARKER_RE.exec(line);
2144
+ if (!m) continue;
2145
+ const [, id, ts, textB64] = m;
2146
+ try {
2147
+ const payload = JSON.parse(b64urlDecode(textB64));
2148
+ comments.push({
2149
+ id,
2150
+ line: i + 1,
2151
+ ts,
2152
+ note: payload.note,
2153
+ hint: payload.hint
2154
+ });
2155
+ } catch {}
2156
+ }
2157
+ return comments;
2158
+ }
2159
+ function newCommentId() {
2160
+ return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
2161
+ }
2162
+ function markerDeleteRegex(id) {
2163
+ return new RegExp(`\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`);
2164
+ }
2165
+ function lineToOffset(source, line) {
2166
+ let off = 0;
2167
+ for (let l = 1; l < line; l++) {
2168
+ const nl = source.indexOf("\n", off);
2169
+ if (nl === -1) return source.length;
2170
+ off = nl + 1;
2171
+ }
2172
+ return off;
2173
+ }
2174
+ function lineIndent(source, lineNumber) {
2175
+ const start = lineToOffset(source, lineNumber);
2176
+ const m = source.slice(start, start + 200).match(/^[ \t]*/);
2177
+ return m?.[0] ?? "";
2178
+ }
2179
+ function findJsxAncestors(ast, line, column) {
2180
+ const hits = [];
2181
+ walkJsx(ast, (n) => {
2182
+ if (!n.loc || !t$2.isJSXElement(n) && !t$2.isJSXFragment(n)) return;
2183
+ const s = n.loc.start;
2184
+ const e = n.loc.end;
2185
+ const afterStart = line > s.line || line === s.line && column >= s.column;
2186
+ const beforeEnd = line < e.line || line === e.line && column < e.column;
2187
+ if (afterStart && beforeEnd) hits.push({
2188
+ node: n,
2189
+ size: (n.end ?? 0) - (n.start ?? 0)
1642
2190
  });
1643
- if (hasDesignSystem) return {
1644
- source,
1645
- offsetShift: 0
1646
- };
1647
- const node = coreImport.node;
1648
- const importText = source.slice(node.start, node.end);
1649
- const braceClose = importText.lastIndexOf("}");
1650
- if (braceClose === -1) return {
1651
- source,
1652
- offsetShift: 0
1653
- };
1654
- const absoluteBrace = node.start + braceClose;
1655
- const insertText = coreImport.specifiers.length > 0 ? ", type DesignSystem" : "type DesignSystem";
1656
- const next$1 = `${source.slice(0, absoluteBrace)}${insertText}${source.slice(absoluteBrace)}`;
2191
+ });
2192
+ hits.sort((a, b) => a.size - b.size);
2193
+ return hits.map((h) => h.node);
2194
+ }
2195
+ function planInsertion(source, target) {
2196
+ if (t$2.isJSXFragment(target)) {
2197
+ const opening = target.openingFragment;
2198
+ const startLine = target.loc?.start.line ?? 1;
1657
2199
  return {
1658
- source: next$1,
1659
- offsetShift: insertText.length
2200
+ offset: opening.end ?? 0,
2201
+ indent: `${lineIndent(source, startLine)} `
1660
2202
  };
1661
2203
  }
1662
- const stmt = `import type { DesignSystem } from '@open-slide/core';\n`;
1663
- if (imports.length > 0) {
1664
- const last = imports[imports.length - 1];
1665
- const insertAt = last.node.end;
1666
- const trail = source[insertAt] === "\n" ? "" : "\n";
1667
- const next$1 = `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
2204
+ if (t$2.isJSXElement(target)) {
2205
+ const opening = target.openingElement;
2206
+ if (opening.selfClosing) return null;
2207
+ const startLine = target.loc?.start.line ?? 1;
1668
2208
  return {
1669
- source: next$1,
1670
- offsetShift: 1 + stmt.length - (trail ? 0 : 1)
2209
+ offset: opening.end ?? 0,
2210
+ indent: `${lineIndent(source, startLine)} `
1671
2211
  };
1672
2212
  }
1673
- const next = `${stmt}\n${source}`;
2213
+ return null;
2214
+ }
2215
+ function findInsertion(source, line, column) {
2216
+ const ast = parseSource$2(source);
2217
+ if (!ast) return null;
2218
+ const col = column ?? 0;
2219
+ const ancestors = findJsxAncestors(ast, line, col);
2220
+ for (const node of ancestors) {
2221
+ const plan = planInsertion(source, node);
2222
+ if (plan) return plan;
2223
+ }
2224
+ return null;
2225
+ }
2226
+ function offsetToLine(source, offset) {
2227
+ let line = 1;
2228
+ for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
2229
+ return line;
2230
+ }
2231
+
2232
+ //#endregion
2233
+ //#region src/vite/routes/comments.ts
2234
+ function registerCommentRoutes(server, ctx) {
2235
+ server.middlewares.use("/__comments", async (req, res, next) => {
2236
+ const url = new URL(req.url ?? "/", "http://local");
2237
+ const method = req.method ?? "GET";
2238
+ try {
2239
+ if (method === "GET" && url.pathname === "/") {
2240
+ const slideId = url.searchParams.get("slideId") ?? "";
2241
+ const file = resolveSlideEntryPath(ctx, slideId);
2242
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2243
+ let source;
2244
+ try {
2245
+ source = await fs.readFile(file, "utf8");
2246
+ } catch {
2247
+ return json$2(res, 404, { error: "slide not found" });
2248
+ }
2249
+ return json$2(res, 200, { comments: parseMarkers(source) });
2250
+ }
2251
+ if (method === "POST" && url.pathname === "/add") {
2252
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2253
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2254
+ const body = await readBody$2(req);
2255
+ const slideId = body.slideId ?? "";
2256
+ const file = resolveSlideEntryPath(ctx, slideId);
2257
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2258
+ if (!body.line || body.line < 1) return json$2(res, 400, { error: "invalid line" });
2259
+ if (!body.text || typeof body.text !== "string") return json$2(res, 400, { error: "missing text" });
2260
+ let source;
2261
+ try {
2262
+ source = await fs.readFile(file, "utf8");
2263
+ } catch {
2264
+ return json$2(res, 404, { error: "slide not found" });
2265
+ }
2266
+ const plan = findInsertion(source, body.line, body.column);
2267
+ if (!plan) return json$2(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
2268
+ const id = newCommentId();
2269
+ const ts = new Date().toISOString();
2270
+ const payload = b64urlEncode(JSON.stringify({
2271
+ note: body.text,
2272
+ hint: body.hint
2273
+ }));
2274
+ const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
2275
+ const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
2276
+ await fs.writeFile(file, next$1, "utf8");
2277
+ const markerLine = offsetToLine(next$1, plan.offset + 1);
2278
+ return json$2(res, 200, {
2279
+ id,
2280
+ line: markerLine
2281
+ });
2282
+ }
2283
+ if (method === "DELETE" && url.pathname.startsWith("/")) {
2284
+ const requestCheck = validateMutationRequest(req);
2285
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2286
+ const id = url.pathname.slice(1);
2287
+ if (!/^c-[a-f0-9]+$/.test(id)) return json$2(res, 400, { error: "invalid id" });
2288
+ const slideId = url.searchParams.get("slideId") ?? "";
2289
+ const file = resolveSlideEntryPath(ctx, slideId);
2290
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2291
+ let source;
2292
+ try {
2293
+ source = await fs.readFile(file, "utf8");
2294
+ } catch {
2295
+ return json$2(res, 404, { error: "slide not found" });
2296
+ }
2297
+ const lines = source.split("\n");
2298
+ const idRe = markerDeleteRegex(id);
2299
+ const hit = lines.findIndex((l) => idRe.test(l));
2300
+ if (hit === -1) return json$2(res, 404, { error: "marker not found" });
2301
+ lines.splice(hit, 1);
2302
+ await fs.writeFile(file, lines.join("\n"), "utf8");
2303
+ return json$2(res, 200, { ok: true });
2304
+ }
2305
+ next();
2306
+ } catch (err) {
2307
+ json$2(res, 500, { error: String(err.message ?? err) });
2308
+ }
2309
+ });
2310
+ }
2311
+
2312
+ //#endregion
2313
+ //#region src/vite/routes/edit.ts
2314
+ function registerEditRoutes(server, ctx) {
2315
+ server.middlewares.use("/__edit", async (req, res, next) => {
2316
+ const url = new URL(req.url ?? "/", "http://local");
2317
+ const method = req.method ?? "GET";
2318
+ if (method !== "POST") return next();
2319
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2320
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2321
+ try {
2322
+ if (url.pathname === "/") {
2323
+ const body = await readBody$2(req);
2324
+ const slideId = body.slideId ?? "";
2325
+ const file = resolveSlideEntryPath(ctx, slideId);
2326
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2327
+ if (!body.line || body.line < 1) return json$2(res, 400, { error: "invalid line" });
2328
+ if (!Array.isArray(body.ops)) return json$2(res, 400, { error: "missing ops" });
2329
+ let source;
2330
+ try {
2331
+ source = await fs.readFile(file, "utf8");
2332
+ } catch {
2333
+ return json$2(res, 404, { error: "slide not found" });
2334
+ }
2335
+ const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
2336
+ if (!result.ok) return json$2(res, result.status, { error: result.error });
2337
+ const changed = result.source !== source;
2338
+ if (changed) await fs.writeFile(file, result.source, "utf8");
2339
+ return json$2(res, 200, {
2340
+ ok: true,
2341
+ changed
2342
+ });
2343
+ }
2344
+ if (url.pathname === "/revert-asset") {
2345
+ const body = await readBody$2(req);
2346
+ const slideId = body.slideId ?? "";
2347
+ const assetPath = body.assetPath;
2348
+ const file = resolveSlideEntryPath(ctx, slideId);
2349
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2350
+ if (typeof assetPath !== "string" || !assetPath) return json$2(res, 400, { error: "missing assetPath" });
2351
+ if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return json$2(res, 400, { error: "asset path must start with ./assets/ or @assets/" });
2352
+ let source;
2353
+ try {
2354
+ source = await fs.readFile(file, "utf8");
2355
+ } catch {
2356
+ return json$2(res, 404, { error: "slide not found" });
2357
+ }
2358
+ const result = applyRevertAsset(source, assetPath);
2359
+ if (!result.ok) return json$2(res, result.status, { error: result.error });
2360
+ const changed = result.source !== source;
2361
+ if (changed) await fs.writeFile(file, result.source, "utf8");
2362
+ return json$2(res, 200, {
2363
+ ok: true,
2364
+ changed
2365
+ });
2366
+ }
2367
+ if (url.pathname === "/batch") {
2368
+ const body = await readBody$2(req);
2369
+ const slideId = body.slideId ?? "";
2370
+ const file = resolveSlideEntryPath(ctx, slideId);
2371
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2372
+ if (!Array.isArray(body.edits)) return json$2(res, 400, { error: "missing edits" });
2373
+ let source;
2374
+ try {
2375
+ source = await fs.readFile(file, "utf8");
2376
+ } catch {
2377
+ return json$2(res, 404, { error: "slide not found" });
2378
+ }
2379
+ const original = source;
2380
+ const results = [];
2381
+ for (const edit of body.edits) {
2382
+ if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
2383
+ results.push({
2384
+ ok: false,
2385
+ error: "invalid edit"
2386
+ });
2387
+ continue;
2388
+ }
2389
+ const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
2390
+ if (r.ok) {
2391
+ source = r.source;
2392
+ results.push({ ok: true });
2393
+ } else results.push({
2394
+ ok: false,
2395
+ error: r.error
2396
+ });
2397
+ }
2398
+ const changed = source !== original;
2399
+ if (changed) await fs.writeFile(file, source, "utf8");
2400
+ return json$2(res, 200, {
2401
+ ok: true,
2402
+ changed,
2403
+ results
2404
+ });
2405
+ }
2406
+ return next();
2407
+ } catch (err) {
2408
+ json$2(res, 500, { error: String(err.message ?? err) });
2409
+ }
2410
+ });
2411
+ }
2412
+
2413
+ //#endregion
2414
+ //#region src/files/folders.ts
2415
+ const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
2416
+ const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
2417
+ function emptyManifest() {
1674
2418
  return {
1675
- source: next,
1676
- offsetShift: stmt.length + 1
2419
+ folders: [],
2420
+ assignments: {}
1677
2421
  };
1678
2422
  }
1679
- function findInsertionPoint(source, ast) {
1680
- const imports = findImports(ast);
1681
- if (imports.length === 0) return 0;
1682
- const last = imports[imports.length - 1];
1683
- let off = last.node.end;
1684
- while (off < source.length && source[off] !== "\n") off++;
1685
- if (off < source.length) off++;
1686
- return off;
1687
- }
1688
- function applyDesignWrite(source, next) {
1689
- let body;
2423
+ async function readManifest(file) {
1690
2424
  try {
1691
- body = serializeDesign(next);
2425
+ const raw = await fs.readFile(file, "utf8");
2426
+ const parsed = JSON.parse(raw);
2427
+ return {
2428
+ folders: Array.isArray(parsed.folders) ? parsed.folders : [],
2429
+ assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
2430
+ };
1692
2431
  } catch (err) {
2432
+ if (err.code === "ENOENT") return emptyManifest();
2433
+ throw err;
2434
+ }
2435
+ }
2436
+ async function writeManifest(file, manifest) {
2437
+ await fs.mkdir(path.dirname(file), { recursive: true });
2438
+ await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
2439
+ }
2440
+ function newFolderId() {
2441
+ return `f-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
2442
+ }
2443
+ function validateName(v) {
2444
+ if (typeof v !== "string") return null;
2445
+ const trimmed = v.trim();
2446
+ if (trimmed.length < 1 || trimmed.length > 40) return null;
2447
+ return trimmed;
2448
+ }
2449
+ function validateIcon(v) {
2450
+ if (!v || typeof v !== "object") return null;
2451
+ const icon = v;
2452
+ if (icon.type === "emoji") {
2453
+ if (typeof icon.value !== "string") return null;
2454
+ if (icon.value.length < 1 || icon.value.length > 8) return null;
1693
2455
  return {
1694
- ok: false,
1695
- status: 422,
1696
- error: `serialize failed: ${err.message}`
2456
+ type: "emoji",
2457
+ value: icon.value
1697
2458
  };
1698
2459
  }
1699
- const ast = parseSource$1(source);
1700
- if (!ast) return {
1701
- ok: false,
1702
- status: 422,
1703
- error: "could not parse slide source"
1704
- };
1705
- const loc = findDesignDecl(ast);
1706
- if (loc) {
1707
- const out$1 = source.slice(0, loc.objectStart) + body + source.slice(loc.objectEnd);
2460
+ if (icon.type === "color") {
2461
+ if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
1708
2462
  return {
1709
- ok: true,
1710
- source: out$1,
1711
- created: false
2463
+ type: "color",
2464
+ value: icon.value
1712
2465
  };
1713
2466
  }
1714
- const withImport = ensureDesignSystemImport(source, ast);
1715
- const ast2 = parseSource$1(withImport.source);
1716
- if (!ast2) return {
1717
- ok: false,
1718
- status: 422,
1719
- error: "failed to re-parse after adding import"
1720
- };
1721
- const insertAt = findInsertionPoint(withImport.source, ast2);
1722
- const block = `\nconst design: DesignSystem = ${body};\n`;
1723
- const out = withImport.source.slice(0, insertAt) + block + withImport.source.slice(insertAt);
1724
- return {
1725
- ok: true,
1726
- source: out,
1727
- created: true
1728
- };
2467
+ return null;
1729
2468
  }
1730
- function designPlugin(opts) {
1731
- const userCwd = opts.userCwd;
1732
- const slidesDir = opts.slidesDir ?? "slides";
1733
- return {
1734
- name: "open-slide:design",
1735
- apply: "serve",
1736
- configureServer(server) {
1737
- server.middlewares.use("/__design", async (req, res, next) => {
1738
- const url = new URL(req.url ?? "/", "http://local");
1739
- const method = req.method ?? "GET";
1740
- const slideId = url.searchParams.get("slideId") ?? "";
1741
- const file = resolveSlidePath$1(userCwd, slidesDir, slideId);
1742
- if (!file) return json$2(res, 400, { error: "invalid slideId" });
1743
- try {
1744
- if (method === "GET" && url.pathname === "/") {
1745
- let source;
1746
- try {
1747
- source = await fs.readFile(file, "utf8");
1748
- } catch {
1749
- return json$2(res, 404, { error: "slide not found" });
1750
- }
1751
- const parsed = parseSlideDesign(source);
1752
- if (parsed.ok) return json$2(res, 200, {
1753
- design: parsed.design,
1754
- exists: true,
1755
- warning: null
1756
- });
1757
- if (parsed.exists === false) return json$2(res, 200, {
1758
- design: defaultDesign,
1759
- exists: false,
1760
- warning: null
1761
- });
1762
- return json$2(res, 200, {
1763
- design: defaultDesign,
1764
- exists: true,
1765
- warning: parsed.error
1766
- });
1767
- }
1768
- if (method === "PUT" && url.pathname === "/") {
1769
- const body = await readBody$2(req);
1770
- const patch = body.patch;
1771
- if (!patch || typeof patch !== "object") return json$2(res, 400, { error: "missing patch object" });
1772
- let source;
1773
- try {
1774
- source = await fs.readFile(file, "utf8");
1775
- } catch {
1776
- return json$2(res, 404, { error: "slide not found" });
1777
- }
1778
- const parsed = parseSlideDesign(source);
1779
- const baseDesign = parsed.ok ? parsed.design : defaultDesign;
1780
- if (!parsed.ok && parsed.exists) return json$2(res, 422, { error: parsed.error });
1781
- const merged = mergeDesign(baseDesign, patch);
1782
- const written = applyDesignWrite(source, merged);
1783
- if (!written.ok) return json$2(res, written.status, { error: written.error });
1784
- if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
1785
- return json$2(res, 200, {
1786
- ok: true,
1787
- design: merged,
1788
- created: written.created
1789
- });
2469
+
2470
+ //#endregion
2471
+ //#region src/vite/routes/folders.ts
2472
+ function registerFolderRoutes(server, ctx) {
2473
+ server.middlewares.use("/__folders", async (req, res, next) => {
2474
+ const url = new URL(req.url ?? "/", "http://local");
2475
+ const method = req.method ?? "GET";
2476
+ try {
2477
+ if (method === "GET" && url.pathname === "/") {
2478
+ const manifest = await readManifest(ctx.manifestPath);
2479
+ return json$2(res, 200, manifest);
2480
+ }
2481
+ if (method === "POST" && url.pathname === "/") {
2482
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2483
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2484
+ const body = await readBody$2(req);
2485
+ const name = validateName(body.name);
2486
+ if (!name) return json$2(res, 400, { error: "invalid name" });
2487
+ const icon = validateIcon(body.icon);
2488
+ if (!icon) return json$2(res, 400, { error: "invalid icon" });
2489
+ const manifest = await readManifest(ctx.manifestPath);
2490
+ const folder = {
2491
+ id: newFolderId(),
2492
+ name,
2493
+ icon
2494
+ };
2495
+ manifest.folders.push(folder);
2496
+ await writeManifest(ctx.manifestPath, manifest);
2497
+ return json$2(res, 200, folder);
2498
+ }
2499
+ if (method === "PUT" && url.pathname === "/assign") {
2500
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2501
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2502
+ const body = await readBody$2(req);
2503
+ if (typeof body.slideId !== "string" || !SLIDE_ID_RE$3.test(body.slideId)) return json$2(res, 400, { error: "invalid slideId" });
2504
+ const slideId = body.slideId;
2505
+ let folderId;
2506
+ if (body.folderId === null) folderId = null;
2507
+ else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
2508
+ else return json$2(res, 400, { error: "invalid folderId" });
2509
+ const manifest = await readManifest(ctx.manifestPath);
2510
+ if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json$2(res, 404, { error: "folder not found" });
2511
+ if (folderId === null) delete manifest.assignments[slideId];
2512
+ else manifest.assignments[slideId] = folderId;
2513
+ await writeManifest(ctx.manifestPath, manifest);
2514
+ return json$2(res, 200, { ok: true });
2515
+ }
2516
+ const idMatch = url.pathname.match(/^\/([^/]+)$/);
2517
+ if (idMatch) {
2518
+ const id = idMatch[1];
2519
+ if (!FOLDER_ID_RE.test(id)) return json$2(res, 400, { error: "invalid id" });
2520
+ if (method === "PATCH") {
2521
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2522
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2523
+ const body = await readBody$2(req);
2524
+ const manifest = await readManifest(ctx.manifestPath);
2525
+ const folder = manifest.folders.find((f) => f.id === id);
2526
+ if (!folder) return json$2(res, 404, { error: "folder not found" });
2527
+ if (body.name !== void 0) {
2528
+ const name = validateName(body.name);
2529
+ if (!name) return json$2(res, 400, { error: "invalid name" });
2530
+ folder.name = name;
1790
2531
  }
1791
- if (method === "POST" && url.pathname === "/reset") {
1792
- let source;
1793
- try {
1794
- source = await fs.readFile(file, "utf8");
1795
- } catch {
1796
- return json$2(res, 404, { error: "slide not found" });
1797
- }
1798
- const written = applyDesignWrite(source, defaultDesign);
1799
- if (!written.ok) return json$2(res, written.status, { error: written.error });
1800
- if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
1801
- return json$2(res, 200, {
1802
- ok: true,
1803
- design: defaultDesign,
1804
- created: written.created
1805
- });
2532
+ if (body.icon !== void 0) {
2533
+ const icon = validateIcon(body.icon);
2534
+ if (!icon) return json$2(res, 400, { error: "invalid icon" });
2535
+ folder.icon = icon;
1806
2536
  }
1807
- return next();
1808
- } catch (err) {
1809
- json$2(res, 500, { error: String(err.message ?? err) });
2537
+ await writeManifest(ctx.manifestPath, manifest);
2538
+ return json$2(res, 200, folder);
2539
+ }
2540
+ if (method === "DELETE") {
2541
+ const requestCheck = validateMutationRequest(req);
2542
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2543
+ const manifest = await readManifest(ctx.manifestPath);
2544
+ const before = manifest.folders.length;
2545
+ manifest.folders = manifest.folders.filter((f) => f.id !== id);
2546
+ if (manifest.folders.length === before) return json$2(res, 404, { error: "folder not found" });
2547
+ for (const [slideId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[slideId];
2548
+ await writeManifest(ctx.manifestPath, manifest);
2549
+ return json$2(res, 200, { ok: true });
2550
+ }
2551
+ }
2552
+ next();
2553
+ } catch (err) {
2554
+ json$2(res, 500, { error: String(err.message ?? err) });
2555
+ }
2556
+ });
2557
+ }
2558
+
2559
+ //#endregion
2560
+ //#region src/vite/routes/slides.ts
2561
+ function registerSlideRoutes(server, ctx) {
2562
+ server.middlewares.use("/__slides", async (req, res, next) => {
2563
+ const url = new URL(req.url ?? "/", "http://local");
2564
+ const method = req.method ?? "GET";
2565
+ try {
2566
+ const reorderMatch = url.pathname.match(/^\/([^/]+)\/reorder$/);
2567
+ if (reorderMatch && method === "PUT") {
2568
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2569
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2570
+ const slideId$1 = reorderMatch[1];
2571
+ if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
2572
+ const body = await readBody$2(req);
2573
+ if (!Array.isArray(body.order)) return json$2(res, 400, { error: "invalid order" });
2574
+ const order = [];
2575
+ for (const v of body.order) {
2576
+ if (!Number.isInteger(v)) return json$2(res, 400, { error: "invalid order" });
2577
+ order.push(v);
2578
+ }
2579
+ const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
2580
+ if (!entry) return json$2(res, 400, { error: "invalid slideId" });
2581
+ let source;
2582
+ try {
2583
+ source = await fs.readFile(entry, "utf8");
2584
+ } catch {
2585
+ return json$2(res, 404, { error: "slide not found" });
2586
+ }
2587
+ const reordered = reorderDefaultExportPagesInSource(source, order);
2588
+ if (reordered === null) return json$2(res, 422, { error: "could not reorder pages — order must be a permutation of the existing array" });
2589
+ const withNotes = reorderNotesArrayInSource(reordered, order);
2590
+ if (withNotes === null) return json$2(res, 422, { error: "could not reorder pages — `notes` export has an unexpected shape" });
2591
+ if (withNotes !== source) await fs.writeFile(entry, withNotes, "utf8");
2592
+ return json$2(res, 200, {
2593
+ ok: true,
2594
+ slideId: slideId$1,
2595
+ order
2596
+ });
2597
+ }
2598
+ const pageOpMatch = url.pathname.match(/^\/([^/]+)\/pages\/(\d+)(?:\/([a-z]+))?$/);
2599
+ if (pageOpMatch) {
2600
+ const slideId$1 = pageOpMatch[1];
2601
+ const pageIndex = Number.parseInt(pageOpMatch[2], 10);
2602
+ const op = pageOpMatch[3];
2603
+ if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
2604
+ if (!Number.isInteger(pageIndex) || pageIndex < 0) return json$2(res, 400, { error: "invalid page index" });
2605
+ const isDelete = method === "DELETE" && !op;
2606
+ const isDuplicate = method === "POST" && op === "duplicate";
2607
+ if (!isDelete && !isDuplicate) return next();
2608
+ const requestCheck = validateMutationRequest(req);
2609
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2610
+ const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
2611
+ if (!entry) return json$2(res, 400, { error: "invalid slideId" });
2612
+ let source;
2613
+ try {
2614
+ source = await fs.readFile(entry, "utf8");
2615
+ } catch {
2616
+ return json$2(res, 404, { error: "slide not found" });
2617
+ }
2618
+ const updated = isDelete ? removePageFromDefaultExportInSource(source, pageIndex) : duplicatePageInDefaultExportInSource(source, pageIndex);
2619
+ if (updated === null) return json$2(res, 422, { error: isDelete ? "could not delete page — index out of range or default export is not an array" : "could not duplicate page — index out of range or default export is not an array" });
2620
+ if (updated !== source) await fs.writeFile(entry, updated, "utf8");
2621
+ return json$2(res, 200, {
2622
+ ok: true,
2623
+ slideId: slideId$1,
2624
+ index: pageIndex
2625
+ });
2626
+ }
2627
+ const duplicateMatch = url.pathname.match(/^\/([^/]+)\/duplicate$/);
2628
+ if (duplicateMatch && method === "POST") {
2629
+ const requestCheck = validateMutationRequest(req);
2630
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2631
+ const slideId$1 = duplicateMatch[1];
2632
+ if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
2633
+ const body = await readBody$2(req);
2634
+ if (body.newId !== void 0 && typeof body.newId !== "string") return json$2(res, 400, { error: "invalid newId" });
2635
+ const duplicated = await duplicateSlideDir(ctx.slidesRoot, slideId$1, body.newId);
2636
+ if (!duplicated.ok) return json$2(res, duplicated.status, { error: duplicated.error });
2637
+ const manifest = await readManifest(ctx.manifestPath);
2638
+ const folderId = manifest.assignments[slideId$1];
2639
+ if (folderId) {
2640
+ manifest.assignments[duplicated.slideId] = folderId;
2641
+ await writeManifest(ctx.manifestPath, manifest);
2642
+ }
2643
+ return json$2(res, 200, {
2644
+ ok: true,
2645
+ slideId: duplicated.slideId
2646
+ });
2647
+ }
2648
+ const idMatch = url.pathname.match(/^\/([^/]+)$/);
2649
+ if (!idMatch) return next();
2650
+ const slideId = idMatch[1];
2651
+ if (!SLIDE_ID_RE$3.test(slideId)) return json$2(res, 400, { error: "invalid slideId" });
2652
+ if (method === "PATCH") {
2653
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2654
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2655
+ const body = await readBody$2(req);
2656
+ const name = validateSlideName(body.name);
2657
+ if (!name) return json$2(res, 400, { error: "invalid name" });
2658
+ const entry = resolveSlideEntry(ctx.slidesRoot, slideId);
2659
+ if (!entry) return json$2(res, 400, { error: "invalid slideId" });
2660
+ let source;
2661
+ try {
2662
+ source = await fs.readFile(entry, "utf8");
2663
+ } catch {
2664
+ return json$2(res, 404, { error: "slide not found" });
2665
+ }
2666
+ const updated = updateMetaTitleInSource(source, name);
2667
+ if (updated === null) return json$2(res, 422, { error: "could not locate a safe place to write meta.title in index.tsx" });
2668
+ if (updated !== source) await fs.writeFile(entry, updated, "utf8");
2669
+ server.ws.send({ type: "full-reload" });
2670
+ return json$2(res, 200, {
2671
+ ok: true,
2672
+ slideId,
2673
+ name
2674
+ });
2675
+ }
2676
+ if (method === "DELETE") {
2677
+ const requestCheck = validateMutationRequest(req);
2678
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2679
+ const removed = await rmSlideDir(ctx.slidesRoot, slideId);
2680
+ if (!removed) return json$2(res, 404, { error: "slide not found" });
2681
+ const manifest = await readManifest(ctx.manifestPath);
2682
+ delete manifest.assignments[slideId];
2683
+ await writeManifest(ctx.manifestPath, manifest);
2684
+ return json$2(res, 200, { ok: true });
2685
+ }
2686
+ return next();
2687
+ } catch (err) {
2688
+ json$2(res, 500, { error: String(err.message ?? err) });
2689
+ }
2690
+ });
2691
+ }
2692
+
2693
+ //#endregion
2694
+ //#region src/vite/routes/svgl.ts
2695
+ function registerSvglRoutes(server) {
2696
+ server.middlewares.use("/__svgl", async (req, res, next) => {
2697
+ const reqUrl = new URL(req.url ?? "/", "http://local");
2698
+ const method = req.method ?? "GET";
2699
+ if (method !== "GET") return next();
2700
+ try {
2701
+ let target = null;
2702
+ if (reqUrl.pathname === "/search") {
2703
+ const params = new URLSearchParams();
2704
+ const q = reqUrl.searchParams.get("q");
2705
+ const limit = reqUrl.searchParams.get("limit");
2706
+ if (q) params.set("search", q);
2707
+ if (limit) params.set("limit", limit);
2708
+ const qs = params.toString();
2709
+ target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
2710
+ } else if (reqUrl.pathname === "/svg") {
2711
+ const u = reqUrl.searchParams.get("u");
2712
+ if (!u) return json$2(res, 400, { error: "missing u" });
2713
+ let parsed;
2714
+ try {
2715
+ parsed = new URL(u);
2716
+ } catch {
2717
+ return json$2(res, 400, { error: "invalid u" });
1810
2718
  }
2719
+ if (parsed.protocol !== "https:") return json$2(res, 400, { error: "https only" });
2720
+ const host = parsed.hostname.toLowerCase();
2721
+ if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json$2(res, 400, { error: "host not allowed" });
2722
+ target = parsed.toString();
2723
+ } else return next();
2724
+ const upstream = await fetch(target);
2725
+ const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
2726
+ res.statusCode = upstream.status;
2727
+ res.setHeader("content-type", ct);
2728
+ res.setHeader("cache-control", "no-store");
2729
+ const buf = Buffer.from(await upstream.arrayBuffer());
2730
+ res.end(buf);
2731
+ } catch (err) {
2732
+ json$2(res, 502, { error: String(err.message ?? err) });
2733
+ }
2734
+ });
2735
+ }
2736
+
2737
+ //#endregion
2738
+ //#region src/vite/routes/watchers.ts
2739
+ function registerWatchers(server, ctx) {
2740
+ server.watcher.add(ctx.manifestPath);
2741
+ server.watcher.on("change", (p) => {
2742
+ if (p === ctx.manifestPath) server.ws.send({
2743
+ type: "custom",
2744
+ event: "open-slide:files-changed"
2745
+ });
2746
+ });
2747
+ server.watcher.add(ctx.globalAssetsRoot);
2748
+ const onAssetChange = (p) => {
2749
+ if (p.startsWith(ctx.globalAssetsRoot + path.sep) || p === ctx.globalAssetsRoot) {
2750
+ server.ws.send({
2751
+ type: "custom",
2752
+ event: "open-slide:assets-changed",
2753
+ data: { slideId: GLOBAL_SCOPE }
1811
2754
  });
2755
+ return;
1812
2756
  }
2757
+ if (!p.startsWith(ctx.slidesRoot + path.sep)) return;
2758
+ const rel = p.slice(ctx.slidesRoot.length + 1);
2759
+ const parts = rel.split(path.sep);
2760
+ if (parts.length < 3 || parts[1] !== "assets") return;
2761
+ const slideId = parts[0];
2762
+ if (!SLIDE_ID_RE$3.test(slideId)) return;
2763
+ server.ws.send({
2764
+ type: "custom",
2765
+ event: "open-slide:assets-changed",
2766
+ data: { slideId }
2767
+ });
1813
2768
  };
2769
+ server.watcher.on("add", onAssetChange);
2770
+ server.watcher.on("change", onAssetChange);
2771
+ server.watcher.on("unlink", onAssetChange);
1814
2772
  }
1815
2773
 
1816
2774
  //#endregion
1817
- //#region src/vite/files-plugin.ts
1818
- const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
1819
- const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
1820
- const GLOBAL_SCOPE = "@global";
1821
- const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
1822
- const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
1823
- const ASSET_MAX_BYTES = 25 * 1024 * 1024;
1824
- const MIME_BY_EXT = {
1825
- png: "image/png",
1826
- jpg: "image/jpeg",
1827
- jpeg: "image/jpeg",
1828
- gif: "image/gif",
1829
- svg: "image/svg+xml",
1830
- webp: "image/webp",
1831
- avif: "image/avif",
1832
- ico: "image/x-icon",
1833
- mp4: "video/mp4",
1834
- webm: "video/webm",
1835
- mov: "video/quicktime",
1836
- woff: "font/woff",
1837
- woff2: "font/woff2",
1838
- ttf: "font/ttf",
1839
- otf: "font/otf",
1840
- json: "application/json",
1841
- txt: "text/plain; charset=utf-8",
1842
- md: "text/markdown; charset=utf-8"
1843
- };
1844
- function mimeForFilename(name) {
1845
- const dot = name.lastIndexOf(".");
1846
- if (dot < 0) return "application/octet-stream";
1847
- const ext = name.slice(dot + 1).toLowerCase();
1848
- return MIME_BY_EXT[ext] ?? "application/octet-stream";
2775
+ //#region src/vite/api-plugin.ts
2776
+ function apiPlugin(opts) {
2777
+ return {
2778
+ name: "open-slide:api",
2779
+ apply: "serve",
2780
+ configureServer(server) {
2781
+ const ctx = makeContext(opts);
2782
+ registerWatchers(server, ctx);
2783
+ registerEditRoutes(server, ctx);
2784
+ registerCommentRoutes(server, ctx);
2785
+ registerSlideRoutes(server, ctx);
2786
+ registerAssetRoutes(server, ctx);
2787
+ registerSvglRoutes(server);
2788
+ registerFolderRoutes(server, ctx);
2789
+ }
2790
+ };
1849
2791
  }
1850
- function validateAssetName(v) {
1851
- if (typeof v !== "string") return null;
1852
- const trimmed = v.trim();
1853
- if (trimmed.length < 1 || trimmed.length > 120) return null;
1854
- if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
1855
- if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
1856
- if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
1857
- const dot = trimmed.lastIndexOf(".");
1858
- if (dot <= 0 || dot === trimmed.length - 1) return null;
1859
- return trimmed;
2792
+
2793
+ //#endregion
2794
+ //#region src/vite/current-plugin.ts
2795
+ const SLIDE_ID_RE$2 = /^[a-z0-9_-]+$/i;
2796
+ const TEXT_SNIPPET_MAX = 120;
2797
+ function parseSelection(raw) {
2798
+ if (raw == null || typeof raw !== "object") return null;
2799
+ const sel = raw;
2800
+ if (typeof sel.line !== "number" || !Number.isFinite(sel.line)) return null;
2801
+ if (typeof sel.column !== "number" || !Number.isFinite(sel.column)) return null;
2802
+ const tagName = typeof sel.tagName === "string" ? sel.tagName.toLowerCase().slice(0, 32) : "unknown";
2803
+ const text = typeof sel.text === "string" ? sel.text.replace(/\s+/g, " ").trim().slice(0, TEXT_SNIPPET_MAX) : "";
2804
+ return {
2805
+ line: Math.max(1, Math.floor(sel.line)),
2806
+ column: Math.max(0, Math.floor(sel.column)),
2807
+ tagName,
2808
+ text
2809
+ };
2810
+ }
2811
+ function currentPlugin(opts) {
2812
+ const userCwd = opts.userCwd;
2813
+ const slidesDir = opts.slidesDir ?? "slides";
2814
+ const outDir = path.join(userCwd, "node_modules", ".open-slide");
2815
+ const outFile = path.join(outDir, "current.json");
2816
+ const tmpFile = `${outFile}.tmp`;
2817
+ let cached = null;
2818
+ return {
2819
+ name: "open-slide:current",
2820
+ apply: "serve",
2821
+ configureServer(server) {
2822
+ server.ws.on("open-slide:current", async (raw) => {
2823
+ const next = cached ? { ...cached } : {
2824
+ slideId: "",
2825
+ pageIndex: 0,
2826
+ pageNumber: 1,
2827
+ totalPages: 1,
2828
+ slideTitle: "",
2829
+ view: "slides",
2830
+ pagePath: "",
2831
+ selection: null
2832
+ };
2833
+ if (typeof raw?.slideId === "string") {
2834
+ if (!SLIDE_ID_RE$2.test(raw.slideId)) return;
2835
+ const totalPages = typeof raw.totalPages === "number" && Number.isFinite(raw.totalPages) && raw.totalPages > 0 ? Math.floor(raw.totalPages) : 1;
2836
+ const rawIndex = typeof raw.pageIndex === "number" && Number.isFinite(raw.pageIndex) ? Math.floor(raw.pageIndex) : 0;
2837
+ const pageIndex = Math.max(0, Math.min(totalPages - 1, rawIndex));
2838
+ const slideTitle = typeof raw.slideTitle === "string" ? raw.slideTitle : raw.slideId;
2839
+ const view = raw.view === "assets" ? "assets" : "slides";
2840
+ const pagePath = path.join(slidesDir, raw.slideId, "index.tsx").split(path.sep).join("/");
2841
+ if (cached?.slideId !== raw.slideId || cached?.pageIndex !== pageIndex) next.selection = null;
2842
+ next.slideId = raw.slideId;
2843
+ next.pageIndex = pageIndex;
2844
+ next.pageNumber = pageIndex + 1;
2845
+ next.totalPages = totalPages;
2846
+ next.slideTitle = slideTitle;
2847
+ next.view = view;
2848
+ next.pagePath = pagePath;
2849
+ }
2850
+ if ("selection" in raw) next.selection = parseSelection(raw.selection);
2851
+ if (!next.slideId) return;
2852
+ cached = next;
2853
+ const body = {
2854
+ ...next,
2855
+ updatedAt: new Date().toISOString()
2856
+ };
2857
+ try {
2858
+ await fs.mkdir(outDir, { recursive: true });
2859
+ await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
2860
+ await fs.rename(tmpFile, outFile);
2861
+ } catch {}
2862
+ });
2863
+ }
2864
+ };
1860
2865
  }
2866
+
2867
+ //#endregion
2868
+ //#region src/vite/design-plugin.ts
2869
+ const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
1861
2870
  async function readBody$1(req) {
1862
2871
  return await new Promise((resolve, reject) => {
1863
2872
  const chunks = [];
@@ -1879,155 +2888,16 @@ function json$1(res, status, body) {
1879
2888
  res.setHeader("content-type", "application/json");
1880
2889
  res.end(JSON.stringify(body));
1881
2890
  }
1882
- function emptyManifest() {
1883
- return {
1884
- folders: [],
1885
- assignments: {}
1886
- };
1887
- }
1888
- async function readManifest(file) {
1889
- try {
1890
- const raw = await fs.readFile(file, "utf8");
1891
- const parsed = JSON.parse(raw);
1892
- return {
1893
- folders: Array.isArray(parsed.folders) ? parsed.folders : [],
1894
- assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
1895
- };
1896
- } catch (err) {
1897
- if (err.code === "ENOENT") return emptyManifest();
1898
- throw err;
1899
- }
1900
- }
1901
- async function writeManifest(file, manifest) {
1902
- await fs.mkdir(path.dirname(file), { recursive: true });
1903
- await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
1904
- }
1905
- function newFolderId() {
1906
- return `f-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
1907
- }
1908
- function validateName(v) {
1909
- if (typeof v !== "string") return null;
1910
- const trimmed = v.trim();
1911
- if (trimmed.length < 1 || trimmed.length > 40) return null;
1912
- return trimmed;
1913
- }
1914
- function validateSlideName(v) {
1915
- if (typeof v !== "string") return null;
1916
- const trimmed = v.trim();
1917
- if (trimmed.length < 1 || trimmed.length > 80) return null;
1918
- return trimmed;
1919
- }
1920
- async function rmSlideDir(slidesRoot, slideId) {
1921
- if (!SLIDE_ID_RE$1.test(slideId)) return false;
1922
- const dir = path.resolve(slidesRoot, slideId);
1923
- if (!dir.startsWith(slidesRoot + path.sep)) return false;
1924
- try {
1925
- await fs.rm(dir, {
1926
- recursive: true,
1927
- force: true
1928
- });
1929
- return true;
1930
- } catch {
1931
- return false;
1932
- }
1933
- }
1934
- function resolveAssetsDir(slidesRoot, slideId) {
1935
- if (!SLIDE_ID_RE$1.test(slideId)) return null;
1936
- const slideDir = path.resolve(slidesRoot, slideId);
1937
- if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
1938
- const assetsDir = path.resolve(slideDir, "assets");
1939
- if (assetsDir !== path.join(slideDir, "assets")) return null;
1940
- return assetsDir;
1941
- }
1942
- function resolveAssetFile(slidesRoot, slideId, filename) {
1943
- const assetsDir = resolveAssetsDir(slidesRoot, slideId);
1944
- if (!assetsDir) return null;
1945
- if (!validateAssetName(filename)) return null;
1946
- const file = path.resolve(assetsDir, filename);
1947
- if (!file.startsWith(assetsDir + path.sep)) return null;
1948
- return file;
1949
- }
1950
- function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
1951
- if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
1952
- return resolveAssetsDir(slidesRoot, scope);
1953
- }
1954
- function resolveScopedAssetFile(slidesRoot, globalAssetsRoot, scope, filename) {
1955
- if (scope === GLOBAL_SCOPE) {
1956
- if (!validateAssetName(filename)) return null;
1957
- const file = path.resolve(globalAssetsRoot, filename);
1958
- if (!file.startsWith(globalAssetsRoot + path.sep)) return null;
1959
- return file;
1960
- }
1961
- return resolveAssetFile(slidesRoot, scope, filename);
1962
- }
1963
- function resolveSlideEntry(slidesRoot, slideId) {
2891
+ function resolveSlidePath$1(userCwd, slidesDir, slideId) {
1964
2892
  if (!SLIDE_ID_RE$1.test(slideId)) return null;
1965
- const dir = path.resolve(slidesRoot, slideId);
1966
- if (!dir.startsWith(slidesRoot + path.sep)) return null;
1967
- return path.join(dir, "index.tsx");
1968
- }
1969
- function escapeSingleQuoted(s) {
1970
- return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1971
- }
1972
- /**
1973
- * Rewrite (or insert) the `title` field in the slide module's `export const meta`.
1974
- *
1975
- * Strategy:
1976
- * 1. Find `export const meta` and brace-match its object literal.
1977
- * 2. If the object already has a `title: '...'` entry, replace the literal.
1978
- * 3. If the object exists but has no title, inject a new `title: '...'` line
1979
- * as the first property (preserving the author's surrounding indentation).
1980
- * 4. If there is no `meta` export at all, insert a fresh one right before
1981
- * `export default`.
1982
- *
1983
- * Returns the rewritten source, or `null` if the file shape was too surprising
1984
- * to touch safely (e.g. `export default` missing when we'd need to inject meta).
1985
- */
1986
- function updateMetaTitleInSource(source, title) {
1987
- const newLiteral = `'${escapeSingleQuoted(title)}'`;
1988
- const metaStart = source.search(/export\s+const\s+meta\b/);
1989
- if (metaStart !== -1) {
1990
- const eqIdx = source.indexOf("=", metaStart);
1991
- if (eqIdx === -1) return null;
1992
- const openBrace = source.indexOf("{", eqIdx);
1993
- if (openBrace === -1) return null;
1994
- let depth = 0;
1995
- let closeBrace = -1;
1996
- for (let i = openBrace; i < source.length; i++) {
1997
- const ch = source[i];
1998
- if (ch === "{") depth++;
1999
- else if (ch === "}") {
2000
- depth--;
2001
- if (depth === 0) {
2002
- closeBrace = i;
2003
- break;
2004
- }
2005
- }
2006
- }
2007
- if (closeBrace === -1) return null;
2008
- const body = source.slice(openBrace + 1, closeBrace);
2009
- const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
2010
- const match = body.match(titleRe);
2011
- if (match) {
2012
- const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
2013
- return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
2014
- }
2015
- const firstIndentMatch = body.match(/\n([ \t]+)\S/);
2016
- const indent$1 = firstIndentMatch ? firstIndentMatch[1] : " ";
2017
- const trimmedBody = body.replace(/^\s*\n?/, "");
2018
- const needsSeparator = trimmedBody.trim().length > 0;
2019
- const insertion$1 = `\n${indent$1}title: ${newLiteral}${needsSeparator ? "," : ""}`;
2020
- return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
2021
- }
2022
- const exportDefaultIdx = source.search(/export\s+default\b/);
2023
- if (exportDefaultIdx === -1) return null;
2024
- const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
2025
- return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
2893
+ const slidesRoot = path.resolve(userCwd, slidesDir);
2894
+ const full = path.resolve(slidesRoot, slideId, "index.tsx");
2895
+ if (!full.startsWith(`${slidesRoot}${path.sep}`)) return null;
2896
+ return full;
2026
2897
  }
2027
- function findDefaultExportArray(source) {
2028
- let ast;
2898
+ function parseSource$1(source) {
2029
2899
  try {
2030
- ast = parse(source, {
2900
+ return parse(source, {
2031
2901
  sourceType: "module",
2032
2902
  plugins: ["typescript", "jsx"],
2033
2903
  errorRecovery: true
@@ -2035,630 +2905,393 @@ function findDefaultExportArray(source) {
2035
2905
  } catch {
2036
2906
  return null;
2037
2907
  }
2908
+ }
2909
+ function findDesignDecl(ast) {
2038
2910
  const body = ast.program?.body ?? [];
2039
2911
  for (const node of body) {
2040
- if (node.type !== "ExportDefaultDeclaration") continue;
2041
- let inner = node.declaration;
2042
- while (inner && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression")) inner = inner.expression;
2043
- if (!inner || inner.type !== "ArrayExpression") return null;
2044
- const arrayStart = inner.start;
2045
- const arrayEnd = inner.end;
2046
- const rawElements = inner.elements ?? [];
2047
- const elements = [];
2048
- for (const el of rawElements) {
2049
- if (!el || typeof el.start !== "number" || typeof el.end !== "number") return null;
2050
- elements.push({
2051
- start: el.start,
2052
- end: el.end
2912
+ let varDecl = null;
2913
+ if (node.type === "VariableDeclaration") varDecl = node;
2914
+ else if (node.type === "ExportNamedDeclaration") {
2915
+ const decl = node.declaration;
2916
+ if (decl?.type === "VariableDeclaration") varDecl = decl;
2917
+ }
2918
+ if (!varDecl) continue;
2919
+ const declarations = varDecl.declarations ?? [];
2920
+ for (const d of declarations) {
2921
+ const id = d.id;
2922
+ if (!id || id.type !== "Identifier" || id.name !== "design") continue;
2923
+ const init = d.init;
2924
+ if (!init) return null;
2925
+ let inner = init;
2926
+ if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
2927
+ const expr = inner.expression;
2928
+ if (expr) inner = expr;
2929
+ }
2930
+ if (inner.type !== "ObjectExpression") return null;
2931
+ return {
2932
+ declStart: node.start,
2933
+ declEnd: node.end,
2934
+ objectStart: inner.start,
2935
+ objectEnd: inner.end
2936
+ };
2937
+ }
2938
+ }
2939
+ return null;
2940
+ }
2941
+ function literalToValue(node) {
2942
+ switch (node.type) {
2943
+ case "StringLiteral": return node.value;
2944
+ case "NumericLiteral": return node.value;
2945
+ case "BooleanLiteral": return node.value;
2946
+ case "NullLiteral": return null;
2947
+ case "UnaryExpression": {
2948
+ const op = node.operator;
2949
+ const arg = node.argument;
2950
+ const v = literalToValue(arg);
2951
+ if (op === "-" && typeof v === "number") return -v;
2952
+ if (op === "+" && typeof v === "number") return v;
2953
+ throw new Error(`unsupported unary operator ${op}`);
2954
+ }
2955
+ case "TemplateLiteral": {
2956
+ const quasis = node.quasis;
2957
+ const expressions = node.expressions;
2958
+ if (expressions.length > 0) throw new Error("template literal has expressions");
2959
+ return quasis[0].value.cooked ?? quasis[0].value.raw;
2960
+ }
2961
+ case "ArrayExpression": {
2962
+ const elements = node.elements;
2963
+ return elements.map((el) => {
2964
+ if (!el) throw new Error("array has hole");
2965
+ return literalToValue(el);
2053
2966
  });
2054
2967
  }
2055
- return {
2056
- elements,
2057
- arrayStart,
2058
- arrayEnd
2059
- };
2968
+ case "ObjectExpression": {
2969
+ const properties = node.properties;
2970
+ const out = {};
2971
+ for (const prop of properties) {
2972
+ if (prop.type !== "ObjectProperty") throw new Error("object has spread or method");
2973
+ const p = prop;
2974
+ if (p.computed) throw new Error("object has computed key");
2975
+ let key;
2976
+ if (p.key.type === "Identifier" && typeof p.key.name === "string") key = p.key.name;
2977
+ else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") key = p.key.value;
2978
+ else throw new Error("unsupported object key");
2979
+ out[key] = literalToValue(p.value);
2980
+ }
2981
+ return out;
2982
+ }
2983
+ default: throw new Error(`unsupported node type ${node.type}`);
2060
2984
  }
2061
- return null;
2062
2985
  }
2063
- /**
2064
- * Rewrite `export default [...]` so its elements appear in the requested order.
2065
- *
2066
- * `order[i]` is the original index that should land at new position `i`. The
2067
- * function preserves each element's exact source slice (including any inline
2068
- * comments that hug an identifier) and keeps the inter-element separator slots
2069
- * in their original positions, so a 3-page array `[A, B, C]` reordered to
2070
- * `[2, 0, 1]` becomes `[C, A, B]` with the same indentation and trailing
2071
- * commas the author wrote.
2072
- *
2073
- * Returns `null` when the file's default export isn't an array literal, or the
2074
- * order is not a valid permutation of `[0, n-1]`.
2075
- */
2076
- function reorderDefaultExportPagesInSource(source, order) {
2077
- const found = findDefaultExportArray(source);
2078
- if (!found) return null;
2079
- const { elements, arrayStart, arrayEnd } = found;
2080
- const n = elements.length;
2081
- if (order.length !== n) return null;
2082
- const seen = new Set();
2083
- for (const idx of order) {
2084
- if (!Number.isInteger(idx) || idx < 0 || idx >= n) return null;
2085
- if (seen.has(idx)) return null;
2086
- seen.add(idx);
2986
+ function isPlainObject(v) {
2987
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2988
+ }
2989
+ function mergeDesign(base, patch) {
2990
+ const out = JSON.parse(JSON.stringify(base));
2991
+ const apply = (target, src) => {
2992
+ for (const [k, v] of Object.entries(src)) if (isPlainObject(v) && isPlainObject(target[k])) apply(target[k], v);
2993
+ else target[k] = v;
2994
+ };
2995
+ if (isPlainObject(patch)) apply(out, patch);
2996
+ return out;
2997
+ }
2998
+ function indent(level) {
2999
+ return " ".repeat(level);
3000
+ }
3001
+ function jsString(s) {
3002
+ return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
3003
+ }
3004
+ function isValidIdentifier(name) {
3005
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
3006
+ }
3007
+ function serializeValue(value, level) {
3008
+ if (value === null) return "null";
3009
+ if (typeof value === "string") return jsString(value);
3010
+ if (typeof value === "number") {
3011
+ if (!Number.isFinite(value)) throw new Error("non-finite number");
3012
+ return String(value);
2087
3013
  }
2088
- if (n === 0) return source;
2089
- let identity = true;
2090
- for (let i = 0; i < n; i++) if (order[i] !== i) {
2091
- identity = false;
2092
- break;
3014
+ if (typeof value === "boolean") return value ? "true" : "false";
3015
+ if (Array.isArray(value)) {
3016
+ if (value.length === 0) return "[]";
3017
+ const inner = value.map((el) => serializeValue(el, level + 1)).join(", ");
3018
+ return `[${inner}]`;
2093
3019
  }
2094
- if (identity) return source;
2095
- const prefix = source.slice(arrayStart, elements[0].start);
2096
- const suffix = source.slice(elements[n - 1].end, arrayEnd);
2097
- const separators = [];
2098
- for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
2099
- const elementText = elements.map((el) => source.slice(el.start, el.end));
2100
- let rebuilt = prefix + elementText[order[0]];
2101
- for (let i = 1; i < n; i++) rebuilt += separators[i - 1] + elementText[order[i]];
2102
- rebuilt += suffix;
2103
- return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
3020
+ if (isPlainObject(value)) {
3021
+ const entries = Object.entries(value);
3022
+ if (entries.length === 0) return "{}";
3023
+ const childIndent = indent(level + 1);
3024
+ const lines = entries.map(([k, v]) => {
3025
+ const key = isValidIdentifier(k) ? k : jsString(k);
3026
+ return `${childIndent}${key}: ${serializeValue(v, level + 1)},`;
3027
+ });
3028
+ return `{\n${lines.join("\n")}\n${indent(level)}}`;
3029
+ }
3030
+ throw new Error(`unsupported value type ${typeof value}`);
2104
3031
  }
2105
- function findNotesArray(source) {
2106
- let ast;
3032
+ function serializeDesign(design) {
3033
+ return serializeValue(design, 0);
3034
+ }
3035
+ function parseSlideDesign(source) {
3036
+ const ast = parseSource$1(source);
3037
+ if (!ast) return {
3038
+ ok: false,
3039
+ exists: true,
3040
+ error: "could not parse slide source"
3041
+ };
3042
+ const loc = findDesignDecl(ast);
3043
+ if (!loc) return {
3044
+ ok: false,
3045
+ exists: false
3046
+ };
3047
+ const objectNode = findDesignObjectNode(ast);
3048
+ if (!objectNode) return {
3049
+ ok: false,
3050
+ exists: true,
3051
+ error: "design has unsupported initializer"
3052
+ };
3053
+ let value;
2107
3054
  try {
2108
- ast = parse(source, {
2109
- sourceType: "module",
2110
- plugins: ["typescript", "jsx"],
2111
- errorRecovery: true
2112
- });
2113
- } catch {
2114
- return "invalid";
3055
+ value = literalToValue(objectNode);
3056
+ } catch (err) {
3057
+ return {
3058
+ ok: false,
3059
+ exists: true,
3060
+ error: err.message
3061
+ };
2115
3062
  }
3063
+ const merged = mergeDesign(defaultDesign, value);
3064
+ return {
3065
+ ok: true,
3066
+ design: merged,
3067
+ loc
3068
+ };
3069
+ }
3070
+ function findDesignObjectNode(ast) {
2116
3071
  const body = ast.program?.body ?? [];
2117
- for (const stmt of body) {
2118
- if (stmt.type !== "ExportNamedDeclaration") continue;
2119
- const decl = stmt.declaration;
2120
- if (!decl || decl.type !== "VariableDeclaration") continue;
2121
- const declarations = decl.declarations ?? [];
3072
+ for (const node of body) {
3073
+ let varDecl = null;
3074
+ if (node.type === "VariableDeclaration") varDecl = node;
3075
+ else if (node.type === "ExportNamedDeclaration") {
3076
+ const decl = node.declaration;
3077
+ if (decl?.type === "VariableDeclaration") varDecl = decl;
3078
+ }
3079
+ if (!varDecl) continue;
3080
+ const declarations = varDecl.declarations ?? [];
2122
3081
  for (const d of declarations) {
2123
3082
  const id = d.id;
2124
- if (!id || id.type !== "Identifier" || id.name !== "notes") continue;
3083
+ if (!id || id.type !== "Identifier" || id.name !== "design") continue;
2125
3084
  const init = d.init;
2126
- if (!init || init.type !== "ArrayExpression") return "invalid";
2127
- const arrayStart = init.start;
2128
- const arrayEnd = init.end;
2129
- if (typeof arrayStart !== "number" || typeof arrayEnd !== "number") return "invalid";
2130
- const rawElements = init.elements ?? [];
2131
- const elementTexts = [];
2132
- for (const el of rawElements) {
2133
- if (el === null) {
2134
- elementTexts.push("undefined");
2135
- continue;
2136
- }
2137
- if (el.type === "SpreadElement") return "invalid";
2138
- const start = el.start;
2139
- const end = el.end;
2140
- if (typeof start !== "number" || typeof end !== "number") return "invalid";
2141
- elementTexts.push(source.slice(start, end));
3085
+ if (!init) return null;
3086
+ let inner = init;
3087
+ if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
3088
+ const expr = inner.expression;
3089
+ if (expr) inner = expr;
2142
3090
  }
2143
- return {
2144
- arrayStart,
2145
- arrayEnd,
2146
- elementTexts
2147
- };
3091
+ if (inner.type !== "ObjectExpression") return null;
3092
+ return inner;
2148
3093
  }
2149
3094
  }
2150
3095
  return null;
2151
3096
  }
2152
- /**
2153
- * Reorder `export const notes = [...]` to follow the page-array reorder.
2154
- *
2155
- * `order[i]` is the original page index that should land at new position `i`.
2156
- * The notes array is index-aligned with the pages array but may be shorter
2157
- * (trailing `undefined` slots are routinely trimmed). Missing elements are
2158
- * treated as `undefined`, and trailing `undefined` is trimmed again after
2159
- * reordering to keep the file tidy.
2160
- *
2161
- * Returns the rewritten source, the original source if no `notes` export
2162
- * exists or the reorder is a no-op, or `null` if the `notes` export's shape
2163
- * is too surprising to touch safely.
2164
- */
2165
- function reorderNotesArrayInSource(source, order) {
2166
- for (const idx of order) if (!Number.isInteger(idx) || idx < 0) return null;
2167
- const found = findNotesArray(source);
2168
- if (found === "invalid") return null;
2169
- if (found === null) return source;
2170
- const { arrayStart, arrayEnd, elementTexts } = found;
2171
- const pick = (i) => i >= 0 && i < elementTexts.length ? elementTexts[i] : "undefined";
2172
- const reordered = order.map(pick);
2173
- while (reordered.length > 0 && reordered[reordered.length - 1] === "undefined") reordered.pop();
2174
- const replacement = reordered.length === 0 ? "[]" : `[\n${reordered.map((s) => ` ${s},`).join("\n")}\n]`;
2175
- if (replacement === source.slice(arrayStart, arrayEnd)) return source;
2176
- return source.slice(0, arrayStart) + replacement + source.slice(arrayEnd);
2177
- }
2178
- /**
2179
- * Remove the element at `index` from `export default [...]`.
2180
- *
2181
- * Preserves the source slice of every other element, dropping the separator
2182
- * immediately following the removed element (or the preceding one when the
2183
- * removed element is the last). Returns `null` when the default export isn't
2184
- * an array literal or `index` is out of range.
2185
- */
2186
- function removePageFromDefaultExportInSource(source, index) {
2187
- const found = findDefaultExportArray(source);
2188
- if (!found) return null;
2189
- const { elements, arrayStart, arrayEnd } = found;
2190
- const n = elements.length;
2191
- if (!Number.isInteger(index) || index < 0 || index >= n) return null;
2192
- if (n === 1) return `${source.slice(0, arrayStart)}[]${source.slice(arrayEnd)}`;
2193
- const prefix = source.slice(arrayStart, elements[0].start);
2194
- const suffix = source.slice(elements[n - 1].end, arrayEnd);
2195
- const separators = [];
2196
- for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
2197
- const elementText = elements.map((el) => source.slice(el.start, el.end));
2198
- const keptElements = [];
2199
- const keptSeparators = [];
2200
- for (let i = 0; i < n; i++) {
2201
- if (i === index) continue;
2202
- keptElements.push(elementText[i]);
2203
- }
2204
- for (let i = 0; i < n - 1; i++) {
2205
- if (index === n - 1 ? i === n - 2 : i === index) continue;
2206
- keptSeparators.push(separators[i]);
2207
- }
2208
- let rebuilt = prefix + keptElements[0];
2209
- for (let i = 1; i < keptElements.length; i++) rebuilt += keptSeparators[i - 1] + keptElements[i];
2210
- rebuilt += suffix;
2211
- return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
2212
- }
2213
- function chooseInsertSeparator(prefix, existingSeparators) {
2214
- const sample = existingSeparators.find((s) => s.includes(","));
2215
- if (sample) return sample;
2216
- if (prefix.includes("\n")) {
2217
- const m = prefix.match(/\n([ \t]*)$/);
2218
- const indent$1 = m ? m[1] : " ";
2219
- return `,\n${indent$1}`;
3097
+ function findImports(ast) {
3098
+ const body = ast.program?.body ?? [];
3099
+ const out = [];
3100
+ for (const node of body) {
3101
+ if (node.type !== "ImportDeclaration") continue;
3102
+ const src = node.source?.value;
3103
+ if (typeof src !== "string") continue;
3104
+ const specs = node.specifiers ?? [];
3105
+ out.push({
3106
+ node,
3107
+ source: src,
3108
+ specifiers: specs
3109
+ });
2220
3110
  }
2221
- return ", ";
3111
+ return out;
2222
3112
  }
2223
- /**
2224
- * Duplicate the element at `index` in `export default [...]`, inserting the
2225
- * copy immediately after the original. Reuses an existing inter-element
2226
- * separator when one is available so the cloned entry matches the surrounding
2227
- * indentation. Returns `null` when the default export isn't an array literal
2228
- * or `index` is out of range.
2229
- */
2230
- function duplicatePageInDefaultExportInSource(source, index) {
2231
- const found = findDefaultExportArray(source);
2232
- if (!found) return null;
2233
- const { elements, arrayStart, arrayEnd } = found;
2234
- const n = elements.length;
2235
- if (!Number.isInteger(index) || index < 0 || index >= n) return null;
2236
- const prefix = source.slice(arrayStart, elements[0].start);
2237
- const suffix = source.slice(elements[n - 1].end, arrayEnd);
2238
- const separators = [];
2239
- for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
2240
- const elementText = elements.map((el) => source.slice(el.start, el.end));
2241
- const insertSep = chooseInsertSeparator(prefix, separators);
2242
- const newElements = [];
2243
- const newSeparators = [];
2244
- for (let i = 0; i < n; i++) {
2245
- newElements.push(elementText[i]);
2246
- if (i === index) {
2247
- newElements.push(elementText[i]);
2248
- newSeparators.push(insertSep);
2249
- }
2250
- if (i < n - 1) newSeparators.push(separators[i]);
3113
+ function ensureDesignSystemImport(source, ast) {
3114
+ const imports = findImports(ast);
3115
+ const coreImport = imports.find((imp) => imp.source === "@open-slide/core");
3116
+ if (coreImport) {
3117
+ const hasDesignSystem = coreImport.specifiers.some((spec) => {
3118
+ if (spec.type !== "ImportSpecifier") return false;
3119
+ const imported = spec.imported;
3120
+ return imported?.name === "DesignSystem";
3121
+ });
3122
+ if (hasDesignSystem) return {
3123
+ source,
3124
+ offsetShift: 0
3125
+ };
3126
+ const node = coreImport.node;
3127
+ const importText = source.slice(node.start, node.end);
3128
+ const braceClose = importText.lastIndexOf("}");
3129
+ if (braceClose === -1) return {
3130
+ source,
3131
+ offsetShift: 0
3132
+ };
3133
+ const absoluteBrace = node.start + braceClose;
3134
+ const insertText = coreImport.specifiers.length > 0 ? ", type DesignSystem" : "type DesignSystem";
3135
+ const next$1 = `${source.slice(0, absoluteBrace)}${insertText}${source.slice(absoluteBrace)}`;
3136
+ return {
3137
+ source: next$1,
3138
+ offsetShift: insertText.length
3139
+ };
2251
3140
  }
2252
- let rebuilt = prefix + newElements[0];
2253
- for (let i = 1; i < newElements.length; i++) rebuilt += newSeparators[i - 1] + newElements[i];
2254
- rebuilt += suffix;
2255
- return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
3141
+ const stmt = `import type { DesignSystem } from '@open-slide/core';\n`;
3142
+ if (imports.length > 0) {
3143
+ const last = imports[imports.length - 1];
3144
+ const insertAt = last.node.end;
3145
+ const trail = source[insertAt] === "\n" ? "" : "\n";
3146
+ const next$1 = `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
3147
+ return {
3148
+ source: next$1,
3149
+ offsetShift: 1 + stmt.length - (trail ? 0 : 1)
3150
+ };
3151
+ }
3152
+ const next = `${stmt}\n${source}`;
3153
+ return {
3154
+ source: next,
3155
+ offsetShift: stmt.length + 1
3156
+ };
2256
3157
  }
2257
- function validateIcon(v) {
2258
- if (!v || typeof v !== "object") return null;
2259
- const icon = v;
2260
- if (icon.type === "emoji") {
2261
- if (typeof icon.value !== "string") return null;
2262
- if (icon.value.length < 1 || icon.value.length > 8) return null;
3158
+ function findInsertionPoint(source, ast) {
3159
+ const imports = findImports(ast);
3160
+ if (imports.length === 0) return 0;
3161
+ const last = imports[imports.length - 1];
3162
+ let off = last.node.end;
3163
+ while (off < source.length && source[off] !== "\n") off++;
3164
+ if (off < source.length) off++;
3165
+ return off;
3166
+ }
3167
+ function applyDesignWrite(source, next) {
3168
+ let body;
3169
+ try {
3170
+ body = serializeDesign(next);
3171
+ } catch (err) {
2263
3172
  return {
2264
- type: "emoji",
2265
- value: icon.value
3173
+ ok: false,
3174
+ status: 422,
3175
+ error: `serialize failed: ${err.message}`
2266
3176
  };
2267
3177
  }
2268
- if (icon.type === "color") {
2269
- if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
3178
+ const ast = parseSource$1(source);
3179
+ if (!ast) return {
3180
+ ok: false,
3181
+ status: 422,
3182
+ error: "could not parse slide source"
3183
+ };
3184
+ const loc = findDesignDecl(ast);
3185
+ if (loc) {
3186
+ const out$1 = source.slice(0, loc.objectStart) + body + source.slice(loc.objectEnd);
2270
3187
  return {
2271
- type: "color",
2272
- value: icon.value
3188
+ ok: true,
3189
+ source: out$1,
3190
+ created: false
2273
3191
  };
2274
3192
  }
2275
- return null;
3193
+ const withImport = ensureDesignSystemImport(source, ast);
3194
+ const ast2 = parseSource$1(withImport.source);
3195
+ if (!ast2) return {
3196
+ ok: false,
3197
+ status: 422,
3198
+ error: "failed to re-parse after adding import"
3199
+ };
3200
+ const insertAt = findInsertionPoint(withImport.source, ast2);
3201
+ const block = `\nconst design: DesignSystem = ${body};\n`;
3202
+ const out = withImport.source.slice(0, insertAt) + block + withImport.source.slice(insertAt);
3203
+ return {
3204
+ ok: true,
3205
+ source: out,
3206
+ created: true
3207
+ };
2276
3208
  }
2277
- function filesPlugin(opts) {
3209
+ function designPlugin(opts) {
2278
3210
  const userCwd = opts.userCwd;
2279
3211
  const slidesDir = opts.slidesDir ?? "slides";
2280
- const assetsDir = opts.assetsDir ?? "assets";
2281
- const slidesRoot = path.resolve(userCwd, slidesDir);
2282
- const globalAssetsRoot = path.resolve(userCwd, assetsDir);
2283
- const manifestPath = path.join(slidesRoot, ".folders.json");
2284
3212
  return {
2285
- name: "open-slide:files",
3213
+ name: "open-slide:design",
2286
3214
  apply: "serve",
2287
3215
  configureServer(server) {
2288
- server.watcher.add(manifestPath);
2289
- server.watcher.on("change", (p) => {
2290
- if (p === manifestPath) server.ws.send({
2291
- type: "custom",
2292
- event: "open-slide:files-changed"
2293
- });
2294
- });
2295
- server.watcher.add(globalAssetsRoot);
2296
- const onAssetChange = (p) => {
2297
- if (p.startsWith(globalAssetsRoot + path.sep) || p === globalAssetsRoot) {
2298
- server.ws.send({
2299
- type: "custom",
2300
- event: "open-slide:assets-changed",
2301
- data: { slideId: GLOBAL_SCOPE }
2302
- });
2303
- return;
2304
- }
2305
- if (!p.startsWith(slidesRoot + path.sep)) return;
2306
- const rel = p.slice(slidesRoot.length + 1);
2307
- const parts = rel.split(path.sep);
2308
- if (parts.length < 3 || parts[1] !== "assets") return;
2309
- const slideId = parts[0];
2310
- if (!SLIDE_ID_RE$1.test(slideId)) return;
2311
- server.ws.send({
2312
- type: "custom",
2313
- event: "open-slide:assets-changed",
2314
- data: { slideId }
2315
- });
2316
- };
2317
- server.watcher.on("add", onAssetChange);
2318
- server.watcher.on("change", onAssetChange);
2319
- server.watcher.on("unlink", onAssetChange);
2320
- server.middlewares.use("/__slides", async (req, res, next) => {
3216
+ server.middlewares.use("/__design", async (req, res, next) => {
2321
3217
  const url = new URL(req.url ?? "/", "http://local");
2322
3218
  const method = req.method ?? "GET";
3219
+ const slideId = url.searchParams.get("slideId") ?? "";
3220
+ const file = resolveSlidePath$1(userCwd, slidesDir, slideId);
3221
+ if (!file) return json$1(res, 400, { error: "invalid slideId" });
2323
3222
  try {
2324
- const reorderMatch = url.pathname.match(/^\/([^/]+)\/reorder$/);
2325
- if (reorderMatch && method === "PUT") {
2326
- const slideId$1 = reorderMatch[1];
2327
- if (!SLIDE_ID_RE$1.test(slideId$1)) return json$1(res, 400, { error: "invalid slideId" });
2328
- const body = await readBody$1(req);
2329
- if (!Array.isArray(body.order)) return json$1(res, 400, { error: "invalid order" });
2330
- const order = [];
2331
- for (const v of body.order) {
2332
- if (!Number.isInteger(v)) return json$1(res, 400, { error: "invalid order" });
2333
- order.push(v);
2334
- }
2335
- const entry = resolveSlideEntry(slidesRoot, slideId$1);
2336
- if (!entry) return json$1(res, 400, { error: "invalid slideId" });
3223
+ if (method === "GET" && url.pathname === "/") {
2337
3224
  let source;
2338
3225
  try {
2339
- source = await fs.readFile(entry, "utf8");
3226
+ source = await fs.readFile(file, "utf8");
2340
3227
  } catch {
2341
3228
  return json$1(res, 404, { error: "slide not found" });
2342
3229
  }
2343
- const reordered = reorderDefaultExportPagesInSource(source, order);
2344
- if (reordered === null) return json$1(res, 422, { error: "could not reorder pages — order must be a permutation of the existing array" });
2345
- const withNotes = reorderNotesArrayInSource(reordered, order);
2346
- if (withNotes === null) return json$1(res, 422, { error: "could not reorder pages — `notes` export has an unexpected shape" });
2347
- if (withNotes !== source) await fs.writeFile(entry, withNotes, "utf8");
3230
+ const parsed = parseSlideDesign(source);
3231
+ if (parsed.ok) return json$1(res, 200, {
3232
+ design: parsed.design,
3233
+ exists: true,
3234
+ warning: null
3235
+ });
3236
+ if (parsed.exists === false) return json$1(res, 200, {
3237
+ design: defaultDesign,
3238
+ exists: false,
3239
+ warning: null
3240
+ });
2348
3241
  return json$1(res, 200, {
2349
- ok: true,
2350
- slideId: slideId$1,
2351
- order
3242
+ design: defaultDesign,
3243
+ exists: true,
3244
+ warning: parsed.error
2352
3245
  });
2353
3246
  }
2354
- const pageOpMatch = url.pathname.match(/^\/([^/]+)\/pages\/(\d+)(?:\/([a-z]+))?$/);
2355
- if (pageOpMatch) {
2356
- const slideId$1 = pageOpMatch[1];
2357
- const pageIndex = Number.parseInt(pageOpMatch[2], 10);
2358
- const op = pageOpMatch[3];
2359
- if (!SLIDE_ID_RE$1.test(slideId$1)) return json$1(res, 400, { error: "invalid slideId" });
2360
- if (!Number.isInteger(pageIndex) || pageIndex < 0) return json$1(res, 400, { error: "invalid page index" });
2361
- const isDelete = method === "DELETE" && !op;
2362
- const isDuplicate = method === "POST" && op === "duplicate";
2363
- if (!isDelete && !isDuplicate) return next();
2364
- const entry = resolveSlideEntry(slidesRoot, slideId$1);
2365
- if (!entry) return json$1(res, 400, { error: "invalid slideId" });
3247
+ if (method === "PUT" && url.pathname === "/") {
3248
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
3249
+ if (!requestCheck.ok) return json$1(res, requestCheck.status, { error: requestCheck.error });
3250
+ const body = await readBody$1(req);
3251
+ const patch = body.patch;
3252
+ if (!patch || typeof patch !== "object") return json$1(res, 400, { error: "missing patch object" });
2366
3253
  let source;
2367
3254
  try {
2368
- source = await fs.readFile(entry, "utf8");
3255
+ source = await fs.readFile(file, "utf8");
2369
3256
  } catch {
2370
3257
  return json$1(res, 404, { error: "slide not found" });
2371
3258
  }
2372
- const updated = isDelete ? removePageFromDefaultExportInSource(source, pageIndex) : duplicatePageInDefaultExportInSource(source, pageIndex);
2373
- if (updated === null) return json$1(res, 422, { error: isDelete ? "could not delete page — index out of range or default export is not an array" : "could not duplicate page — index out of range or default export is not an array" });
2374
- if (updated !== source) await fs.writeFile(entry, updated, "utf8");
3259
+ const parsed = parseSlideDesign(source);
3260
+ const baseDesign = parsed.ok ? parsed.design : defaultDesign;
3261
+ if (!parsed.ok && parsed.exists) return json$1(res, 422, { error: parsed.error });
3262
+ const merged = mergeDesign(baseDesign, patch);
3263
+ const written = applyDesignWrite(source, merged);
3264
+ if (!written.ok) return json$1(res, written.status, { error: written.error });
3265
+ if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
2375
3266
  return json$1(res, 200, {
2376
3267
  ok: true,
2377
- slideId: slideId$1,
2378
- index: pageIndex
3268
+ design: merged,
3269
+ created: written.created
2379
3270
  });
2380
3271
  }
2381
- const idMatch = url.pathname.match(/^\/([^/]+)$/);
2382
- if (!idMatch) return next();
2383
- const slideId = idMatch[1];
2384
- if (!SLIDE_ID_RE$1.test(slideId)) return json$1(res, 400, { error: "invalid slideId" });
2385
- if (method === "PATCH") {
2386
- const body = await readBody$1(req);
2387
- const name = validateSlideName(body.name);
2388
- if (!name) return json$1(res, 400, { error: "invalid name" });
2389
- const entry = resolveSlideEntry(slidesRoot, slideId);
2390
- if (!entry) return json$1(res, 400, { error: "invalid slideId" });
3272
+ if (method === "POST" && url.pathname === "/reset") {
3273
+ const requestCheck = validateMutationRequest(req);
3274
+ if (!requestCheck.ok) return json$1(res, requestCheck.status, { error: requestCheck.error });
2391
3275
  let source;
2392
3276
  try {
2393
- source = await fs.readFile(entry, "utf8");
3277
+ source = await fs.readFile(file, "utf8");
2394
3278
  } catch {
2395
3279
  return json$1(res, 404, { error: "slide not found" });
2396
3280
  }
2397
- const updated = updateMetaTitleInSource(source, name);
2398
- if (updated === null) return json$1(res, 422, { error: "could not locate a safe place to write meta.title in index.tsx" });
2399
- if (updated !== source) await fs.writeFile(entry, updated, "utf8");
2400
- server.ws.send({ type: "full-reload" });
3281
+ const written = applyDesignWrite(source, defaultDesign);
3282
+ if (!written.ok) return json$1(res, written.status, { error: written.error });
3283
+ if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
2401
3284
  return json$1(res, 200, {
2402
3285
  ok: true,
2403
- slideId,
2404
- name
3286
+ design: defaultDesign,
3287
+ created: written.created
2405
3288
  });
2406
3289
  }
2407
- if (method === "DELETE") {
2408
- const removed = await rmSlideDir(slidesRoot, slideId);
2409
- if (!removed) return json$1(res, 404, { error: "slide not found" });
2410
- const manifest = await readManifest(manifestPath);
2411
- delete manifest.assignments[slideId];
2412
- await writeManifest(manifestPath, manifest);
2413
- return json$1(res, 200, { ok: true });
2414
- }
2415
3290
  return next();
2416
3291
  } catch (err) {
2417
3292
  json$1(res, 500, { error: String(err.message ?? err) });
2418
3293
  }
2419
3294
  });
2420
- server.middlewares.use("/__assets", async (req, res, next) => {
2421
- const url = new URL(req.url ?? "/", "http://local");
2422
- const method = req.method ?? "GET";
2423
- try {
2424
- const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
2425
- const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
2426
- if (listMatch && method === "GET") {
2427
- const slideId = listMatch[1];
2428
- const assetsDir$1 = resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, slideId);
2429
- if (!assetsDir$1) return json$1(res, 400, { error: "invalid slideId" });
2430
- let entries;
2431
- try {
2432
- entries = await fs.readdir(assetsDir$1);
2433
- } catch (err) {
2434
- if (err.code === "ENOENT") return json$1(res, 200, { assets: [] });
2435
- throw err;
2436
- }
2437
- const assets = [];
2438
- for (const name of entries) {
2439
- if (!validateAssetName(name)) continue;
2440
- const stat = await fs.stat(path.join(assetsDir$1, name));
2441
- if (!stat.isFile()) continue;
2442
- assets.push({
2443
- name,
2444
- size: stat.size,
2445
- mtime: stat.mtimeMs,
2446
- mime: mimeForFilename(name),
2447
- url: `/__assets/${slideId}/${encodeURIComponent(name)}`
2448
- });
2449
- }
2450
- assets.sort((a, b) => a.name.localeCompare(b.name));
2451
- return json$1(res, 200, { assets });
2452
- }
2453
- if (fileMatch) {
2454
- const slideId = fileMatch[1];
2455
- const filename = decodeURIComponent(fileMatch[2]);
2456
- const file = resolveScopedAssetFile(slidesRoot, globalAssetsRoot, slideId, filename);
2457
- if (!file) return json$1(res, 400, { error: "invalid path" });
2458
- if (method === "GET") try {
2459
- const buf = await fs.readFile(file);
2460
- res.statusCode = 200;
2461
- res.setHeader("content-type", mimeForFilename(filename));
2462
- res.setHeader("cache-control", "no-store");
2463
- res.end(buf);
2464
- return;
2465
- } catch (err) {
2466
- if (err.code === "ENOENT") return json$1(res, 404, { error: "asset not found" });
2467
- throw err;
2468
- }
2469
- if (method === "POST") {
2470
- const overwrite = url.searchParams.get("overwrite") === "1";
2471
- const lenHeader = req.headers["content-length"];
2472
- const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
2473
- if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json$1(res, 413, { error: "file too large" });
2474
- if (!overwrite) try {
2475
- await fs.access(file);
2476
- return json$1(res, 409, { error: "asset exists" });
2477
- } catch {}
2478
- const assetsDir$1 = resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, slideId);
2479
- if (!assetsDir$1) return json$1(res, 400, { error: "invalid slideId" });
2480
- await fs.mkdir(assetsDir$1, { recursive: true });
2481
- const chunks = [];
2482
- let total = 0;
2483
- let oversized = false;
2484
- await new Promise((resolve, reject) => {
2485
- req.on("data", (c) => {
2486
- total += c.length;
2487
- if (total > ASSET_MAX_BYTES) {
2488
- oversized = true;
2489
- req.destroy();
2490
- return;
2491
- }
2492
- chunks.push(c);
2493
- });
2494
- req.on("end", () => resolve());
2495
- req.on("error", reject);
2496
- });
2497
- if (oversized) return json$1(res, 413, { error: "file too large" });
2498
- await fs.writeFile(file, Buffer.concat(chunks));
2499
- return json$1(res, 200, {
2500
- ok: true,
2501
- name: filename,
2502
- size: total,
2503
- mime: mimeForFilename(filename),
2504
- url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
2505
- });
2506
- }
2507
- if (method === "PATCH") {
2508
- const body = await readBody$1(req);
2509
- const target = validateAssetName(body.name);
2510
- if (!target) return json$1(res, 400, { error: "invalid name" });
2511
- if (target === filename) return json$1(res, 200, {
2512
- ok: true,
2513
- name: filename
2514
- });
2515
- const dest = resolveScopedAssetFile(slidesRoot, globalAssetsRoot, slideId, target);
2516
- if (!dest) return json$1(res, 400, { error: "invalid name" });
2517
- try {
2518
- await fs.access(dest);
2519
- return json$1(res, 409, { error: "target exists" });
2520
- } catch {}
2521
- try {
2522
- await fs.rename(file, dest);
2523
- } catch (err) {
2524
- if (err.code === "ENOENT") return json$1(res, 404, { error: "asset not found" });
2525
- throw err;
2526
- }
2527
- return json$1(res, 200, {
2528
- ok: true,
2529
- name: target
2530
- });
2531
- }
2532
- if (method === "DELETE") {
2533
- try {
2534
- await fs.unlink(file);
2535
- } catch (err) {
2536
- if (err.code === "ENOENT") return json$1(res, 404, { error: "asset not found" });
2537
- throw err;
2538
- }
2539
- return json$1(res, 200, { ok: true });
2540
- }
2541
- }
2542
- return next();
2543
- } catch (err) {
2544
- json$1(res, 500, { error: String(err.message ?? err) });
2545
- }
2546
- });
2547
- server.middlewares.use("/__svgl", async (req, res, next) => {
2548
- const reqUrl = new URL(req.url ?? "/", "http://local");
2549
- const method = req.method ?? "GET";
2550
- if (method !== "GET") return next();
2551
- try {
2552
- let target = null;
2553
- if (reqUrl.pathname === "/search") {
2554
- const params = new URLSearchParams();
2555
- const q = reqUrl.searchParams.get("q");
2556
- const limit = reqUrl.searchParams.get("limit");
2557
- if (q) params.set("search", q);
2558
- if (limit) params.set("limit", limit);
2559
- const qs = params.toString();
2560
- target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
2561
- } else if (reqUrl.pathname === "/svg") {
2562
- const u = reqUrl.searchParams.get("u");
2563
- if (!u) return json$1(res, 400, { error: "missing u" });
2564
- let parsed;
2565
- try {
2566
- parsed = new URL(u);
2567
- } catch {
2568
- return json$1(res, 400, { error: "invalid u" });
2569
- }
2570
- if (parsed.protocol !== "https:") return json$1(res, 400, { error: "https only" });
2571
- const host = parsed.hostname.toLowerCase();
2572
- if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json$1(res, 400, { error: "host not allowed" });
2573
- target = parsed.toString();
2574
- } else return next();
2575
- const upstream = await fetch(target);
2576
- const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
2577
- res.statusCode = upstream.status;
2578
- res.setHeader("content-type", ct);
2579
- res.setHeader("cache-control", "no-store");
2580
- const buf = Buffer.from(await upstream.arrayBuffer());
2581
- res.end(buf);
2582
- } catch (err) {
2583
- json$1(res, 502, { error: String(err.message ?? err) });
2584
- }
2585
- });
2586
- server.middlewares.use("/__folders", async (req, res, next) => {
2587
- const url = new URL(req.url ?? "/", "http://local");
2588
- const method = req.method ?? "GET";
2589
- try {
2590
- if (method === "GET" && url.pathname === "/") {
2591
- const manifest = await readManifest(manifestPath);
2592
- return json$1(res, 200, manifest);
2593
- }
2594
- if (method === "POST" && url.pathname === "/") {
2595
- const body = await readBody$1(req);
2596
- const name = validateName(body.name);
2597
- if (!name) return json$1(res, 400, { error: "invalid name" });
2598
- const icon = validateIcon(body.icon);
2599
- if (!icon) return json$1(res, 400, { error: "invalid icon" });
2600
- const manifest = await readManifest(manifestPath);
2601
- const folder = {
2602
- id: newFolderId(),
2603
- name,
2604
- icon
2605
- };
2606
- manifest.folders.push(folder);
2607
- await writeManifest(manifestPath, manifest);
2608
- return json$1(res, 200, folder);
2609
- }
2610
- if (method === "PUT" && url.pathname === "/assign") {
2611
- const body = await readBody$1(req);
2612
- if (typeof body.slideId !== "string" || !SLIDE_ID_RE$1.test(body.slideId)) return json$1(res, 400, { error: "invalid slideId" });
2613
- const slideId = body.slideId;
2614
- let folderId;
2615
- if (body.folderId === null) folderId = null;
2616
- else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
2617
- else return json$1(res, 400, { error: "invalid folderId" });
2618
- const manifest = await readManifest(manifestPath);
2619
- if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json$1(res, 404, { error: "folder not found" });
2620
- if (folderId === null) delete manifest.assignments[slideId];
2621
- else manifest.assignments[slideId] = folderId;
2622
- await writeManifest(manifestPath, manifest);
2623
- return json$1(res, 200, { ok: true });
2624
- }
2625
- const idMatch = url.pathname.match(/^\/([^/]+)$/);
2626
- if (idMatch) {
2627
- const id = idMatch[1];
2628
- if (!FOLDER_ID_RE.test(id)) return json$1(res, 400, { error: "invalid id" });
2629
- if (method === "PATCH") {
2630
- const body = await readBody$1(req);
2631
- const manifest = await readManifest(manifestPath);
2632
- const folder = manifest.folders.find((f) => f.id === id);
2633
- if (!folder) return json$1(res, 404, { error: "folder not found" });
2634
- if (body.name !== void 0) {
2635
- const name = validateName(body.name);
2636
- if (!name) return json$1(res, 400, { error: "invalid name" });
2637
- folder.name = name;
2638
- }
2639
- if (body.icon !== void 0) {
2640
- const icon = validateIcon(body.icon);
2641
- if (!icon) return json$1(res, 400, { error: "invalid icon" });
2642
- folder.icon = icon;
2643
- }
2644
- await writeManifest(manifestPath, manifest);
2645
- return json$1(res, 200, folder);
2646
- }
2647
- if (method === "DELETE") {
2648
- const manifest = await readManifest(manifestPath);
2649
- const before = manifest.folders.length;
2650
- manifest.folders = manifest.folders.filter((f) => f.id !== id);
2651
- if (manifest.folders.length === before) return json$1(res, 404, { error: "folder not found" });
2652
- for (const [slideId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[slideId];
2653
- await writeManifest(manifestPath, manifest);
2654
- return json$1(res, 200, { ok: true });
2655
- }
2656
- }
2657
- next();
2658
- } catch (err) {
2659
- json$1(res, 500, { error: String(err.message ?? err) });
2660
- }
2661
- });
2662
3295
  }
2663
3296
  };
2664
3297
  }
@@ -2710,7 +3343,10 @@ function locTagsPlugin(opts) {
2710
3343
  transform(code, id) {
2711
3344
  const filePath = id.split("?")[0];
2712
3345
  if (!filePath.startsWith(slidesRoot + path.sep)) return null;
2713
- if (!filePath.endsWith(`${path.sep}index.tsx`)) return null;
3346
+ if (!filePath.endsWith(".tsx")) return null;
3347
+ if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.tsx")) return null;
3348
+ const rel = filePath.slice(slidesRoot.length + path.sep.length);
3349
+ if (!rel.includes(path.sep)) return null;
2714
3350
  const next = injectLocTags(code);
2715
3351
  if (next === null) return null;
2716
3352
  return {
@@ -2887,6 +3523,8 @@ function notesPlugin(opts) {
2887
3523
  const url = new URL(req.url ?? "/", "http://local");
2888
3524
  const method = req.method ?? "GET";
2889
3525
  if (method !== "PUT" || url.pathname !== "/") return next();
3526
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
3527
+ if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
2890
3528
  try {
2891
3529
  const body = await readBody(req);
2892
3530
  const slideId = body.slideId ?? "";
@@ -2959,13 +3597,18 @@ function toId(absFile, slidesRoot) {
2959
3597
  return rel.split(path.sep)[0];
2960
3598
  }
2961
3599
  const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
2962
- function extractMetaTheme(src) {
3600
+ const META_CREATED_AT_RE = /(?:^|[\s,{])createdAt\s*:\s*['"]([^'"]+)['"]/;
3601
+ function extractMeta(src) {
3602
+ const empty = {
3603
+ theme: null,
3604
+ createdAt: null
3605
+ };
2963
3606
  const metaStart = src.search(/export\s+const\s+meta\b/);
2964
- if (metaStart === -1) return null;
3607
+ if (metaStart === -1) return empty;
2965
3608
  const eqIdx = src.indexOf("=", metaStart);
2966
- if (eqIdx === -1) return null;
3609
+ if (eqIdx === -1) return empty;
2967
3610
  const openBrace = src.indexOf("{", eqIdx);
2968
- if (openBrace === -1) return null;
3611
+ if (openBrace === -1) return empty;
2969
3612
  let depth = 0;
2970
3613
  let closeBrace = -1;
2971
3614
  for (let i = openBrace; i < src.length; i++) {
@@ -2979,34 +3622,52 @@ function extractMetaTheme(src) {
2979
3622
  }
2980
3623
  }
2981
3624
  }
2982
- if (closeBrace === -1) return null;
3625
+ if (closeBrace === -1) return empty;
2983
3626
  const body = src.slice(openBrace + 1, closeBrace);
2984
- const m = body.match(META_THEME_RE);
2985
- return m ? m[1] : null;
3627
+ const themeMatch = body.match(META_THEME_RE);
3628
+ const createdAtMatch = body.match(META_CREATED_AT_RE);
3629
+ return {
3630
+ theme: themeMatch ? themeMatch[1] : null,
3631
+ createdAt: createdAtMatch ? createdAtMatch[1] : null
3632
+ };
2986
3633
  }
2987
- async function readSlideTheme(abs) {
3634
+ async function readSlideMeta(abs) {
2988
3635
  try {
2989
3636
  const src = await fs.readFile(abs, "utf8");
2990
- return extractMetaTheme(src);
3637
+ return extractMeta(src);
2991
3638
  } catch {
2992
- return null;
3639
+ return {
3640
+ theme: null,
3641
+ createdAt: null
3642
+ };
2993
3643
  }
2994
3644
  }
3645
+ function parseCreatedAtMs(iso) {
3646
+ if (!iso) return null;
3647
+ const ms = Date.parse(iso);
3648
+ return Number.isFinite(ms) ? ms : null;
3649
+ }
2995
3650
  async function generateSlidesModule(files, slidesRoot, isDev) {
2996
3651
  const entries = await Promise.all(files.map(async (abs) => {
2997
3652
  const id = toId(abs, slidesRoot);
2998
3653
  const importPath = isDev ? `/@fs/${abs.replace(/^\/+/, "")}` : abs;
2999
- const theme = await readSlideTheme(abs);
3654
+ const meta = await readSlideMeta(abs);
3000
3655
  return {
3001
3656
  id,
3002
3657
  importPath,
3003
- theme
3658
+ theme: meta.theme,
3659
+ createdAt: parseCreatedAtMs(meta.createdAt)
3004
3660
  };
3005
3661
  }));
3006
3662
  const ids = JSON.stringify(entries.map((e) => e.id).sort());
3007
3663
  const themesMap = {};
3008
- for (const e of entries) if (e.theme) themesMap[e.id] = e.theme;
3664
+ const createdAtMap = {};
3665
+ for (const e of entries) {
3666
+ if (e.theme) themesMap[e.id] = e.theme;
3667
+ if (e.createdAt !== null) createdAtMap[e.id] = e.createdAt;
3668
+ }
3009
3669
  const themesJson = JSON.stringify(themesMap);
3670
+ const createdAtJson = JSON.stringify(createdAtMap);
3010
3671
  const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
3011
3672
  const devRuntime = isDev ? `
3012
3673
  const slideImportTokens = ${importTokens};
@@ -3027,6 +3688,7 @@ if (import.meta.hot) {
3027
3688
  return `// virtual:open-slide/slides — generated
3028
3689
  export const slideIds = ${ids};
3029
3690
  export const slideThemes = ${themesJson};
3691
+ export const slideCreatedAt = ${createdAtJson};
3030
3692
  ${devRuntime}
3031
3693
 
3032
3694
  export async function loadSlide(id) {
@@ -3229,18 +3891,18 @@ async function readTheme(mdAbs, themesRoot) {
3229
3891
  };
3230
3892
  }
3231
3893
  function generateThemesModule(themes, isDev) {
3232
- const meta = themes.map((t$3) => ({
3233
- id: t$3.id,
3234
- name: t$3.frontmatter.name,
3235
- description: t$3.frontmatter.description,
3236
- body: t$3.body,
3237
- hasDemo: t$3.demoAbs !== null
3894
+ const meta = themes.map((t$5) => ({
3895
+ id: t$5.id,
3896
+ name: t$5.frontmatter.name,
3897
+ description: t$5.frontmatter.description,
3898
+ body: t$5.body,
3899
+ hasDemo: t$5.demoAbs !== null
3238
3900
  }));
3239
- const cases = themes.flatMap((t$3) => {
3240
- const abs = t$3.demoAbs;
3901
+ const cases = themes.flatMap((t$5) => {
3902
+ const abs = t$5.demoAbs;
3241
3903
  if (!abs) return [];
3242
3904
  const importPath = isDev ? `/@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
3243
- return [` case ${JSON.stringify(t$3.id)}: return import(${JSON.stringify(importPath)});`];
3905
+ return [` case ${JSON.stringify(t$5.id)}: return import(${JSON.stringify(importPath)});`];
3244
3906
  }).join("\n");
3245
3907
  return `// virtual:open-slide/themes — generated
3246
3908
  export const themes = ${JSON.stringify(meta)};
@@ -3345,19 +4007,15 @@ async function createViteConfig(opts) {
3345
4007
  config
3346
4008
  }),
3347
4009
  designPlugin({ userCwd }),
3348
- commentsPlugin({
4010
+ apiPlugin({
3349
4011
  userCwd,
3350
- slidesDir
4012
+ slidesDir,
4013
+ assetsDir
3351
4014
  }),
3352
4015
  notesPlugin({
3353
4016
  userCwd,
3354
4017
  slidesDir
3355
4018
  }),
3356
- filesPlugin({
3357
- userCwd,
3358
- slidesDir,
3359
- assetsDir
3360
- }),
3361
4019
  currentPlugin({
3362
4020
  userCwd,
3363
4021
  slidesDir