@open-slide/core 1.4.0 → 1.5.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.
@@ -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,267 +1048,267 @@ 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 applyRevertAsset(source, assetPath) {
1154
+ const ast = parseSource$2(source);
1155
+ if (!ast) return {
1156
+ ok: false,
1157
+ status: 422,
1158
+ error: "could not parse source"
1159
+ };
1160
+ const imports = findImports$1(ast);
1161
+ const target = imports.find((imp) => imp.source === assetPath && imp.defaultIdent);
1162
+ if (!target?.defaultIdent) return {
1163
+ ok: true,
1164
+ source
1165
+ };
1166
+ const identifier = target.defaultIdent;
1167
+ const importLocal = (() => {
1168
+ for (const spec of target.node.specifiers) if (t$3.isImportDefaultSpecifier(spec) && spec.local.name === identifier) return spec.local;
1169
+ return null;
1170
+ })();
1171
+ const imgUses = collectImgSrcUses(ast, identifier);
1172
+ const allowed = new Set(imgUses.map((u) => u.identNode));
1173
+ if (importLocal) allowed.add(importLocal);
1174
+ let foreign = false;
1175
+ walkAll(ast, (n) => {
1176
+ if (!t$3.isIdentifier(n) || n.name !== identifier) return;
1177
+ if (!allowed.has(n)) foreign = true;
1178
+ });
1179
+ if (foreign) return {
1180
+ ok: false,
1181
+ status: 422,
1182
+ error: `cannot revert: '${identifier}' is referenced outside <img src={${identifier}}>`
1183
+ };
1184
+ const splices = [];
1185
+ for (const use of imgUses) {
1186
+ const opening = use.element.openingElement;
1187
+ const hint = readJsxStringAttr(opening, "alt") ?? "";
1188
+ const width = readStyleNumericDim(opening, "width");
1189
+ const height = readStyleNumericDim(opening, "height");
1190
+ splices.push(spliceRange(use.element, buildPlaceholderReplacement(hint, width, height)));
1191
+ }
1192
+ const importNode = target.node;
1193
+ const importFrom = importNode.start ?? 0;
1194
+ let importTo = importNode.end ?? 0;
1195
+ if (source[importTo] === "\n") importTo += 1;
1196
+ splices.push({
1197
+ from: importFrom,
1198
+ to: importTo,
1199
+ text: ""
1200
+ });
1201
+ const ensureSplice = planEnsureImagePlaceholderImport(ast);
1202
+ if (ensureSplice) splices.push(ensureSplice);
1203
+ if (splices.length === 0) return {
1204
+ ok: true,
1205
+ source
1206
+ };
1207
+ splices.sort((a, b) => b.from - a.from);
1208
+ let next = source;
1209
+ for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
1210
+ if (!parseSource$2(next)) return {
1211
+ ok: false,
1212
+ status: 422,
1213
+ error: "edit would produce invalid source"
1214
+ };
1339
1215
  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
- }
1216
+ ok: true,
1217
+ source: next
1385
1218
  };
1386
1219
  }
1387
1220
 
1388
1221
  //#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
- });
1222
+ //#region src/editing/slide-ops.ts
1223
+ const SLIDE_ID_RE$3 = /^[a-z0-9_-]+$/i;
1224
+ function validateSlideName(v) {
1225
+ if (typeof v !== "string") return null;
1226
+ const trimmed = v.trim();
1227
+ if (trimmed.length < 1 || trimmed.length > 80) return null;
1228
+ return trimmed;
1406
1229
  }
1407
- function json$2(res, status, body) {
1408
- res.statusCode = status;
1409
- res.setHeader("content-type", "application/json");
1410
- res.end(JSON.stringify(body));
1230
+ async function rmSlideDir(slidesRoot, slideId) {
1231
+ if (!SLIDE_ID_RE$3.test(slideId)) return false;
1232
+ const dir = path.resolve(slidesRoot, slideId);
1233
+ if (!dir.startsWith(slidesRoot + path.sep)) return false;
1234
+ try {
1235
+ await fs.rm(dir, {
1236
+ recursive: true,
1237
+ force: true
1238
+ });
1239
+ return true;
1240
+ } catch {
1241
+ return false;
1242
+ }
1411
1243
  }
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;
1244
+ function resolveSlideEntry(slidesRoot, slideId) {
1245
+ if (!SLIDE_ID_RE$3.test(slideId)) return null;
1246
+ const dir = path.resolve(slidesRoot, slideId);
1247
+ if (!dir.startsWith(slidesRoot + path.sep)) return null;
1248
+ return path.join(dir, "index.tsx");
1418
1249
  }
1419
- function parseSource$1(source) {
1250
+ function escapeSingleQuoted(s) {
1251
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1252
+ }
1253
+ /**
1254
+ * Rewrite (or insert) the `title` field in the slide module's `export const meta`.
1255
+ *
1256
+ * Strategy:
1257
+ * 1. Find `export const meta` and brace-match its object literal.
1258
+ * 2. If the object already has a `title: '...'` entry, replace the literal.
1259
+ * 3. If the object exists but has no title, inject a new `title: '...'` line
1260
+ * as the first property (preserving the author's surrounding indentation).
1261
+ * 4. If there is no `meta` export at all, insert a fresh one right before
1262
+ * `export default`.
1263
+ *
1264
+ * Returns the rewritten source, or `null` if the file shape was too surprising
1265
+ * to touch safely (e.g. `export default` missing when we'd need to inject meta).
1266
+ */
1267
+ function updateMetaTitleInSource(source, title) {
1268
+ const newLiteral = `'${escapeSingleQuoted(title)}'`;
1269
+ const metaStart = source.search(/export\s+const\s+meta\b/);
1270
+ if (metaStart !== -1) {
1271
+ const eqIdx = source.indexOf("=", metaStart);
1272
+ if (eqIdx === -1) return null;
1273
+ const openBrace = source.indexOf("{", eqIdx);
1274
+ if (openBrace === -1) return null;
1275
+ let depth = 0;
1276
+ let closeBrace = -1;
1277
+ for (let i = openBrace; i < source.length; i++) {
1278
+ const ch = source[i];
1279
+ if (ch === "{") depth++;
1280
+ else if (ch === "}") {
1281
+ depth--;
1282
+ if (depth === 0) {
1283
+ closeBrace = i;
1284
+ break;
1285
+ }
1286
+ }
1287
+ }
1288
+ if (closeBrace === -1) return null;
1289
+ const body = source.slice(openBrace + 1, closeBrace);
1290
+ const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
1291
+ const match = body.match(titleRe);
1292
+ if (match) {
1293
+ const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
1294
+ return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
1295
+ }
1296
+ const firstIndentMatch = body.match(/\n([ \t]+)\S/);
1297
+ const indent$1 = firstIndentMatch ? firstIndentMatch[1] : " ";
1298
+ const trimmedBody = body.replace(/^\s*\n?/, "");
1299
+ const needsSeparator = trimmedBody.trim().length > 0;
1300
+ const insertion$1 = `\n${indent$1}title: ${newLiteral}${needsSeparator ? "," : ""}`;
1301
+ return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
1302
+ }
1303
+ const exportDefaultIdx = source.search(/export\s+default\b/);
1304
+ if (exportDefaultIdx === -1) return null;
1305
+ const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
1306
+ return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
1307
+ }
1308
+ function findDefaultExportArray(source) {
1309
+ let ast;
1420
1310
  try {
1421
- return parse(source, {
1311
+ ast = parse(source, {
1422
1312
  sourceType: "module",
1423
1313
  plugins: ["typescript", "jsx"],
1424
1314
  errorRecovery: true
@@ -1426,439 +1316,386 @@ function parseSource$1(source) {
1426
1316
  } catch {
1427
1317
  return null;
1428
1318
  }
1429
- }
1430
- function findDesignDecl(ast) {
1431
1319
  const body = ast.program?.body ?? [];
1432
1320
  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;
1321
+ if (node.type !== "ExportDefaultDeclaration") continue;
1322
+ let inner = node.declaration;
1323
+ while (inner && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression")) inner = inner.expression;
1324
+ if (!inner || inner.type !== "ArrayExpression") return null;
1325
+ const arrayStart = inner.start;
1326
+ const arrayEnd = inner.end;
1327
+ const rawElements = inner.elements ?? [];
1328
+ const elements = [];
1329
+ for (const el of rawElements) {
1330
+ if (!el || typeof el.start !== "number" || typeof el.end !== "number") return null;
1331
+ elements.push({
1332
+ start: el.start,
1333
+ end: el.end
1334
+ });
1438
1335
  }
1439
- if (!varDecl) continue;
1440
- const declarations = varDecl.declarations ?? [];
1336
+ return {
1337
+ elements,
1338
+ arrayStart,
1339
+ arrayEnd
1340
+ };
1341
+ }
1342
+ return null;
1343
+ }
1344
+ /**
1345
+ * Rewrite `export default [...]` so its elements appear in the requested order.
1346
+ *
1347
+ * `order[i]` is the original index that should land at new position `i`. The
1348
+ * function preserves each element's exact source slice (including any inline
1349
+ * comments that hug an identifier) and keeps the inter-element separator slots
1350
+ * in their original positions, so a 3-page array `[A, B, C]` reordered to
1351
+ * `[2, 0, 1]` becomes `[C, A, B]` with the same indentation and trailing
1352
+ * commas the author wrote.
1353
+ *
1354
+ * Returns `null` when the file's default export isn't an array literal, or the
1355
+ * order is not a valid permutation of `[0, n-1]`.
1356
+ */
1357
+ function reorderDefaultExportPagesInSource(source, order) {
1358
+ const found = findDefaultExportArray(source);
1359
+ if (!found) return null;
1360
+ const { elements, arrayStart, arrayEnd } = found;
1361
+ const n = elements.length;
1362
+ if (order.length !== n) return null;
1363
+ const seen = new Set();
1364
+ for (const idx of order) {
1365
+ if (!Number.isInteger(idx) || idx < 0 || idx >= n) return null;
1366
+ if (seen.has(idx)) return null;
1367
+ seen.add(idx);
1368
+ }
1369
+ if (n === 0) return source;
1370
+ let identity = true;
1371
+ for (let i = 0; i < n; i++) if (order[i] !== i) {
1372
+ identity = false;
1373
+ break;
1374
+ }
1375
+ if (identity) return source;
1376
+ const prefix = source.slice(arrayStart, elements[0].start);
1377
+ const suffix = source.slice(elements[n - 1].end, arrayEnd);
1378
+ const separators = [];
1379
+ for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1380
+ const elementText = elements.map((el) => source.slice(el.start, el.end));
1381
+ let rebuilt = prefix + elementText[order[0]];
1382
+ for (let i = 1; i < n; i++) rebuilt += separators[i - 1] + elementText[order[i]];
1383
+ rebuilt += suffix;
1384
+ return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
1385
+ }
1386
+ function findNotesArray(source) {
1387
+ let ast;
1388
+ try {
1389
+ ast = parse(source, {
1390
+ sourceType: "module",
1391
+ plugins: ["typescript", "jsx"],
1392
+ errorRecovery: true
1393
+ });
1394
+ } catch {
1395
+ return "invalid";
1396
+ }
1397
+ const body = ast.program?.body ?? [];
1398
+ for (const stmt of body) {
1399
+ if (stmt.type !== "ExportNamedDeclaration") continue;
1400
+ const decl = stmt.declaration;
1401
+ if (!decl || decl.type !== "VariableDeclaration") continue;
1402
+ const declarations = decl.declarations ?? [];
1441
1403
  for (const d of declarations) {
1442
1404
  const id = d.id;
1443
- if (!id || id.type !== "Identifier" || id.name !== "design") continue;
1405
+ if (!id || id.type !== "Identifier" || id.name !== "notes") continue;
1444
1406
  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;
1407
+ if (!init || init.type !== "ArrayExpression") return "invalid";
1408
+ const arrayStart = init.start;
1409
+ const arrayEnd = init.end;
1410
+ if (typeof arrayStart !== "number" || typeof arrayEnd !== "number") return "invalid";
1411
+ const rawElements = init.elements ?? [];
1412
+ const elementTexts = [];
1413
+ for (const el of rawElements) {
1414
+ if (el === null) {
1415
+ elementTexts.push("undefined");
1416
+ continue;
1417
+ }
1418
+ if (el.type === "SpreadElement") return "invalid";
1419
+ const start = el.start;
1420
+ const end = el.end;
1421
+ if (typeof start !== "number" || typeof end !== "number") return "invalid";
1422
+ elementTexts.push(source.slice(start, end));
1450
1423
  }
1451
- if (inner.type !== "ObjectExpression") return null;
1452
1424
  return {
1453
- declStart: node.start,
1454
- declEnd: node.end,
1455
- objectStart: inner.start,
1456
- objectEnd: inner.end
1425
+ arrayStart,
1426
+ arrayEnd,
1427
+ elementTexts
1457
1428
  };
1458
1429
  }
1459
1430
  }
1460
1431
  return null;
1461
1432
  }
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);
1501
- }
1502
- return out;
1433
+ /**
1434
+ * Reorder `export const notes = [...]` to follow the page-array reorder.
1435
+ *
1436
+ * `order[i]` is the original page index that should land at new position `i`.
1437
+ * The notes array is index-aligned with the pages array but may be shorter
1438
+ * (trailing `undefined` slots are routinely trimmed). Missing elements are
1439
+ * treated as `undefined`, and trailing `undefined` is trimmed again after
1440
+ * reordering to keep the file tidy.
1441
+ *
1442
+ * Returns the rewritten source, the original source if no `notes` export
1443
+ * exists or the reorder is a no-op, or `null` if the `notes` export's shape
1444
+ * is too surprising to touch safely.
1445
+ */
1446
+ function reorderNotesArrayInSource(source, order) {
1447
+ for (const idx of order) if (!Number.isInteger(idx) || idx < 0) return null;
1448
+ const found = findNotesArray(source);
1449
+ if (found === "invalid") return null;
1450
+ if (found === null) return source;
1451
+ const { arrayStart, arrayEnd, elementTexts } = found;
1452
+ const pick = (i) => i >= 0 && i < elementTexts.length ? elementTexts[i] : "undefined";
1453
+ const reordered = order.map(pick);
1454
+ while (reordered.length > 0 && reordered[reordered.length - 1] === "undefined") reordered.pop();
1455
+ const replacement = reordered.length === 0 ? "[]" : `[\n${reordered.map((s) => ` ${s},`).join("\n")}\n]`;
1456
+ if (replacement === source.slice(arrayStart, arrayEnd)) return source;
1457
+ return source.slice(0, arrayStart) + replacement + source.slice(arrayEnd);
1458
+ }
1459
+ /**
1460
+ * Remove the element at `index` from `export default [...]`.
1461
+ *
1462
+ * Preserves the source slice of every other element, dropping the separator
1463
+ * immediately following the removed element (or the preceding one when the
1464
+ * removed element is the last). Returns `null` when the default export isn't
1465
+ * an array literal or `index` is out of range.
1466
+ */
1467
+ function removePageFromDefaultExportInSource(source, index) {
1468
+ const found = findDefaultExportArray(source);
1469
+ if (!found) return null;
1470
+ const { elements, arrayStart, arrayEnd } = found;
1471
+ const n = elements.length;
1472
+ if (!Number.isInteger(index) || index < 0 || index >= n) return null;
1473
+ if (n === 1) return `${source.slice(0, arrayStart)}[]${source.slice(arrayEnd)}`;
1474
+ const prefix = source.slice(arrayStart, elements[0].start);
1475
+ const suffix = source.slice(elements[n - 1].end, arrayEnd);
1476
+ const separators = [];
1477
+ for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1478
+ const elementText = elements.map((el) => source.slice(el.start, el.end));
1479
+ const keptElements = [];
1480
+ const keptSeparators = [];
1481
+ for (let i = 0; i < n; i++) {
1482
+ if (i === index) continue;
1483
+ keptElements.push(elementText[i]);
1484
+ }
1485
+ for (let i = 0; i < n - 1; i++) {
1486
+ if (index === n - 1 ? i === n - 2 : i === index) continue;
1487
+ keptSeparators.push(separators[i]);
1488
+ }
1489
+ let rebuilt = prefix + keptElements[0];
1490
+ for (let i = 1; i < keptElements.length; i++) rebuilt += keptSeparators[i - 1] + keptElements[i];
1491
+ rebuilt += suffix;
1492
+ return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
1493
+ }
1494
+ function chooseInsertSeparator(prefix, existingSeparators) {
1495
+ const sample = existingSeparators.find((s) => s.includes(","));
1496
+ if (sample) return sample;
1497
+ if (prefix.includes("\n")) {
1498
+ const m = prefix.match(/\n([ \t]*)$/);
1499
+ const indent$1 = m ? m[1] : " ";
1500
+ return `,\n${indent$1}`;
1501
+ }
1502
+ return ", ";
1503
+ }
1504
+ /**
1505
+ * Duplicate the element at `index` in `export default [...]`, inserting the
1506
+ * copy immediately after the original. Reuses an existing inter-element
1507
+ * separator when one is available so the cloned entry matches the surrounding
1508
+ * indentation. Returns `null` when the default export isn't an array literal
1509
+ * or `index` is out of range.
1510
+ */
1511
+ function duplicatePageInDefaultExportInSource(source, index) {
1512
+ const found = findDefaultExportArray(source);
1513
+ if (!found) return null;
1514
+ const { elements, arrayStart, arrayEnd } = found;
1515
+ const n = elements.length;
1516
+ if (!Number.isInteger(index) || index < 0 || index >= n) return null;
1517
+ const prefix = source.slice(arrayStart, elements[0].start);
1518
+ const suffix = source.slice(elements[n - 1].end, arrayEnd);
1519
+ const separators = [];
1520
+ for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1521
+ const elementText = elements.map((el) => source.slice(el.start, el.end));
1522
+ const insertSep = chooseInsertSeparator(prefix, separators);
1523
+ const newElements = [];
1524
+ const newSeparators = [];
1525
+ for (let i = 0; i < n; i++) {
1526
+ newElements.push(elementText[i]);
1527
+ if (i === index) {
1528
+ newElements.push(elementText[i]);
1529
+ newSeparators.push(insertSep);
1503
1530
  }
1504
- default: throw new Error(`unsupported node type ${node.type}`);
1531
+ if (i < n - 1) newSeparators.push(separators[i]);
1505
1532
  }
1533
+ let rebuilt = prefix + newElements[0];
1534
+ for (let i = 1; i < newElements.length; i++) rebuilt += newSeparators[i - 1] + newElements[i];
1535
+ rebuilt += suffix;
1536
+ return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
1506
1537
  }
1507
- function isPlainObject(v) {
1508
- return typeof v === "object" && v !== null && !Array.isArray(v);
1538
+
1539
+ //#endregion
1540
+ //#region src/files/assets.ts
1541
+ const GLOBAL_SCOPE = "@global";
1542
+ const ASSET_MAX_BYTES = 25 * 1024 * 1024;
1543
+ const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
1544
+ const MIME_BY_EXT = {
1545
+ png: "image/png",
1546
+ jpg: "image/jpeg",
1547
+ jpeg: "image/jpeg",
1548
+ gif: "image/gif",
1549
+ svg: "image/svg+xml",
1550
+ webp: "image/webp",
1551
+ avif: "image/avif",
1552
+ ico: "image/x-icon",
1553
+ mp4: "video/mp4",
1554
+ webm: "video/webm",
1555
+ mov: "video/quicktime",
1556
+ woff: "font/woff",
1557
+ woff2: "font/woff2",
1558
+ ttf: "font/ttf",
1559
+ otf: "font/otf",
1560
+ json: "application/json",
1561
+ txt: "text/plain; charset=utf-8",
1562
+ md: "text/markdown; charset=utf-8"
1563
+ };
1564
+ function mimeForFilename(name) {
1565
+ const dot = name.lastIndexOf(".");
1566
+ if (dot < 0) return "application/octet-stream";
1567
+ const ext = name.slice(dot + 1).toLowerCase();
1568
+ return MIME_BY_EXT[ext] ?? "application/octet-stream";
1509
1569
  }
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;
1570
+ function validateAssetName(v) {
1571
+ if (typeof v !== "string") return null;
1572
+ const trimmed = v.trim();
1573
+ if (trimmed.length < 1 || trimmed.length > 120) return null;
1574
+ if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
1575
+ if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
1576
+ if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
1577
+ const dot = trimmed.lastIndexOf(".");
1578
+ if (dot <= 0 || dot === trimmed.length - 1) return null;
1579
+ return trimmed;
1518
1580
  }
1519
- function indent(level) {
1520
- return " ".repeat(level);
1581
+ function resolveAssetsDir(slidesRoot, slideId) {
1582
+ if (!SLIDE_ID_RE$3.test(slideId)) return null;
1583
+ const slideDir = path.resolve(slidesRoot, slideId);
1584
+ if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
1585
+ const assetsDir = path.resolve(slideDir, "assets");
1586
+ if (assetsDir !== path.join(slideDir, "assets")) return null;
1587
+ return assetsDir;
1521
1588
  }
1522
- function jsString(s) {
1523
- return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
1589
+ function resolveAssetFile(slidesRoot, slideId, filename) {
1590
+ const assetsDir = resolveAssetsDir(slidesRoot, slideId);
1591
+ if (!assetsDir) return null;
1592
+ if (!validateAssetName(filename)) return null;
1593
+ const file = path.resolve(assetsDir, filename);
1594
+ if (!file.startsWith(assetsDir + path.sep)) return null;
1595
+ return file;
1524
1596
  }
1525
- function isValidIdentifier(name) {
1526
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
1597
+ function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
1598
+ if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
1599
+ return resolveAssetsDir(slidesRoot, scope);
1527
1600
  }
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)},`;
1548
- });
1549
- return `{\n${lines.join("\n")}\n${indent(level)}}`;
1601
+ function resolveScopedAssetFile(slidesRoot, globalAssetsRoot, scope, filename) {
1602
+ if (scope === GLOBAL_SCOPE) {
1603
+ if (!validateAssetName(filename)) return null;
1604
+ const file = path.resolve(globalAssetsRoot, filename);
1605
+ if (!file.startsWith(globalAssetsRoot + path.sep)) return null;
1606
+ return file;
1550
1607
  }
1551
- throw new Error(`unsupported value type ${typeof value}`);
1608
+ return resolveAssetFile(slidesRoot, scope, filename);
1552
1609
  }
1553
- function serializeDesign(design) {
1554
- return serializeValue(design, 0);
1610
+
1611
+ //#endregion
1612
+ //#region src/http/request-guard.ts
1613
+ function firstHeaderValue(value) {
1614
+ if (Array.isArray(value)) return value[0] ?? null;
1615
+ return value ?? null;
1555
1616
  }
1556
- function parseSlideDesign(source) {
1557
- const ast = parseSource$1(source);
1558
- if (!ast) return {
1617
+ function headerValue(req, name) {
1618
+ return firstHeaderValue(req.headers[name.toLowerCase()])?.trim() ?? null;
1619
+ }
1620
+ function firstCommaToken(value) {
1621
+ if (!value) return null;
1622
+ const [first] = value.split(",", 1);
1623
+ return first?.trim() || null;
1624
+ }
1625
+ function requestProto(req) {
1626
+ const forwarded = firstCommaToken(headerValue(req, "x-forwarded-proto"))?.toLowerCase();
1627
+ if (forwarded === "http" || forwarded === "https") return forwarded;
1628
+ return "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
1629
+ }
1630
+ function normalizedOrigin(origin) {
1631
+ try {
1632
+ const url = new URL(origin);
1633
+ return `${url.protocol}//${url.host}`.toLowerCase();
1634
+ } catch {
1635
+ return null;
1636
+ }
1637
+ }
1638
+ function validateMutationRequest(req, opts = {}) {
1639
+ if (opts.requireJsonBody) {
1640
+ const contentType = headerValue(req, "content-type")?.toLowerCase();
1641
+ if (!contentType || !contentType.startsWith("application/json")) return {
1642
+ ok: false,
1643
+ status: 415,
1644
+ error: "content-type must be application/json"
1645
+ };
1646
+ }
1647
+ const fetchSite = firstCommaToken(headerValue(req, "sec-fetch-site"))?.toLowerCase();
1648
+ if (fetchSite === "cross-site") return {
1559
1649
  ok: false,
1560
- exists: true,
1561
- error: "could not parse slide source"
1650
+ status: 403,
1651
+ error: "cross-site request blocked"
1562
1652
  };
1563
- const loc = findDesignDecl(ast);
1564
- if (!loc) return {
1653
+ const originRaw = headerValue(req, "origin");
1654
+ if (!originRaw) return { ok: true };
1655
+ if (originRaw.toLowerCase() === "null") return {
1565
1656
  ok: false,
1566
- exists: false
1657
+ status: 403,
1658
+ error: "opaque origin is not allowed"
1567
1659
  };
1568
- const objectNode = findDesignObjectNode(ast);
1569
- if (!objectNode) return {
1660
+ const actualOrigin = normalizedOrigin(originRaw);
1661
+ if (!actualOrigin) return {
1570
1662
  ok: false,
1571
- exists: true,
1572
- error: "design has unsupported initializer"
1663
+ status: 403,
1664
+ error: "invalid origin header"
1573
1665
  };
1574
- let value;
1575
- try {
1576
- value = literalToValue(objectNode);
1577
- } catch (err) {
1578
- return {
1579
- ok: false,
1580
- exists: true,
1581
- error: err.message
1582
- };
1583
- }
1584
- const merged = mergeDesign(defaultDesign, value);
1585
- return {
1586
- ok: true,
1587
- design: merged,
1588
- loc
1666
+ const host = firstCommaToken(headerValue(req, "x-forwarded-host")) ?? headerValue(req, "host");
1667
+ if (!host) return {
1668
+ ok: false,
1669
+ status: 400,
1670
+ error: "missing host header"
1671
+ };
1672
+ const expectedOrigin = `${requestProto(req)}://${host}`.toLowerCase();
1673
+ if (actualOrigin !== expectedOrigin) return {
1674
+ ok: false,
1675
+ status: 403,
1676
+ error: "origin mismatch"
1589
1677
  };
1678
+ return { ok: true };
1590
1679
  }
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;
1611
- }
1612
- if (inner.type !== "ObjectExpression") return null;
1613
- return inner;
1614
- }
1615
- }
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
- });
1631
- }
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";
1642
- });
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)}`;
1657
- return {
1658
- source: next$1,
1659
- offsetShift: insertText.length
1660
- };
1661
- }
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)}`;
1668
- return {
1669
- source: next$1,
1670
- offsetShift: 1 + stmt.length - (trail ? 0 : 1)
1671
- };
1672
- }
1673
- const next = `${stmt}\n${source}`;
1674
- return {
1675
- source: next,
1676
- offsetShift: stmt.length + 1
1677
- };
1678
- }
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;
1690
- try {
1691
- body = serializeDesign(next);
1692
- } catch (err) {
1693
- return {
1694
- ok: false,
1695
- status: 422,
1696
- error: `serialize failed: ${err.message}`
1697
- };
1698
- }
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);
1708
- return {
1709
- ok: true,
1710
- source: out$1,
1711
- created: false
1712
- };
1713
- }
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
- };
1729
- }
1730
- function designPlugin(opts) {
1680
+
1681
+ //#endregion
1682
+ //#region src/vite/routes/context.ts
1683
+ function makeContext(opts) {
1731
1684
  const userCwd = opts.userCwd;
1732
1685
  const slidesDir = opts.slidesDir ?? "slides";
1686
+ const assetsDir = opts.assetsDir ?? "assets";
1687
+ const slidesRoot = path.resolve(userCwd, slidesDir);
1688
+ const globalAssetsRoot = path.resolve(userCwd, assetsDir);
1689
+ const manifestPath = path.join(slidesRoot, ".folders.json");
1733
1690
  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
- });
1790
- }
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
- });
1806
- }
1807
- return next();
1808
- } catch (err) {
1809
- json$2(res, 500, { error: String(err.message ?? err) });
1810
- }
1811
- });
1812
- }
1691
+ userCwd,
1692
+ slidesDir,
1693
+ slidesRoot,
1694
+ globalAssetsRoot,
1695
+ manifestPath
1813
1696
  };
1814
1697
  }
1815
-
1816
- //#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";
1849
- }
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;
1860
- }
1861
- async function readBody$1(req) {
1698
+ async function readBody$2(req) {
1862
1699
  return await new Promise((resolve, reject) => {
1863
1700
  const chunks = [];
1864
1701
  req.on("data", (c) => chunks.push(c));
@@ -1874,11 +1711,489 @@ async function readBody$1(req) {
1874
1711
  req.on("error", reject);
1875
1712
  });
1876
1713
  }
1877
- function json$1(res, status, body) {
1714
+ function json$2(res, status, body) {
1878
1715
  res.statusCode = status;
1879
1716
  res.setHeader("content-type", "application/json");
1880
1717
  res.end(JSON.stringify(body));
1881
1718
  }
1719
+ function resolveSlideEntryPath(ctx, slideId) {
1720
+ if (!SLIDE_ID_RE$3.test(slideId)) return null;
1721
+ const full = path.resolve(ctx.slidesRoot, slideId, "index.tsx");
1722
+ if (!full.startsWith(ctx.slidesRoot + path.sep)) return null;
1723
+ return full;
1724
+ }
1725
+
1726
+ //#endregion
1727
+ //#region src/vite/routes/assets.ts
1728
+ function registerAssetRoutes(server, ctx) {
1729
+ server.middlewares.use("/__assets", async (req, res, next) => {
1730
+ const url = new URL(req.url ?? "/", "http://local");
1731
+ const method = req.method ?? "GET";
1732
+ try {
1733
+ const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
1734
+ const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
1735
+ const usagesMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)\/usages$/);
1736
+ if (usagesMatch && method === "GET") {
1737
+ const scope = usagesMatch[1];
1738
+ const filename = decodeURIComponent(usagesMatch[2]);
1739
+ if (!validateAssetName(filename)) return json$2(res, 400, { error: "invalid path" });
1740
+ const isGlobal = scope === GLOBAL_SCOPE;
1741
+ const assetPath = isGlobal ? `@assets/${filename}` : `./assets/${filename}`;
1742
+ let slideIds;
1743
+ if (isGlobal) try {
1744
+ const entries = await fs.readdir(ctx.slidesRoot, { withFileTypes: true });
1745
+ slideIds = entries.filter((e) => e.isDirectory() && SLIDE_ID_RE$3.test(e.name)).map((e) => e.name);
1746
+ } catch {
1747
+ slideIds = [];
1748
+ }
1749
+ else {
1750
+ if (!SLIDE_ID_RE$3.test(scope)) return json$2(res, 400, { error: "invalid slideId" });
1751
+ slideIds = [scope];
1752
+ }
1753
+ const usages = [];
1754
+ let totalCount = 0;
1755
+ for (const sid of slideIds) {
1756
+ const entry = resolveSlideEntry(ctx.slidesRoot, sid);
1757
+ if (!entry) continue;
1758
+ let source;
1759
+ try {
1760
+ source = await fs.readFile(entry, "utf8");
1761
+ } catch {
1762
+ continue;
1763
+ }
1764
+ const count = findAssetUsages(source, assetPath);
1765
+ if (count > 0) {
1766
+ usages.push({
1767
+ slideId: sid,
1768
+ count
1769
+ });
1770
+ totalCount += count;
1771
+ }
1772
+ }
1773
+ return json$2(res, 200, {
1774
+ usages,
1775
+ totalCount
1776
+ });
1777
+ }
1778
+ if (listMatch && method === "GET") {
1779
+ const slideId = listMatch[1];
1780
+ const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
1781
+ if (!scopedDir) return json$2(res, 400, { error: "invalid slideId" });
1782
+ let entries;
1783
+ try {
1784
+ entries = await fs.readdir(scopedDir);
1785
+ } catch (err) {
1786
+ if (err.code === "ENOENT") return json$2(res, 200, { assets: [] });
1787
+ throw err;
1788
+ }
1789
+ const assets = [];
1790
+ for (const name of entries) {
1791
+ if (!validateAssetName(name)) continue;
1792
+ const stat = await fs.stat(path.join(scopedDir, name));
1793
+ if (!stat.isFile()) continue;
1794
+ assets.push({
1795
+ name,
1796
+ size: stat.size,
1797
+ mtime: stat.mtimeMs,
1798
+ mime: mimeForFilename(name),
1799
+ url: `/__assets/${slideId}/${encodeURIComponent(name)}`
1800
+ });
1801
+ }
1802
+ assets.sort((a, b) => a.name.localeCompare(b.name));
1803
+ return json$2(res, 200, { assets });
1804
+ }
1805
+ if (fileMatch) {
1806
+ const slideId = fileMatch[1];
1807
+ const filename = decodeURIComponent(fileMatch[2]);
1808
+ const file = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, filename);
1809
+ if (!file) return json$2(res, 400, { error: "invalid path" });
1810
+ if (method === "GET") try {
1811
+ const buf = await fs.readFile(file);
1812
+ res.statusCode = 200;
1813
+ res.setHeader("content-type", mimeForFilename(filename));
1814
+ res.setHeader("cache-control", "no-store");
1815
+ res.end(buf);
1816
+ return;
1817
+ } catch (err) {
1818
+ if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
1819
+ throw err;
1820
+ }
1821
+ if (method === "POST") {
1822
+ const requestCheck = validateMutationRequest(req);
1823
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
1824
+ const overwrite = url.searchParams.get("overwrite") === "1";
1825
+ const lenHeader = req.headers["content-length"];
1826
+ const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
1827
+ if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json$2(res, 413, { error: "file too large" });
1828
+ if (!overwrite) try {
1829
+ await fs.access(file);
1830
+ return json$2(res, 409, { error: "asset exists" });
1831
+ } catch {}
1832
+ const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
1833
+ if (!scopedDir) return json$2(res, 400, { error: "invalid slideId" });
1834
+ await fs.mkdir(scopedDir, { recursive: true });
1835
+ const chunks = [];
1836
+ let total = 0;
1837
+ let oversized = false;
1838
+ await new Promise((resolve, reject) => {
1839
+ req.on("data", (c) => {
1840
+ total += c.length;
1841
+ if (total > ASSET_MAX_BYTES) {
1842
+ oversized = true;
1843
+ req.destroy();
1844
+ return;
1845
+ }
1846
+ chunks.push(c);
1847
+ });
1848
+ req.on("end", () => resolve());
1849
+ req.on("error", reject);
1850
+ });
1851
+ if (oversized) return json$2(res, 413, { error: "file too large" });
1852
+ await fs.writeFile(file, Buffer.concat(chunks));
1853
+ return json$2(res, 200, {
1854
+ ok: true,
1855
+ name: filename,
1856
+ size: total,
1857
+ mime: mimeForFilename(filename),
1858
+ url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
1859
+ });
1860
+ }
1861
+ if (method === "PATCH") {
1862
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
1863
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
1864
+ const body = await readBody$2(req);
1865
+ const target = validateAssetName(body.name);
1866
+ if (!target) return json$2(res, 400, { error: "invalid name" });
1867
+ if (target === filename) return json$2(res, 200, {
1868
+ ok: true,
1869
+ name: filename
1870
+ });
1871
+ const dest = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, target);
1872
+ if (!dest) return json$2(res, 400, { error: "invalid name" });
1873
+ try {
1874
+ await fs.access(dest);
1875
+ return json$2(res, 409, { error: "target exists" });
1876
+ } catch {}
1877
+ try {
1878
+ await fs.rename(file, dest);
1879
+ } catch (err) {
1880
+ if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
1881
+ throw err;
1882
+ }
1883
+ return json$2(res, 200, {
1884
+ ok: true,
1885
+ name: target
1886
+ });
1887
+ }
1888
+ if (method === "DELETE") {
1889
+ const requestCheck = validateMutationRequest(req);
1890
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
1891
+ try {
1892
+ await fs.unlink(file);
1893
+ } catch (err) {
1894
+ if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
1895
+ throw err;
1896
+ }
1897
+ return json$2(res, 200, { ok: true });
1898
+ }
1899
+ }
1900
+ return next();
1901
+ } catch (err) {
1902
+ json$2(res, 500, { error: String(err.message ?? err) });
1903
+ }
1904
+ });
1905
+ }
1906
+
1907
+ //#endregion
1908
+ //#region src/editing/comments.ts
1909
+ const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
1910
+ function b64urlEncode(s) {
1911
+ return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1912
+ }
1913
+ function b64urlDecode(s) {
1914
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
1915
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
1916
+ }
1917
+ function parseMarkers(source) {
1918
+ const comments = [];
1919
+ const lines = source.split("\n");
1920
+ for (let i = 0; i < lines.length; i++) {
1921
+ const line = lines[i];
1922
+ MARKER_RE.lastIndex = 0;
1923
+ const m = MARKER_RE.exec(line);
1924
+ if (!m) continue;
1925
+ const [, id, ts, textB64] = m;
1926
+ try {
1927
+ const payload = JSON.parse(b64urlDecode(textB64));
1928
+ comments.push({
1929
+ id,
1930
+ line: i + 1,
1931
+ ts,
1932
+ note: payload.note,
1933
+ hint: payload.hint
1934
+ });
1935
+ } catch {}
1936
+ }
1937
+ return comments;
1938
+ }
1939
+ function newCommentId() {
1940
+ return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
1941
+ }
1942
+ function markerDeleteRegex(id) {
1943
+ return new RegExp(`\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`);
1944
+ }
1945
+ function lineToOffset(source, line) {
1946
+ let off = 0;
1947
+ for (let l = 1; l < line; l++) {
1948
+ const nl = source.indexOf("\n", off);
1949
+ if (nl === -1) return source.length;
1950
+ off = nl + 1;
1951
+ }
1952
+ return off;
1953
+ }
1954
+ function lineIndent(source, lineNumber) {
1955
+ const start = lineToOffset(source, lineNumber);
1956
+ const m = source.slice(start, start + 200).match(/^[ \t]*/);
1957
+ return m?.[0] ?? "";
1958
+ }
1959
+ function findJsxAncestors(ast, line, column) {
1960
+ const hits = [];
1961
+ walkJsx(ast, (n) => {
1962
+ if (!n.loc || !t$2.isJSXElement(n) && !t$2.isJSXFragment(n)) return;
1963
+ const s = n.loc.start;
1964
+ const e = n.loc.end;
1965
+ const afterStart = line > s.line || line === s.line && column >= s.column;
1966
+ const beforeEnd = line < e.line || line === e.line && column < e.column;
1967
+ if (afterStart && beforeEnd) hits.push({
1968
+ node: n,
1969
+ size: (n.end ?? 0) - (n.start ?? 0)
1970
+ });
1971
+ });
1972
+ hits.sort((a, b) => a.size - b.size);
1973
+ return hits.map((h) => h.node);
1974
+ }
1975
+ function planInsertion(source, target) {
1976
+ if (t$2.isJSXFragment(target)) {
1977
+ const opening = target.openingFragment;
1978
+ const startLine = target.loc?.start.line ?? 1;
1979
+ return {
1980
+ offset: opening.end ?? 0,
1981
+ indent: `${lineIndent(source, startLine)} `
1982
+ };
1983
+ }
1984
+ if (t$2.isJSXElement(target)) {
1985
+ const opening = target.openingElement;
1986
+ if (opening.selfClosing) return null;
1987
+ const startLine = target.loc?.start.line ?? 1;
1988
+ return {
1989
+ offset: opening.end ?? 0,
1990
+ indent: `${lineIndent(source, startLine)} `
1991
+ };
1992
+ }
1993
+ return null;
1994
+ }
1995
+ function findInsertion(source, line, column) {
1996
+ const ast = parseSource$2(source);
1997
+ if (!ast) return null;
1998
+ const col = column ?? 0;
1999
+ const ancestors = findJsxAncestors(ast, line, col);
2000
+ for (const node of ancestors) {
2001
+ const plan = planInsertion(source, node);
2002
+ if (plan) return plan;
2003
+ }
2004
+ return null;
2005
+ }
2006
+ function offsetToLine(source, offset) {
2007
+ let line = 1;
2008
+ for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
2009
+ return line;
2010
+ }
2011
+
2012
+ //#endregion
2013
+ //#region src/vite/routes/comments.ts
2014
+ function registerCommentRoutes(server, ctx) {
2015
+ server.middlewares.use("/__comments", async (req, res, next) => {
2016
+ const url = new URL(req.url ?? "/", "http://local");
2017
+ const method = req.method ?? "GET";
2018
+ try {
2019
+ if (method === "GET" && url.pathname === "/") {
2020
+ const slideId = url.searchParams.get("slideId") ?? "";
2021
+ const file = resolveSlideEntryPath(ctx, slideId);
2022
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2023
+ let source;
2024
+ try {
2025
+ source = await fs.readFile(file, "utf8");
2026
+ } catch {
2027
+ return json$2(res, 404, { error: "slide not found" });
2028
+ }
2029
+ return json$2(res, 200, { comments: parseMarkers(source) });
2030
+ }
2031
+ if (method === "POST" && url.pathname === "/add") {
2032
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2033
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2034
+ const body = await readBody$2(req);
2035
+ const slideId = body.slideId ?? "";
2036
+ const file = resolveSlideEntryPath(ctx, slideId);
2037
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2038
+ if (!body.line || body.line < 1) return json$2(res, 400, { error: "invalid line" });
2039
+ if (!body.text || typeof body.text !== "string") return json$2(res, 400, { error: "missing text" });
2040
+ let source;
2041
+ try {
2042
+ source = await fs.readFile(file, "utf8");
2043
+ } catch {
2044
+ return json$2(res, 404, { error: "slide not found" });
2045
+ }
2046
+ const plan = findInsertion(source, body.line, body.column);
2047
+ if (!plan) return json$2(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
2048
+ const id = newCommentId();
2049
+ const ts = new Date().toISOString();
2050
+ const payload = b64urlEncode(JSON.stringify({
2051
+ note: body.text,
2052
+ hint: body.hint
2053
+ }));
2054
+ const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
2055
+ const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
2056
+ await fs.writeFile(file, next$1, "utf8");
2057
+ const markerLine = offsetToLine(next$1, plan.offset + 1);
2058
+ return json$2(res, 200, {
2059
+ id,
2060
+ line: markerLine
2061
+ });
2062
+ }
2063
+ if (method === "DELETE" && url.pathname.startsWith("/")) {
2064
+ const requestCheck = validateMutationRequest(req);
2065
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2066
+ const id = url.pathname.slice(1);
2067
+ if (!/^c-[a-f0-9]+$/.test(id)) return json$2(res, 400, { error: "invalid id" });
2068
+ const slideId = url.searchParams.get("slideId") ?? "";
2069
+ const file = resolveSlideEntryPath(ctx, slideId);
2070
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2071
+ let source;
2072
+ try {
2073
+ source = await fs.readFile(file, "utf8");
2074
+ } catch {
2075
+ return json$2(res, 404, { error: "slide not found" });
2076
+ }
2077
+ const lines = source.split("\n");
2078
+ const idRe = markerDeleteRegex(id);
2079
+ const hit = lines.findIndex((l) => idRe.test(l));
2080
+ if (hit === -1) return json$2(res, 404, { error: "marker not found" });
2081
+ lines.splice(hit, 1);
2082
+ await fs.writeFile(file, lines.join("\n"), "utf8");
2083
+ return json$2(res, 200, { ok: true });
2084
+ }
2085
+ next();
2086
+ } catch (err) {
2087
+ json$2(res, 500, { error: String(err.message ?? err) });
2088
+ }
2089
+ });
2090
+ }
2091
+
2092
+ //#endregion
2093
+ //#region src/vite/routes/edit.ts
2094
+ function registerEditRoutes(server, ctx) {
2095
+ server.middlewares.use("/__edit", async (req, res, next) => {
2096
+ const url = new URL(req.url ?? "/", "http://local");
2097
+ const method = req.method ?? "GET";
2098
+ if (method !== "POST") return next();
2099
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2100
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2101
+ try {
2102
+ if (url.pathname === "/") {
2103
+ const body = await readBody$2(req);
2104
+ const slideId = body.slideId ?? "";
2105
+ const file = resolveSlideEntryPath(ctx, slideId);
2106
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2107
+ if (!body.line || body.line < 1) return json$2(res, 400, { error: "invalid line" });
2108
+ if (!Array.isArray(body.ops)) return json$2(res, 400, { error: "missing ops" });
2109
+ let source;
2110
+ try {
2111
+ source = await fs.readFile(file, "utf8");
2112
+ } catch {
2113
+ return json$2(res, 404, { error: "slide not found" });
2114
+ }
2115
+ const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
2116
+ if (!result.ok) return json$2(res, result.status, { error: result.error });
2117
+ const changed = result.source !== source;
2118
+ if (changed) await fs.writeFile(file, result.source, "utf8");
2119
+ return json$2(res, 200, {
2120
+ ok: true,
2121
+ changed
2122
+ });
2123
+ }
2124
+ if (url.pathname === "/revert-asset") {
2125
+ const body = await readBody$2(req);
2126
+ const slideId = body.slideId ?? "";
2127
+ const assetPath = body.assetPath;
2128
+ const file = resolveSlideEntryPath(ctx, slideId);
2129
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2130
+ if (typeof assetPath !== "string" || !assetPath) return json$2(res, 400, { error: "missing assetPath" });
2131
+ if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return json$2(res, 400, { error: "asset path must start with ./assets/ or @assets/" });
2132
+ let source;
2133
+ try {
2134
+ source = await fs.readFile(file, "utf8");
2135
+ } catch {
2136
+ return json$2(res, 404, { error: "slide not found" });
2137
+ }
2138
+ const result = applyRevertAsset(source, assetPath);
2139
+ if (!result.ok) return json$2(res, result.status, { error: result.error });
2140
+ const changed = result.source !== source;
2141
+ if (changed) await fs.writeFile(file, result.source, "utf8");
2142
+ return json$2(res, 200, {
2143
+ ok: true,
2144
+ changed
2145
+ });
2146
+ }
2147
+ if (url.pathname === "/batch") {
2148
+ const body = await readBody$2(req);
2149
+ const slideId = body.slideId ?? "";
2150
+ const file = resolveSlideEntryPath(ctx, slideId);
2151
+ if (!file) return json$2(res, 400, { error: "invalid slideId" });
2152
+ if (!Array.isArray(body.edits)) return json$2(res, 400, { error: "missing edits" });
2153
+ let source;
2154
+ try {
2155
+ source = await fs.readFile(file, "utf8");
2156
+ } catch {
2157
+ return json$2(res, 404, { error: "slide not found" });
2158
+ }
2159
+ const original = source;
2160
+ const results = [];
2161
+ for (const edit of body.edits) {
2162
+ if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
2163
+ results.push({
2164
+ ok: false,
2165
+ error: "invalid edit"
2166
+ });
2167
+ continue;
2168
+ }
2169
+ const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
2170
+ if (r.ok) {
2171
+ source = r.source;
2172
+ results.push({ ok: true });
2173
+ } else results.push({
2174
+ ok: false,
2175
+ error: r.error
2176
+ });
2177
+ }
2178
+ const changed = source !== original;
2179
+ if (changed) await fs.writeFile(file, source, "utf8");
2180
+ return json$2(res, 200, {
2181
+ ok: true,
2182
+ changed,
2183
+ results
2184
+ });
2185
+ }
2186
+ return next();
2187
+ } catch (err) {
2188
+ json$2(res, 500, { error: String(err.message ?? err) });
2189
+ }
2190
+ });
2191
+ }
2192
+
2193
+ //#endregion
2194
+ //#region src/files/folders.ts
2195
+ const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
2196
+ const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
1882
2197
  function emptyManifest() {
1883
2198
  return {
1884
2199
  folders: [],
@@ -1911,123 +2226,437 @@ function validateName(v) {
1911
2226
  if (trimmed.length < 1 || trimmed.length > 40) return null;
1912
2227
  return trimmed;
1913
2228
  }
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;
2229
+ function validateIcon(v) {
2230
+ if (!v || typeof v !== "object") return null;
2231
+ const icon = v;
2232
+ if (icon.type === "emoji") {
2233
+ if (typeof icon.value !== "string") return null;
2234
+ if (icon.value.length < 1 || icon.value.length > 8) return null;
2235
+ return {
2236
+ type: "emoji",
2237
+ value: icon.value
2238
+ };
1932
2239
  }
2240
+ if (icon.type === "color") {
2241
+ if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
2242
+ return {
2243
+ type: "color",
2244
+ value: icon.value
2245
+ };
2246
+ }
2247
+ return null;
1933
2248
  }
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) {
1964
- 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, "\\'");
2249
+
2250
+ //#endregion
2251
+ //#region src/vite/routes/folders.ts
2252
+ function registerFolderRoutes(server, ctx) {
2253
+ server.middlewares.use("/__folders", async (req, res, next) => {
2254
+ const url = new URL(req.url ?? "/", "http://local");
2255
+ const method = req.method ?? "GET";
2256
+ try {
2257
+ if (method === "GET" && url.pathname === "/") {
2258
+ const manifest = await readManifest(ctx.manifestPath);
2259
+ return json$2(res, 200, manifest);
2260
+ }
2261
+ if (method === "POST" && url.pathname === "/") {
2262
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2263
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2264
+ const body = await readBody$2(req);
2265
+ const name = validateName(body.name);
2266
+ if (!name) return json$2(res, 400, { error: "invalid name" });
2267
+ const icon = validateIcon(body.icon);
2268
+ if (!icon) return json$2(res, 400, { error: "invalid icon" });
2269
+ const manifest = await readManifest(ctx.manifestPath);
2270
+ const folder = {
2271
+ id: newFolderId(),
2272
+ name,
2273
+ icon
2274
+ };
2275
+ manifest.folders.push(folder);
2276
+ await writeManifest(ctx.manifestPath, manifest);
2277
+ return json$2(res, 200, folder);
2278
+ }
2279
+ if (method === "PUT" && url.pathname === "/assign") {
2280
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2281
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2282
+ const body = await readBody$2(req);
2283
+ if (typeof body.slideId !== "string" || !SLIDE_ID_RE$3.test(body.slideId)) return json$2(res, 400, { error: "invalid slideId" });
2284
+ const slideId = body.slideId;
2285
+ let folderId;
2286
+ if (body.folderId === null) folderId = null;
2287
+ else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
2288
+ else return json$2(res, 400, { error: "invalid folderId" });
2289
+ const manifest = await readManifest(ctx.manifestPath);
2290
+ if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json$2(res, 404, { error: "folder not found" });
2291
+ if (folderId === null) delete manifest.assignments[slideId];
2292
+ else manifest.assignments[slideId] = folderId;
2293
+ await writeManifest(ctx.manifestPath, manifest);
2294
+ return json$2(res, 200, { ok: true });
2295
+ }
2296
+ const idMatch = url.pathname.match(/^\/([^/]+)$/);
2297
+ if (idMatch) {
2298
+ const id = idMatch[1];
2299
+ if (!FOLDER_ID_RE.test(id)) return json$2(res, 400, { error: "invalid id" });
2300
+ if (method === "PATCH") {
2301
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2302
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2303
+ const body = await readBody$2(req);
2304
+ const manifest = await readManifest(ctx.manifestPath);
2305
+ const folder = manifest.folders.find((f) => f.id === id);
2306
+ if (!folder) return json$2(res, 404, { error: "folder not found" });
2307
+ if (body.name !== void 0) {
2308
+ const name = validateName(body.name);
2309
+ if (!name) return json$2(res, 400, { error: "invalid name" });
2310
+ folder.name = name;
2311
+ }
2312
+ if (body.icon !== void 0) {
2313
+ const icon = validateIcon(body.icon);
2314
+ if (!icon) return json$2(res, 400, { error: "invalid icon" });
2315
+ folder.icon = icon;
2316
+ }
2317
+ await writeManifest(ctx.manifestPath, manifest);
2318
+ return json$2(res, 200, folder);
2319
+ }
2320
+ if (method === "DELETE") {
2321
+ const requestCheck = validateMutationRequest(req);
2322
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2323
+ const manifest = await readManifest(ctx.manifestPath);
2324
+ const before = manifest.folders.length;
2325
+ manifest.folders = manifest.folders.filter((f) => f.id !== id);
2326
+ if (manifest.folders.length === before) return json$2(res, 404, { error: "folder not found" });
2327
+ for (const [slideId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[slideId];
2328
+ await writeManifest(ctx.manifestPath, manifest);
2329
+ return json$2(res, 200, { ok: true });
2330
+ }
2331
+ }
2332
+ next();
2333
+ } catch (err) {
2334
+ json$2(res, 500, { error: String(err.message ?? err) });
2335
+ }
2336
+ });
1971
2337
  }
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;
2338
+
2339
+ //#endregion
2340
+ //#region src/vite/routes/slides.ts
2341
+ function registerSlideRoutes(server, ctx) {
2342
+ server.middlewares.use("/__slides", async (req, res, next) => {
2343
+ const url = new URL(req.url ?? "/", "http://local");
2344
+ const method = req.method ?? "GET";
2345
+ try {
2346
+ const reorderMatch = url.pathname.match(/^\/([^/]+)\/reorder$/);
2347
+ if (reorderMatch && method === "PUT") {
2348
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2349
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2350
+ const slideId$1 = reorderMatch[1];
2351
+ if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
2352
+ const body = await readBody$2(req);
2353
+ if (!Array.isArray(body.order)) return json$2(res, 400, { error: "invalid order" });
2354
+ const order = [];
2355
+ for (const v of body.order) {
2356
+ if (!Number.isInteger(v)) return json$2(res, 400, { error: "invalid order" });
2357
+ order.push(v);
2358
+ }
2359
+ const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
2360
+ if (!entry) return json$2(res, 400, { error: "invalid slideId" });
2361
+ let source;
2362
+ try {
2363
+ source = await fs.readFile(entry, "utf8");
2364
+ } catch {
2365
+ return json$2(res, 404, { error: "slide not found" });
2366
+ }
2367
+ const reordered = reorderDefaultExportPagesInSource(source, order);
2368
+ if (reordered === null) return json$2(res, 422, { error: "could not reorder pages — order must be a permutation of the existing array" });
2369
+ const withNotes = reorderNotesArrayInSource(reordered, order);
2370
+ if (withNotes === null) return json$2(res, 422, { error: "could not reorder pages — `notes` export has an unexpected shape" });
2371
+ if (withNotes !== source) await fs.writeFile(entry, withNotes, "utf8");
2372
+ return json$2(res, 200, {
2373
+ ok: true,
2374
+ slideId: slideId$1,
2375
+ order
2376
+ });
2377
+ }
2378
+ const pageOpMatch = url.pathname.match(/^\/([^/]+)\/pages\/(\d+)(?:\/([a-z]+))?$/);
2379
+ if (pageOpMatch) {
2380
+ const slideId$1 = pageOpMatch[1];
2381
+ const pageIndex = Number.parseInt(pageOpMatch[2], 10);
2382
+ const op = pageOpMatch[3];
2383
+ if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
2384
+ if (!Number.isInteger(pageIndex) || pageIndex < 0) return json$2(res, 400, { error: "invalid page index" });
2385
+ const isDelete = method === "DELETE" && !op;
2386
+ const isDuplicate = method === "POST" && op === "duplicate";
2387
+ if (!isDelete && !isDuplicate) return next();
2388
+ const requestCheck = validateMutationRequest(req);
2389
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2390
+ const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
2391
+ if (!entry) return json$2(res, 400, { error: "invalid slideId" });
2392
+ let source;
2393
+ try {
2394
+ source = await fs.readFile(entry, "utf8");
2395
+ } catch {
2396
+ return json$2(res, 404, { error: "slide not found" });
2397
+ }
2398
+ const updated = isDelete ? removePageFromDefaultExportInSource(source, pageIndex) : duplicatePageInDefaultExportInSource(source, pageIndex);
2399
+ 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" });
2400
+ if (updated !== source) await fs.writeFile(entry, updated, "utf8");
2401
+ return json$2(res, 200, {
2402
+ ok: true,
2403
+ slideId: slideId$1,
2404
+ index: pageIndex
2405
+ });
2406
+ }
2407
+ const idMatch = url.pathname.match(/^\/([^/]+)$/);
2408
+ if (!idMatch) return next();
2409
+ const slideId = idMatch[1];
2410
+ if (!SLIDE_ID_RE$3.test(slideId)) return json$2(res, 400, { error: "invalid slideId" });
2411
+ if (method === "PATCH") {
2412
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2413
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2414
+ const body = await readBody$2(req);
2415
+ const name = validateSlideName(body.name);
2416
+ if (!name) return json$2(res, 400, { error: "invalid name" });
2417
+ const entry = resolveSlideEntry(ctx.slidesRoot, slideId);
2418
+ if (!entry) return json$2(res, 400, { error: "invalid slideId" });
2419
+ let source;
2420
+ try {
2421
+ source = await fs.readFile(entry, "utf8");
2422
+ } catch {
2423
+ return json$2(res, 404, { error: "slide not found" });
2004
2424
  }
2425
+ const updated = updateMetaTitleInSource(source, name);
2426
+ if (updated === null) return json$2(res, 422, { error: "could not locate a safe place to write meta.title in index.tsx" });
2427
+ if (updated !== source) await fs.writeFile(entry, updated, "utf8");
2428
+ server.ws.send({ type: "full-reload" });
2429
+ return json$2(res, 200, {
2430
+ ok: true,
2431
+ slideId,
2432
+ name
2433
+ });
2005
2434
  }
2435
+ if (method === "DELETE") {
2436
+ const requestCheck = validateMutationRequest(req);
2437
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2438
+ const removed = await rmSlideDir(ctx.slidesRoot, slideId);
2439
+ if (!removed) return json$2(res, 404, { error: "slide not found" });
2440
+ const manifest = await readManifest(ctx.manifestPath);
2441
+ delete manifest.assignments[slideId];
2442
+ await writeManifest(ctx.manifestPath, manifest);
2443
+ return json$2(res, 200, { ok: true });
2444
+ }
2445
+ return next();
2446
+ } catch (err) {
2447
+ json$2(res, 500, { error: String(err.message ?? err) });
2006
2448
  }
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);
2449
+ });
2450
+ }
2451
+
2452
+ //#endregion
2453
+ //#region src/vite/routes/svgl.ts
2454
+ function registerSvglRoutes(server) {
2455
+ server.middlewares.use("/__svgl", async (req, res, next) => {
2456
+ const reqUrl = new URL(req.url ?? "/", "http://local");
2457
+ const method = req.method ?? "GET";
2458
+ if (method !== "GET") return next();
2459
+ try {
2460
+ let target = null;
2461
+ if (reqUrl.pathname === "/search") {
2462
+ const params = new URLSearchParams();
2463
+ const q = reqUrl.searchParams.get("q");
2464
+ const limit = reqUrl.searchParams.get("limit");
2465
+ if (q) params.set("search", q);
2466
+ if (limit) params.set("limit", limit);
2467
+ const qs = params.toString();
2468
+ target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
2469
+ } else if (reqUrl.pathname === "/svg") {
2470
+ const u = reqUrl.searchParams.get("u");
2471
+ if (!u) return json$2(res, 400, { error: "missing u" });
2472
+ let parsed;
2473
+ try {
2474
+ parsed = new URL(u);
2475
+ } catch {
2476
+ return json$2(res, 400, { error: "invalid u" });
2477
+ }
2478
+ if (parsed.protocol !== "https:") return json$2(res, 400, { error: "https only" });
2479
+ const host = parsed.hostname.toLowerCase();
2480
+ if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json$2(res, 400, { error: "host not allowed" });
2481
+ target = parsed.toString();
2482
+ } else return next();
2483
+ const upstream = await fetch(target);
2484
+ const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
2485
+ res.statusCode = upstream.status;
2486
+ res.setHeader("content-type", ct);
2487
+ res.setHeader("cache-control", "no-store");
2488
+ const buf = Buffer.from(await upstream.arrayBuffer());
2489
+ res.end(buf);
2490
+ } catch (err) {
2491
+ json$2(res, 502, { error: String(err.message ?? err) });
2014
2492
  }
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);
2493
+ });
2026
2494
  }
2027
- function findDefaultExportArray(source) {
2028
- let ast;
2495
+
2496
+ //#endregion
2497
+ //#region src/vite/routes/watchers.ts
2498
+ function registerWatchers(server, ctx) {
2499
+ server.watcher.add(ctx.manifestPath);
2500
+ server.watcher.on("change", (p) => {
2501
+ if (p === ctx.manifestPath) server.ws.send({
2502
+ type: "custom",
2503
+ event: "open-slide:files-changed"
2504
+ });
2505
+ });
2506
+ server.watcher.add(ctx.globalAssetsRoot);
2507
+ const onAssetChange = (p) => {
2508
+ if (p.startsWith(ctx.globalAssetsRoot + path.sep) || p === ctx.globalAssetsRoot) {
2509
+ server.ws.send({
2510
+ type: "custom",
2511
+ event: "open-slide:assets-changed",
2512
+ data: { slideId: GLOBAL_SCOPE }
2513
+ });
2514
+ return;
2515
+ }
2516
+ if (!p.startsWith(ctx.slidesRoot + path.sep)) return;
2517
+ const rel = p.slice(ctx.slidesRoot.length + 1);
2518
+ const parts = rel.split(path.sep);
2519
+ if (parts.length < 3 || parts[1] !== "assets") return;
2520
+ const slideId = parts[0];
2521
+ if (!SLIDE_ID_RE$3.test(slideId)) return;
2522
+ server.ws.send({
2523
+ type: "custom",
2524
+ event: "open-slide:assets-changed",
2525
+ data: { slideId }
2526
+ });
2527
+ };
2528
+ server.watcher.on("add", onAssetChange);
2529
+ server.watcher.on("change", onAssetChange);
2530
+ server.watcher.on("unlink", onAssetChange);
2531
+ }
2532
+
2533
+ //#endregion
2534
+ //#region src/vite/api-plugin.ts
2535
+ function apiPlugin(opts) {
2536
+ return {
2537
+ name: "open-slide:api",
2538
+ apply: "serve",
2539
+ configureServer(server) {
2540
+ const ctx = makeContext(opts);
2541
+ registerWatchers(server, ctx);
2542
+ registerEditRoutes(server, ctx);
2543
+ registerCommentRoutes(server, ctx);
2544
+ registerSlideRoutes(server, ctx);
2545
+ registerAssetRoutes(server, ctx);
2546
+ registerSvglRoutes(server);
2547
+ registerFolderRoutes(server, ctx);
2548
+ }
2549
+ };
2550
+ }
2551
+
2552
+ //#endregion
2553
+ //#region src/vite/current-plugin.ts
2554
+ const SLIDE_ID_RE$2 = /^[a-z0-9_-]+$/i;
2555
+ const TEXT_SNIPPET_MAX = 120;
2556
+ function parseSelection(raw) {
2557
+ if (raw == null || typeof raw !== "object") return null;
2558
+ const sel = raw;
2559
+ if (typeof sel.line !== "number" || !Number.isFinite(sel.line)) return null;
2560
+ if (typeof sel.column !== "number" || !Number.isFinite(sel.column)) return null;
2561
+ const tagName = typeof sel.tagName === "string" ? sel.tagName.toLowerCase().slice(0, 32) : "unknown";
2562
+ const text = typeof sel.text === "string" ? sel.text.replace(/\s+/g, " ").trim().slice(0, TEXT_SNIPPET_MAX) : "";
2563
+ return {
2564
+ line: Math.max(1, Math.floor(sel.line)),
2565
+ column: Math.max(0, Math.floor(sel.column)),
2566
+ tagName,
2567
+ text
2568
+ };
2569
+ }
2570
+ function currentPlugin(opts) {
2571
+ const userCwd = opts.userCwd;
2572
+ const slidesDir = opts.slidesDir ?? "slides";
2573
+ const outDir = path.join(userCwd, "node_modules", ".open-slide");
2574
+ const outFile = path.join(outDir, "current.json");
2575
+ const tmpFile = `${outFile}.tmp`;
2576
+ let cached = null;
2577
+ return {
2578
+ name: "open-slide:current",
2579
+ apply: "serve",
2580
+ configureServer(server) {
2581
+ server.ws.on("open-slide:current", async (raw) => {
2582
+ const next = cached ? { ...cached } : {
2583
+ slideId: "",
2584
+ pageIndex: 0,
2585
+ pageNumber: 1,
2586
+ totalPages: 1,
2587
+ slideTitle: "",
2588
+ view: "slides",
2589
+ pagePath: "",
2590
+ selection: null
2591
+ };
2592
+ if (typeof raw?.slideId === "string") {
2593
+ if (!SLIDE_ID_RE$2.test(raw.slideId)) return;
2594
+ const totalPages = typeof raw.totalPages === "number" && Number.isFinite(raw.totalPages) && raw.totalPages > 0 ? Math.floor(raw.totalPages) : 1;
2595
+ const rawIndex = typeof raw.pageIndex === "number" && Number.isFinite(raw.pageIndex) ? Math.floor(raw.pageIndex) : 0;
2596
+ const pageIndex = Math.max(0, Math.min(totalPages - 1, rawIndex));
2597
+ const slideTitle = typeof raw.slideTitle === "string" ? raw.slideTitle : raw.slideId;
2598
+ const view = raw.view === "assets" ? "assets" : "slides";
2599
+ const pagePath = path.join(slidesDir, raw.slideId, "index.tsx").split(path.sep).join("/");
2600
+ if (cached?.slideId !== raw.slideId || cached?.pageIndex !== pageIndex) next.selection = null;
2601
+ next.slideId = raw.slideId;
2602
+ next.pageIndex = pageIndex;
2603
+ next.pageNumber = pageIndex + 1;
2604
+ next.totalPages = totalPages;
2605
+ next.slideTitle = slideTitle;
2606
+ next.view = view;
2607
+ next.pagePath = pagePath;
2608
+ }
2609
+ if ("selection" in raw) next.selection = parseSelection(raw.selection);
2610
+ if (!next.slideId) return;
2611
+ cached = next;
2612
+ const body = {
2613
+ ...next,
2614
+ updatedAt: new Date().toISOString()
2615
+ };
2616
+ try {
2617
+ await fs.mkdir(outDir, { recursive: true });
2618
+ await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
2619
+ await fs.rename(tmpFile, outFile);
2620
+ } catch {}
2621
+ });
2622
+ }
2623
+ };
2624
+ }
2625
+
2626
+ //#endregion
2627
+ //#region src/vite/design-plugin.ts
2628
+ const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
2629
+ async function readBody$1(req) {
2630
+ return await new Promise((resolve, reject) => {
2631
+ const chunks = [];
2632
+ req.on("data", (c) => chunks.push(c));
2633
+ req.on("end", () => {
2634
+ const raw = Buffer.concat(chunks).toString("utf8");
2635
+ if (!raw) return resolve({});
2636
+ try {
2637
+ resolve(JSON.parse(raw));
2638
+ } catch (e) {
2639
+ reject(e);
2640
+ }
2641
+ });
2642
+ req.on("error", reject);
2643
+ });
2644
+ }
2645
+ function json$1(res, status, body) {
2646
+ res.statusCode = status;
2647
+ res.setHeader("content-type", "application/json");
2648
+ res.end(JSON.stringify(body));
2649
+ }
2650
+ function resolveSlidePath$1(userCwd, slidesDir, slideId) {
2651
+ if (!SLIDE_ID_RE$1.test(slideId)) return null;
2652
+ const slidesRoot = path.resolve(userCwd, slidesDir);
2653
+ const full = path.resolve(slidesRoot, slideId, "index.tsx");
2654
+ if (!full.startsWith(`${slidesRoot}${path.sep}`)) return null;
2655
+ return full;
2656
+ }
2657
+ function parseSource$1(source) {
2029
2658
  try {
2030
- ast = parse(source, {
2659
+ return parse(source, {
2031
2660
  sourceType: "module",
2032
2661
  plugins: ["typescript", "jsx"],
2033
2662
  errorRecovery: true
@@ -2035,630 +2664,393 @@ function findDefaultExportArray(source) {
2035
2664
  } catch {
2036
2665
  return null;
2037
2666
  }
2667
+ }
2668
+ function findDesignDecl(ast) {
2038
2669
  const body = ast.program?.body ?? [];
2039
2670
  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
2671
+ let varDecl = null;
2672
+ if (node.type === "VariableDeclaration") varDecl = node;
2673
+ else if (node.type === "ExportNamedDeclaration") {
2674
+ const decl = node.declaration;
2675
+ if (decl?.type === "VariableDeclaration") varDecl = decl;
2676
+ }
2677
+ if (!varDecl) continue;
2678
+ const declarations = varDecl.declarations ?? [];
2679
+ for (const d of declarations) {
2680
+ const id = d.id;
2681
+ if (!id || id.type !== "Identifier" || id.name !== "design") continue;
2682
+ const init = d.init;
2683
+ if (!init) return null;
2684
+ let inner = init;
2685
+ if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
2686
+ const expr = inner.expression;
2687
+ if (expr) inner = expr;
2688
+ }
2689
+ if (inner.type !== "ObjectExpression") return null;
2690
+ return {
2691
+ declStart: node.start,
2692
+ declEnd: node.end,
2693
+ objectStart: inner.start,
2694
+ objectEnd: inner.end
2695
+ };
2696
+ }
2697
+ }
2698
+ return null;
2699
+ }
2700
+ function literalToValue(node) {
2701
+ switch (node.type) {
2702
+ case "StringLiteral": return node.value;
2703
+ case "NumericLiteral": return node.value;
2704
+ case "BooleanLiteral": return node.value;
2705
+ case "NullLiteral": return null;
2706
+ case "UnaryExpression": {
2707
+ const op = node.operator;
2708
+ const arg = node.argument;
2709
+ const v = literalToValue(arg);
2710
+ if (op === "-" && typeof v === "number") return -v;
2711
+ if (op === "+" && typeof v === "number") return v;
2712
+ throw new Error(`unsupported unary operator ${op}`);
2713
+ }
2714
+ case "TemplateLiteral": {
2715
+ const quasis = node.quasis;
2716
+ const expressions = node.expressions;
2717
+ if (expressions.length > 0) throw new Error("template literal has expressions");
2718
+ return quasis[0].value.cooked ?? quasis[0].value.raw;
2719
+ }
2720
+ case "ArrayExpression": {
2721
+ const elements = node.elements;
2722
+ return elements.map((el) => {
2723
+ if (!el) throw new Error("array has hole");
2724
+ return literalToValue(el);
2053
2725
  });
2054
2726
  }
2055
- return {
2056
- elements,
2057
- arrayStart,
2058
- arrayEnd
2059
- };
2727
+ case "ObjectExpression": {
2728
+ const properties = node.properties;
2729
+ const out = {};
2730
+ for (const prop of properties) {
2731
+ if (prop.type !== "ObjectProperty") throw new Error("object has spread or method");
2732
+ const p = prop;
2733
+ if (p.computed) throw new Error("object has computed key");
2734
+ let key;
2735
+ if (p.key.type === "Identifier" && typeof p.key.name === "string") key = p.key.name;
2736
+ else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") key = p.key.value;
2737
+ else throw new Error("unsupported object key");
2738
+ out[key] = literalToValue(p.value);
2739
+ }
2740
+ return out;
2741
+ }
2742
+ default: throw new Error(`unsupported node type ${node.type}`);
2060
2743
  }
2061
- return null;
2062
2744
  }
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);
2745
+ function isPlainObject(v) {
2746
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2747
+ }
2748
+ function mergeDesign(base, patch) {
2749
+ const out = JSON.parse(JSON.stringify(base));
2750
+ const apply = (target, src) => {
2751
+ for (const [k, v] of Object.entries(src)) if (isPlainObject(v) && isPlainObject(target[k])) apply(target[k], v);
2752
+ else target[k] = v;
2753
+ };
2754
+ if (isPlainObject(patch)) apply(out, patch);
2755
+ return out;
2756
+ }
2757
+ function indent(level) {
2758
+ return " ".repeat(level);
2759
+ }
2760
+ function jsString(s) {
2761
+ return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
2762
+ }
2763
+ function isValidIdentifier(name) {
2764
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
2765
+ }
2766
+ function serializeValue(value, level) {
2767
+ if (value === null) return "null";
2768
+ if (typeof value === "string") return jsString(value);
2769
+ if (typeof value === "number") {
2770
+ if (!Number.isFinite(value)) throw new Error("non-finite number");
2771
+ return String(value);
2087
2772
  }
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;
2773
+ if (typeof value === "boolean") return value ? "true" : "false";
2774
+ if (Array.isArray(value)) {
2775
+ if (value.length === 0) return "[]";
2776
+ const inner = value.map((el) => serializeValue(el, level + 1)).join(", ");
2777
+ return `[${inner}]`;
2093
2778
  }
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);
2779
+ if (isPlainObject(value)) {
2780
+ const entries = Object.entries(value);
2781
+ if (entries.length === 0) return "{}";
2782
+ const childIndent = indent(level + 1);
2783
+ const lines = entries.map(([k, v]) => {
2784
+ const key = isValidIdentifier(k) ? k : jsString(k);
2785
+ return `${childIndent}${key}: ${serializeValue(v, level + 1)},`;
2786
+ });
2787
+ return `{\n${lines.join("\n")}\n${indent(level)}}`;
2788
+ }
2789
+ throw new Error(`unsupported value type ${typeof value}`);
2104
2790
  }
2105
- function findNotesArray(source) {
2106
- let ast;
2791
+ function serializeDesign(design) {
2792
+ return serializeValue(design, 0);
2793
+ }
2794
+ function parseSlideDesign(source) {
2795
+ const ast = parseSource$1(source);
2796
+ if (!ast) return {
2797
+ ok: false,
2798
+ exists: true,
2799
+ error: "could not parse slide source"
2800
+ };
2801
+ const loc = findDesignDecl(ast);
2802
+ if (!loc) return {
2803
+ ok: false,
2804
+ exists: false
2805
+ };
2806
+ const objectNode = findDesignObjectNode(ast);
2807
+ if (!objectNode) return {
2808
+ ok: false,
2809
+ exists: true,
2810
+ error: "design has unsupported initializer"
2811
+ };
2812
+ let value;
2107
2813
  try {
2108
- ast = parse(source, {
2109
- sourceType: "module",
2110
- plugins: ["typescript", "jsx"],
2111
- errorRecovery: true
2112
- });
2113
- } catch {
2114
- return "invalid";
2814
+ value = literalToValue(objectNode);
2815
+ } catch (err) {
2816
+ return {
2817
+ ok: false,
2818
+ exists: true,
2819
+ error: err.message
2820
+ };
2115
2821
  }
2822
+ const merged = mergeDesign(defaultDesign, value);
2823
+ return {
2824
+ ok: true,
2825
+ design: merged,
2826
+ loc
2827
+ };
2828
+ }
2829
+ function findDesignObjectNode(ast) {
2116
2830
  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 ?? [];
2831
+ for (const node of body) {
2832
+ let varDecl = null;
2833
+ if (node.type === "VariableDeclaration") varDecl = node;
2834
+ else if (node.type === "ExportNamedDeclaration") {
2835
+ const decl = node.declaration;
2836
+ if (decl?.type === "VariableDeclaration") varDecl = decl;
2837
+ }
2838
+ if (!varDecl) continue;
2839
+ const declarations = varDecl.declarations ?? [];
2122
2840
  for (const d of declarations) {
2123
2841
  const id = d.id;
2124
- if (!id || id.type !== "Identifier" || id.name !== "notes") continue;
2842
+ if (!id || id.type !== "Identifier" || id.name !== "design") continue;
2125
2843
  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));
2844
+ if (!init) return null;
2845
+ let inner = init;
2846
+ if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
2847
+ const expr = inner.expression;
2848
+ if (expr) inner = expr;
2142
2849
  }
2143
- return {
2144
- arrayStart,
2145
- arrayEnd,
2146
- elementTexts
2147
- };
2850
+ if (inner.type !== "ObjectExpression") return null;
2851
+ return inner;
2148
2852
  }
2149
2853
  }
2150
2854
  return null;
2151
2855
  }
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}`;
2856
+ function findImports(ast) {
2857
+ const body = ast.program?.body ?? [];
2858
+ const out = [];
2859
+ for (const node of body) {
2860
+ if (node.type !== "ImportDeclaration") continue;
2861
+ const src = node.source?.value;
2862
+ if (typeof src !== "string") continue;
2863
+ const specs = node.specifiers ?? [];
2864
+ out.push({
2865
+ node,
2866
+ source: src,
2867
+ specifiers: specs
2868
+ });
2220
2869
  }
2221
- return ", ";
2870
+ return out;
2222
2871
  }
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]);
2872
+ function ensureDesignSystemImport(source, ast) {
2873
+ const imports = findImports(ast);
2874
+ const coreImport = imports.find((imp) => imp.source === "@open-slide/core");
2875
+ if (coreImport) {
2876
+ const hasDesignSystem = coreImport.specifiers.some((spec) => {
2877
+ if (spec.type !== "ImportSpecifier") return false;
2878
+ const imported = spec.imported;
2879
+ return imported?.name === "DesignSystem";
2880
+ });
2881
+ if (hasDesignSystem) return {
2882
+ source,
2883
+ offsetShift: 0
2884
+ };
2885
+ const node = coreImport.node;
2886
+ const importText = source.slice(node.start, node.end);
2887
+ const braceClose = importText.lastIndexOf("}");
2888
+ if (braceClose === -1) return {
2889
+ source,
2890
+ offsetShift: 0
2891
+ };
2892
+ const absoluteBrace = node.start + braceClose;
2893
+ const insertText = coreImport.specifiers.length > 0 ? ", type DesignSystem" : "type DesignSystem";
2894
+ const next$1 = `${source.slice(0, absoluteBrace)}${insertText}${source.slice(absoluteBrace)}`;
2895
+ return {
2896
+ source: next$1,
2897
+ offsetShift: insertText.length
2898
+ };
2251
2899
  }
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);
2900
+ const stmt = `import type { DesignSystem } from '@open-slide/core';\n`;
2901
+ if (imports.length > 0) {
2902
+ const last = imports[imports.length - 1];
2903
+ const insertAt = last.node.end;
2904
+ const trail = source[insertAt] === "\n" ? "" : "\n";
2905
+ const next$1 = `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
2906
+ return {
2907
+ source: next$1,
2908
+ offsetShift: 1 + stmt.length - (trail ? 0 : 1)
2909
+ };
2910
+ }
2911
+ const next = `${stmt}\n${source}`;
2912
+ return {
2913
+ source: next,
2914
+ offsetShift: stmt.length + 1
2915
+ };
2256
2916
  }
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;
2917
+ function findInsertionPoint(source, ast) {
2918
+ const imports = findImports(ast);
2919
+ if (imports.length === 0) return 0;
2920
+ const last = imports[imports.length - 1];
2921
+ let off = last.node.end;
2922
+ while (off < source.length && source[off] !== "\n") off++;
2923
+ if (off < source.length) off++;
2924
+ return off;
2925
+ }
2926
+ function applyDesignWrite(source, next) {
2927
+ let body;
2928
+ try {
2929
+ body = serializeDesign(next);
2930
+ } catch (err) {
2263
2931
  return {
2264
- type: "emoji",
2265
- value: icon.value
2932
+ ok: false,
2933
+ status: 422,
2934
+ error: `serialize failed: ${err.message}`
2266
2935
  };
2267
2936
  }
2268
- if (icon.type === "color") {
2269
- if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
2937
+ const ast = parseSource$1(source);
2938
+ if (!ast) return {
2939
+ ok: false,
2940
+ status: 422,
2941
+ error: "could not parse slide source"
2942
+ };
2943
+ const loc = findDesignDecl(ast);
2944
+ if (loc) {
2945
+ const out$1 = source.slice(0, loc.objectStart) + body + source.slice(loc.objectEnd);
2270
2946
  return {
2271
- type: "color",
2272
- value: icon.value
2947
+ ok: true,
2948
+ source: out$1,
2949
+ created: false
2273
2950
  };
2274
2951
  }
2275
- return null;
2952
+ const withImport = ensureDesignSystemImport(source, ast);
2953
+ const ast2 = parseSource$1(withImport.source);
2954
+ if (!ast2) return {
2955
+ ok: false,
2956
+ status: 422,
2957
+ error: "failed to re-parse after adding import"
2958
+ };
2959
+ const insertAt = findInsertionPoint(withImport.source, ast2);
2960
+ const block = `\nconst design: DesignSystem = ${body};\n`;
2961
+ const out = withImport.source.slice(0, insertAt) + block + withImport.source.slice(insertAt);
2962
+ return {
2963
+ ok: true,
2964
+ source: out,
2965
+ created: true
2966
+ };
2276
2967
  }
2277
- function filesPlugin(opts) {
2968
+ function designPlugin(opts) {
2278
2969
  const userCwd = opts.userCwd;
2279
2970
  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
2971
  return {
2285
- name: "open-slide:files",
2972
+ name: "open-slide:design",
2286
2973
  apply: "serve",
2287
2974
  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) => {
2975
+ server.middlewares.use("/__design", async (req, res, next) => {
2321
2976
  const url = new URL(req.url ?? "/", "http://local");
2322
2977
  const method = req.method ?? "GET";
2978
+ const slideId = url.searchParams.get("slideId") ?? "";
2979
+ const file = resolveSlidePath$1(userCwd, slidesDir, slideId);
2980
+ if (!file) return json$1(res, 400, { error: "invalid slideId" });
2323
2981
  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" });
2982
+ if (method === "GET" && url.pathname === "/") {
2337
2983
  let source;
2338
2984
  try {
2339
- source = await fs.readFile(entry, "utf8");
2985
+ source = await fs.readFile(file, "utf8");
2340
2986
  } catch {
2341
2987
  return json$1(res, 404, { error: "slide not found" });
2342
2988
  }
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");
2989
+ const parsed = parseSlideDesign(source);
2990
+ if (parsed.ok) return json$1(res, 200, {
2991
+ design: parsed.design,
2992
+ exists: true,
2993
+ warning: null
2994
+ });
2995
+ if (parsed.exists === false) return json$1(res, 200, {
2996
+ design: defaultDesign,
2997
+ exists: false,
2998
+ warning: null
2999
+ });
2348
3000
  return json$1(res, 200, {
2349
- ok: true,
2350
- slideId: slideId$1,
2351
- order
3001
+ design: defaultDesign,
3002
+ exists: true,
3003
+ warning: parsed.error
2352
3004
  });
2353
3005
  }
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" });
3006
+ if (method === "PUT" && url.pathname === "/") {
3007
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
3008
+ if (!requestCheck.ok) return json$1(res, requestCheck.status, { error: requestCheck.error });
3009
+ const body = await readBody$1(req);
3010
+ const patch = body.patch;
3011
+ if (!patch || typeof patch !== "object") return json$1(res, 400, { error: "missing patch object" });
2366
3012
  let source;
2367
3013
  try {
2368
- source = await fs.readFile(entry, "utf8");
3014
+ source = await fs.readFile(file, "utf8");
2369
3015
  } catch {
2370
3016
  return json$1(res, 404, { error: "slide not found" });
2371
3017
  }
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");
3018
+ const parsed = parseSlideDesign(source);
3019
+ const baseDesign = parsed.ok ? parsed.design : defaultDesign;
3020
+ if (!parsed.ok && parsed.exists) return json$1(res, 422, { error: parsed.error });
3021
+ const merged = mergeDesign(baseDesign, patch);
3022
+ const written = applyDesignWrite(source, merged);
3023
+ if (!written.ok) return json$1(res, written.status, { error: written.error });
3024
+ if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
2375
3025
  return json$1(res, 200, {
2376
3026
  ok: true,
2377
- slideId: slideId$1,
2378
- index: pageIndex
3027
+ design: merged,
3028
+ created: written.created
2379
3029
  });
2380
3030
  }
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" });
3031
+ if (method === "POST" && url.pathname === "/reset") {
3032
+ const requestCheck = validateMutationRequest(req);
3033
+ if (!requestCheck.ok) return json$1(res, requestCheck.status, { error: requestCheck.error });
2391
3034
  let source;
2392
3035
  try {
2393
- source = await fs.readFile(entry, "utf8");
3036
+ source = await fs.readFile(file, "utf8");
2394
3037
  } catch {
2395
3038
  return json$1(res, 404, { error: "slide not found" });
2396
3039
  }
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" });
3040
+ const written = applyDesignWrite(source, defaultDesign);
3041
+ if (!written.ok) return json$1(res, written.status, { error: written.error });
3042
+ if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
2401
3043
  return json$1(res, 200, {
2402
3044
  ok: true,
2403
- slideId,
2404
- name
3045
+ design: defaultDesign,
3046
+ created: written.created
2405
3047
  });
2406
3048
  }
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
3049
  return next();
2416
3050
  } catch (err) {
2417
3051
  json$1(res, 500, { error: String(err.message ?? err) });
2418
3052
  }
2419
3053
  });
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
3054
  }
2663
3055
  };
2664
3056
  }
@@ -2887,6 +3279,8 @@ function notesPlugin(opts) {
2887
3279
  const url = new URL(req.url ?? "/", "http://local");
2888
3280
  const method = req.method ?? "GET";
2889
3281
  if (method !== "PUT" || url.pathname !== "/") return next();
3282
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
3283
+ if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
2890
3284
  try {
2891
3285
  const body = await readBody(req);
2892
3286
  const slideId = body.slideId ?? "";
@@ -2959,13 +3353,18 @@ function toId(absFile, slidesRoot) {
2959
3353
  return rel.split(path.sep)[0];
2960
3354
  }
2961
3355
  const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
2962
- function extractMetaTheme(src) {
3356
+ const META_CREATED_AT_RE = /(?:^|[\s,{])createdAt\s*:\s*['"]([^'"]+)['"]/;
3357
+ function extractMeta(src) {
3358
+ const empty = {
3359
+ theme: null,
3360
+ createdAt: null
3361
+ };
2963
3362
  const metaStart = src.search(/export\s+const\s+meta\b/);
2964
- if (metaStart === -1) return null;
3363
+ if (metaStart === -1) return empty;
2965
3364
  const eqIdx = src.indexOf("=", metaStart);
2966
- if (eqIdx === -1) return null;
3365
+ if (eqIdx === -1) return empty;
2967
3366
  const openBrace = src.indexOf("{", eqIdx);
2968
- if (openBrace === -1) return null;
3367
+ if (openBrace === -1) return empty;
2969
3368
  let depth = 0;
2970
3369
  let closeBrace = -1;
2971
3370
  for (let i = openBrace; i < src.length; i++) {
@@ -2979,34 +3378,52 @@ function extractMetaTheme(src) {
2979
3378
  }
2980
3379
  }
2981
3380
  }
2982
- if (closeBrace === -1) return null;
3381
+ if (closeBrace === -1) return empty;
2983
3382
  const body = src.slice(openBrace + 1, closeBrace);
2984
- const m = body.match(META_THEME_RE);
2985
- return m ? m[1] : null;
3383
+ const themeMatch = body.match(META_THEME_RE);
3384
+ const createdAtMatch = body.match(META_CREATED_AT_RE);
3385
+ return {
3386
+ theme: themeMatch ? themeMatch[1] : null,
3387
+ createdAt: createdAtMatch ? createdAtMatch[1] : null
3388
+ };
2986
3389
  }
2987
- async function readSlideTheme(abs) {
3390
+ async function readSlideMeta(abs) {
2988
3391
  try {
2989
3392
  const src = await fs.readFile(abs, "utf8");
2990
- return extractMetaTheme(src);
3393
+ return extractMeta(src);
2991
3394
  } catch {
2992
- return null;
3395
+ return {
3396
+ theme: null,
3397
+ createdAt: null
3398
+ };
2993
3399
  }
2994
3400
  }
3401
+ function parseCreatedAtMs(iso) {
3402
+ if (!iso) return null;
3403
+ const ms = Date.parse(iso);
3404
+ return Number.isFinite(ms) ? ms : null;
3405
+ }
2995
3406
  async function generateSlidesModule(files, slidesRoot, isDev) {
2996
3407
  const entries = await Promise.all(files.map(async (abs) => {
2997
3408
  const id = toId(abs, slidesRoot);
2998
3409
  const importPath = isDev ? `/@fs/${abs.replace(/^\/+/, "")}` : abs;
2999
- const theme = await readSlideTheme(abs);
3410
+ const meta = await readSlideMeta(abs);
3000
3411
  return {
3001
3412
  id,
3002
3413
  importPath,
3003
- theme
3414
+ theme: meta.theme,
3415
+ createdAt: parseCreatedAtMs(meta.createdAt)
3004
3416
  };
3005
3417
  }));
3006
3418
  const ids = JSON.stringify(entries.map((e) => e.id).sort());
3007
3419
  const themesMap = {};
3008
- for (const e of entries) if (e.theme) themesMap[e.id] = e.theme;
3420
+ const createdAtMap = {};
3421
+ for (const e of entries) {
3422
+ if (e.theme) themesMap[e.id] = e.theme;
3423
+ if (e.createdAt !== null) createdAtMap[e.id] = e.createdAt;
3424
+ }
3009
3425
  const themesJson = JSON.stringify(themesMap);
3426
+ const createdAtJson = JSON.stringify(createdAtMap);
3010
3427
  const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
3011
3428
  const devRuntime = isDev ? `
3012
3429
  const slideImportTokens = ${importTokens};
@@ -3027,6 +3444,7 @@ if (import.meta.hot) {
3027
3444
  return `// virtual:open-slide/slides — generated
3028
3445
  export const slideIds = ${ids};
3029
3446
  export const slideThemes = ${themesJson};
3447
+ export const slideCreatedAt = ${createdAtJson};
3030
3448
  ${devRuntime}
3031
3449
 
3032
3450
  export async function loadSlide(id) {
@@ -3229,18 +3647,18 @@ async function readTheme(mdAbs, themesRoot) {
3229
3647
  };
3230
3648
  }
3231
3649
  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
3650
+ const meta = themes.map((t$5) => ({
3651
+ id: t$5.id,
3652
+ name: t$5.frontmatter.name,
3653
+ description: t$5.frontmatter.description,
3654
+ body: t$5.body,
3655
+ hasDemo: t$5.demoAbs !== null
3238
3656
  }));
3239
- const cases = themes.flatMap((t$3) => {
3240
- const abs = t$3.demoAbs;
3657
+ const cases = themes.flatMap((t$5) => {
3658
+ const abs = t$5.demoAbs;
3241
3659
  if (!abs) return [];
3242
3660
  const importPath = isDev ? `/@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
3243
- return [` case ${JSON.stringify(t$3.id)}: return import(${JSON.stringify(importPath)});`];
3661
+ return [` case ${JSON.stringify(t$5.id)}: return import(${JSON.stringify(importPath)});`];
3244
3662
  }).join("\n");
3245
3663
  return `// virtual:open-slide/themes — generated
3246
3664
  export const themes = ${JSON.stringify(meta)};
@@ -3345,19 +3763,15 @@ async function createViteConfig(opts) {
3345
3763
  config
3346
3764
  }),
3347
3765
  designPlugin({ userCwd }),
3348
- commentsPlugin({
3766
+ apiPlugin({
3349
3767
  userCwd,
3350
- slidesDir
3768
+ slidesDir,
3769
+ assetsDir
3351
3770
  }),
3352
3771
  notesPlugin({
3353
3772
  userCwd,
3354
3773
  slidesDir
3355
3774
  }),
3356
- filesPlugin({
3357
- userCwd,
3358
- slidesDir,
3359
- assetsDir
3360
- }),
3361
3775
  currentPlugin({
3362
3776
  userCwd,
3363
3777
  slidesDir