@open-slide/core 1.3.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.
Files changed (45) hide show
  1. package/dist/{build-_276DMmJ.js → build-DZhbjQpQ.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-D9cZ1A0X.d.ts → config-BQdTMho4.d.ts} +2 -1
  4. package/dist/{config-BAwKWNtW.js → config-iKjqaX08.js} +2528 -1640
  5. package/dist/{dev-BoqeVXVq.js → dev-BjLGk5nN.js} +1 -1
  6. package/dist/{en-CDKzoZvf.js → en-DDGqyNaW.js} +27 -4
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +82 -13
  11. package/dist/{preview-BLPxspc9.js → preview-jwLWHWkQ.js} +1 -1
  12. package/dist/{types-JYG1cmwC.d.ts → types-Dpr8nbih.d.ts} +27 -1
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/slide-authoring/SKILL.md +19 -4
  17. package/src/app/app.tsx +2 -0
  18. package/src/app/components/asset-view.tsx +111 -18
  19. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  20. package/src/app/components/inspector/inspector-panel.tsx +267 -25
  21. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  22. package/src/app/components/panel/panel-shell.tsx +5 -3
  23. package/src/app/components/player.tsx +25 -5
  24. package/src/app/components/present/control-bar.tsx +12 -0
  25. package/src/app/components/present/laser-pointer.tsx +3 -4
  26. package/src/app/components/present/progress-bar.tsx +4 -4
  27. package/src/app/components/sidebar/folder-item.tsx +14 -3
  28. package/src/app/components/sidebar/sidebar.tsx +10 -0
  29. package/src/app/lib/assets.ts +21 -0
  30. package/src/app/lib/export-pdf.ts +6 -0
  31. package/src/app/lib/inspector/use-editor.ts +9 -1
  32. package/src/app/lib/sdk.ts +2 -0
  33. package/src/app/lib/slides.ts +9 -0
  34. package/src/app/lib/use-slide-module.ts +48 -0
  35. package/src/app/routes/assets.tsx +9 -0
  36. package/src/app/routes/home-shell.tsx +23 -2
  37. package/src/app/routes/home.tsx +101 -3
  38. package/src/app/routes/presenter.tsx +2 -20
  39. package/src/app/routes/slide.tsx +117 -39
  40. package/src/app/virtual.d.ts +1 -0
  41. package/src/locale/en.ts +28 -5
  42. package/src/locale/ja.ts +28 -5
  43. package/src/locale/types.ts +27 -1
  44. package/src/locale/zh-cn.ts +28 -6
  45. package/src/locale/zh-tw.ts +28 -6
@@ -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,63 +168,10 @@ 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
- return parse(source, {
191
- sourceType: "module",
192
- plugins: ["typescript", "jsx"],
193
- errorRecovery: true
194
- });
195
- } catch {
196
- return null;
197
- }
198
- }
199
- function findInnermostJsxElement(ast, line, column) {
200
- const exact = findJsxByStart(ast, line, column);
201
- if (exact) return exact;
202
- for (const n of findJsxAncestors(ast, line, column)) if (t$2.isJSXElement(n)) return n;
203
- return null;
204
- }
205
171
  function findJsxByStart(ast, line, column) {
206
172
  let hit = null;
207
173
  walkJsx(ast, (n) => {
208
- if (!t$2.isJSXElement(n) || !n.loc) return;
174
+ if (!t$4.isJSXElement(n) || !n.loc) return;
209
175
  const s = n.loc.start;
210
176
  if (s.line === line && s.column === column) {
211
177
  hit = n;
@@ -214,40 +180,114 @@ function findJsxByStart(ast, line, column) {
214
180
  });
215
181
  return hit;
216
182
  }
217
- function jsString$1(s) {
218
- return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
219
- }
220
- function jsxAttrName(attr) {
221
- return t$2.isJSXIdentifier(attr.name) ? attr.name.name : null;
183
+ function findInnermostJsxElement(ast, line, column) {
184
+ const exact = findJsxByStart(ast, line, column);
185
+ if (exact) return exact;
186
+ for (const n of findJsxAncestors$1(ast, line, column)) if (t$4.isJSXElement(n)) return n;
187
+ return null;
222
188
  }
223
- function findJsxAttr(opening, name) {
224
- for (const attr of opening.attributes) if (t$2.isJSXAttribute(attr) && jsxAttrName(attr) === name) return attr;
189
+ function findUniqueElementByText(ast, prevText) {
190
+ const hits = [];
191
+ walkJsx(ast, (n) => {
192
+ if (!t$4.isJSXElement(n)) return;
193
+ const parts = [];
194
+ collectTextRangeParts(n, parts);
195
+ if (textRangeContent(parts) !== prevText) return;
196
+ hits.push({
197
+ node: n,
198
+ size: (n.end ?? 0) - (n.start ?? 0)
199
+ });
200
+ });
201
+ if (hits.length === 0) return null;
202
+ hits.sort((a, b) => a.size - b.size);
203
+ const best = hits[0];
204
+ const bestStart = best.node.start ?? 0;
205
+ const bestEnd = best.node.end ?? 0;
206
+ const hasSiblingMatch = hits.slice(1).some(({ node }) => (node.start ?? 0) > bestStart || (node.end ?? 0) < bestEnd);
207
+ return hasSiblingMatch ? null : best.node;
208
+ }
209
+ function fallbackTextForOps(ops) {
210
+ for (const op of ops) if ((op.kind === "set-style" || op.kind === "set-text" || op.kind === "set-text-range-style") && op.prevText !== void 0) return op.prevText;
225
211
  return null;
226
212
  }
213
+ function hasOnlyTextOps(ops) {
214
+ return ops.length > 0 && ops.every((op) => op.kind === "set-text");
215
+ }
216
+ function elementTextMatches(element, prevText) {
217
+ const parts = [];
218
+ collectTextRangeParts(element, parts);
219
+ return textRangeContent(parts) === prevText;
220
+ }
221
+ function elementHasTextCandidate(ast, element, prevText) {
222
+ const norm = prevText.trim();
223
+ return collectElementTextCandidates(ast, element).some((candidate) => candidate.current === norm);
224
+ }
225
+ function findElementForEdit(ast, line, column, ops) {
226
+ const element = findInnermostJsxElement(ast, line, column);
227
+ const prevText = fallbackTextForOps(ops);
228
+ if (prevText === null) return element;
229
+ if (hasOnlyTextOps(ops) && element && (elementTextMatches(element, prevText) || elementHasTextCandidate(ast, element, prevText))) return element;
230
+ const textMatch = findUniqueElementByText(ast, prevText);
231
+ if (element && elementTextMatches(element, prevText)) return textMatch ?? element;
232
+ return textMatch ?? element;
233
+ }
227
234
  function buildStyleSplice(source, element, ops) {
228
235
  const opening = element.openingElement;
229
236
  const existing = findJsxAttr(opening, "style");
230
- const style = new Map();
237
+ const entries = [];
238
+ let hasRawEntry = false;
231
239
  if (existing) {
232
240
  const value = existing.value;
233
- 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" };
234
242
  const expr = value.expression;
235
- if (!t$2.isObjectExpression(expr)) return { error: "style is not a literal object" };
236
- for (const prop of expr.properties) {
237
- if (!t$2.isObjectProperty(prop)) return { error: "style contains spread or method" };
238
- if (prop.computed) return { error: "style has computed key" };
243
+ if (!t$4.isObjectExpression(expr)) {
244
+ if (typeof expr.start !== "number" || typeof expr.end !== "number") return { error: "style value missing source range" };
245
+ entries.push({
246
+ kind: "raw",
247
+ text: `...(${source.slice(expr.start, expr.end)})`
248
+ });
249
+ hasRawEntry = true;
250
+ } else for (const prop of expr.properties) if (t$4.isObjectProperty(prop) && !prop.computed) {
239
251
  let keyName = null;
240
- if (t$2.isIdentifier(prop.key)) keyName = prop.key.name;
241
- 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;
242
254
  if (!keyName) return { error: "style has unsupported key" };
243
255
  const v = prop.value;
244
- if (typeof v.start !== "number" || typeof v.end !== "number") return { error: "style value missing source range" };
245
- style.set(keyName, source.slice(v.start, v.end));
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" };
257
+ entries.push({
258
+ kind: "prop",
259
+ key: keyName,
260
+ keyText: source.slice(prop.key.start, prop.key.end),
261
+ valueText: source.slice(v.start, v.end)
262
+ });
263
+ } else {
264
+ if (typeof prop.start !== "number" || typeof prop.end !== "number") return { error: "style value missing source range" };
265
+ entries.push({
266
+ kind: "raw",
267
+ text: source.slice(prop.start, prop.end)
268
+ });
269
+ hasRawEntry = true;
246
270
  }
247
271
  }
248
- for (const op of ops) if (op.value === null) style.delete(op.key);
249
- else style.set(op.key, jsString$1(op.value));
250
- if (style.size === 0) {
272
+ for (const op of ops) {
273
+ const matching = entries.filter((entry) => entry.kind === "prop" && entry.key === op.key);
274
+ if (op.value === null) {
275
+ for (const entry of matching) entries.splice(entries.indexOf(entry), 1);
276
+ if (hasRawEntry) entries.push({
277
+ kind: "prop",
278
+ key: op.key,
279
+ keyText: op.key,
280
+ valueText: "undefined"
281
+ });
282
+ } else if (matching.length > 0) matching[matching.length - 1].valueText = jsString$1(op.value);
283
+ else entries.push({
284
+ kind: "prop",
285
+ key: op.key,
286
+ keyText: op.key,
287
+ valueText: jsString$1(op.value)
288
+ });
289
+ }
290
+ if (entries.length === 0) {
251
291
  if (!existing) return null;
252
292
  let from = existing.start ?? 0;
253
293
  if (from > 0 && source[from - 1] === " ") from -= 1;
@@ -257,16 +297,29 @@ function buildStyleSplice(source, element, ops) {
257
297
  text: ""
258
298
  };
259
299
  }
260
- const propsText = Array.from(style.entries()).map(([k, v]) => `${k}: ${v}`).join(", ");
300
+ const propsText = entries.map((entry) => entry.kind === "prop" ? `${entry.keyText}: ${entry.valueText}` : entry.text).join(", ");
261
301
  const newAttr = `style={{ ${propsText} }}`;
262
- if (existing) return {
263
- from: existing.start ?? 0,
264
- to: existing.end ?? 0,
265
- text: newAttr
266
- };
302
+ if (existing) {
303
+ const lastAttr$1 = opening.attributes[opening.attributes.length - 1];
304
+ if (lastAttr$1 && lastAttr$1 !== existing && typeof lastAttr$1.end === "number") {
305
+ const attrsAfterStyle = source.slice(existing.end ?? 0, lastAttr$1.end).replace(/^[ \t]+/, "");
306
+ return {
307
+ from: existing.start ?? 0,
308
+ to: lastAttr$1.end,
309
+ text: `${attrsAfterStyle} ${newAttr}`
310
+ };
311
+ }
312
+ return {
313
+ from: existing.start ?? 0,
314
+ to: existing.end ?? 0,
315
+ text: newAttr
316
+ };
317
+ }
318
+ const lastAttr = opening.attributes[opening.attributes.length - 1];
319
+ const at = lastAttr?.end ?? opening.name.end ?? 0;
267
320
  return {
268
- from: opening.name.end ?? 0,
269
- to: opening.name.end ?? 0,
321
+ from: at,
322
+ to: at,
270
323
  text: ` ${newAttr}`
271
324
  };
272
325
  }
@@ -276,10 +329,14 @@ function formatJsxText(value) {
276
329
  }
277
330
  function meaningfulChildren(parent) {
278
331
  return parent.children.filter((c) => {
279
- if (t$2.isJSXText(c)) return c.value.trim() !== "";
332
+ if (t$4.isJSXText(c)) return c.value.trim() !== "";
280
333
  return true;
281
334
  });
282
335
  }
336
+ function isOnlyMeaningfulChild(parent, child) {
337
+ const meaningful = meaningfulChildren(parent);
338
+ return meaningful.length === 1 && meaningful[0] === child;
339
+ }
283
340
  function wrapSplice(parent, text) {
284
341
  const first = parent.children[0];
285
342
  const last = parent.children[parent.children.length - 1];
@@ -289,10 +346,64 @@ function wrapSplice(parent, text) {
289
346
  text
290
347
  };
291
348
  }
349
+ function splitLinesWithOffsets(value) {
350
+ const lines = [];
351
+ let start = 0;
352
+ for (let i = 0; i < value.length; i++) {
353
+ const ch = value[i];
354
+ if (ch !== "\n" && ch !== "\r") continue;
355
+ lines.push({
356
+ text: value.slice(start, i),
357
+ start
358
+ });
359
+ if (ch === "\r" && value[i + 1] === "\n") i += 1;
360
+ start = i + 1;
361
+ }
362
+ lines.push({
363
+ text: value.slice(start),
364
+ start
365
+ });
366
+ return lines;
367
+ }
368
+ function cleanJsxTextWithOffsets(value) {
369
+ const lines = splitLinesWithOffsets(value);
370
+ let lastNonEmptyLine = 0;
371
+ for (let i = 0; i < lines.length; i++) if (lines[i].text.trim()) lastNonEmptyLine = i;
372
+ let text = "";
373
+ const offsets = [];
374
+ for (let i = 0; i < lines.length; i++) {
375
+ const chars = Array.from(lines[i].text, (ch, j) => ({
376
+ ch: ch === " " ? " " : ch,
377
+ offset: lines[i].start + j
378
+ }));
379
+ let from = 0;
380
+ let to = chars.length;
381
+ if (i !== 0) while (from < to && chars[from].ch === " ") from += 1;
382
+ if (i !== lines.length - 1) while (to > from && chars[to - 1].ch === " ") to -= 1;
383
+ if (from >= to) continue;
384
+ for (const item of chars.slice(from, to)) {
385
+ text += item.ch;
386
+ offsets.push(item.offset);
387
+ }
388
+ if (i !== lastNonEmptyLine) {
389
+ text += " ";
390
+ offsets.push(null);
391
+ }
392
+ }
393
+ return {
394
+ text,
395
+ offsets
396
+ };
397
+ }
398
+ function isJsxBrElement(node) {
399
+ if (!t$4.isJSXElement(node)) return false;
400
+ const name = node.openingElement.name;
401
+ return t$4.isJSXIdentifier(name) && name.name.toLowerCase() === "br";
402
+ }
292
403
  function collectTextCandidates(element, out) {
293
404
  const meaningful = meaningfulChildren(element);
294
405
  const isSole = meaningful.length === 1;
295
- for (const child of meaningful) if (t$2.isJSXText(child)) {
406
+ for (const child of meaningful) if (t$4.isJSXText(child)) {
296
407
  const current = child.value.trim();
297
408
  if (!current) continue;
298
409
  out.push({
@@ -303,9 +414,9 @@ function collectTextCandidates(element, out) {
303
414
  text: formatJsxText(v)
304
415
  }
305
416
  });
306
- } else if (t$2.isJSXExpressionContainer(child)) {
417
+ } else if (t$4.isJSXExpressionContainer(child)) {
307
418
  const expr = child.expression;
308
- if (t$2.isStringLiteral(expr) || t$2.isNumericLiteral(expr)) {
419
+ if (t$4.isStringLiteral(expr) || t$4.isNumericLiteral(expr)) {
309
420
  const current = String(expr.value);
310
421
  out.push({
311
422
  current,
@@ -316,16 +427,236 @@ function collectTextCandidates(element, out) {
316
427
  }
317
428
  });
318
429
  }
319
- } 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);
431
+ }
432
+ function collectTextRangeParts(element, out) {
433
+ const parts = [];
434
+ collectTextRangePartsRaw(element, parts);
435
+ out.push(...normalizeTextRangeParts(parts));
436
+ }
437
+ function collectTextRangePartsRaw(element, out) {
438
+ for (const child of element.children) if (t$4.isJSXText(child)) {
439
+ const { text: current, offsets } = cleanJsxTextWithOffsets(child.value);
440
+ if (current) out.push({
441
+ node: child,
442
+ parent: element,
443
+ current,
444
+ raw: child.value,
445
+ text: formatJsxText,
446
+ offsets
447
+ });
448
+ } else if (t$4.isJSXExpressionContainer(child)) {
449
+ const expression = child.expression;
450
+ if (t$4.isStringLiteral(expression) || t$4.isNumericLiteral(expression)) {
451
+ const raw = String(expression.value);
452
+ const current = raw;
453
+ if (current) out.push({
454
+ node: child,
455
+ parent: element,
456
+ current,
457
+ raw,
458
+ text: (value) => `{${jsString$1(value)}}`,
459
+ offsets: Array.from({ length: current.length }, (_, i) => i)
460
+ });
461
+ }
462
+ } else if (isJsxBrElement(child)) out.push({
463
+ node: child,
464
+ current: "\n"
465
+ });
466
+ else if (t$4.isJSXElement(child) || t$4.isJSXFragment(child)) collectTextRangePartsRaw(child, out);
467
+ }
468
+ function normalizeTextRangeParts(parts) {
469
+ return parts.flatMap((part, index) => {
470
+ if (!("raw" in part)) return [part];
471
+ let start = 0;
472
+ let end = part.current.length;
473
+ if (parts[index - 1]?.current === "\n") while (start < end && /\s/.test(part.current[start] ?? "")) start++;
474
+ if (parts[index + 1]?.current === "\n") while (end > start && /\s/.test(part.current[end - 1] ?? "")) end--;
475
+ if (start === 0 && end === part.current.length) return [part];
476
+ if (start >= end) return [];
477
+ return [{
478
+ ...part,
479
+ current: part.current.slice(start, end),
480
+ offsets: part.offsets.slice(start, end)
481
+ }];
482
+ });
320
483
  }
321
- function propPassthroughName(element) {
322
- const meaningful = meaningfulChildren(element);
323
- if (meaningful.length !== 1) return null;
324
- const child = meaningful[0];
325
- if (!t$2.isJSXExpressionContainer(child)) return null;
326
- return t$2.isIdentifier(child.expression) ? child.expression.name : null;
484
+ function resetValueForRangeStyle(key) {
485
+ if (key === "fontWeight") return "400";
486
+ if (key === "fontStyle") return "normal";
487
+ return null;
327
488
  }
328
- function findEnclosingComponent(ast, target) {
489
+ function styleSpanForText(text, key, value) {
490
+ const styleValue = value ?? resetValueForRangeStyle(key);
491
+ if (styleValue === null) return formatJsxText(text);
492
+ return `<span style={{ ${key}: ${jsString$1(styleValue)} }}>${formatJsxText(text)}</span>`;
493
+ }
494
+ function textRangeContent(parts) {
495
+ return parts.map((part) => part.current).join("");
496
+ }
497
+ function compactText(value) {
498
+ return value.replace(/\s+/g, "");
499
+ }
500
+ function textMatchesExpected(current, expected) {
501
+ return current === expected || compactText(current) === compactText(expected);
502
+ }
503
+ function formatRichText(value, formatText = formatJsxText) {
504
+ return value.split("\n").map((part) => formatText(part)).join("<br />");
505
+ }
506
+ function formatOptionalText(value, formatText = formatJsxText) {
507
+ return value ? formatText(value) : "";
508
+ }
509
+ function textDiff(prevText, nextText) {
510
+ let start = 0;
511
+ while (start < prevText.length && start < nextText.length && prevText[start] === nextText[start]) start += 1;
512
+ let prevEnd = prevText.length;
513
+ let nextEnd = nextText.length;
514
+ while (prevEnd > start && nextEnd > start && prevText[prevEnd - 1] === nextText[nextEnd - 1]) {
515
+ prevEnd -= 1;
516
+ nextEnd -= 1;
517
+ }
518
+ return {
519
+ start,
520
+ end: prevEnd,
521
+ value: nextText.slice(start, nextEnd)
522
+ };
523
+ }
524
+ function textLeafSplice(part, value) {
525
+ const rawRange = textLeafRawRange(part, 0, part.current.length);
526
+ if (!rawRange) return spliceRange(part.node, part.text(value));
527
+ const { rawStart, rawEnd } = rawRange;
528
+ return {
529
+ from: part.node.start ?? 0,
530
+ to: part.node.end ?? 0,
531
+ text: `${part.raw.slice(0, rawStart)}${formatRichText(value, part.text)}${part.raw.slice(rawEnd)}`
532
+ };
533
+ }
534
+ function textLeafRawRange(part, start, end) {
535
+ if (start >= end) return null;
536
+ let first = null;
537
+ let last = null;
538
+ for (let i = start; i < end; i++) {
539
+ const offset = part.offsets[i];
540
+ if (offset === void 0) return null;
541
+ if (offset === null) continue;
542
+ first ??= offset;
543
+ last = offset;
544
+ }
545
+ if (first === null || last === null) return null;
546
+ return {
547
+ rawStart: first,
548
+ rawEnd: last + 1
549
+ };
550
+ }
551
+ function buildTextRangeReplaceSplices(parts, start, end, value) {
552
+ const splices = [];
553
+ let offset = 0;
554
+ let inserted = false;
555
+ for (const part of parts) {
556
+ const partStart = offset;
557
+ const partEnd = partStart + part.current.length;
558
+ offset = partEnd;
559
+ const overlaps = start < partEnd && end > partStart;
560
+ const insertsHere = start === end && !inserted && start >= partStart && start <= partEnd;
561
+ if (!overlaps && !insertsHere) continue;
562
+ if ("raw" in part) {
563
+ const localStart = Math.max(start, partStart) - partStart;
564
+ const localEnd = overlaps ? Math.min(end, partEnd) - partStart : localStart;
565
+ const nextText = `${part.current.slice(0, localStart)}${inserted ? "" : value}${part.current.slice(localEnd)}`;
566
+ splices.push(textLeafSplice(part, nextText));
567
+ } else if (overlaps) splices.push(spliceRange(part.node, inserted ? "" : formatRichText(value)));
568
+ else if (insertsHere) {
569
+ const at = start === partStart ? part.node.start ?? 0 : part.node.end ?? 0;
570
+ splices.push({
571
+ from: at,
572
+ to: at,
573
+ text: formatRichText(value)
574
+ });
575
+ }
576
+ inserted = true;
577
+ }
578
+ if (!inserted && start === end && start === offset) {
579
+ const last = parts[parts.length - 1];
580
+ if (!last) return { error: "element has no editable text" };
581
+ if ("raw" in last) splices.push(textLeafSplice(last, `${last.current}${value}`));
582
+ else splices.push({
583
+ from: last.node.end ?? 0,
584
+ to: last.node.end ?? 0,
585
+ text: formatRichText(value)
586
+ });
587
+ }
588
+ return splices;
589
+ }
590
+ function buildTextContentSplices(element, value, prevText) {
591
+ const parts = [];
592
+ collectTextRangeParts(element, parts);
593
+ const current = textRangeContent(parts);
594
+ if (!textMatchesExpected(current, prevText)) return { error: "no text candidate matches the current value" };
595
+ const diff = textDiff(current, value);
596
+ if (diff.start === diff.end && diff.value === "") return [];
597
+ return buildTextRangeReplaceSplices(parts, diff.start, diff.end, diff.value);
598
+ }
599
+ function buildTextRangeStyleSplices(ast, source, element, start, end, op, prevText) {
600
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start) return { error: "invalid text range" };
601
+ const parts = [];
602
+ collectTextRangeParts(element, parts);
603
+ const current = prevText ?? textRangeContent(parts);
604
+ if (!current) return { error: "element has no editable text" };
605
+ if (end > current.length) return { error: "text range is out of bounds" };
606
+ const renderedText = textRangeContent(parts);
607
+ if (prevText !== void 0 && renderedText !== prevText) {
608
+ if (elementTextCandidateMatches(ast, element, prevText)) {
609
+ const result = buildStyleSplice(source, element, [op]);
610
+ if (result && "error" in result) return result;
611
+ return result ? [result] : [];
612
+ }
613
+ return { error: "no text candidate matches the current value" };
614
+ }
615
+ const splices = [];
616
+ let leafStart = 0;
617
+ for (const leaf of parts) {
618
+ const leafEnd = leafStart + leaf.current.length;
619
+ if (!("raw" in leaf)) {
620
+ leafStart = leafEnd;
621
+ continue;
622
+ }
623
+ const selectedStart = Math.max(start, leafStart);
624
+ const selectedEnd = Math.min(end, leafEnd);
625
+ if (selectedStart >= selectedEnd) {
626
+ leafStart = leafEnd;
627
+ continue;
628
+ }
629
+ if (selectedStart === leafStart && selectedEnd === leafEnd && t$4.isJSXElement(leaf.parent) && leaf.parent !== element && isOnlyMeaningfulChild(leaf.parent, leaf.node)) {
630
+ const result = buildStyleSplice(source, leaf.parent, [op]);
631
+ if (result && "error" in result) return result;
632
+ if (result) splices.push(result);
633
+ leafStart = leafEnd;
634
+ continue;
635
+ }
636
+ const localStart = selectedStart - leafStart;
637
+ const localEnd = selectedEnd - leafStart;
638
+ const rawRange = textLeafRawRange(leaf, localStart, localEnd);
639
+ if (!rawRange) return { error: "text range source mismatch" };
640
+ const raw = leaf.raw;
641
+ const { rawStart, rawEnd } = rawRange;
642
+ const before = raw.slice(0, rawStart);
643
+ const selected = leaf.current.slice(localStart, localEnd);
644
+ const after = raw.slice(rawEnd);
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);
647
+ splices.push(spliceRange(leaf.node, `${beforeText}${styleSpanForText(selected, op.key, op.value)}${afterText}`));
648
+ leafStart = leafEnd;
649
+ }
650
+ return splices.length > 0 ? splices : null;
651
+ }
652
+ function propPassthroughName(element) {
653
+ const meaningful = meaningfulChildren(element);
654
+ if (meaningful.length !== 1) return null;
655
+ const child = meaningful[0];
656
+ if (!t$4.isJSXExpressionContainer(child)) return null;
657
+ return t$4.isIdentifier(child.expression) ? child.expression.name : null;
658
+ }
659
+ function findEnclosingComponent(ast, target) {
329
660
  let best = null;
330
661
  let bestSize = Number.POSITIVE_INFINITY;
331
662
  const targetStart = target.start ?? 0;
@@ -345,17 +676,17 @@ function findEnclosingComponent(ast, target) {
345
676
  }
346
677
  };
347
678
  const visitDecl = (decl) => {
348
- if (t$2.isFunctionDeclaration(decl) && decl.id) consider(decl.id.name, decl);
349
- else if (t$2.isVariableDeclaration(decl)) for (const d of decl.declarations) {
350
- if (!t$2.isVariableDeclarator(d) || !t$2.isIdentifier(d.id) || !d.init) continue;
351
- 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);
352
683
  }
353
684
  };
354
685
  for (const decl of ast.program.body) {
355
686
  visitDecl(decl);
356
- if (t$2.isExportNamedDeclaration(decl) || t$2.isExportDefaultDeclaration(decl)) {
687
+ if (t$4.isExportNamedDeclaration(decl) || t$4.isExportDefaultDeclaration(decl)) {
357
688
  const inner = decl.declaration;
358
- 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);
359
690
  }
360
691
  }
361
692
  return best;
@@ -363,51 +694,40 @@ function findEnclosingComponent(ast, target) {
363
694
  function componentDestructuresProp(fn, propName) {
364
695
  if (fn.params.length === 0) return false;
365
696
  let first = fn.params[0];
366
- if (t$2.isAssignmentPattern(first)) first = first.left;
367
- if (!t$2.isObjectPattern(first)) return false;
697
+ if (t$4.isAssignmentPattern(first)) first = first.left;
698
+ if (!t$4.isObjectPattern(first)) return false;
368
699
  for (const prop of first.properties) {
369
- if (!t$2.isObjectProperty(prop)) continue;
370
- if (t$2.isIdentifier(prop.key) && prop.key.name === propName) return true;
371
- 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;
372
703
  }
373
704
  return false;
374
705
  }
375
706
  function collectCallSiteCandidates(ast, componentName) {
376
707
  const out = [];
377
708
  walkJsx(ast, (n) => {
378
- if (!t$2.isJSXElement(n)) return;
709
+ if (!t$4.isJSXElement(n)) return;
379
710
  const elName = n.openingElement.name;
380
- if (t$2.isJSXIdentifier(elName) && elName.name === componentName) collectTextCandidates(n, out);
711
+ if (t$4.isJSXIdentifier(elName) && elName.name === componentName) collectTextCandidates(n, out);
381
712
  });
382
713
  return out;
383
714
  }
384
- function formatJsxAttrValue(value) {
385
- if (/^[^"\\<>&{}\n\r]*$/.test(value)) return `"${value}"`;
386
- return `{${jsString$1(value)}}`;
387
- }
388
- function spliceRange(node, text) {
389
- return {
390
- from: node.start ?? 0,
391
- to: node.end ?? 0,
392
- text
393
- };
394
- }
395
715
  function collectPropCallSiteCandidates(ast, componentName, propName) {
396
716
  const out = [];
397
717
  walkJsx(ast, (n) => {
398
- if (!t$2.isJSXElement(n)) return;
718
+ if (!t$4.isJSXElement(n)) return;
399
719
  const elName = n.openingElement.name;
400
- if (!t$2.isJSXIdentifier(elName) || elName.name !== componentName) return;
720
+ if (!t$4.isJSXIdentifier(elName) || elName.name !== componentName) return;
401
721
  const attr = findJsxAttr(n.openingElement, propName);
402
722
  if (!attr?.value) return;
403
723
  const v = attr.value;
404
- if (t$2.isStringLiteral(v)) out.push({
724
+ if (t$4.isStringLiteral(v)) out.push({
405
725
  current: v.value,
406
726
  splice: (s) => spliceRange(v, formatJsxAttrValue(s))
407
727
  });
408
- else if (t$2.isJSXExpressionContainer(v)) {
728
+ else if (t$4.isJSXExpressionContainer(v)) {
409
729
  const expr = v.expression;
410
- if (t$2.isStringLiteral(expr) || t$2.isNumericLiteral(expr)) out.push({
730
+ if (t$4.isStringLiteral(expr) || t$4.isNumericLiteral(expr)) out.push({
411
731
  current: String(expr.value),
412
732
  splice: (s) => spliceRange(v, formatJsxAttrValue(s))
413
733
  });
@@ -420,17 +740,17 @@ function findEnclosingMapCallback(ast, target) {
420
740
  const targetStart = target.start ?? 0;
421
741
  const targetEnd = target.end ?? 0;
422
742
  walkAll(ast, (node) => {
423
- if (!t$2.isCallExpression(node)) return;
743
+ if (!t$4.isCallExpression(node)) return;
424
744
  const callee = node.callee;
425
- if (!t$2.isMemberExpression(callee) || callee.computed) return;
426
- if (!t$2.isIdentifier(callee.property)) return;
745
+ if (!t$4.isMemberExpression(callee) || callee.computed) return;
746
+ if (!t$4.isIdentifier(callee.property)) return;
427
747
  if (callee.property.name !== "map" && callee.property.name !== "flatMap") return;
428
748
  const fn = node.arguments[0];
429
- if (!fn || !t$2.isArrowFunctionExpression(fn) && !t$2.isFunctionExpression(fn)) return;
749
+ if (!fn || !t$4.isArrowFunctionExpression(fn) && !t$4.isFunctionExpression(fn)) return;
430
750
  const fnStart = fn.start ?? 0;
431
751
  const fnEnd = fn.end ?? 0;
432
752
  if (fnStart > targetStart || fnEnd < targetEnd) return;
433
- if (!t$2.isExpression(callee.object)) return;
753
+ if (!t$4.isExpression(callee.object)) return;
434
754
  const size = fnEnd - fnStart;
435
755
  if (!best || size < best.size) best = {
436
756
  fn,
@@ -447,26 +767,26 @@ function findEnclosingMapCallback(ast, target) {
447
767
  }
448
768
  function resolveArrayLiteralElements(ast, expr) {
449
769
  const dropHoles = (arr) => arr.elements.filter((e) => e != null);
450
- if (t$2.isArrayExpression(expr)) return dropHoles(expr);
451
- if (!t$2.isIdentifier(expr)) return null;
770
+ if (t$4.isArrayExpression(expr)) return dropHoles(expr);
771
+ if (!t$4.isIdentifier(expr)) return null;
452
772
  const name = expr.name;
453
773
  const useStart = expr.start ?? 0;
454
774
  let init = null;
455
775
  walkAll(ast, (node) => {
456
- if (!t$2.isVariableDeclarator(node)) return;
457
- if (!t$2.isIdentifier(node.id) || node.id.name !== name) return;
458
- 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;
459
779
  if ((node.init.start ?? 0) > useStart) return;
460
780
  init = node.init;
461
781
  });
462
782
  return init ? dropHoles(init) : null;
463
783
  }
464
784
  function findObjectProperty(obj, name) {
465
- if (!t$2.isObjectExpression(obj)) return null;
785
+ if (!t$4.isObjectExpression(obj)) return null;
466
786
  for (const prop of obj.properties) {
467
- if (!t$2.isObjectProperty(prop) || prop.computed) continue;
468
- if (t$2.isIdentifier(prop.key) && prop.key.name === name) return prop;
469
- 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;
470
790
  }
471
791
  return null;
472
792
  }
@@ -474,22 +794,22 @@ function decodeMapPassthrough(element, callbackParam) {
474
794
  const meaningful = meaningfulChildren(element);
475
795
  if (meaningful.length !== 1) return null;
476
796
  const child = meaningful[0];
477
- if (!t$2.isJSXExpressionContainer(child)) return null;
797
+ if (!t$4.isJSXExpressionContainer(child)) return null;
478
798
  const expr = child.expression;
479
- if (t$2.isMemberExpression(expr)) {
799
+ if (t$4.isMemberExpression(expr)) {
480
800
  if (expr.computed) return null;
481
- if (!t$2.isIdentifier(expr.object) || !t$2.isIdentifier(expr.property)) return null;
482
- 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;
483
803
  if (callbackParam.name !== expr.object.name) return null;
484
804
  return expr.property.name;
485
805
  }
486
- if (t$2.isIdentifier(expr)) {
806
+ if (t$4.isIdentifier(expr)) {
487
807
  const fieldName = expr.name;
488
- if (!callbackParam || !t$2.isObjectPattern(callbackParam)) return null;
808
+ if (!callbackParam || !t$4.isObjectPattern(callbackParam)) return null;
489
809
  for (const prop of callbackParam.properties) {
490
- if (!t$2.isObjectProperty(prop) || prop.computed) continue;
491
- if (!t$2.isIdentifier(prop.key) || prop.key.name !== fieldName) continue;
492
- 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;
493
813
  }
494
814
  }
495
815
  return null;
@@ -506,18 +826,18 @@ function collectArrayMapCandidates(ast, element) {
506
826
  const prop = findObjectProperty(obj, fieldName);
507
827
  if (!prop) continue;
508
828
  const v = prop.value;
509
- if (t$2.isStringLiteral(v)) out.push({
829
+ if (t$4.isStringLiteral(v)) out.push({
510
830
  current: v.value,
511
831
  splice: (s) => spliceRange(v, jsString$1(s))
512
832
  });
513
- else if (t$2.isNumericLiteral(v)) out.push({
833
+ else if (t$4.isNumericLiteral(v)) out.push({
514
834
  current: String(v.value),
515
835
  splice: (s) => spliceRange(v, jsString$1(s))
516
836
  });
517
837
  }
518
838
  return out;
519
839
  }
520
- function buildTextSplice(ast, element, value, prevText) {
840
+ function collectElementTextCandidates(ast, element) {
521
841
  const candidates = [];
522
842
  collectTextCandidates(element, candidates);
523
843
  if (candidates.length === 0) {
@@ -527,6 +847,14 @@ function buildTextSplice(ast, element, value, prevText) {
527
847
  else if (passthrough && enclosing && componentDestructuresProp(enclosing.fn, passthrough)) candidates.push(...collectPropCallSiteCandidates(ast, enclosing.name, passthrough));
528
848
  }
529
849
  if (candidates.length === 0) candidates.push(...collectArrayMapCandidates(ast, element));
850
+ return candidates;
851
+ }
852
+ function elementTextCandidateMatches(ast, element, prevText) {
853
+ const norm = prevText.trim();
854
+ return collectElementTextCandidates(ast, element).some((candidate) => candidate.current === norm);
855
+ }
856
+ function buildTextSplice(ast, element, value, prevText) {
857
+ const candidates = collectElementTextCandidates(ast, element);
530
858
  if (candidates.length === 0) return { error: "element has no editable text" };
531
859
  if (candidates.length === 1) return candidates[0].splice(value);
532
860
  if (prevText === void 0) return { error: "element has multiple text candidates; missing prevText" };
@@ -536,50 +864,6 @@ function buildTextSplice(ast, element, value, prevText) {
536
864
  if (matches.length > 1) return { error: "multiple text candidates share the same value; cannot disambiguate" };
537
865
  return matches[0].splice(value);
538
866
  }
539
- function findImports$1(ast) {
540
- const out = [];
541
- for (const node of ast.program.body) {
542
- if (!t$2.isImportDeclaration(node)) continue;
543
- let def = null;
544
- for (const spec of node.specifiers) if (t$2.isImportDefaultSpecifier(spec)) {
545
- def = spec.local.name;
546
- break;
547
- }
548
- out.push({
549
- node,
550
- source: node.source.value,
551
- defaultIdent: def
552
- });
553
- }
554
- return out;
555
- }
556
- function collectTopLevelIdentifiers(ast) {
557
- const names = new Set();
558
- for (const imp of findImports$1(ast)) {
559
- if (imp.defaultIdent) names.add(imp.defaultIdent);
560
- for (const spec of imp.node.specifiers) if (!t$2.isImportDefaultSpecifier(spec)) names.add(spec.local.name);
561
- }
562
- return names;
563
- }
564
- function safeAssetIdentifier(filename, taken) {
565
- const stem = filename.replace(/\.[^.]+$/, "");
566
- let camel = "";
567
- let upper = false;
568
- for (const ch of stem) if (/[A-Za-z0-9]/.test(ch)) {
569
- camel += upper ? ch.toUpperCase() : ch;
570
- upper = false;
571
- } else upper = camel.length > 0;
572
- let base = camel;
573
- if (!base || !/^[A-Za-z_$]/.test(base)) base = `asset${base.charAt(0).toUpperCase()}${base.slice(1)}` || "asset";
574
- base = base.charAt(0).toLowerCase() + base.slice(1);
575
- let candidate = base;
576
- let i = 2;
577
- while (taken.has(candidate)) {
578
- candidate = `${base}${i}`;
579
- i += 1;
580
- }
581
- return candidate;
582
- }
583
867
  function planAssetImport(ast, assetPath) {
584
868
  const imports = findImports$1(ast);
585
869
  for (const imp of imports) if (imp.source === assetPath && imp.defaultIdent) return {
@@ -603,7 +887,7 @@ function planAssetImport(ast, assetPath) {
603
887
  }
604
888
  function planAssetAttr(ast, element, attr, assetPath) {
605
889
  if (!attr || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(attr)) return { error: "invalid attribute name" };
606
- if (!assetPath.startsWith("./assets/")) return { error: "asset path must start with ./assets/" };
890
+ if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
607
891
  const { identifier, importSplice } = planAssetImport(ast, assetPath);
608
892
  const opening = element.openingElement;
609
893
  const newAttr = `${attr}={${identifier}}`;
@@ -622,26 +906,10 @@ function planAssetAttr(ast, element, attr, assetPath) {
622
906
  attrSplice
623
907
  };
624
908
  }
625
- function readJsxStringAttr(opening, name) {
626
- const attr = findJsxAttr(opening, name);
627
- const v = attr?.value;
628
- if (!v) return null;
629
- if (t$2.isStringLiteral(v)) return v.value;
630
- if (t$2.isJSXExpressionContainer(v) && t$2.isStringLiteral(v.expression)) return v.expression.value;
631
- return null;
632
- }
633
- function readJsxNumberAttr(opening, name) {
634
- const attr = findJsxAttr(opening, name);
635
- const v = attr?.value;
636
- if (!v || !t$2.isJSXExpressionContainer(v)) return null;
637
- if (!t$2.isNumericLiteral(v.expression)) return null;
638
- const n = v.expression.value;
639
- return Number.isFinite(n) ? n : null;
640
- }
641
909
  function planReplacePlaceholder(ast, element, assetPath) {
642
910
  const opening = element.openingElement;
643
- if (!t$2.isJSXIdentifier(opening.name) || opening.name.name !== "ImagePlaceholder") return { error: "not a placeholder" };
644
- if (!assetPath.startsWith("./assets/")) return { error: "asset path must start with ./assets/" };
911
+ if (!t$4.isJSXIdentifier(opening.name) || opening.name.name !== "ImagePlaceholder") return { error: "not a placeholder" };
912
+ if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
645
913
  const hint = readJsxStringAttr(opening, "hint") ?? "";
646
914
  const width = readJsxNumberAttr(opening, "width");
647
915
  const height = readJsxNumberAttr(opening, "height");
@@ -670,7 +938,7 @@ function applyEdit(source, line, column, ops) {
670
938
  status: 422,
671
939
  error: "could not parse source"
672
940
  };
673
- const element = findInnermostJsxElement(ast, line, column);
941
+ const element = findElementForEdit(ast, line, column, ops);
674
942
  if (!element) return {
675
943
  ok: false,
676
944
  status: 422,
@@ -691,14 +959,42 @@ function applyEdit(source, line, column, ops) {
691
959
  if (result) splices.push(result);
692
960
  }
693
961
  for (const op of ops) {
694
- if (op.kind !== "set-text") continue;
695
- const result = buildTextSplice(ast, element, op.value, op.prevText);
696
- if ("error" in result) return {
962
+ if (op.kind !== "set-text-range-style") continue;
963
+ const result = buildTextRangeStyleSplices(ast, source, element, op.start, op.end, {
964
+ key: op.key,
965
+ value: op.value
966
+ }, op.prevText);
967
+ if (result && "error" in result) return {
697
968
  ok: false,
698
969
  status: 422,
699
970
  error: result.error
700
971
  };
701
- splices.push(result);
972
+ if (result) splices.push(...result);
973
+ }
974
+ for (const op of ops) {
975
+ if (op.kind !== "set-text") continue;
976
+ if (op.prevText !== void 0 && (op.value.includes("\n") || op.prevText.includes("\n"))) {
977
+ const richResult = buildTextContentSplices(element, op.value, op.prevText);
978
+ if (!("error" in richResult)) {
979
+ splices.push(...richResult);
980
+ continue;
981
+ }
982
+ }
983
+ const result = buildTextSplice(ast, element, op.value, op.prevText);
984
+ if ("error" in result) {
985
+ if (op.prevText === void 0) return {
986
+ ok: false,
987
+ status: 422,
988
+ error: result.error
989
+ };
990
+ const richResult = buildTextContentSplices(element, op.value, op.prevText);
991
+ if ("error" in richResult) return {
992
+ ok: false,
993
+ status: 422,
994
+ error: result.error
995
+ };
996
+ splices.push(...richResult);
997
+ } else splices.push(result);
702
998
  }
703
999
  const assetOps = ops.flatMap((op) => op.kind === "set-attr-asset" ? [op] : []);
704
1000
  const placeholderOps = ops.flatMap((op) => op.kind === "replace-placeholder-with-image" ? [op] : []);
@@ -742,272 +1038,277 @@ function applyEdit(source, line, column, ops) {
742
1038
  splices.sort((a, b) => b.from - a.from);
743
1039
  let next = source;
744
1040
  for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
1041
+ if (!parseSource$2(next)) return {
1042
+ ok: false,
1043
+ status: 422,
1044
+ error: "edit would produce invalid source"
1045
+ };
745
1046
  return {
746
1047
  ok: true,
747
1048
  source: next
748
1049
  };
749
1050
  }
750
- function commentsPlugin(opts) {
751
- const userCwd = opts.userCwd;
752
- const slidesDir = opts.slidesDir ?? "slides";
753
- return {
754
- name: "open-slide:comments",
755
- apply: "serve",
756
- configureServer(server) {
757
- server.middlewares.use("/__edit", async (req, res, next) => {
758
- const url = new URL(req.url ?? "/", "http://local");
759
- const method = req.method ?? "GET";
760
- if (method !== "POST") return next();
761
- try {
762
- if (url.pathname === "/") {
763
- const body = await readBody$3(req);
764
- const slideId = body.slideId ?? "";
765
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
766
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
767
- if (!body.line || body.line < 1) return json$3(res, 400, { error: "invalid line" });
768
- if (!Array.isArray(body.ops)) return json$3(res, 400, { error: "missing ops" });
769
- let source;
770
- try {
771
- source = await fs.readFile(file, "utf8");
772
- } catch {
773
- return json$3(res, 404, { error: "slide not found" });
774
- }
775
- const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
776
- if (!result.ok) return json$3(res, result.status, { error: result.error });
777
- const changed = result.source !== source;
778
- if (changed) await fs.writeFile(file, result.source, "utf8");
779
- return json$3(res, 200, {
780
- ok: true,
781
- changed
782
- });
783
- }
784
- if (url.pathname === "/batch") {
785
- const body = await readBody$3(req);
786
- const slideId = body.slideId ?? "";
787
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
788
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
789
- if (!Array.isArray(body.edits)) return json$3(res, 400, { error: "missing edits" });
790
- let source;
791
- try {
792
- source = await fs.readFile(file, "utf8");
793
- } catch {
794
- return json$3(res, 404, { error: "slide not found" });
795
- }
796
- const original = source;
797
- const results = [];
798
- for (const edit of body.edits) {
799
- if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
800
- results.push({
801
- ok: false,
802
- error: "invalid edit"
803
- });
804
- continue;
805
- }
806
- const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
807
- if (r.ok) {
808
- source = r.source;
809
- results.push({ ok: true });
810
- } else results.push({
811
- ok: false,
812
- error: r.error
813
- });
814
- }
815
- const changed = source !== original;
816
- if (changed) await fs.writeFile(file, source, "utf8");
817
- return json$3(res, 200, {
818
- ok: true,
819
- changed,
820
- results
821
- });
822
- }
823
- return next();
824
- } catch (err) {
825
- json$3(res, 500, { error: String(err.message ?? err) });
826
- }
827
- });
828
- server.middlewares.use("/__comments", async (req, res, next) => {
829
- const url = new URL(req.url ?? "/", "http://local");
830
- const method = req.method ?? "GET";
831
- try {
832
- if (method === "GET" && url.pathname === "/") {
833
- const slideId = url.searchParams.get("slideId") ?? "";
834
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
835
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
836
- let source;
837
- try {
838
- source = await fs.readFile(file, "utf8");
839
- } catch {
840
- return json$3(res, 404, { error: "slide not found" });
841
- }
842
- return json$3(res, 200, { comments: parseMarkers(source) });
843
- }
844
- if (method === "POST" && url.pathname === "/add") {
845
- const body = await readBody$3(req);
846
- const slideId = body.slideId ?? "";
847
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
848
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
849
- if (!body.line || body.line < 1) return json$3(res, 400, { error: "invalid line" });
850
- if (!body.text || typeof body.text !== "string") return json$3(res, 400, { error: "missing text" });
851
- let source;
852
- try {
853
- source = await fs.readFile(file, "utf8");
854
- } catch {
855
- return json$3(res, 404, { error: "slide not found" });
856
- }
857
- const plan = findInsertion(source, body.line, body.column);
858
- if (!plan) return json$3(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
859
- const id = newId();
860
- const ts = new Date().toISOString();
861
- const payload = b64urlEncode(JSON.stringify({
862
- note: body.text,
863
- hint: body.hint
864
- }));
865
- const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
866
- const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
867
- await fs.writeFile(file, next$1, "utf8");
868
- const markerLine = offsetToLine(next$1, plan.offset + 1);
869
- return json$3(res, 200, {
870
- id,
871
- line: markerLine
872
- });
873
- }
874
- if (method === "DELETE" && url.pathname.startsWith("/")) {
875
- const id = url.pathname.slice(1);
876
- if (!/^c-[a-f0-9]+$/.test(id)) return json$3(res, 400, { error: "invalid id" });
877
- const slideId = url.searchParams.get("slideId") ?? "";
878
- const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
879
- if (!file) return json$3(res, 400, { error: "invalid slideId" });
880
- let source;
881
- try {
882
- source = await fs.readFile(file, "utf8");
883
- } catch {
884
- return json$3(res, 404, { error: "slide not found" });
885
- }
886
- const lines = source.split("\n");
887
- const idRe = new RegExp(`\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`);
888
- const hit = lines.findIndex((l) => idRe.test(l));
889
- if (hit === -1) return json$3(res, 404, { error: "marker not found" });
890
- lines.splice(hit, 1);
891
- await fs.writeFile(file, lines.join("\n"), "utf8");
892
- return json$3(res, 200, { ok: true });
893
- }
894
- next();
895
- } catch (err) {
896
- json$3(res, 500, { error: String(err.message ?? err) });
897
- }
898
- });
1051
+
1052
+ //#endregion
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
+ };
899
1131
  }
1132
+ const insertAt = (node.source.start ?? 0) - 5;
1133
+ return {
1134
+ from: insertAt,
1135
+ to: insertAt,
1136
+ text: "{ ImagePlaceholder } "
1137
+ };
1138
+ }
1139
+ return {
1140
+ from: 0,
1141
+ to: 0,
1142
+ text: "import { ImagePlaceholder } from '@open-slide/core';\n"
1143
+ };
1144
+ }
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
+ };
1215
+ return {
1216
+ ok: true,
1217
+ source: next
900
1218
  };
901
1219
  }
902
1220
 
903
1221
  //#endregion
904
- //#region src/vite/current-plugin.ts
1222
+ //#region src/editing/slide-ops.ts
905
1223
  const SLIDE_ID_RE$3 = /^[a-z0-9_-]+$/i;
906
- const TEXT_SNIPPET_MAX = 120;
907
- function parseSelection(raw) {
908
- if (raw == null || typeof raw !== "object") return null;
909
- const sel = raw;
910
- if (typeof sel.line !== "number" || !Number.isFinite(sel.line)) return null;
911
- if (typeof sel.column !== "number" || !Number.isFinite(sel.column)) return null;
912
- const tagName = typeof sel.tagName === "string" ? sel.tagName.toLowerCase().slice(0, 32) : "unknown";
913
- const text = typeof sel.text === "string" ? sel.text.replace(/\s+/g, " ").trim().slice(0, TEXT_SNIPPET_MAX) : "";
914
- return {
915
- line: Math.max(1, Math.floor(sel.line)),
916
- column: Math.max(0, Math.floor(sel.column)),
917
- tagName,
918
- text
919
- };
920
- }
921
- function currentPlugin(opts) {
922
- const userCwd = opts.userCwd;
923
- const slidesDir = opts.slidesDir ?? "slides";
924
- const outDir = path.join(userCwd, "node_modules", ".open-slide");
925
- const outFile = path.join(outDir, "current.json");
926
- const tmpFile = `${outFile}.tmp`;
927
- let cached = null;
928
- return {
929
- name: "open-slide:current",
930
- apply: "serve",
931
- configureServer(server) {
932
- server.ws.on("open-slide:current", async (raw) => {
933
- const next = cached ? { ...cached } : {
934
- slideId: "",
935
- pageIndex: 0,
936
- pageNumber: 1,
937
- totalPages: 1,
938
- slideTitle: "",
939
- view: "slides",
940
- pagePath: "",
941
- selection: null
942
- };
943
- if (typeof raw?.slideId === "string") {
944
- if (!SLIDE_ID_RE$3.test(raw.slideId)) return;
945
- const totalPages = typeof raw.totalPages === "number" && Number.isFinite(raw.totalPages) && raw.totalPages > 0 ? Math.floor(raw.totalPages) : 1;
946
- const rawIndex = typeof raw.pageIndex === "number" && Number.isFinite(raw.pageIndex) ? Math.floor(raw.pageIndex) : 0;
947
- const pageIndex = Math.max(0, Math.min(totalPages - 1, rawIndex));
948
- const slideTitle = typeof raw.slideTitle === "string" ? raw.slideTitle : raw.slideId;
949
- const view = raw.view === "assets" ? "assets" : "slides";
950
- const pagePath = path.join(slidesDir, raw.slideId, "index.tsx").split(path.sep).join("/");
951
- if (cached?.slideId !== raw.slideId || cached?.pageIndex !== pageIndex) next.selection = null;
952
- next.slideId = raw.slideId;
953
- next.pageIndex = pageIndex;
954
- next.pageNumber = pageIndex + 1;
955
- next.totalPages = totalPages;
956
- next.slideTitle = slideTitle;
957
- next.view = view;
958
- next.pagePath = pagePath;
959
- }
960
- if ("selection" in raw) next.selection = parseSelection(raw.selection);
961
- if (!next.slideId) return;
962
- cached = next;
963
- const body = {
964
- ...next,
965
- updatedAt: new Date().toISOString()
966
- };
967
- try {
968
- await fs.mkdir(outDir, { recursive: true });
969
- await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
970
- await fs.rename(tmpFile, outFile);
971
- } catch {}
972
- });
973
- }
974
- };
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;
975
1229
  }
976
-
977
- //#endregion
978
- //#region src/vite/design-plugin.ts
979
- const SLIDE_ID_RE$2 = /^[a-z0-9_-]+$/i;
980
- async function readBody$2(req) {
981
- return await new Promise((resolve, reject) => {
982
- const chunks = [];
983
- req.on("data", (c) => chunks.push(c));
984
- req.on("end", () => {
985
- const raw = Buffer.concat(chunks).toString("utf8");
986
- if (!raw) return resolve({});
987
- try {
988
- resolve(JSON.parse(raw));
989
- } catch (e) {
990
- reject(e);
991
- }
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
992
1238
  });
993
- req.on("error", reject);
994
- });
1239
+ return true;
1240
+ } catch {
1241
+ return false;
1242
+ }
995
1243
  }
996
- function json$2(res, status, body) {
997
- res.statusCode = status;
998
- res.setHeader("content-type", "application/json");
999
- res.end(JSON.stringify(body));
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");
1000
1249
  }
1001
- function resolveSlidePath$1(userCwd, slidesDir, slideId) {
1002
- if (!SLIDE_ID_RE$2.test(slideId)) return null;
1003
- const slidesRoot = path.resolve(userCwd, slidesDir);
1004
- const full = path.resolve(slidesRoot, slideId, "index.tsx");
1005
- if (!full.startsWith(`${slidesRoot}${path.sep}`)) return null;
1006
- return full;
1250
+ function escapeSingleQuoted(s) {
1251
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1007
1252
  }
1008
- function parseSource$1(source) {
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;
1009
1310
  try {
1010
- return parse(source, {
1311
+ ast = parse(source, {
1011
1312
  sourceType: "module",
1012
1313
  plugins: ["typescript", "jsx"],
1013
1314
  errorRecovery: true
@@ -1015,437 +1316,1316 @@ function parseSource$1(source) {
1015
1316
  } catch {
1016
1317
  return null;
1017
1318
  }
1018
- }
1019
- function findDesignDecl(ast) {
1020
1319
  const body = ast.program?.body ?? [];
1021
1320
  for (const node of body) {
1022
- let varDecl = null;
1023
- if (node.type === "VariableDeclaration") varDecl = node;
1024
- else if (node.type === "ExportNamedDeclaration") {
1025
- const decl = node.declaration;
1026
- if (decl?.type === "VariableDeclaration") varDecl = decl;
1027
- }
1028
- if (!varDecl) continue;
1029
- const declarations = varDecl.declarations ?? [];
1030
- for (const d of declarations) {
1031
- const id = d.id;
1032
- if (!id || id.type !== "Identifier" || id.name !== "design") continue;
1033
- const init = d.init;
1034
- if (!init) return null;
1035
- let inner = init;
1036
- if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
1037
- const expr = inner.expression;
1038
- if (expr) inner = expr;
1039
- }
1040
- if (inner.type !== "ObjectExpression") return null;
1041
- return {
1042
- declStart: node.start,
1043
- declEnd: node.end,
1044
- objectStart: inner.start,
1045
- objectEnd: inner.end
1046
- };
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
+ });
1047
1335
  }
1336
+ return {
1337
+ elements,
1338
+ arrayStart,
1339
+ arrayEnd
1340
+ };
1048
1341
  }
1049
1342
  return null;
1050
1343
  }
1051
- function literalToValue(node) {
1052
- switch (node.type) {
1053
- case "StringLiteral": return node.value;
1054
- case "NumericLiteral": return node.value;
1055
- case "BooleanLiteral": return node.value;
1056
- case "NullLiteral": return null;
1057
- case "UnaryExpression": {
1058
- const op = node.operator;
1059
- const arg = node.argument;
1060
- const v = literalToValue(arg);
1061
- if (op === "-" && typeof v === "number") return -v;
1062
- if (op === "+" && typeof v === "number") return v;
1063
- throw new Error(`unsupported unary operator ${op}`);
1064
- }
1065
- case "TemplateLiteral": {
1066
- const quasis = node.quasis;
1067
- const expressions = node.expressions;
1068
- if (expressions.length > 0) throw new Error("template literal has expressions");
1069
- return quasis[0].value.cooked ?? quasis[0].value.raw;
1070
- }
1071
- case "ArrayExpression": {
1072
- const elements = node.elements;
1073
- return elements.map((el) => {
1074
- if (!el) throw new Error("array has hole");
1075
- return literalToValue(el);
1076
- });
1077
- }
1078
- case "ObjectExpression": {
1079
- const properties = node.properties;
1080
- const out = {};
1081
- for (const prop of properties) {
1082
- if (prop.type !== "ObjectProperty") throw new Error("object has spread or method");
1083
- const p = prop;
1084
- if (p.computed) throw new Error("object has computed key");
1085
- let key;
1086
- if (p.key.type === "Identifier" && typeof p.key.name === "string") key = p.key.name;
1087
- else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") key = p.key.value;
1088
- else throw new Error("unsupported object key");
1089
- out[key] = literalToValue(p.value);
1090
- }
1091
- return out;
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 ?? [];
1403
+ for (const d of declarations) {
1404
+ const id = d.id;
1405
+ if (!id || id.type !== "Identifier" || id.name !== "notes") continue;
1406
+ const init = d.init;
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));
1423
+ }
1424
+ return {
1425
+ arrayStart,
1426
+ arrayEnd,
1427
+ elementTexts
1428
+ };
1092
1429
  }
1093
- default: throw new Error(`unsupported node type ${node.type}`);
1094
1430
  }
1431
+ return null;
1095
1432
  }
1096
- function isPlainObject(v) {
1097
- return typeof v === "object" && v !== null && !Array.isArray(v);
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);
1098
1458
  }
1099
- function mergeDesign(base, patch) {
1100
- const out = JSON.parse(JSON.stringify(base));
1101
- const apply = (target, src) => {
1102
- for (const [k, v] of Object.entries(src)) if (isPlainObject(v) && isPlainObject(target[k])) apply(target[k], v);
1103
- else target[k] = v;
1104
- };
1105
- if (isPlainObject(patch)) apply(out, patch);
1106
- return out;
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);
1107
1493
  }
1108
- function indent(level) {
1109
- return " ".repeat(level);
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 ", ";
1110
1503
  }
1111
- function jsString(s) {
1112
- return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
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);
1530
+ }
1531
+ if (i < n - 1) newSeparators.push(separators[i]);
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);
1113
1537
  }
1114
- function isValidIdentifier(name) {
1115
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
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";
1116
1569
  }
1117
- function serializeValue(value, level) {
1118
- if (value === null) return "null";
1119
- if (typeof value === "string") return jsString(value);
1120
- if (typeof value === "number") {
1121
- if (!Number.isFinite(value)) throw new Error("non-finite number");
1122
- return String(value);
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;
1580
+ }
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;
1588
+ }
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;
1596
+ }
1597
+ function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
1598
+ if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
1599
+ return resolveAssetsDir(slidesRoot, scope);
1600
+ }
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;
1123
1607
  }
1124
- if (typeof value === "boolean") return value ? "true" : "false";
1125
- if (Array.isArray(value)) {
1126
- if (value.length === 0) return "[]";
1127
- const inner = value.map((el) => serializeValue(el, level + 1)).join(", ");
1128
- return `[${inner}]`;
1608
+ return resolveAssetFile(slidesRoot, scope, filename);
1609
+ }
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;
1616
+ }
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;
1129
1636
  }
1130
- if (isPlainObject(value)) {
1131
- const entries = Object.entries(value);
1132
- if (entries.length === 0) return "{}";
1133
- const childIndent = indent(level + 1);
1134
- const lines = entries.map(([k, v]) => {
1135
- const key = isValidIdentifier(k) ? k : jsString(k);
1136
- return `${childIndent}${key}: ${serializeValue(v, level + 1)},`;
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 {
1649
+ ok: false,
1650
+ status: 403,
1651
+ error: "cross-site request blocked"
1652
+ };
1653
+ const originRaw = headerValue(req, "origin");
1654
+ if (!originRaw) return { ok: true };
1655
+ if (originRaw.toLowerCase() === "null") return {
1656
+ ok: false,
1657
+ status: 403,
1658
+ error: "opaque origin is not allowed"
1659
+ };
1660
+ const actualOrigin = normalizedOrigin(originRaw);
1661
+ if (!actualOrigin) return {
1662
+ ok: false,
1663
+ status: 403,
1664
+ error: "invalid origin header"
1665
+ };
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"
1677
+ };
1678
+ return { ok: true };
1679
+ }
1680
+
1681
+ //#endregion
1682
+ //#region src/vite/routes/context.ts
1683
+ function makeContext(opts) {
1684
+ const userCwd = opts.userCwd;
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");
1690
+ return {
1691
+ userCwd,
1692
+ slidesDir,
1693
+ slidesRoot,
1694
+ globalAssetsRoot,
1695
+ manifestPath
1696
+ };
1697
+ }
1698
+ async function readBody$2(req) {
1699
+ return await new Promise((resolve, reject) => {
1700
+ const chunks = [];
1701
+ req.on("data", (c) => chunks.push(c));
1702
+ req.on("end", () => {
1703
+ const raw = Buffer.concat(chunks).toString("utf8");
1704
+ if (!raw) return resolve({});
1705
+ try {
1706
+ resolve(JSON.parse(raw));
1707
+ } catch (e) {
1708
+ reject(e);
1709
+ }
1137
1710
  });
1138
- return `{\n${lines.join("\n")}\n${indent(level)}}`;
1711
+ req.on("error", reject);
1712
+ });
1713
+ }
1714
+ function json$2(res, status, body) {
1715
+ res.statusCode = status;
1716
+ res.setHeader("content-type", "application/json");
1717
+ res.end(JSON.stringify(body));
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 {}
1139
1936
  }
1140
- throw new Error(`unsupported value type ${typeof value}`);
1937
+ return comments;
1141
1938
  }
1142
- function serializeDesign(design) {
1143
- return serializeValue(design, 0);
1939
+ function newCommentId() {
1940
+ return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
1144
1941
  }
1145
- function parseSlideDesign(source) {
1146
- const ast = parseSource$1(source);
1147
- if (!ast) return {
1148
- ok: false,
1149
- exists: true,
1150
- error: "could not parse slide source"
1151
- };
1152
- const loc = findDesignDecl(ast);
1153
- if (!loc) return {
1154
- ok: false,
1155
- exists: false
1156
- };
1157
- const objectNode = findDesignObjectNode(ast);
1158
- if (!objectNode) return {
1159
- ok: false,
1160
- exists: true,
1161
- error: "design has unsupported initializer"
1162
- };
1163
- let value;
1164
- try {
1165
- value = literalToValue(objectNode);
1166
- } catch (err) {
1167
- return {
1168
- ok: false,
1169
- exists: true,
1170
- error: err.message
1171
- };
1172
- }
1173
- const merged = mergeDesign(defaultDesign, value);
1174
- return {
1175
- ok: true,
1176
- design: merged,
1177
- loc
1178
- };
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*\\*\\/\\}`);
1179
1944
  }
1180
- function findDesignObjectNode(ast) {
1181
- const body = ast.program?.body ?? [];
1182
- for (const node of body) {
1183
- let varDecl = null;
1184
- if (node.type === "VariableDeclaration") varDecl = node;
1185
- else if (node.type === "ExportNamedDeclaration") {
1186
- const decl = node.declaration;
1187
- if (decl?.type === "VariableDeclaration") varDecl = decl;
1188
- }
1189
- if (!varDecl) continue;
1190
- const declarations = varDecl.declarations ?? [];
1191
- for (const d of declarations) {
1192
- const id = d.id;
1193
- if (!id || id.type !== "Identifier" || id.name !== "design") continue;
1194
- const init = d.init;
1195
- if (!init) return null;
1196
- let inner = init;
1197
- if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
1198
- const expr = inner.expression;
1199
- if (expr) inner = expr;
1200
- }
1201
- if (inner.type !== "ObjectExpression") return null;
1202
- return inner;
1203
- }
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;
1204
1951
  }
1205
- return null;
1952
+ return off;
1206
1953
  }
1207
- function findImports(ast) {
1208
- const body = ast.program?.body ?? [];
1209
- const out = [];
1210
- for (const node of body) {
1211
- if (node.type !== "ImportDeclaration") continue;
1212
- const src = node.source?.value;
1213
- if (typeof src !== "string") continue;
1214
- const specs = node.specifiers ?? [];
1215
- out.push({
1216
- node,
1217
- source: src,
1218
- specifiers: specs
1219
- });
1220
- }
1221
- return out;
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] ?? "";
1222
1958
  }
1223
- function ensureDesignSystemImport(source, ast) {
1224
- const imports = findImports(ast);
1225
- const coreImport = imports.find((imp) => imp.source === "@open-slide/core");
1226
- if (coreImport) {
1227
- const hasDesignSystem = coreImport.specifiers.some((spec) => {
1228
- if (spec.type !== "ImportSpecifier") return false;
1229
- const imported = spec.imported;
1230
- return imported?.name === "DesignSystem";
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)
1231
1970
  });
1232
- if (hasDesignSystem) return {
1233
- source,
1234
- offsetShift: 0
1235
- };
1236
- const node = coreImport.node;
1237
- const importText = source.slice(node.start, node.end);
1238
- const braceClose = importText.lastIndexOf("}");
1239
- if (braceClose === -1) return {
1240
- source,
1241
- offsetShift: 0
1242
- };
1243
- const absoluteBrace = node.start + braceClose;
1244
- const insertText = coreImport.specifiers.length > 0 ? ", type DesignSystem" : "type DesignSystem";
1245
- const next$1 = `${source.slice(0, absoluteBrace)}${insertText}${source.slice(absoluteBrace)}`;
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;
1246
1979
  return {
1247
- source: next$1,
1248
- offsetShift: insertText.length
1980
+ offset: opening.end ?? 0,
1981
+ indent: `${lineIndent(source, startLine)} `
1249
1982
  };
1250
1983
  }
1251
- const stmt = `import type { DesignSystem } from '@open-slide/core';\n`;
1252
- if (imports.length > 0) {
1253
- const last = imports[imports.length - 1];
1254
- const insertAt = last.node.end;
1255
- const trail = source[insertAt] === "\n" ? "" : "\n";
1256
- const next$1 = `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
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;
1257
1988
  return {
1258
- source: next$1,
1259
- offsetShift: 1 + stmt.length - (trail ? 0 : 1)
1989
+ offset: opening.end ?? 0,
1990
+ indent: `${lineIndent(source, startLine)} `
1260
1991
  };
1261
1992
  }
1262
- const next = `${stmt}\n${source}`;
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}$/;
2197
+ function emptyManifest() {
1263
2198
  return {
1264
- source: next,
1265
- offsetShift: stmt.length + 1
2199
+ folders: [],
2200
+ assignments: {}
1266
2201
  };
1267
2202
  }
1268
- function findInsertionPoint(source, ast) {
1269
- const imports = findImports(ast);
1270
- if (imports.length === 0) return 0;
1271
- const last = imports[imports.length - 1];
1272
- let off = last.node.end;
1273
- while (off < source.length && source[off] !== "\n") off++;
1274
- if (off < source.length) off++;
1275
- return off;
2203
+ async function readManifest(file) {
2204
+ try {
2205
+ const raw = await fs.readFile(file, "utf8");
2206
+ const parsed = JSON.parse(raw);
2207
+ return {
2208
+ folders: Array.isArray(parsed.folders) ? parsed.folders : [],
2209
+ assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
2210
+ };
2211
+ } catch (err) {
2212
+ if (err.code === "ENOENT") return emptyManifest();
2213
+ throw err;
2214
+ }
2215
+ }
2216
+ async function writeManifest(file, manifest) {
2217
+ await fs.mkdir(path.dirname(file), { recursive: true });
2218
+ await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
2219
+ }
2220
+ function newFolderId() {
2221
+ return `f-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
2222
+ }
2223
+ function validateName(v) {
2224
+ if (typeof v !== "string") return null;
2225
+ const trimmed = v.trim();
2226
+ if (trimmed.length < 1 || trimmed.length > 40) return null;
2227
+ return trimmed;
1276
2228
  }
1277
- function applyDesignWrite(source, next) {
1278
- let body;
1279
- try {
1280
- body = serializeDesign(next);
1281
- } catch (err) {
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;
1282
2235
  return {
1283
- ok: false,
1284
- status: 422,
1285
- error: `serialize failed: ${err.message}`
2236
+ type: "emoji",
2237
+ value: icon.value
1286
2238
  };
1287
2239
  }
1288
- const ast = parseSource$1(source);
1289
- if (!ast) return {
1290
- ok: false,
1291
- status: 422,
1292
- error: "could not parse slide source"
1293
- };
1294
- const loc = findDesignDecl(ast);
1295
- if (loc) {
1296
- const out$1 = source.slice(0, loc.objectStart) + body + source.slice(loc.objectEnd);
2240
+ if (icon.type === "color") {
2241
+ if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
1297
2242
  return {
1298
- ok: true,
1299
- source: out$1,
1300
- created: false
2243
+ type: "color",
2244
+ value: icon.value
1301
2245
  };
1302
2246
  }
1303
- const withImport = ensureDesignSystemImport(source, ast);
1304
- const ast2 = parseSource$1(withImport.source);
1305
- if (!ast2) return {
1306
- ok: false,
1307
- status: 422,
1308
- error: "failed to re-parse after adding import"
1309
- };
1310
- const insertAt = findInsertionPoint(withImport.source, ast2);
1311
- const block = `\nconst design: DesignSystem = ${body};\n`;
1312
- const out = withImport.source.slice(0, insertAt) + block + withImport.source.slice(insertAt);
1313
- return {
1314
- ok: true,
1315
- source: out,
1316
- created: true
1317
- };
2247
+ return null;
1318
2248
  }
1319
- function designPlugin(opts) {
1320
- const userCwd = opts.userCwd;
1321
- const slidesDir = opts.slidesDir ?? "slides";
1322
- return {
1323
- name: "open-slide:design",
1324
- apply: "serve",
1325
- configureServer(server) {
1326
- server.middlewares.use("/__design", async (req, res, next) => {
1327
- const url = new URL(req.url ?? "/", "http://local");
1328
- const method = req.method ?? "GET";
1329
- const slideId = url.searchParams.get("slideId") ?? "";
1330
- const file = resolveSlidePath$1(userCwd, slidesDir, slideId);
1331
- if (!file) return json$2(res, 400, { error: "invalid slideId" });
1332
- try {
1333
- if (method === "GET" && url.pathname === "/") {
1334
- let source;
1335
- try {
1336
- source = await fs.readFile(file, "utf8");
1337
- } catch {
1338
- return json$2(res, 404, { error: "slide not found" });
1339
- }
1340
- const parsed = parseSlideDesign(source);
1341
- if (parsed.ok) return json$2(res, 200, {
1342
- design: parsed.design,
1343
- exists: true,
1344
- warning: null
1345
- });
1346
- if (parsed.exists === false) return json$2(res, 200, {
1347
- design: defaultDesign,
1348
- exists: false,
1349
- warning: null
1350
- });
1351
- return json$2(res, 200, {
1352
- design: defaultDesign,
1353
- exists: true,
1354
- warning: parsed.error
1355
- });
1356
- }
1357
- if (method === "PUT" && url.pathname === "/") {
1358
- const body = await readBody$2(req);
1359
- const patch = body.patch;
1360
- if (!patch || typeof patch !== "object") return json$2(res, 400, { error: "missing patch object" });
1361
- let source;
1362
- try {
1363
- source = await fs.readFile(file, "utf8");
1364
- } catch {
1365
- return json$2(res, 404, { error: "slide not found" });
1366
- }
1367
- const parsed = parseSlideDesign(source);
1368
- const baseDesign = parsed.ok ? parsed.design : defaultDesign;
1369
- if (!parsed.ok && parsed.exists) return json$2(res, 422, { error: parsed.error });
1370
- const merged = mergeDesign(baseDesign, patch);
1371
- const written = applyDesignWrite(source, merged);
1372
- if (!written.ok) return json$2(res, written.status, { error: written.error });
1373
- if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
1374
- return json$2(res, 200, {
1375
- ok: true,
1376
- design: merged,
1377
- created: written.created
1378
- });
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;
1379
2311
  }
1380
- if (method === "POST" && url.pathname === "/reset") {
1381
- let source;
1382
- try {
1383
- source = await fs.readFile(file, "utf8");
1384
- } catch {
1385
- return json$2(res, 404, { error: "slide not found" });
1386
- }
1387
- const written = applyDesignWrite(source, defaultDesign);
1388
- if (!written.ok) return json$2(res, written.status, { error: written.error });
1389
- if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
1390
- return json$2(res, 200, {
1391
- ok: true,
1392
- design: defaultDesign,
1393
- created: written.created
1394
- });
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;
1395
2316
  }
1396
- return next();
1397
- } catch (err) {
1398
- json$2(res, 500, { error: String(err.message ?? err) });
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
+ });
2337
+ }
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" });
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
+ });
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) });
2448
+ }
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" });
1399
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) });
2492
+ }
2493
+ });
2494
+ }
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 }
1400
2513
  });
2514
+ return;
1401
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
+ });
1402
2527
  };
2528
+ server.watcher.on("add", onAssetChange);
2529
+ server.watcher.on("change", onAssetChange);
2530
+ server.watcher.on("unlink", onAssetChange);
1403
2531
  }
1404
2532
 
1405
2533
  //#endregion
1406
- //#region src/vite/files-plugin.ts
1407
- const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
1408
- const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
1409
- const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
1410
- const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
1411
- const ASSET_MAX_BYTES = 25 * 1024 * 1024;
1412
- const MIME_BY_EXT = {
1413
- png: "image/png",
1414
- jpg: "image/jpeg",
1415
- jpeg: "image/jpeg",
1416
- gif: "image/gif",
1417
- svg: "image/svg+xml",
1418
- webp: "image/webp",
1419
- avif: "image/avif",
1420
- ico: "image/x-icon",
1421
- mp4: "video/mp4",
1422
- webm: "video/webm",
1423
- mov: "video/quicktime",
1424
- woff: "font/woff",
1425
- woff2: "font/woff2",
1426
- ttf: "font/ttf",
1427
- otf: "font/otf",
1428
- json: "application/json",
1429
- txt: "text/plain; charset=utf-8",
1430
- md: "text/markdown; charset=utf-8"
1431
- };
1432
- function mimeForFilename(name) {
1433
- const dot = name.lastIndexOf(".");
1434
- if (dot < 0) return "application/octet-stream";
1435
- const ext = name.slice(dot + 1).toLowerCase();
1436
- return MIME_BY_EXT[ext] ?? "application/octet-stream";
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
+ };
1437
2550
  }
1438
- function validateAssetName(v) {
1439
- if (typeof v !== "string") return null;
1440
- const trimmed = v.trim();
1441
- if (trimmed.length < 1 || trimmed.length > 120) return null;
1442
- if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
1443
- if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
1444
- if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
1445
- const dot = trimmed.lastIndexOf(".");
1446
- if (dot <= 0 || dot === trimmed.length - 1) return null;
1447
- return trimmed;
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
+ };
1448
2624
  }
2625
+
2626
+ //#endregion
2627
+ //#region src/vite/design-plugin.ts
2628
+ const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
1449
2629
  async function readBody$1(req) {
1450
2630
  return await new Promise((resolve, reject) => {
1451
2631
  const chunks = [];
@@ -1467,142 +2647,16 @@ function json$1(res, status, body) {
1467
2647
  res.setHeader("content-type", "application/json");
1468
2648
  res.end(JSON.stringify(body));
1469
2649
  }
1470
- function emptyManifest() {
1471
- return {
1472
- folders: [],
1473
- assignments: {}
1474
- };
1475
- }
1476
- async function readManifest(file) {
1477
- try {
1478
- const raw = await fs.readFile(file, "utf8");
1479
- const parsed = JSON.parse(raw);
1480
- return {
1481
- folders: Array.isArray(parsed.folders) ? parsed.folders : [],
1482
- assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
1483
- };
1484
- } catch (err) {
1485
- if (err.code === "ENOENT") return emptyManifest();
1486
- throw err;
1487
- }
1488
- }
1489
- async function writeManifest(file, manifest) {
1490
- await fs.mkdir(path.dirname(file), { recursive: true });
1491
- await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
1492
- }
1493
- function newFolderId() {
1494
- return `f-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
1495
- }
1496
- function validateName(v) {
1497
- if (typeof v !== "string") return null;
1498
- const trimmed = v.trim();
1499
- if (trimmed.length < 1 || trimmed.length > 40) return null;
1500
- return trimmed;
1501
- }
1502
- function validateSlideName(v) {
1503
- if (typeof v !== "string") return null;
1504
- const trimmed = v.trim();
1505
- if (trimmed.length < 1 || trimmed.length > 80) return null;
1506
- return trimmed;
1507
- }
1508
- async function rmSlideDir(slidesRoot, slideId) {
1509
- if (!SLIDE_ID_RE$1.test(slideId)) return false;
1510
- const dir = path.resolve(slidesRoot, slideId);
1511
- if (!dir.startsWith(slidesRoot + path.sep)) return false;
1512
- try {
1513
- await fs.rm(dir, {
1514
- recursive: true,
1515
- force: true
1516
- });
1517
- return true;
1518
- } catch {
1519
- return false;
1520
- }
1521
- }
1522
- function resolveAssetsDir(slidesRoot, slideId) {
1523
- if (!SLIDE_ID_RE$1.test(slideId)) return null;
1524
- const slideDir = path.resolve(slidesRoot, slideId);
1525
- if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
1526
- const assetsDir = path.resolve(slideDir, "assets");
1527
- if (assetsDir !== path.join(slideDir, "assets")) return null;
1528
- return assetsDir;
1529
- }
1530
- function resolveAssetFile(slidesRoot, slideId, filename) {
1531
- const assetsDir = resolveAssetsDir(slidesRoot, slideId);
1532
- if (!assetsDir) return null;
1533
- if (!validateAssetName(filename)) return null;
1534
- const file = path.resolve(assetsDir, filename);
1535
- if (!file.startsWith(assetsDir + path.sep)) return null;
1536
- return file;
1537
- }
1538
- function resolveSlideEntry(slidesRoot, slideId) {
2650
+ function resolveSlidePath$1(userCwd, slidesDir, slideId) {
1539
2651
  if (!SLIDE_ID_RE$1.test(slideId)) return null;
1540
- const dir = path.resolve(slidesRoot, slideId);
1541
- if (!dir.startsWith(slidesRoot + path.sep)) return null;
1542
- return path.join(dir, "index.tsx");
1543
- }
1544
- function escapeSingleQuoted(s) {
1545
- return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1546
- }
1547
- /**
1548
- * Rewrite (or insert) the `title` field in the slide module's `export const meta`.
1549
- *
1550
- * Strategy:
1551
- * 1. Find `export const meta` and brace-match its object literal.
1552
- * 2. If the object already has a `title: '...'` entry, replace the literal.
1553
- * 3. If the object exists but has no title, inject a new `title: '...'` line
1554
- * as the first property (preserving the author's surrounding indentation).
1555
- * 4. If there is no `meta` export at all, insert a fresh one right before
1556
- * `export default`.
1557
- *
1558
- * Returns the rewritten source, or `null` if the file shape was too surprising
1559
- * to touch safely (e.g. `export default` missing when we'd need to inject meta).
1560
- */
1561
- function updateMetaTitleInSource(source, title) {
1562
- const newLiteral = `'${escapeSingleQuoted(title)}'`;
1563
- const metaStart = source.search(/export\s+const\s+meta\b/);
1564
- if (metaStart !== -1) {
1565
- const eqIdx = source.indexOf("=", metaStart);
1566
- if (eqIdx === -1) return null;
1567
- const openBrace = source.indexOf("{", eqIdx);
1568
- if (openBrace === -1) return null;
1569
- let depth = 0;
1570
- let closeBrace = -1;
1571
- for (let i = openBrace; i < source.length; i++) {
1572
- const ch = source[i];
1573
- if (ch === "{") depth++;
1574
- else if (ch === "}") {
1575
- depth--;
1576
- if (depth === 0) {
1577
- closeBrace = i;
1578
- break;
1579
- }
1580
- }
1581
- }
1582
- if (closeBrace === -1) return null;
1583
- const body = source.slice(openBrace + 1, closeBrace);
1584
- const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
1585
- const match = body.match(titleRe);
1586
- if (match) {
1587
- const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
1588
- return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
1589
- }
1590
- const firstIndentMatch = body.match(/\n([ \t]+)\S/);
1591
- const indent$1 = firstIndentMatch ? firstIndentMatch[1] : " ";
1592
- const trimmedBody = body.replace(/^\s*\n?/, "");
1593
- const needsSeparator = trimmedBody.trim().length > 0;
1594
- const insertion$1 = `\n${indent$1}title: ${newLiteral}${needsSeparator ? "," : ""}`;
1595
- return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
1596
- }
1597
- const exportDefaultIdx = source.search(/export\s+default\b/);
1598
- if (exportDefaultIdx === -1) return null;
1599
- const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
1600
- return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
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;
1601
2656
  }
1602
- function findDefaultExportArray(source) {
1603
- let ast;
2657
+ function parseSource$1(source) {
1604
2658
  try {
1605
- ast = parse(source, {
2659
+ return parse(source, {
1606
2660
  sourceType: "module",
1607
2661
  plugins: ["typescript", "jsx"],
1608
2662
  errorRecovery: true
@@ -1610,619 +2664,393 @@ function findDefaultExportArray(source) {
1610
2664
  } catch {
1611
2665
  return null;
1612
2666
  }
2667
+ }
2668
+ function findDesignDecl(ast) {
1613
2669
  const body = ast.program?.body ?? [];
1614
2670
  for (const node of body) {
1615
- if (node.type !== "ExportDefaultDeclaration") continue;
1616
- let inner = node.declaration;
1617
- while (inner && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression")) inner = inner.expression;
1618
- if (!inner || inner.type !== "ArrayExpression") return null;
1619
- const arrayStart = inner.start;
1620
- const arrayEnd = inner.end;
1621
- const rawElements = inner.elements ?? [];
1622
- const elements = [];
1623
- for (const el of rawElements) {
1624
- if (!el || typeof el.start !== "number" || typeof el.end !== "number") return null;
1625
- elements.push({
1626
- start: el.start,
1627
- 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);
1628
2725
  });
1629
2726
  }
1630
- return {
1631
- elements,
1632
- arrayStart,
1633
- arrayEnd
1634
- };
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}`);
1635
2743
  }
1636
- return null;
1637
2744
  }
1638
- /**
1639
- * Rewrite `export default [...]` so its elements appear in the requested order.
1640
- *
1641
- * `order[i]` is the original index that should land at new position `i`. The
1642
- * function preserves each element's exact source slice (including any inline
1643
- * comments that hug an identifier) and keeps the inter-element separator slots
1644
- * in their original positions, so a 3-page array `[A, B, C]` reordered to
1645
- * `[2, 0, 1]` becomes `[C, A, B]` with the same indentation and trailing
1646
- * commas the author wrote.
1647
- *
1648
- * Returns `null` when the file's default export isn't an array literal, or the
1649
- * order is not a valid permutation of `[0, n-1]`.
1650
- */
1651
- function reorderDefaultExportPagesInSource(source, order) {
1652
- const found = findDefaultExportArray(source);
1653
- if (!found) return null;
1654
- const { elements, arrayStart, arrayEnd } = found;
1655
- const n = elements.length;
1656
- if (order.length !== n) return null;
1657
- const seen = new Set();
1658
- for (const idx of order) {
1659
- if (!Number.isInteger(idx) || idx < 0 || idx >= n) return null;
1660
- if (seen.has(idx)) return null;
1661
- 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);
1662
2772
  }
1663
- if (n === 0) return source;
1664
- let identity = true;
1665
- for (let i = 0; i < n; i++) if (order[i] !== i) {
1666
- identity = false;
1667
- 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}]`;
1668
2778
  }
1669
- if (identity) return source;
1670
- const prefix = source.slice(arrayStart, elements[0].start);
1671
- const suffix = source.slice(elements[n - 1].end, arrayEnd);
1672
- const separators = [];
1673
- for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1674
- const elementText = elements.map((el) => source.slice(el.start, el.end));
1675
- let rebuilt = prefix + elementText[order[0]];
1676
- for (let i = 1; i < n; i++) rebuilt += separators[i - 1] + elementText[order[i]];
1677
- rebuilt += suffix;
1678
- 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}`);
1679
2790
  }
1680
- function findNotesArray(source) {
1681
- 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;
1682
2813
  try {
1683
- ast = parse(source, {
1684
- sourceType: "module",
1685
- plugins: ["typescript", "jsx"],
1686
- errorRecovery: true
1687
- });
1688
- } catch {
1689
- return "invalid";
2814
+ value = literalToValue(objectNode);
2815
+ } catch (err) {
2816
+ return {
2817
+ ok: false,
2818
+ exists: true,
2819
+ error: err.message
2820
+ };
1690
2821
  }
2822
+ const merged = mergeDesign(defaultDesign, value);
2823
+ return {
2824
+ ok: true,
2825
+ design: merged,
2826
+ loc
2827
+ };
2828
+ }
2829
+ function findDesignObjectNode(ast) {
1691
2830
  const body = ast.program?.body ?? [];
1692
- for (const stmt of body) {
1693
- if (stmt.type !== "ExportNamedDeclaration") continue;
1694
- const decl = stmt.declaration;
1695
- if (!decl || decl.type !== "VariableDeclaration") continue;
1696
- 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 ?? [];
1697
2840
  for (const d of declarations) {
1698
2841
  const id = d.id;
1699
- if (!id || id.type !== "Identifier" || id.name !== "notes") continue;
2842
+ if (!id || id.type !== "Identifier" || id.name !== "design") continue;
1700
2843
  const init = d.init;
1701
- if (!init || init.type !== "ArrayExpression") return "invalid";
1702
- const arrayStart = init.start;
1703
- const arrayEnd = init.end;
1704
- if (typeof arrayStart !== "number" || typeof arrayEnd !== "number") return "invalid";
1705
- const rawElements = init.elements ?? [];
1706
- const elementTexts = [];
1707
- for (const el of rawElements) {
1708
- if (el === null) {
1709
- elementTexts.push("undefined");
1710
- continue;
1711
- }
1712
- if (el.type === "SpreadElement") return "invalid";
1713
- const start = el.start;
1714
- const end = el.end;
1715
- if (typeof start !== "number" || typeof end !== "number") return "invalid";
1716
- 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;
1717
2849
  }
1718
- return {
1719
- arrayStart,
1720
- arrayEnd,
1721
- elementTexts
1722
- };
2850
+ if (inner.type !== "ObjectExpression") return null;
2851
+ return inner;
1723
2852
  }
1724
2853
  }
1725
2854
  return null;
1726
2855
  }
1727
- /**
1728
- * Reorder `export const notes = [...]` to follow the page-array reorder.
1729
- *
1730
- * `order[i]` is the original page index that should land at new position `i`.
1731
- * The notes array is index-aligned with the pages array but may be shorter
1732
- * (trailing `undefined` slots are routinely trimmed). Missing elements are
1733
- * treated as `undefined`, and trailing `undefined` is trimmed again after
1734
- * reordering to keep the file tidy.
1735
- *
1736
- * Returns the rewritten source, the original source if no `notes` export
1737
- * exists or the reorder is a no-op, or `null` if the `notes` export's shape
1738
- * is too surprising to touch safely.
1739
- */
1740
- function reorderNotesArrayInSource(source, order) {
1741
- for (const idx of order) if (!Number.isInteger(idx) || idx < 0) return null;
1742
- const found = findNotesArray(source);
1743
- if (found === "invalid") return null;
1744
- if (found === null) return source;
1745
- const { arrayStart, arrayEnd, elementTexts } = found;
1746
- const pick = (i) => i >= 0 && i < elementTexts.length ? elementTexts[i] : "undefined";
1747
- const reordered = order.map(pick);
1748
- while (reordered.length > 0 && reordered[reordered.length - 1] === "undefined") reordered.pop();
1749
- const replacement = reordered.length === 0 ? "[]" : `[\n${reordered.map((s) => ` ${s},`).join("\n")}\n]`;
1750
- if (replacement === source.slice(arrayStart, arrayEnd)) return source;
1751
- return source.slice(0, arrayStart) + replacement + source.slice(arrayEnd);
1752
- }
1753
- /**
1754
- * Remove the element at `index` from `export default [...]`.
1755
- *
1756
- * Preserves the source slice of every other element, dropping the separator
1757
- * immediately following the removed element (or the preceding one when the
1758
- * removed element is the last). Returns `null` when the default export isn't
1759
- * an array literal or `index` is out of range.
1760
- */
1761
- function removePageFromDefaultExportInSource(source, index) {
1762
- const found = findDefaultExportArray(source);
1763
- if (!found) return null;
1764
- const { elements, arrayStart, arrayEnd } = found;
1765
- const n = elements.length;
1766
- if (!Number.isInteger(index) || index < 0 || index >= n) return null;
1767
- if (n === 1) return `${source.slice(0, arrayStart)}[]${source.slice(arrayEnd)}`;
1768
- const prefix = source.slice(arrayStart, elements[0].start);
1769
- const suffix = source.slice(elements[n - 1].end, arrayEnd);
1770
- const separators = [];
1771
- for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1772
- const elementText = elements.map((el) => source.slice(el.start, el.end));
1773
- const keptElements = [];
1774
- const keptSeparators = [];
1775
- for (let i = 0; i < n; i++) {
1776
- if (i === index) continue;
1777
- keptElements.push(elementText[i]);
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
+ });
1778
2869
  }
1779
- for (let i = 0; i < n - 1; i++) {
1780
- if (index === n - 1 ? i === n - 2 : i === index) continue;
1781
- keptSeparators.push(separators[i]);
2870
+ return out;
2871
+ }
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
+ };
1782
2899
  }
1783
- let rebuilt = prefix + keptElements[0];
1784
- for (let i = 1; i < keptElements.length; i++) rebuilt += keptSeparators[i - 1] + keptElements[i];
1785
- rebuilt += suffix;
1786
- return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
1787
- }
1788
- function chooseInsertSeparator(prefix, existingSeparators) {
1789
- const sample = existingSeparators.find((s) => s.includes(","));
1790
- if (sample) return sample;
1791
- if (prefix.includes("\n")) {
1792
- const m = prefix.match(/\n([ \t]*)$/);
1793
- const indent$1 = m ? m[1] : " ";
1794
- return `,\n${indent$1}`;
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
+ };
1795
2910
  }
1796
- return ", ";
2911
+ const next = `${stmt}\n${source}`;
2912
+ return {
2913
+ source: next,
2914
+ offsetShift: stmt.length + 1
2915
+ };
1797
2916
  }
1798
- /**
1799
- * Duplicate the element at `index` in `export default [...]`, inserting the
1800
- * copy immediately after the original. Reuses an existing inter-element
1801
- * separator when one is available so the cloned entry matches the surrounding
1802
- * indentation. Returns `null` when the default export isn't an array literal
1803
- * or `index` is out of range.
1804
- */
1805
- function duplicatePageInDefaultExportInSource(source, index) {
1806
- const found = findDefaultExportArray(source);
1807
- if (!found) return null;
1808
- const { elements, arrayStart, arrayEnd } = found;
1809
- const n = elements.length;
1810
- if (!Number.isInteger(index) || index < 0 || index >= n) return null;
1811
- const prefix = source.slice(arrayStart, elements[0].start);
1812
- const suffix = source.slice(elements[n - 1].end, arrayEnd);
1813
- const separators = [];
1814
- for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1815
- const elementText = elements.map((el) => source.slice(el.start, el.end));
1816
- const insertSep = chooseInsertSeparator(prefix, separators);
1817
- const newElements = [];
1818
- const newSeparators = [];
1819
- for (let i = 0; i < n; i++) {
1820
- newElements.push(elementText[i]);
1821
- if (i === index) {
1822
- newElements.push(elementText[i]);
1823
- newSeparators.push(insertSep);
1824
- }
1825
- if (i < n - 1) newSeparators.push(separators[i]);
1826
- }
1827
- let rebuilt = prefix + newElements[0];
1828
- for (let i = 1; i < newElements.length; i++) rebuilt += newSeparators[i - 1] + newElements[i];
1829
- rebuilt += suffix;
1830
- return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
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;
1831
2925
  }
1832
- function validateIcon(v) {
1833
- if (!v || typeof v !== "object") return null;
1834
- const icon = v;
1835
- if (icon.type === "emoji") {
1836
- if (typeof icon.value !== "string") return null;
1837
- if (icon.value.length < 1 || icon.value.length > 8) return null;
2926
+ function applyDesignWrite(source, next) {
2927
+ let body;
2928
+ try {
2929
+ body = serializeDesign(next);
2930
+ } catch (err) {
1838
2931
  return {
1839
- type: "emoji",
1840
- value: icon.value
2932
+ ok: false,
2933
+ status: 422,
2934
+ error: `serialize failed: ${err.message}`
1841
2935
  };
1842
2936
  }
1843
- if (icon.type === "color") {
1844
- 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);
1845
2946
  return {
1846
- type: "color",
1847
- value: icon.value
2947
+ ok: true,
2948
+ source: out$1,
2949
+ created: false
1848
2950
  };
1849
2951
  }
1850
- 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
+ };
1851
2967
  }
1852
- function filesPlugin(opts) {
2968
+ function designPlugin(opts) {
1853
2969
  const userCwd = opts.userCwd;
1854
2970
  const slidesDir = opts.slidesDir ?? "slides";
1855
- const slidesRoot = path.resolve(userCwd, slidesDir);
1856
- const manifestPath = path.join(slidesRoot, ".folders.json");
1857
2971
  return {
1858
- name: "open-slide:files",
2972
+ name: "open-slide:design",
1859
2973
  apply: "serve",
1860
2974
  configureServer(server) {
1861
- server.watcher.add(manifestPath);
1862
- server.watcher.on("change", (p) => {
1863
- if (p === manifestPath) server.ws.send({
1864
- type: "custom",
1865
- event: "open-slide:files-changed"
1866
- });
1867
- });
1868
- const onAssetChange = (p) => {
1869
- if (!p.startsWith(slidesRoot + path.sep)) return;
1870
- const rel = p.slice(slidesRoot.length + 1);
1871
- const parts = rel.split(path.sep);
1872
- if (parts.length < 3 || parts[1] !== "assets") return;
1873
- const slideId = parts[0];
1874
- if (!SLIDE_ID_RE$1.test(slideId)) return;
1875
- server.ws.send({
1876
- type: "custom",
1877
- event: "open-slide:assets-changed",
1878
- data: { slideId }
1879
- });
1880
- };
1881
- server.watcher.on("add", onAssetChange);
1882
- server.watcher.on("change", onAssetChange);
1883
- server.watcher.on("unlink", onAssetChange);
1884
- server.middlewares.use("/__slides", async (req, res, next) => {
2975
+ server.middlewares.use("/__design", async (req, res, next) => {
1885
2976
  const url = new URL(req.url ?? "/", "http://local");
1886
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" });
1887
2981
  try {
1888
- const reorderMatch = url.pathname.match(/^\/([^/]+)\/reorder$/);
1889
- if (reorderMatch && method === "PUT") {
1890
- const slideId$1 = reorderMatch[1];
1891
- if (!SLIDE_ID_RE$1.test(slideId$1)) return json$1(res, 400, { error: "invalid slideId" });
1892
- const body = await readBody$1(req);
1893
- if (!Array.isArray(body.order)) return json$1(res, 400, { error: "invalid order" });
1894
- const order = [];
1895
- for (const v of body.order) {
1896
- if (!Number.isInteger(v)) return json$1(res, 400, { error: "invalid order" });
1897
- order.push(v);
1898
- }
1899
- const entry = resolveSlideEntry(slidesRoot, slideId$1);
1900
- if (!entry) return json$1(res, 400, { error: "invalid slideId" });
2982
+ if (method === "GET" && url.pathname === "/") {
1901
2983
  let source;
1902
2984
  try {
1903
- source = await fs.readFile(entry, "utf8");
2985
+ source = await fs.readFile(file, "utf8");
1904
2986
  } catch {
1905
2987
  return json$1(res, 404, { error: "slide not found" });
1906
2988
  }
1907
- const reordered = reorderDefaultExportPagesInSource(source, order);
1908
- if (reordered === null) return json$1(res, 422, { error: "could not reorder pages — order must be a permutation of the existing array" });
1909
- const withNotes = reorderNotesArrayInSource(reordered, order);
1910
- if (withNotes === null) return json$1(res, 422, { error: "could not reorder pages — `notes` export has an unexpected shape" });
1911
- 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
+ });
1912
3000
  return json$1(res, 200, {
1913
- ok: true,
1914
- slideId: slideId$1,
1915
- order
3001
+ design: defaultDesign,
3002
+ exists: true,
3003
+ warning: parsed.error
1916
3004
  });
1917
3005
  }
1918
- const pageOpMatch = url.pathname.match(/^\/([^/]+)\/pages\/(\d+)(?:\/([a-z]+))?$/);
1919
- if (pageOpMatch) {
1920
- const slideId$1 = pageOpMatch[1];
1921
- const pageIndex = Number.parseInt(pageOpMatch[2], 10);
1922
- const op = pageOpMatch[3];
1923
- if (!SLIDE_ID_RE$1.test(slideId$1)) return json$1(res, 400, { error: "invalid slideId" });
1924
- if (!Number.isInteger(pageIndex) || pageIndex < 0) return json$1(res, 400, { error: "invalid page index" });
1925
- const isDelete = method === "DELETE" && !op;
1926
- const isDuplicate = method === "POST" && op === "duplicate";
1927
- if (!isDelete && !isDuplicate) return next();
1928
- const entry = resolveSlideEntry(slidesRoot, slideId$1);
1929
- 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" });
1930
3012
  let source;
1931
3013
  try {
1932
- source = await fs.readFile(entry, "utf8");
3014
+ source = await fs.readFile(file, "utf8");
1933
3015
  } catch {
1934
3016
  return json$1(res, 404, { error: "slide not found" });
1935
3017
  }
1936
- const updated = isDelete ? removePageFromDefaultExportInSource(source, pageIndex) : duplicatePageInDefaultExportInSource(source, pageIndex);
1937
- 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" });
1938
- 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");
1939
3025
  return json$1(res, 200, {
1940
3026
  ok: true,
1941
- slideId: slideId$1,
1942
- index: pageIndex
3027
+ design: merged,
3028
+ created: written.created
1943
3029
  });
1944
3030
  }
1945
- const idMatch = url.pathname.match(/^\/([^/]+)$/);
1946
- if (!idMatch) return next();
1947
- const slideId = idMatch[1];
1948
- if (!SLIDE_ID_RE$1.test(slideId)) return json$1(res, 400, { error: "invalid slideId" });
1949
- if (method === "PATCH") {
1950
- const body = await readBody$1(req);
1951
- const name = validateSlideName(body.name);
1952
- if (!name) return json$1(res, 400, { error: "invalid name" });
1953
- const entry = resolveSlideEntry(slidesRoot, slideId);
1954
- 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 });
1955
3034
  let source;
1956
3035
  try {
1957
- source = await fs.readFile(entry, "utf8");
3036
+ source = await fs.readFile(file, "utf8");
1958
3037
  } catch {
1959
3038
  return json$1(res, 404, { error: "slide not found" });
1960
3039
  }
1961
- const updated = updateMetaTitleInSource(source, name);
1962
- if (updated === null) return json$1(res, 422, { error: "could not locate a safe place to write meta.title in index.tsx" });
1963
- if (updated !== source) await fs.writeFile(entry, updated, "utf8");
1964
- 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");
1965
3043
  return json$1(res, 200, {
1966
3044
  ok: true,
1967
- slideId,
1968
- name
3045
+ design: defaultDesign,
3046
+ created: written.created
1969
3047
  });
1970
3048
  }
1971
- if (method === "DELETE") {
1972
- const removed = await rmSlideDir(slidesRoot, slideId);
1973
- if (!removed) return json$1(res, 404, { error: "slide not found" });
1974
- const manifest = await readManifest(manifestPath);
1975
- delete manifest.assignments[slideId];
1976
- await writeManifest(manifestPath, manifest);
1977
- return json$1(res, 200, { ok: true });
1978
- }
1979
- return next();
1980
- } catch (err) {
1981
- json$1(res, 500, { error: String(err.message ?? err) });
1982
- }
1983
- });
1984
- server.middlewares.use("/__assets", async (req, res, next) => {
1985
- const url = new URL(req.url ?? "/", "http://local");
1986
- const method = req.method ?? "GET";
1987
- try {
1988
- const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
1989
- const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
1990
- if (listMatch && method === "GET") {
1991
- const slideId = listMatch[1];
1992
- const assetsDir = resolveAssetsDir(slidesRoot, slideId);
1993
- if (!assetsDir) return json$1(res, 400, { error: "invalid slideId" });
1994
- let entries;
1995
- try {
1996
- entries = await fs.readdir(assetsDir);
1997
- } catch (err) {
1998
- if (err.code === "ENOENT") return json$1(res, 200, { assets: [] });
1999
- throw err;
2000
- }
2001
- const assets = [];
2002
- for (const name of entries) {
2003
- if (!validateAssetName(name)) continue;
2004
- const stat = await fs.stat(path.join(assetsDir, name));
2005
- if (!stat.isFile()) continue;
2006
- assets.push({
2007
- name,
2008
- size: stat.size,
2009
- mtime: stat.mtimeMs,
2010
- mime: mimeForFilename(name),
2011
- url: `/__assets/${slideId}/${encodeURIComponent(name)}`
2012
- });
2013
- }
2014
- assets.sort((a, b) => a.name.localeCompare(b.name));
2015
- return json$1(res, 200, { assets });
2016
- }
2017
- if (fileMatch) {
2018
- const slideId = fileMatch[1];
2019
- const filename = decodeURIComponent(fileMatch[2]);
2020
- const file = resolveAssetFile(slidesRoot, slideId, filename);
2021
- if (!file) return json$1(res, 400, { error: "invalid path" });
2022
- if (method === "GET") try {
2023
- const buf = await fs.readFile(file);
2024
- res.statusCode = 200;
2025
- res.setHeader("content-type", mimeForFilename(filename));
2026
- res.setHeader("cache-control", "no-store");
2027
- res.end(buf);
2028
- return;
2029
- } catch (err) {
2030
- if (err.code === "ENOENT") return json$1(res, 404, { error: "asset not found" });
2031
- throw err;
2032
- }
2033
- if (method === "POST") {
2034
- const overwrite = url.searchParams.get("overwrite") === "1";
2035
- const lenHeader = req.headers["content-length"];
2036
- const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
2037
- if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json$1(res, 413, { error: "file too large" });
2038
- if (!overwrite) try {
2039
- await fs.access(file);
2040
- return json$1(res, 409, { error: "asset exists" });
2041
- } catch {}
2042
- const assetsDir = resolveAssetsDir(slidesRoot, slideId);
2043
- if (!assetsDir) return json$1(res, 400, { error: "invalid slideId" });
2044
- await fs.mkdir(assetsDir, { recursive: true });
2045
- const chunks = [];
2046
- let total = 0;
2047
- let oversized = false;
2048
- await new Promise((resolve, reject) => {
2049
- req.on("data", (c) => {
2050
- total += c.length;
2051
- if (total > ASSET_MAX_BYTES) {
2052
- oversized = true;
2053
- req.destroy();
2054
- return;
2055
- }
2056
- chunks.push(c);
2057
- });
2058
- req.on("end", () => resolve());
2059
- req.on("error", reject);
2060
- });
2061
- if (oversized) return json$1(res, 413, { error: "file too large" });
2062
- await fs.writeFile(file, Buffer.concat(chunks));
2063
- return json$1(res, 200, {
2064
- ok: true,
2065
- name: filename,
2066
- size: total,
2067
- mime: mimeForFilename(filename),
2068
- url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
2069
- });
2070
- }
2071
- if (method === "PATCH") {
2072
- const body = await readBody$1(req);
2073
- const target = validateAssetName(body.name);
2074
- if (!target) return json$1(res, 400, { error: "invalid name" });
2075
- if (target === filename) return json$1(res, 200, {
2076
- ok: true,
2077
- name: filename
2078
- });
2079
- const dest = resolveAssetFile(slidesRoot, slideId, target);
2080
- if (!dest) return json$1(res, 400, { error: "invalid name" });
2081
- try {
2082
- await fs.access(dest);
2083
- return json$1(res, 409, { error: "target exists" });
2084
- } catch {}
2085
- try {
2086
- await fs.rename(file, dest);
2087
- } catch (err) {
2088
- if (err.code === "ENOENT") return json$1(res, 404, { error: "asset not found" });
2089
- throw err;
2090
- }
2091
- return json$1(res, 200, {
2092
- ok: true,
2093
- name: target
2094
- });
2095
- }
2096
- if (method === "DELETE") {
2097
- try {
2098
- await fs.unlink(file);
2099
- } catch (err) {
2100
- if (err.code === "ENOENT") return json$1(res, 404, { error: "asset not found" });
2101
- throw err;
2102
- }
2103
- return json$1(res, 200, { ok: true });
2104
- }
2105
- }
2106
3049
  return next();
2107
3050
  } catch (err) {
2108
3051
  json$1(res, 500, { error: String(err.message ?? err) });
2109
3052
  }
2110
3053
  });
2111
- server.middlewares.use("/__svgl", async (req, res, next) => {
2112
- const reqUrl = new URL(req.url ?? "/", "http://local");
2113
- const method = req.method ?? "GET";
2114
- if (method !== "GET") return next();
2115
- try {
2116
- let target = null;
2117
- if (reqUrl.pathname === "/search") {
2118
- const params = new URLSearchParams();
2119
- const q = reqUrl.searchParams.get("q");
2120
- const limit = reqUrl.searchParams.get("limit");
2121
- if (q) params.set("search", q);
2122
- if (limit) params.set("limit", limit);
2123
- const qs = params.toString();
2124
- target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
2125
- } else if (reqUrl.pathname === "/svg") {
2126
- const u = reqUrl.searchParams.get("u");
2127
- if (!u) return json$1(res, 400, { error: "missing u" });
2128
- let parsed;
2129
- try {
2130
- parsed = new URL(u);
2131
- } catch {
2132
- return json$1(res, 400, { error: "invalid u" });
2133
- }
2134
- if (parsed.protocol !== "https:") return json$1(res, 400, { error: "https only" });
2135
- const host = parsed.hostname.toLowerCase();
2136
- if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json$1(res, 400, { error: "host not allowed" });
2137
- target = parsed.toString();
2138
- } else return next();
2139
- const upstream = await fetch(target);
2140
- const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
2141
- res.statusCode = upstream.status;
2142
- res.setHeader("content-type", ct);
2143
- res.setHeader("cache-control", "no-store");
2144
- const buf = Buffer.from(await upstream.arrayBuffer());
2145
- res.end(buf);
2146
- } catch (err) {
2147
- json$1(res, 502, { error: String(err.message ?? err) });
2148
- }
2149
- });
2150
- server.middlewares.use("/__folders", async (req, res, next) => {
2151
- const url = new URL(req.url ?? "/", "http://local");
2152
- const method = req.method ?? "GET";
2153
- try {
2154
- if (method === "GET" && url.pathname === "/") {
2155
- const manifest = await readManifest(manifestPath);
2156
- return json$1(res, 200, manifest);
2157
- }
2158
- if (method === "POST" && url.pathname === "/") {
2159
- const body = await readBody$1(req);
2160
- const name = validateName(body.name);
2161
- if (!name) return json$1(res, 400, { error: "invalid name" });
2162
- const icon = validateIcon(body.icon);
2163
- if (!icon) return json$1(res, 400, { error: "invalid icon" });
2164
- const manifest = await readManifest(manifestPath);
2165
- const folder = {
2166
- id: newFolderId(),
2167
- name,
2168
- icon
2169
- };
2170
- manifest.folders.push(folder);
2171
- await writeManifest(manifestPath, manifest);
2172
- return json$1(res, 200, folder);
2173
- }
2174
- if (method === "PUT" && url.pathname === "/assign") {
2175
- const body = await readBody$1(req);
2176
- if (typeof body.slideId !== "string" || !SLIDE_ID_RE$1.test(body.slideId)) return json$1(res, 400, { error: "invalid slideId" });
2177
- const slideId = body.slideId;
2178
- let folderId;
2179
- if (body.folderId === null) folderId = null;
2180
- else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
2181
- else return json$1(res, 400, { error: "invalid folderId" });
2182
- const manifest = await readManifest(manifestPath);
2183
- if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json$1(res, 404, { error: "folder not found" });
2184
- if (folderId === null) delete manifest.assignments[slideId];
2185
- else manifest.assignments[slideId] = folderId;
2186
- await writeManifest(manifestPath, manifest);
2187
- return json$1(res, 200, { ok: true });
2188
- }
2189
- const idMatch = url.pathname.match(/^\/([^/]+)$/);
2190
- if (idMatch) {
2191
- const id = idMatch[1];
2192
- if (!FOLDER_ID_RE.test(id)) return json$1(res, 400, { error: "invalid id" });
2193
- if (method === "PATCH") {
2194
- const body = await readBody$1(req);
2195
- const manifest = await readManifest(manifestPath);
2196
- const folder = manifest.folders.find((f) => f.id === id);
2197
- if (!folder) return json$1(res, 404, { error: "folder not found" });
2198
- if (body.name !== void 0) {
2199
- const name = validateName(body.name);
2200
- if (!name) return json$1(res, 400, { error: "invalid name" });
2201
- folder.name = name;
2202
- }
2203
- if (body.icon !== void 0) {
2204
- const icon = validateIcon(body.icon);
2205
- if (!icon) return json$1(res, 400, { error: "invalid icon" });
2206
- folder.icon = icon;
2207
- }
2208
- await writeManifest(manifestPath, manifest);
2209
- return json$1(res, 200, folder);
2210
- }
2211
- if (method === "DELETE") {
2212
- const manifest = await readManifest(manifestPath);
2213
- const before = manifest.folders.length;
2214
- manifest.folders = manifest.folders.filter((f) => f.id !== id);
2215
- if (manifest.folders.length === before) return json$1(res, 404, { error: "folder not found" });
2216
- for (const [slideId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[slideId];
2217
- await writeManifest(manifestPath, manifest);
2218
- return json$1(res, 200, { ok: true });
2219
- }
2220
- }
2221
- next();
2222
- } catch (err) {
2223
- json$1(res, 500, { error: String(err.message ?? err) });
2224
- }
2225
- });
2226
3054
  }
2227
3055
  };
2228
3056
  }
@@ -2451,6 +3279,8 @@ function notesPlugin(opts) {
2451
3279
  const url = new URL(req.url ?? "/", "http://local");
2452
3280
  const method = req.method ?? "GET";
2453
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 });
2454
3284
  try {
2455
3285
  const body = await readBody(req);
2456
3286
  const slideId = body.slideId ?? "";
@@ -2523,13 +3353,18 @@ function toId(absFile, slidesRoot) {
2523
3353
  return rel.split(path.sep)[0];
2524
3354
  }
2525
3355
  const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
2526
- 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
+ };
2527
3362
  const metaStart = src.search(/export\s+const\s+meta\b/);
2528
- if (metaStart === -1) return null;
3363
+ if (metaStart === -1) return empty;
2529
3364
  const eqIdx = src.indexOf("=", metaStart);
2530
- if (eqIdx === -1) return null;
3365
+ if (eqIdx === -1) return empty;
2531
3366
  const openBrace = src.indexOf("{", eqIdx);
2532
- if (openBrace === -1) return null;
3367
+ if (openBrace === -1) return empty;
2533
3368
  let depth = 0;
2534
3369
  let closeBrace = -1;
2535
3370
  for (let i = openBrace; i < src.length; i++) {
@@ -2543,38 +3378,74 @@ function extractMetaTheme(src) {
2543
3378
  }
2544
3379
  }
2545
3380
  }
2546
- if (closeBrace === -1) return null;
3381
+ if (closeBrace === -1) return empty;
2547
3382
  const body = src.slice(openBrace + 1, closeBrace);
2548
- const m = body.match(META_THEME_RE);
2549
- 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
+ };
2550
3389
  }
2551
- async function readSlideTheme(abs) {
3390
+ async function readSlideMeta(abs) {
2552
3391
  try {
2553
3392
  const src = await fs.readFile(abs, "utf8");
2554
- return extractMetaTheme(src);
3393
+ return extractMeta(src);
2555
3394
  } catch {
2556
- return null;
3395
+ return {
3396
+ theme: null,
3397
+ createdAt: null
3398
+ };
2557
3399
  }
2558
3400
  }
3401
+ function parseCreatedAtMs(iso) {
3402
+ if (!iso) return null;
3403
+ const ms = Date.parse(iso);
3404
+ return Number.isFinite(ms) ? ms : null;
3405
+ }
2559
3406
  async function generateSlidesModule(files, slidesRoot, isDev) {
2560
3407
  const entries = await Promise.all(files.map(async (abs) => {
2561
3408
  const id = toId(abs, slidesRoot);
2562
3409
  const importPath = isDev ? `/@fs/${abs.replace(/^\/+/, "")}` : abs;
2563
- const theme = await readSlideTheme(abs);
3410
+ const meta = await readSlideMeta(abs);
2564
3411
  return {
2565
3412
  id,
2566
3413
  importPath,
2567
- theme
3414
+ theme: meta.theme,
3415
+ createdAt: parseCreatedAtMs(meta.createdAt)
2568
3416
  };
2569
3417
  }));
2570
3418
  const ids = JSON.stringify(entries.map((e) => e.id).sort());
2571
3419
  const themesMap = {};
2572
- 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
+ }
2573
3425
  const themesJson = JSON.stringify(themesMap);
2574
- const cases = entries.map((e) => ` case ${JSON.stringify(e.id)}: return import(${JSON.stringify(e.importPath)});`).join("\n");
3426
+ const createdAtJson = JSON.stringify(createdAtMap);
3427
+ const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
3428
+ const devRuntime = isDev ? `
3429
+ const slideImportTokens = ${importTokens};
3430
+ if (import.meta.hot) {
3431
+ import.meta.hot.on('open-slide:slide-changed', (data) => {
3432
+ const ids = Array.isArray(data?.slideIds) ? data.slideIds : data?.slideId ? [data.slideId] : [];
3433
+ const token = Date.now();
3434
+ for (const id of ids) {
3435
+ if (Object.prototype.hasOwnProperty.call(slideImportTokens, id)) slideImportTokens[id] = token;
3436
+ }
3437
+ });
3438
+ }
3439
+ ` : "";
3440
+ const cases = entries.map((e) => {
3441
+ const importExpr = isDev ? `import(/* @vite-ignore */ ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
3442
+ return ` case ${JSON.stringify(e.id)}: return ${importExpr};`;
3443
+ }).join("\n");
2575
3444
  return `// virtual:open-slide/slides — generated
2576
3445
  export const slideIds = ${ids};
2577
3446
  export const slideThemes = ${themesJson};
3447
+ export const slideCreatedAt = ${createdAtJson};
3448
+ ${devRuntime}
2578
3449
 
2579
3450
  export async function loadSlide(id) {
2580
3451
  switch (id) {
@@ -2590,6 +3461,32 @@ function openSlidePlugin(opts) {
2590
3461
  const slidesRoot = path.resolve(userCwd, slidesDir);
2591
3462
  const foldersManifestPath = path.join(slidesRoot, ".folders.json");
2592
3463
  let isDev = false;
3464
+ const slideIdForEntry = (p) => {
3465
+ const rel = path.relative(slidesRoot, p);
3466
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
3467
+ const parts = rel.split(path.sep);
3468
+ if (parts.length !== 2) return null;
3469
+ if (!/^index\.(tsx|jsx|ts|js)$/.test(parts[1])) return null;
3470
+ return parts[0];
3471
+ };
3472
+ let slideChangeTimer = null;
3473
+ const pendingSlideChanges = new Set();
3474
+ const queueSlideChanged = (server, id) => {
3475
+ pendingSlideChanges.add(id);
3476
+ if (slideChangeTimer) clearTimeout(slideChangeTimer);
3477
+ slideChangeTimer = setTimeout(() => {
3478
+ slideChangeTimer = null;
3479
+ const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
3480
+ if (mod) server.moduleGraph.invalidateModule(mod);
3481
+ const slideIds = Array.from(pendingSlideChanges);
3482
+ pendingSlideChanges.clear();
3483
+ server.ws.send({
3484
+ type: "custom",
3485
+ event: "open-slide:slide-changed",
3486
+ data: { slideIds }
3487
+ });
3488
+ }, 100);
3489
+ };
2593
3490
  return {
2594
3491
  name: "open-slide",
2595
3492
  config(_c, env) {
@@ -2630,14 +3527,14 @@ function openSlidePlugin(opts) {
2630
3527
  }
2631
3528
  return null;
2632
3529
  },
3530
+ handleHotUpdate(ctx) {
3531
+ const slideId = slideIdForEntry(ctx.file);
3532
+ if (!slideId) return;
3533
+ queueSlideChanged(ctx.server, slideId);
3534
+ return [];
3535
+ },
2633
3536
  configureServer(server) {
2634
- const isSlideEntry = (p) => {
2635
- const rel = path.relative(slidesRoot, p);
2636
- if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
2637
- const parts = rel.split(path.sep);
2638
- if (parts.length !== 2) return false;
2639
- return /^index\.(tsx|jsx|ts|js)$/.test(parts[1]);
2640
- };
3537
+ const isSlideEntry = (p) => slideIdForEntry(p) !== null;
2641
3538
  let reloadTimer = null;
2642
3539
  const reload = () => {
2643
3540
  if (reloadTimer) clearTimeout(reloadTimer);
@@ -2655,18 +3552,6 @@ function openSlidePlugin(opts) {
2655
3552
  server.watcher.on("unlink", (p) => {
2656
3553
  if (isSlideEntry(p)) reload();
2657
3554
  });
2658
- let slideThemeTimer = null;
2659
- const invalidateSlidesVmod = () => {
2660
- if (slideThemeTimer) clearTimeout(slideThemeTimer);
2661
- slideThemeTimer = setTimeout(() => {
2662
- slideThemeTimer = null;
2663
- const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
2664
- if (mod) server.moduleGraph.invalidateModule(mod);
2665
- }, 100);
2666
- };
2667
- server.watcher.on("change", (p) => {
2668
- if (isSlideEntry(p)) invalidateSlidesVmod();
2669
- });
2670
3555
  let foldersTimer = null;
2671
3556
  const invalidateFolders = () => {
2672
3557
  if (foldersTimer) clearTimeout(foldersTimer);
@@ -2762,18 +3647,18 @@ async function readTheme(mdAbs, themesRoot) {
2762
3647
  };
2763
3648
  }
2764
3649
  function generateThemesModule(themes, isDev) {
2765
- const meta = themes.map((t$3) => ({
2766
- id: t$3.id,
2767
- name: t$3.frontmatter.name,
2768
- description: t$3.frontmatter.description,
2769
- body: t$3.body,
2770
- 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
2771
3656
  }));
2772
- const cases = themes.flatMap((t$3) => {
2773
- const abs = t$3.demoAbs;
3657
+ const cases = themes.flatMap((t$5) => {
3658
+ const abs = t$5.demoAbs;
2774
3659
  if (!abs) return [];
2775
3660
  const importPath = isDev ? `/@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
2776
- 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)});`];
2777
3662
  }).join("\n");
2778
3663
  return `// virtual:open-slide/themes — generated
2779
3664
  export const themes = ${JSON.stringify(meta)};
@@ -2854,8 +3739,10 @@ async function createViteConfig(opts) {
2854
3739
  const config = opts.config ?? await loadUserConfig(userCwd);
2855
3740
  const slidesDir = config.slidesDir ?? "slides";
2856
3741
  const themesDir = config.themesDir ?? "themes";
3742
+ const assetsDir = config.assetsDir ?? "assets";
2857
3743
  const slidesAbs = path.resolve(userCwd, slidesDir);
2858
3744
  const themesAbs = path.resolve(userCwd, themesDir);
3745
+ const assetsAbs = path.resolve(userCwd, assetsDir);
2859
3746
  return {
2860
3747
  root: APP_ROOT,
2861
3748
  configFile: false,
@@ -2876,24 +3763,24 @@ async function createViteConfig(opts) {
2876
3763
  config
2877
3764
  }),
2878
3765
  designPlugin({ userCwd }),
2879
- commentsPlugin({
3766
+ apiPlugin({
2880
3767
  userCwd,
2881
- slidesDir
3768
+ slidesDir,
3769
+ assetsDir
2882
3770
  }),
2883
3771
  notesPlugin({
2884
3772
  userCwd,
2885
3773
  slidesDir
2886
3774
  }),
2887
- filesPlugin({
2888
- userCwd,
2889
- slidesDir
2890
- }),
2891
3775
  currentPlugin({
2892
3776
  userCwd,
2893
3777
  slidesDir
2894
3778
  })
2895
3779
  ],
2896
- resolve: { alias: { "@": APP_ROOT } },
3780
+ resolve: { alias: {
3781
+ "@": APP_ROOT,
3782
+ "@assets": assetsAbs
3783
+ } },
2897
3784
  optimizeDeps: {
2898
3785
  entries: [path.join(APP_ROOT, "main.tsx")],
2899
3786
  include: [
@@ -2925,7 +3812,8 @@ async function createViteConfig(opts) {
2925
3812
  APP_ROOT,
2926
3813
  userCwd,
2927
3814
  slidesAbs,
2928
- themesAbs
3815
+ themesAbs,
3816
+ assetsAbs
2929
3817
  ] }
2930
3818
  },
2931
3819
  build: {