@open-slide/core 1.2.0 → 1.4.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 (56) hide show
  1. package/dist/{build-6BeQ3cxb.js → build-1Rqivz0d.js} +2 -2
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-AxZ5OE1u.js → config-XZJnC_fu.js} +735 -64
  4. package/dist/{config-CtT8K4VF.d.ts → config-s0YUbmUe.d.ts} +3 -1
  5. package/dist/{dev-C9eLmUEq.js → dev-0W8gYiSa.js} +2 -2
  6. package/dist/en-7GU-DHbJ.js +361 -0
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +229 -39
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +136 -342
  11. package/dist/{preview-Cunm-f4i.js → preview-DT9hJvzM.js} +2 -2
  12. package/dist/sync-j9_QPovT.js +3 -0
  13. package/dist/{types-CRHIeoNq.d.ts → types-QCpkHkiS.d.ts} +42 -2
  14. package/dist/vite/index.d.ts +2 -2
  15. package/dist/vite/index.js +2 -2
  16. package/package.json +9 -1
  17. package/skills/create-slide/SKILL.md +1 -1
  18. package/skills/create-theme/SKILL.md +60 -12
  19. package/skills/slide-authoring/SKILL.md +21 -2
  20. package/src/app/app.tsx +13 -1
  21. package/src/app/components/asset-view.tsx +37 -22
  22. package/src/app/components/image-placeholder.tsx +123 -1
  23. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  24. package/src/app/components/inspector/inspector-panel.tsx +370 -30
  25. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  26. package/src/app/components/player.tsx +25 -5
  27. package/src/app/components/present/control-bar.tsx +12 -0
  28. package/src/app/components/sidebar/folder-item.tsx +27 -5
  29. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  30. package/src/app/components/sidebar/sidebar.tsx +20 -0
  31. package/src/app/components/themes/theme-detail.tsx +300 -0
  32. package/src/app/components/themes/themes-gallery.tsx +146 -0
  33. package/src/app/components/thumbnail-rail.tsx +17 -5
  34. package/src/app/lib/assets.ts +55 -2
  35. package/src/app/lib/export-pdf.ts +6 -0
  36. package/src/app/lib/inspector/use-editor.ts +9 -1
  37. package/src/app/lib/sdk.ts +1 -0
  38. package/src/app/lib/slides.ts +17 -1
  39. package/src/app/lib/themes.ts +22 -0
  40. package/src/app/lib/use-agent-socket.ts +18 -0
  41. package/src/app/lib/use-slide-module.ts +48 -0
  42. package/src/app/routes/assets.tsx +9 -0
  43. package/src/app/routes/home-shell.tsx +194 -0
  44. package/src/app/routes/home.tsx +89 -207
  45. package/src/app/routes/presenter.tsx +2 -20
  46. package/src/app/routes/slide.tsx +217 -54
  47. package/src/app/routes/themes.tsx +34 -0
  48. package/src/app/virtual.d.ts +20 -0
  49. package/src/locale/en.ts +49 -7
  50. package/src/locale/ja.ts +50 -7
  51. package/src/locale/types.ts +44 -2
  52. package/src/locale/zh-cn.ts +49 -8
  53. package/src/locale/zh-tw.ts +49 -8
  54. package/dist/sync-B4eLo2H6.js +0 -3
  55. /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
  56. /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { defaultDesign } from "./design-C13iz9_4.js";
1
+ import { defaultDesign } from "./design-cpzS8aud.js";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -12,7 +12,7 @@ import * as t$1 from "@babel/types";
12
12
  import * as t from "@babel/types";
13
13
  import { isJSXElement, isJSXFragment } from "@babel/types";
14
14
  import fg from "fast-glob";
15
- import { loadConfigFromFile } from "vite";
15
+ import { loadConfigFromFile, normalizePath } from "vite";
16
16
 
17
17
  //#region src/vite/babel-walk.ts
18
18
  const SKIP_KEYS = new Set([
@@ -187,11 +187,12 @@ function offsetToLine(source, offset) {
187
187
  }
188
188
  function parseSource$2(source) {
189
189
  try {
190
- return parse(source, {
190
+ const ast = parse(source, {
191
191
  sourceType: "module",
192
192
  plugins: ["typescript", "jsx"],
193
193
  errorRecovery: true
194
194
  });
195
+ return ast.errors && ast.errors.length > 0 ? null : ast;
195
196
  } catch {
196
197
  return null;
197
198
  }
@@ -202,6 +203,51 @@ function findInnermostJsxElement(ast, line, column) {
202
203
  for (const n of findJsxAncestors(ast, line, column)) if (t$2.isJSXElement(n)) return n;
203
204
  return null;
204
205
  }
206
+ function findUniqueElementByText(ast, prevText) {
207
+ const hits = [];
208
+ walkJsx(ast, (n) => {
209
+ if (!t$2.isJSXElement(n)) return;
210
+ const parts = [];
211
+ collectTextRangeParts(n, parts);
212
+ if (textRangeContent(parts) !== prevText) return;
213
+ hits.push({
214
+ node: n,
215
+ size: (n.end ?? 0) - (n.start ?? 0)
216
+ });
217
+ });
218
+ if (hits.length === 0) return null;
219
+ hits.sort((a, b) => a.size - b.size);
220
+ const best = hits[0];
221
+ const bestStart = best.node.start ?? 0;
222
+ const bestEnd = best.node.end ?? 0;
223
+ const hasSiblingMatch = hits.slice(1).some(({ node }) => (node.start ?? 0) > bestStart || (node.end ?? 0) < bestEnd);
224
+ return hasSiblingMatch ? null : best.node;
225
+ }
226
+ function fallbackTextForOps(ops) {
227
+ 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;
228
+ return null;
229
+ }
230
+ function hasOnlyTextOps(ops) {
231
+ return ops.length > 0 && ops.every((op) => op.kind === "set-text");
232
+ }
233
+ function elementTextMatches(element, prevText) {
234
+ const parts = [];
235
+ collectTextRangeParts(element, parts);
236
+ return textRangeContent(parts) === prevText;
237
+ }
238
+ function elementHasTextCandidate(ast, element, prevText) {
239
+ const norm = prevText.trim();
240
+ return collectElementTextCandidates(ast, element).some((candidate) => candidate.current === norm);
241
+ }
242
+ function findElementForEdit(ast, line, column, ops) {
243
+ const element = findInnermostJsxElement(ast, line, column);
244
+ const prevText = fallbackTextForOps(ops);
245
+ if (prevText === null) return element;
246
+ if (hasOnlyTextOps(ops) && element && (elementTextMatches(element, prevText) || elementHasTextCandidate(ast, element, prevText))) return element;
247
+ const textMatch = findUniqueElementByText(ast, prevText);
248
+ if (element && elementTextMatches(element, prevText)) return textMatch ?? element;
249
+ return textMatch ?? element;
250
+ }
205
251
  function findJsxByStart(ast, line, column) {
206
252
  let hit = null;
207
253
  walkJsx(ast, (n) => {
@@ -227,27 +273,60 @@ function findJsxAttr(opening, name) {
227
273
  function buildStyleSplice(source, element, ops) {
228
274
  const opening = element.openingElement;
229
275
  const existing = findJsxAttr(opening, "style");
230
- const style = new Map();
276
+ const entries = [];
277
+ let hasRawEntry = false;
231
278
  if (existing) {
232
279
  const value = existing.value;
233
280
  if (!value || !t$2.isJSXExpressionContainer(value)) return { error: "style attribute has unsupported form" };
234
281
  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" };
282
+ if (!t$2.isObjectExpression(expr)) {
283
+ if (typeof expr.start !== "number" || typeof expr.end !== "number") return { error: "style value missing source range" };
284
+ entries.push({
285
+ kind: "raw",
286
+ text: `...(${source.slice(expr.start, expr.end)})`
287
+ });
288
+ hasRawEntry = true;
289
+ } else for (const prop of expr.properties) if (t$2.isObjectProperty(prop) && !prop.computed) {
239
290
  let keyName = null;
240
291
  if (t$2.isIdentifier(prop.key)) keyName = prop.key.name;
241
292
  else if (t$2.isStringLiteral(prop.key)) keyName = prop.key.value;
242
293
  if (!keyName) return { error: "style has unsupported key" };
243
294
  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));
295
+ 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" };
296
+ entries.push({
297
+ kind: "prop",
298
+ key: keyName,
299
+ keyText: source.slice(prop.key.start, prop.key.end),
300
+ valueText: source.slice(v.start, v.end)
301
+ });
302
+ } else {
303
+ if (typeof prop.start !== "number" || typeof prop.end !== "number") return { error: "style value missing source range" };
304
+ entries.push({
305
+ kind: "raw",
306
+ text: source.slice(prop.start, prop.end)
307
+ });
308
+ hasRawEntry = true;
246
309
  }
247
310
  }
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) {
311
+ for (const op of ops) {
312
+ const matching = entries.filter((entry) => entry.kind === "prop" && entry.key === op.key);
313
+ if (op.value === null) {
314
+ for (const entry of matching) entries.splice(entries.indexOf(entry), 1);
315
+ if (hasRawEntry) entries.push({
316
+ kind: "prop",
317
+ key: op.key,
318
+ keyText: op.key,
319
+ valueText: "undefined"
320
+ });
321
+ } else if (matching.length > 0) matching[matching.length - 1].valueText = jsString$1(op.value);
322
+ else entries.push({
323
+ kind: "prop",
324
+ key: op.key,
325
+ keyText: op.key,
326
+ valueText: jsString$1(op.value)
327
+ });
328
+ }
329
+ if (entries.length === 0) {
251
330
  if (!existing) return null;
252
331
  let from = existing.start ?? 0;
253
332
  if (from > 0 && source[from - 1] === " ") from -= 1;
@@ -257,16 +336,29 @@ function buildStyleSplice(source, element, ops) {
257
336
  text: ""
258
337
  };
259
338
  }
260
- const propsText = Array.from(style.entries()).map(([k, v]) => `${k}: ${v}`).join(", ");
339
+ const propsText = entries.map((entry) => entry.kind === "prop" ? `${entry.keyText}: ${entry.valueText}` : entry.text).join(", ");
261
340
  const newAttr = `style={{ ${propsText} }}`;
262
- if (existing) return {
263
- from: existing.start ?? 0,
264
- to: existing.end ?? 0,
265
- text: newAttr
266
- };
341
+ if (existing) {
342
+ const lastAttr$1 = opening.attributes[opening.attributes.length - 1];
343
+ if (lastAttr$1 && lastAttr$1 !== existing && typeof lastAttr$1.end === "number") {
344
+ const attrsAfterStyle = source.slice(existing.end ?? 0, lastAttr$1.end).replace(/^[ \t]+/, "");
345
+ return {
346
+ from: existing.start ?? 0,
347
+ to: lastAttr$1.end,
348
+ text: `${attrsAfterStyle} ${newAttr}`
349
+ };
350
+ }
351
+ return {
352
+ from: existing.start ?? 0,
353
+ to: existing.end ?? 0,
354
+ text: newAttr
355
+ };
356
+ }
357
+ const lastAttr = opening.attributes[opening.attributes.length - 1];
358
+ const at = lastAttr?.end ?? opening.name.end ?? 0;
267
359
  return {
268
- from: opening.name.end ?? 0,
269
- to: opening.name.end ?? 0,
360
+ from: at,
361
+ to: at,
270
362
  text: ` ${newAttr}`
271
363
  };
272
364
  }
@@ -280,6 +372,10 @@ function meaningfulChildren(parent) {
280
372
  return true;
281
373
  });
282
374
  }
375
+ function isOnlyMeaningfulChild(parent, child) {
376
+ const meaningful = meaningfulChildren(parent);
377
+ return meaningful.length === 1 && meaningful[0] === child;
378
+ }
283
379
  function wrapSplice(parent, text) {
284
380
  const first = parent.children[0];
285
381
  const last = parent.children[parent.children.length - 1];
@@ -289,6 +385,60 @@ function wrapSplice(parent, text) {
289
385
  text
290
386
  };
291
387
  }
388
+ function splitLinesWithOffsets(value) {
389
+ const lines = [];
390
+ let start = 0;
391
+ for (let i = 0; i < value.length; i++) {
392
+ const ch = value[i];
393
+ if (ch !== "\n" && ch !== "\r") continue;
394
+ lines.push({
395
+ text: value.slice(start, i),
396
+ start
397
+ });
398
+ if (ch === "\r" && value[i + 1] === "\n") i += 1;
399
+ start = i + 1;
400
+ }
401
+ lines.push({
402
+ text: value.slice(start),
403
+ start
404
+ });
405
+ return lines;
406
+ }
407
+ function cleanJsxTextWithOffsets(value) {
408
+ const lines = splitLinesWithOffsets(value);
409
+ let lastNonEmptyLine = 0;
410
+ for (let i = 0; i < lines.length; i++) if (lines[i].text.trim()) lastNonEmptyLine = i;
411
+ let text = "";
412
+ const offsets = [];
413
+ for (let i = 0; i < lines.length; i++) {
414
+ const chars = Array.from(lines[i].text, (ch, j) => ({
415
+ ch: ch === " " ? " " : ch,
416
+ offset: lines[i].start + j
417
+ }));
418
+ let from = 0;
419
+ let to = chars.length;
420
+ if (i !== 0) while (from < to && chars[from].ch === " ") from += 1;
421
+ if (i !== lines.length - 1) while (to > from && chars[to - 1].ch === " ") to -= 1;
422
+ if (from >= to) continue;
423
+ for (const item of chars.slice(from, to)) {
424
+ text += item.ch;
425
+ offsets.push(item.offset);
426
+ }
427
+ if (i !== lastNonEmptyLine) {
428
+ text += " ";
429
+ offsets.push(null);
430
+ }
431
+ }
432
+ return {
433
+ text,
434
+ offsets
435
+ };
436
+ }
437
+ function isJsxBrElement(node) {
438
+ if (!t$2.isJSXElement(node)) return false;
439
+ const name = node.openingElement.name;
440
+ return t$2.isJSXIdentifier(name) && name.name.toLowerCase() === "br";
441
+ }
292
442
  function collectTextCandidates(element, out) {
293
443
  const meaningful = meaningfulChildren(element);
294
444
  const isSole = meaningful.length === 1;
@@ -318,6 +468,226 @@ function collectTextCandidates(element, out) {
318
468
  }
319
469
  } else if (t$2.isJSXElement(child) || t$2.isJSXFragment(child)) collectTextCandidates(child, out);
320
470
  }
471
+ function collectTextRangeParts(element, out) {
472
+ const parts = [];
473
+ collectTextRangePartsRaw(element, parts);
474
+ out.push(...normalizeTextRangeParts(parts));
475
+ }
476
+ function collectTextRangePartsRaw(element, out) {
477
+ for (const child of element.children) if (t$2.isJSXText(child)) {
478
+ const { text: current, offsets } = cleanJsxTextWithOffsets(child.value);
479
+ if (current) out.push({
480
+ node: child,
481
+ parent: element,
482
+ current,
483
+ raw: child.value,
484
+ text: formatJsxText,
485
+ offsets
486
+ });
487
+ } else if (t$2.isJSXExpressionContainer(child)) {
488
+ const expression = child.expression;
489
+ if (t$2.isStringLiteral(expression) || t$2.isNumericLiteral(expression)) {
490
+ const raw = String(expression.value);
491
+ const current = raw;
492
+ if (current) out.push({
493
+ node: child,
494
+ parent: element,
495
+ current,
496
+ raw,
497
+ text: (value) => `{${jsString$1(value)}}`,
498
+ offsets: Array.from({ length: current.length }, (_, i) => i)
499
+ });
500
+ }
501
+ } else if (isJsxBrElement(child)) out.push({
502
+ node: child,
503
+ current: "\n"
504
+ });
505
+ else if (t$2.isJSXElement(child) || t$2.isJSXFragment(child)) collectTextRangePartsRaw(child, out);
506
+ }
507
+ function normalizeTextRangeParts(parts) {
508
+ return parts.flatMap((part, index) => {
509
+ if (!("raw" in part)) return [part];
510
+ let start = 0;
511
+ let end = part.current.length;
512
+ if (parts[index - 1]?.current === "\n") while (start < end && /\s/.test(part.current[start] ?? "")) start++;
513
+ if (parts[index + 1]?.current === "\n") while (end > start && /\s/.test(part.current[end - 1] ?? "")) end--;
514
+ if (start === 0 && end === part.current.length) return [part];
515
+ if (start >= end) return [];
516
+ return [{
517
+ ...part,
518
+ current: part.current.slice(start, end),
519
+ offsets: part.offsets.slice(start, end)
520
+ }];
521
+ });
522
+ }
523
+ function resetValueForRangeStyle(key) {
524
+ if (key === "fontWeight") return "400";
525
+ if (key === "fontStyle") return "normal";
526
+ return null;
527
+ }
528
+ function styleSpanForText(text, key, value) {
529
+ const styleValue = value ?? resetValueForRangeStyle(key);
530
+ if (styleValue === null) return formatJsxText(text);
531
+ return `<span style={{ ${key}: ${jsString$1(styleValue)} }}>${formatJsxText(text)}</span>`;
532
+ }
533
+ function textRangeContent(parts) {
534
+ return parts.map((part) => part.current).join("");
535
+ }
536
+ function compactText(value) {
537
+ return value.replace(/\s+/g, "");
538
+ }
539
+ function textMatchesExpected(current, expected) {
540
+ return current === expected || compactText(current) === compactText(expected);
541
+ }
542
+ function formatRichText(value, formatText = formatJsxText) {
543
+ return value.split("\n").map((part) => formatText(part)).join("<br />");
544
+ }
545
+ function formatOptionalText(value, formatText = formatJsxText) {
546
+ return value ? formatText(value) : "";
547
+ }
548
+ function textDiff(prevText, nextText) {
549
+ let start = 0;
550
+ while (start < prevText.length && start < nextText.length && prevText[start] === nextText[start]) start += 1;
551
+ let prevEnd = prevText.length;
552
+ let nextEnd = nextText.length;
553
+ while (prevEnd > start && nextEnd > start && prevText[prevEnd - 1] === nextText[nextEnd - 1]) {
554
+ prevEnd -= 1;
555
+ nextEnd -= 1;
556
+ }
557
+ return {
558
+ start,
559
+ end: prevEnd,
560
+ value: nextText.slice(start, nextEnd)
561
+ };
562
+ }
563
+ function textLeafSplice(part, value) {
564
+ const rawRange = textLeafRawRange(part, 0, part.current.length);
565
+ if (!rawRange) return spliceRange(part.node, part.text(value));
566
+ const { rawStart, rawEnd } = rawRange;
567
+ return {
568
+ from: part.node.start ?? 0,
569
+ to: part.node.end ?? 0,
570
+ text: `${part.raw.slice(0, rawStart)}${formatRichText(value, part.text)}${part.raw.slice(rawEnd)}`
571
+ };
572
+ }
573
+ function textLeafRawRange(part, start, end) {
574
+ if (start >= end) return null;
575
+ let first = null;
576
+ let last = null;
577
+ for (let i = start; i < end; i++) {
578
+ const offset = part.offsets[i];
579
+ if (offset === void 0) return null;
580
+ if (offset === null) continue;
581
+ first ??= offset;
582
+ last = offset;
583
+ }
584
+ if (first === null || last === null) return null;
585
+ return {
586
+ rawStart: first,
587
+ rawEnd: last + 1
588
+ };
589
+ }
590
+ function buildTextRangeReplaceSplices(parts, start, end, value) {
591
+ const splices = [];
592
+ let offset = 0;
593
+ let inserted = false;
594
+ for (const part of parts) {
595
+ const partStart = offset;
596
+ const partEnd = partStart + part.current.length;
597
+ offset = partEnd;
598
+ const overlaps = start < partEnd && end > partStart;
599
+ const insertsHere = start === end && !inserted && start >= partStart && start <= partEnd;
600
+ if (!overlaps && !insertsHere) continue;
601
+ if ("raw" in part) {
602
+ const localStart = Math.max(start, partStart) - partStart;
603
+ const localEnd = overlaps ? Math.min(end, partEnd) - partStart : localStart;
604
+ const nextText = `${part.current.slice(0, localStart)}${inserted ? "" : value}${part.current.slice(localEnd)}`;
605
+ splices.push(textLeafSplice(part, nextText));
606
+ } else if (overlaps) splices.push(spliceRange(part.node, inserted ? "" : formatRichText(value)));
607
+ else if (insertsHere) {
608
+ const at = start === partStart ? part.node.start ?? 0 : part.node.end ?? 0;
609
+ splices.push({
610
+ from: at,
611
+ to: at,
612
+ text: formatRichText(value)
613
+ });
614
+ }
615
+ inserted = true;
616
+ }
617
+ if (!inserted && start === end && start === offset) {
618
+ const last = parts[parts.length - 1];
619
+ if (!last) return { error: "element has no editable text" };
620
+ if ("raw" in last) splices.push(textLeafSplice(last, `${last.current}${value}`));
621
+ else splices.push({
622
+ from: last.node.end ?? 0,
623
+ to: last.node.end ?? 0,
624
+ text: formatRichText(value)
625
+ });
626
+ }
627
+ return splices;
628
+ }
629
+ function buildTextContentSplices(element, value, prevText) {
630
+ const parts = [];
631
+ collectTextRangeParts(element, parts);
632
+ const current = textRangeContent(parts);
633
+ if (!textMatchesExpected(current, prevText)) return { error: "no text candidate matches the current value" };
634
+ const diff = textDiff(current, value);
635
+ if (diff.start === diff.end && diff.value === "") return [];
636
+ return buildTextRangeReplaceSplices(parts, diff.start, diff.end, diff.value);
637
+ }
638
+ function buildTextRangeStyleSplices(ast, source, element, start, end, op, prevText) {
639
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start) return { error: "invalid text range" };
640
+ const parts = [];
641
+ collectTextRangeParts(element, parts);
642
+ const current = prevText ?? textRangeContent(parts);
643
+ if (!current) return { error: "element has no editable text" };
644
+ if (end > current.length) return { error: "text range is out of bounds" };
645
+ const renderedText = textRangeContent(parts);
646
+ if (prevText !== void 0 && renderedText !== prevText) {
647
+ if (elementTextCandidateMatches(ast, element, prevText)) {
648
+ const result = buildStyleSplice(source, element, [op]);
649
+ if (result && "error" in result) return result;
650
+ return result ? [result] : [];
651
+ }
652
+ return { error: "no text candidate matches the current value" };
653
+ }
654
+ const splices = [];
655
+ let leafStart = 0;
656
+ for (const leaf of parts) {
657
+ const leafEnd = leafStart + leaf.current.length;
658
+ if (!("raw" in leaf)) {
659
+ leafStart = leafEnd;
660
+ continue;
661
+ }
662
+ const selectedStart = Math.max(start, leafStart);
663
+ const selectedEnd = Math.min(end, leafEnd);
664
+ if (selectedStart >= selectedEnd) {
665
+ leafStart = leafEnd;
666
+ continue;
667
+ }
668
+ if (selectedStart === leafStart && selectedEnd === leafEnd && t$2.isJSXElement(leaf.parent) && leaf.parent !== element && isOnlyMeaningfulChild(leaf.parent, leaf.node)) {
669
+ const result = buildStyleSplice(source, leaf.parent, [op]);
670
+ if (result && "error" in result) return result;
671
+ if (result) splices.push(result);
672
+ leafStart = leafEnd;
673
+ continue;
674
+ }
675
+ const localStart = selectedStart - leafStart;
676
+ const localEnd = selectedEnd - leafStart;
677
+ const rawRange = textLeafRawRange(leaf, localStart, localEnd);
678
+ if (!rawRange) return { error: "text range source mismatch" };
679
+ const raw = leaf.raw;
680
+ const { rawStart, rawEnd } = rawRange;
681
+ const before = raw.slice(0, rawStart);
682
+ const selected = leaf.current.slice(localStart, localEnd);
683
+ const after = raw.slice(rawEnd);
684
+ const beforeText = t$2.isJSXText(leaf.node) ? before : formatOptionalText(before, leaf.text);
685
+ const afterText = t$2.isJSXText(leaf.node) ? after : formatOptionalText(after, leaf.text);
686
+ splices.push(spliceRange(leaf.node, `${beforeText}${styleSpanForText(selected, op.key, op.value)}${afterText}`));
687
+ leafStart = leafEnd;
688
+ }
689
+ return splices.length > 0 ? splices : null;
690
+ }
321
691
  function propPassthroughName(element) {
322
692
  const meaningful = meaningfulChildren(element);
323
693
  if (meaningful.length !== 1) return null;
@@ -517,7 +887,7 @@ function collectArrayMapCandidates(ast, element) {
517
887
  }
518
888
  return out;
519
889
  }
520
- function buildTextSplice(ast, element, value, prevText) {
890
+ function collectElementTextCandidates(ast, element) {
521
891
  const candidates = [];
522
892
  collectTextCandidates(element, candidates);
523
893
  if (candidates.length === 0) {
@@ -527,6 +897,14 @@ function buildTextSplice(ast, element, value, prevText) {
527
897
  else if (passthrough && enclosing && componentDestructuresProp(enclosing.fn, passthrough)) candidates.push(...collectPropCallSiteCandidates(ast, enclosing.name, passthrough));
528
898
  }
529
899
  if (candidates.length === 0) candidates.push(...collectArrayMapCandidates(ast, element));
900
+ return candidates;
901
+ }
902
+ function elementTextCandidateMatches(ast, element, prevText) {
903
+ const norm = prevText.trim();
904
+ return collectElementTextCandidates(ast, element).some((candidate) => candidate.current === norm);
905
+ }
906
+ function buildTextSplice(ast, element, value, prevText) {
907
+ const candidates = collectElementTextCandidates(ast, element);
530
908
  if (candidates.length === 0) return { error: "element has no editable text" };
531
909
  if (candidates.length === 1) return candidates[0].splice(value);
532
910
  if (prevText === void 0) return { error: "element has multiple text candidates; missing prevText" };
@@ -603,7 +981,7 @@ function planAssetImport(ast, assetPath) {
603
981
  }
604
982
  function planAssetAttr(ast, element, attr, assetPath) {
605
983
  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/" };
984
+ if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
607
985
  const { identifier, importSplice } = planAssetImport(ast, assetPath);
608
986
  const opening = element.openingElement;
609
987
  const newAttr = `${attr}={${identifier}}`;
@@ -641,7 +1019,7 @@ function readJsxNumberAttr(opening, name) {
641
1019
  function planReplacePlaceholder(ast, element, assetPath) {
642
1020
  const opening = element.openingElement;
643
1021
  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/" };
1022
+ if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
645
1023
  const hint = readJsxStringAttr(opening, "hint") ?? "";
646
1024
  const width = readJsxNumberAttr(opening, "width");
647
1025
  const height = readJsxNumberAttr(opening, "height");
@@ -670,7 +1048,7 @@ function applyEdit(source, line, column, ops) {
670
1048
  status: 422,
671
1049
  error: "could not parse source"
672
1050
  };
673
- const element = findInnermostJsxElement(ast, line, column);
1051
+ const element = findElementForEdit(ast, line, column, ops);
674
1052
  if (!element) return {
675
1053
  ok: false,
676
1054
  status: 422,
@@ -691,14 +1069,42 @@ function applyEdit(source, line, column, ops) {
691
1069
  if (result) splices.push(result);
692
1070
  }
693
1071
  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 {
1072
+ if (op.kind !== "set-text-range-style") continue;
1073
+ const result = buildTextRangeStyleSplices(ast, source, element, op.start, op.end, {
1074
+ key: op.key,
1075
+ value: op.value
1076
+ }, op.prevText);
1077
+ if (result && "error" in result) return {
697
1078
  ok: false,
698
1079
  status: 422,
699
1080
  error: result.error
700
1081
  };
701
- splices.push(result);
1082
+ if (result) splices.push(...result);
1083
+ }
1084
+ for (const op of ops) {
1085
+ if (op.kind !== "set-text") continue;
1086
+ if (op.prevText !== void 0 && (op.value.includes("\n") || op.prevText.includes("\n"))) {
1087
+ const richResult = buildTextContentSplices(element, op.value, op.prevText);
1088
+ if (!("error" in richResult)) {
1089
+ splices.push(...richResult);
1090
+ continue;
1091
+ }
1092
+ }
1093
+ const result = buildTextSplice(ast, element, op.value, op.prevText);
1094
+ if ("error" in result) {
1095
+ if (op.prevText === void 0) return {
1096
+ ok: false,
1097
+ status: 422,
1098
+ error: result.error
1099
+ };
1100
+ const richResult = buildTextContentSplices(element, op.value, op.prevText);
1101
+ if ("error" in richResult) return {
1102
+ ok: false,
1103
+ status: 422,
1104
+ error: result.error
1105
+ };
1106
+ splices.push(...richResult);
1107
+ } else splices.push(result);
702
1108
  }
703
1109
  const assetOps = ops.flatMap((op) => op.kind === "set-attr-asset" ? [op] : []);
704
1110
  const placeholderOps = ops.flatMap((op) => op.kind === "replace-placeholder-with-image" ? [op] : []);
@@ -742,6 +1148,11 @@ function applyEdit(source, line, column, ops) {
742
1148
  splices.sort((a, b) => b.from - a.from);
743
1149
  let next = source;
744
1150
  for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
1151
+ if (!parseSource$2(next)) return {
1152
+ ok: false,
1153
+ status: 422,
1154
+ error: "edit would produce invalid source"
1155
+ };
745
1156
  return {
746
1157
  ok: true,
747
1158
  source: next
@@ -1406,6 +1817,7 @@ function designPlugin(opts) {
1406
1817
  //#region src/vite/files-plugin.ts
1407
1818
  const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
1408
1819
  const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
1820
+ const GLOBAL_SCOPE = "@global";
1409
1821
  const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
1410
1822
  const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
1411
1823
  const ASSET_MAX_BYTES = 25 * 1024 * 1024;
@@ -1535,6 +1947,19 @@ function resolveAssetFile(slidesRoot, slideId, filename) {
1535
1947
  if (!file.startsWith(assetsDir + path.sep)) return null;
1536
1948
  return file;
1537
1949
  }
1950
+ function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
1951
+ if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
1952
+ return resolveAssetsDir(slidesRoot, scope);
1953
+ }
1954
+ function resolveScopedAssetFile(slidesRoot, globalAssetsRoot, scope, filename) {
1955
+ if (scope === GLOBAL_SCOPE) {
1956
+ if (!validateAssetName(filename)) return null;
1957
+ const file = path.resolve(globalAssetsRoot, filename);
1958
+ if (!file.startsWith(globalAssetsRoot + path.sep)) return null;
1959
+ return file;
1960
+ }
1961
+ return resolveAssetFile(slidesRoot, scope, filename);
1962
+ }
1538
1963
  function resolveSlideEntry(slidesRoot, slideId) {
1539
1964
  if (!SLIDE_ID_RE$1.test(slideId)) return null;
1540
1965
  const dir = path.resolve(slidesRoot, slideId);
@@ -1852,7 +2277,9 @@ function validateIcon(v) {
1852
2277
  function filesPlugin(opts) {
1853
2278
  const userCwd = opts.userCwd;
1854
2279
  const slidesDir = opts.slidesDir ?? "slides";
2280
+ const assetsDir = opts.assetsDir ?? "assets";
1855
2281
  const slidesRoot = path.resolve(userCwd, slidesDir);
2282
+ const globalAssetsRoot = path.resolve(userCwd, assetsDir);
1856
2283
  const manifestPath = path.join(slidesRoot, ".folders.json");
1857
2284
  return {
1858
2285
  name: "open-slide:files",
@@ -1865,7 +2292,16 @@ function filesPlugin(opts) {
1865
2292
  event: "open-slide:files-changed"
1866
2293
  });
1867
2294
  });
2295
+ server.watcher.add(globalAssetsRoot);
1868
2296
  const onAssetChange = (p) => {
2297
+ if (p.startsWith(globalAssetsRoot + path.sep) || p === globalAssetsRoot) {
2298
+ server.ws.send({
2299
+ type: "custom",
2300
+ event: "open-slide:assets-changed",
2301
+ data: { slideId: GLOBAL_SCOPE }
2302
+ });
2303
+ return;
2304
+ }
1869
2305
  if (!p.startsWith(slidesRoot + path.sep)) return;
1870
2306
  const rel = p.slice(slidesRoot.length + 1);
1871
2307
  const parts = rel.split(path.sep);
@@ -1989,11 +2425,11 @@ function filesPlugin(opts) {
1989
2425
  const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
1990
2426
  if (listMatch && method === "GET") {
1991
2427
  const slideId = listMatch[1];
1992
- const assetsDir = resolveAssetsDir(slidesRoot, slideId);
1993
- if (!assetsDir) return json$1(res, 400, { error: "invalid slideId" });
2428
+ const assetsDir$1 = resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, slideId);
2429
+ if (!assetsDir$1) return json$1(res, 400, { error: "invalid slideId" });
1994
2430
  let entries;
1995
2431
  try {
1996
- entries = await fs.readdir(assetsDir);
2432
+ entries = await fs.readdir(assetsDir$1);
1997
2433
  } catch (err) {
1998
2434
  if (err.code === "ENOENT") return json$1(res, 200, { assets: [] });
1999
2435
  throw err;
@@ -2001,7 +2437,7 @@ function filesPlugin(opts) {
2001
2437
  const assets = [];
2002
2438
  for (const name of entries) {
2003
2439
  if (!validateAssetName(name)) continue;
2004
- const stat = await fs.stat(path.join(assetsDir, name));
2440
+ const stat = await fs.stat(path.join(assetsDir$1, name));
2005
2441
  if (!stat.isFile()) continue;
2006
2442
  assets.push({
2007
2443
  name,
@@ -2017,7 +2453,7 @@ function filesPlugin(opts) {
2017
2453
  if (fileMatch) {
2018
2454
  const slideId = fileMatch[1];
2019
2455
  const filename = decodeURIComponent(fileMatch[2]);
2020
- const file = resolveAssetFile(slidesRoot, slideId, filename);
2456
+ const file = resolveScopedAssetFile(slidesRoot, globalAssetsRoot, slideId, filename);
2021
2457
  if (!file) return json$1(res, 400, { error: "invalid path" });
2022
2458
  if (method === "GET") try {
2023
2459
  const buf = await fs.readFile(file);
@@ -2039,9 +2475,9 @@ function filesPlugin(opts) {
2039
2475
  await fs.access(file);
2040
2476
  return json$1(res, 409, { error: "asset exists" });
2041
2477
  } 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 });
2478
+ const assetsDir$1 = resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, slideId);
2479
+ if (!assetsDir$1) return json$1(res, 400, { error: "invalid slideId" });
2480
+ await fs.mkdir(assetsDir$1, { recursive: true });
2045
2481
  const chunks = [];
2046
2482
  let total = 0;
2047
2483
  let oversized = false;
@@ -2076,7 +2512,7 @@ function filesPlugin(opts) {
2076
2512
  ok: true,
2077
2513
  name: filename
2078
2514
  });
2079
- const dest = resolveAssetFile(slidesRoot, slideId, target);
2515
+ const dest = resolveScopedAssetFile(slidesRoot, globalAssetsRoot, slideId, target);
2080
2516
  if (!dest) return json$1(res, 400, { error: "invalid name" });
2081
2517
  try {
2082
2518
  await fs.access(dest);
@@ -2505,7 +2941,7 @@ async function readFoldersManifest(file) {
2505
2941
  throw err;
2506
2942
  }
2507
2943
  }
2508
- function resolved(id) {
2944
+ function resolved$1(id) {
2509
2945
  return `\0${id}`;
2510
2946
  }
2511
2947
  async function findSlides(userCwd, slidesDir) {
@@ -2522,19 +2958,76 @@ function toId(absFile, slidesRoot) {
2522
2958
  const rel = path.relative(slidesRoot, absFile);
2523
2959
  return rel.split(path.sep)[0];
2524
2960
  }
2525
- function generateSlidesModule(files, slidesRoot, isDev) {
2526
- const entries = files.map((abs) => {
2961
+ const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
2962
+ function extractMetaTheme(src) {
2963
+ const metaStart = src.search(/export\s+const\s+meta\b/);
2964
+ if (metaStart === -1) return null;
2965
+ const eqIdx = src.indexOf("=", metaStart);
2966
+ if (eqIdx === -1) return null;
2967
+ const openBrace = src.indexOf("{", eqIdx);
2968
+ if (openBrace === -1) return null;
2969
+ let depth = 0;
2970
+ let closeBrace = -1;
2971
+ for (let i = openBrace; i < src.length; i++) {
2972
+ const ch = src[i];
2973
+ if (ch === "{") depth++;
2974
+ else if (ch === "}") {
2975
+ depth--;
2976
+ if (depth === 0) {
2977
+ closeBrace = i;
2978
+ break;
2979
+ }
2980
+ }
2981
+ }
2982
+ if (closeBrace === -1) return null;
2983
+ const body = src.slice(openBrace + 1, closeBrace);
2984
+ const m = body.match(META_THEME_RE);
2985
+ return m ? m[1] : null;
2986
+ }
2987
+ async function readSlideTheme(abs) {
2988
+ try {
2989
+ const src = await fs.readFile(abs, "utf8");
2990
+ return extractMetaTheme(src);
2991
+ } catch {
2992
+ return null;
2993
+ }
2994
+ }
2995
+ async function generateSlidesModule(files, slidesRoot, isDev) {
2996
+ const entries = await Promise.all(files.map(async (abs) => {
2527
2997
  const id = toId(abs, slidesRoot);
2528
2998
  const importPath = isDev ? `/@fs/${abs.replace(/^\/+/, "")}` : abs;
2999
+ const theme = await readSlideTheme(abs);
2529
3000
  return {
2530
3001
  id,
2531
- importPath
3002
+ importPath,
3003
+ theme
2532
3004
  };
2533
- });
3005
+ }));
2534
3006
  const ids = JSON.stringify(entries.map((e) => e.id).sort());
2535
- const cases = entries.map((e) => ` case ${JSON.stringify(e.id)}: return import(${JSON.stringify(e.importPath)});`).join("\n");
3007
+ const themesMap = {};
3008
+ for (const e of entries) if (e.theme) themesMap[e.id] = e.theme;
3009
+ const themesJson = JSON.stringify(themesMap);
3010
+ const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
3011
+ const devRuntime = isDev ? `
3012
+ const slideImportTokens = ${importTokens};
3013
+ if (import.meta.hot) {
3014
+ import.meta.hot.on('open-slide:slide-changed', (data) => {
3015
+ const ids = Array.isArray(data?.slideIds) ? data.slideIds : data?.slideId ? [data.slideId] : [];
3016
+ const token = Date.now();
3017
+ for (const id of ids) {
3018
+ if (Object.prototype.hasOwnProperty.call(slideImportTokens, id)) slideImportTokens[id] = token;
3019
+ }
3020
+ });
3021
+ }
3022
+ ` : "";
3023
+ const cases = entries.map((e) => {
3024
+ const importExpr = isDev ? `import(/* @vite-ignore */ ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
3025
+ return ` case ${JSON.stringify(e.id)}: return ${importExpr};`;
3026
+ }).join("\n");
2536
3027
  return `// virtual:open-slide/slides — generated
2537
3028
  export const slideIds = ${ids};
3029
+ export const slideThemes = ${themesJson};
3030
+ ${devRuntime}
2538
3031
 
2539
3032
  export async function loadSlide(id) {
2540
3033
  switch (id) {
@@ -2550,6 +3043,32 @@ function openSlidePlugin(opts) {
2550
3043
  const slidesRoot = path.resolve(userCwd, slidesDir);
2551
3044
  const foldersManifestPath = path.join(slidesRoot, ".folders.json");
2552
3045
  let isDev = false;
3046
+ const slideIdForEntry = (p) => {
3047
+ const rel = path.relative(slidesRoot, p);
3048
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
3049
+ const parts = rel.split(path.sep);
3050
+ if (parts.length !== 2) return null;
3051
+ if (!/^index\.(tsx|jsx|ts|js)$/.test(parts[1])) return null;
3052
+ return parts[0];
3053
+ };
3054
+ let slideChangeTimer = null;
3055
+ const pendingSlideChanges = new Set();
3056
+ const queueSlideChanged = (server, id) => {
3057
+ pendingSlideChanges.add(id);
3058
+ if (slideChangeTimer) clearTimeout(slideChangeTimer);
3059
+ slideChangeTimer = setTimeout(() => {
3060
+ slideChangeTimer = null;
3061
+ const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
3062
+ if (mod) server.moduleGraph.invalidateModule(mod);
3063
+ const slideIds = Array.from(pendingSlideChanges);
3064
+ pendingSlideChanges.clear();
3065
+ server.ws.send({
3066
+ type: "custom",
3067
+ event: "open-slide:slide-changed",
3068
+ data: { slideIds }
3069
+ });
3070
+ }, 100);
3071
+ };
2553
3072
  return {
2554
3073
  name: "open-slide",
2555
3074
  config(_c, env) {
@@ -2557,17 +3076,17 @@ function openSlidePlugin(opts) {
2557
3076
  return { server: { fs: { allow: [userCwd] } } };
2558
3077
  },
2559
3078
  resolveId(id) {
2560
- if (id === SLIDES_VMOD) return resolved(SLIDES_VMOD);
2561
- if (id === CONFIG_VMOD) return resolved(CONFIG_VMOD);
2562
- if (id === FOLDERS_VMOD) return resolved(FOLDERS_VMOD);
3079
+ if (id === SLIDES_VMOD) return resolved$1(SLIDES_VMOD);
3080
+ if (id === CONFIG_VMOD) return resolved$1(CONFIG_VMOD);
3081
+ if (id === FOLDERS_VMOD) return resolved$1(FOLDERS_VMOD);
2563
3082
  return null;
2564
3083
  },
2565
3084
  async load(id) {
2566
- if (id === resolved(SLIDES_VMOD)) {
3085
+ if (id === resolved$1(SLIDES_VMOD)) {
2567
3086
  const files = await findSlides(userCwd, slidesDir);
2568
- return generateSlidesModule(files, slidesRoot, isDev);
3087
+ return await generateSlidesModule(files, slidesRoot, isDev);
2569
3088
  }
2570
- if (id === resolved(CONFIG_VMOD)) {
3089
+ if (id === resolved$1(CONFIG_VMOD)) {
2571
3090
  const userBuild = config.build ?? {};
2572
3091
  const buildResolved = isDev ? {
2573
3092
  showSlideBrowser: true,
@@ -2584,31 +3103,31 @@ function openSlidePlugin(opts) {
2584
3103
  };
2585
3104
  return `export default ${JSON.stringify(resolvedConfig)};\n`;
2586
3105
  }
2587
- if (id === resolved(FOLDERS_VMOD)) {
3106
+ if (id === resolved$1(FOLDERS_VMOD)) {
2588
3107
  const manifest = await readFoldersManifest(foldersManifestPath);
2589
3108
  return `export default ${JSON.stringify(manifest)};\n`;
2590
3109
  }
2591
3110
  return null;
2592
3111
  },
3112
+ handleHotUpdate(ctx) {
3113
+ const slideId = slideIdForEntry(ctx.file);
3114
+ if (!slideId) return;
3115
+ queueSlideChanged(ctx.server, slideId);
3116
+ return [];
3117
+ },
2593
3118
  configureServer(server) {
2594
- const isSlideEntry = (p) => {
2595
- const rel = path.relative(slidesRoot, p);
2596
- if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
2597
- const parts = rel.split(path.sep);
2598
- if (parts.length !== 2) return false;
2599
- return /^index\.(tsx|jsx|ts|js)$/.test(parts[1]);
2600
- };
3119
+ const isSlideEntry = (p) => slideIdForEntry(p) !== null;
2601
3120
  let reloadTimer = null;
2602
3121
  const reload = () => {
2603
3122
  if (reloadTimer) clearTimeout(reloadTimer);
2604
3123
  reloadTimer = setTimeout(() => {
2605
3124
  reloadTimer = null;
2606
- const mod = server.moduleGraph.getModuleById(resolved(SLIDES_VMOD));
3125
+ const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
2607
3126
  if (mod) server.moduleGraph.invalidateModule(mod);
2608
3127
  server.ws.send({ type: "full-reload" });
2609
3128
  }, 150);
2610
3129
  };
2611
- server.watcher.add(path.join(slidesRoot, "*/index.{tsx,jsx,ts,js}"));
3130
+ if (existsSync(slidesRoot)) server.watcher.add(slidesRoot);
2612
3131
  server.watcher.on("add", (p) => {
2613
3132
  if (isSlideEntry(p)) reload();
2614
3133
  });
@@ -2620,7 +3139,7 @@ function openSlidePlugin(opts) {
2620
3139
  if (foldersTimer) clearTimeout(foldersTimer);
2621
3140
  foldersTimer = setTimeout(() => {
2622
3141
  foldersTimer = null;
2623
- const mod = server.moduleGraph.getModuleById(resolved(FOLDERS_VMOD));
3142
+ const mod = server.moduleGraph.getModuleById(resolved$1(FOLDERS_VMOD));
2624
3143
  if (mod) server.moduleGraph.invalidateModule(mod);
2625
3144
  }, 100);
2626
3145
  };
@@ -2647,6 +3166,144 @@ async function loadUserConfig(userCwd) {
2647
3166
  return loaded?.config ?? {};
2648
3167
  }
2649
3168
 
3169
+ //#endregion
3170
+ //#region src/vite/themes-plugin.ts
3171
+ const THEMES_VMOD = "virtual:open-slide/themes";
3172
+ function resolved(id) {
3173
+ return `\0${id}`;
3174
+ }
3175
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
3176
+ function parseFrontmatter(raw, themeId) {
3177
+ const match = raw.match(FM_RE);
3178
+ const fmText = match ? match[1] : "";
3179
+ const body = match ? match[2] : raw;
3180
+ const data = {};
3181
+ for (const line of fmText.split(/\r?\n/)) {
3182
+ const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
3183
+ if (!m) continue;
3184
+ let value = m[2].trim();
3185
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
3186
+ data[m[1]] = value;
3187
+ }
3188
+ return {
3189
+ fm: {
3190
+ name: data.name || themeId,
3191
+ description: data.description || ""
3192
+ },
3193
+ body: body.trim()
3194
+ };
3195
+ }
3196
+ async function findThemes(userCwd, themesDir) {
3197
+ const abs = path.resolve(userCwd, themesDir);
3198
+ if (!existsSync(abs)) return [];
3199
+ const hits = await fg("*.md", {
3200
+ cwd: abs,
3201
+ absolute: true,
3202
+ onlyFiles: true
3203
+ });
3204
+ return hits.sort();
3205
+ }
3206
+ async function readTheme(mdAbs, themesRoot) {
3207
+ const id = path.basename(mdAbs, ".md");
3208
+ const raw = await fs.readFile(mdAbs, "utf8");
3209
+ const { fm, body } = parseFrontmatter(raw, id);
3210
+ const demoCandidates = [
3211
+ `${id}.demo.tsx`,
3212
+ `${id}.demo.jsx`,
3213
+ `${id}.demo.ts`,
3214
+ `${id}.demo.js`
3215
+ ];
3216
+ let demoAbs = null;
3217
+ for (const cand of demoCandidates) {
3218
+ const p = path.join(themesRoot, cand);
3219
+ if (existsSync(p)) {
3220
+ demoAbs = p;
3221
+ break;
3222
+ }
3223
+ }
3224
+ return {
3225
+ id,
3226
+ frontmatter: fm,
3227
+ body,
3228
+ demoAbs
3229
+ };
3230
+ }
3231
+ function generateThemesModule(themes, isDev) {
3232
+ const meta = themes.map((t$3) => ({
3233
+ id: t$3.id,
3234
+ name: t$3.frontmatter.name,
3235
+ description: t$3.frontmatter.description,
3236
+ body: t$3.body,
3237
+ hasDemo: t$3.demoAbs !== null
3238
+ }));
3239
+ const cases = themes.flatMap((t$3) => {
3240
+ const abs = t$3.demoAbs;
3241
+ if (!abs) return [];
3242
+ const importPath = isDev ? `/@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
3243
+ return [` case ${JSON.stringify(t$3.id)}: return import(${JSON.stringify(importPath)});`];
3244
+ }).join("\n");
3245
+ return `// virtual:open-slide/themes — generated
3246
+ export const themes = ${JSON.stringify(meta)};
3247
+
3248
+ export async function loadThemeDemo(id) {
3249
+ switch (id) {
3250
+ ${cases}
3251
+ default: throw new Error('Theme demo not found: ' + id);
3252
+ }
3253
+ }
3254
+ `;
3255
+ }
3256
+ function themesPlugin(opts) {
3257
+ const { userCwd, config } = opts;
3258
+ const themesDir = config.themesDir ?? "themes";
3259
+ const themesRoot = path.resolve(userCwd, themesDir);
3260
+ let isDev = false;
3261
+ return {
3262
+ name: "open-slide:themes",
3263
+ config(_c, env) {
3264
+ isDev = env.command === "serve";
3265
+ },
3266
+ resolveId(id) {
3267
+ if (id === THEMES_VMOD) return resolved(THEMES_VMOD);
3268
+ return null;
3269
+ },
3270
+ async load(id) {
3271
+ if (id !== resolved(THEMES_VMOD)) return null;
3272
+ const files = await findThemes(userCwd, themesDir);
3273
+ const themes = await Promise.all(files.map((f) => readTheme(f, themesRoot)));
3274
+ return generateThemesModule(themes, isDev);
3275
+ },
3276
+ configureServer(server) {
3277
+ const isThemeFile = (p) => {
3278
+ const rel = path.relative(themesRoot, p);
3279
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
3280
+ if (rel.includes(path.sep)) return false;
3281
+ return /\.(md|demo\.(tsx|jsx|ts|js))$/.test(rel);
3282
+ };
3283
+ let reloadTimer = null;
3284
+ const reload = () => {
3285
+ if (reloadTimer) clearTimeout(reloadTimer);
3286
+ reloadTimer = setTimeout(() => {
3287
+ reloadTimer = null;
3288
+ const mod = server.moduleGraph.getModuleById(resolved(THEMES_VMOD));
3289
+ if (mod) server.moduleGraph.invalidateModule(mod);
3290
+ server.ws.send({ type: "full-reload" });
3291
+ }, 150);
3292
+ };
3293
+ if (existsSync(themesRoot)) server.watcher.add(themesRoot);
3294
+ server.watcher.on("add", (p) => {
3295
+ if (isThemeFile(p)) reload();
3296
+ });
3297
+ server.watcher.on("unlink", (p) => {
3298
+ if (isThemeFile(p)) reload();
3299
+ });
3300
+ server.watcher.on("change", (p) => {
3301
+ if (isThemeFile(p)) reload();
3302
+ });
3303
+ }
3304
+ };
3305
+ }
3306
+
2650
3307
  //#endregion
2651
3308
  //#region src/vite/config.ts
2652
3309
  function findPackageRoot(fromFile) {
@@ -2663,7 +3320,11 @@ async function createViteConfig(opts) {
2663
3320
  const userCwd = path.resolve(opts.userCwd);
2664
3321
  const config = opts.config ?? await loadUserConfig(userCwd);
2665
3322
  const slidesDir = config.slidesDir ?? "slides";
3323
+ const themesDir = config.themesDir ?? "themes";
3324
+ const assetsDir = config.assetsDir ?? "assets";
2666
3325
  const slidesAbs = path.resolve(userCwd, slidesDir);
3326
+ const themesAbs = path.resolve(userCwd, themesDir);
3327
+ const assetsAbs = path.resolve(userCwd, assetsDir);
2667
3328
  return {
2668
3329
  root: APP_ROOT,
2669
3330
  configFile: false,
@@ -2679,6 +3340,10 @@ async function createViteConfig(opts) {
2679
3340
  userCwd,
2680
3341
  config
2681
3342
  }),
3343
+ themesPlugin({
3344
+ userCwd,
3345
+ config
3346
+ }),
2682
3347
  designPlugin({ userCwd }),
2683
3348
  commentsPlugin({
2684
3349
  userCwd,
@@ -2690,14 +3355,18 @@ async function createViteConfig(opts) {
2690
3355
  }),
2691
3356
  filesPlugin({
2692
3357
  userCwd,
2693
- slidesDir
3358
+ slidesDir,
3359
+ assetsDir
2694
3360
  }),
2695
3361
  currentPlugin({
2696
3362
  userCwd,
2697
3363
  slidesDir
2698
3364
  })
2699
3365
  ],
2700
- resolve: { alias: { "@": APP_ROOT } },
3366
+ resolve: { alias: {
3367
+ "@": APP_ROOT,
3368
+ "@assets": assetsAbs
3369
+ } },
2701
3370
  optimizeDeps: {
2702
3371
  entries: [path.join(APP_ROOT, "main.tsx")],
2703
3372
  include: [
@@ -2728,7 +3397,9 @@ async function createViteConfig(opts) {
2728
3397
  fs: { allow: [
2729
3398
  APP_ROOT,
2730
3399
  userCwd,
2731
- slidesAbs
3400
+ slidesAbs,
3401
+ themesAbs,
3402
+ assetsAbs
2732
3403
  ] }
2733
3404
  },
2734
3405
  build: {