@open-slide/core 0.0.8 → 0.0.10

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 (34) hide show
  1. package/dist/{build-CXY2DSzy.js → build-DHiRlpjn.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-BYTf0qVz.js → config-LZM903FE.js} +742 -44
  4. package/dist/{dev-BxCKugi3.js → dev-B3JzCYn7.js} +1 -1
  5. package/dist/{preview-C1F-rHfx.js → preview-UikovHEt.js} +1 -1
  6. package/dist/vite/index.js +1 -1
  7. package/package.json +3 -1
  8. package/src/app/App.tsx +2 -0
  9. package/src/app/components/AssetView.tsx +846 -0
  10. package/src/app/components/ClickNavZones.tsx +2 -2
  11. package/src/app/components/PdfProgressToast.tsx +23 -0
  12. package/src/app/components/ThumbnailRail.tsx +2 -2
  13. package/src/app/components/inspector/CommentWidget.tsx +1 -1
  14. package/src/app/components/inspector/InspectOverlay.tsx +81 -41
  15. package/src/app/components/inspector/InspectorPanel.tsx +948 -0
  16. package/src/app/components/inspector/InspectorProvider.tsx +229 -13
  17. package/src/app/components/inspector/SaveBar.tsx +77 -0
  18. package/src/app/components/ui/input.tsx +21 -0
  19. package/src/app/components/ui/label.tsx +24 -0
  20. package/src/app/components/ui/progress.tsx +31 -0
  21. package/src/app/components/ui/select.tsx +190 -0
  22. package/src/app/components/ui/slider.tsx +61 -0
  23. package/src/app/components/ui/sonner.tsx +38 -0
  24. package/src/app/components/ui/textarea.tsx +18 -0
  25. package/src/app/components/ui/toggle-group.tsx +83 -0
  26. package/src/app/components/ui/toggle.tsx +45 -0
  27. package/src/app/components/ui/tooltip.tsx +55 -0
  28. package/src/app/lib/assets.ts +166 -0
  29. package/src/app/lib/export-pdf.ts +194 -0
  30. package/src/app/lib/inspector/fiber.ts +40 -5
  31. package/src/app/lib/inspector/useEditor.ts +62 -0
  32. package/src/app/lib/print-ready.ts +58 -0
  33. package/src/app/routes/Slide.tsx +140 -51
  34. package/src/app/components/inspector/CommentPopover.tsx +0 -94
@@ -9,6 +9,42 @@ import { parse } from "@babel/parser";
9
9
  import fg from "fast-glob";
10
10
  import { loadConfigFromFile } from "vite";
11
11
 
12
+ //#region src/vite/babel-walk.ts
13
+ const SKIP_KEYS = new Set([
14
+ "loc",
15
+ "start",
16
+ "end",
17
+ "type",
18
+ "extra",
19
+ "leadingComments",
20
+ "trailingComments",
21
+ "innerComments"
22
+ ]);
23
+ function walkJsx(ast, visit) {
24
+ let stopped = false;
25
+ const walk = (node) => {
26
+ if (stopped || !node || typeof node !== "object") return;
27
+ if (Array.isArray(node)) {
28
+ for (const c of node) walk(c);
29
+ return;
30
+ }
31
+ const n = node;
32
+ if (typeof n.type !== "string") return;
33
+ if (n.type === "JSXElement" || n.type === "JSXFragment") {
34
+ if (visit(n) === "stop") {
35
+ stopped = true;
36
+ return;
37
+ }
38
+ }
39
+ for (const key of Object.keys(n)) {
40
+ if (SKIP_KEYS.has(key)) continue;
41
+ walk(n[key]);
42
+ }
43
+ };
44
+ walk(ast);
45
+ }
46
+
47
+ //#endregion
12
48
  //#region src/vite/comments-plugin.ts
13
49
  const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
14
50
  const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
@@ -86,42 +122,19 @@ function lineIndent(source, lineNumber) {
86
122
  const m = source.slice(start, start + 200).match(/^[ \t]*/);
87
123
  return m?.[0] ?? "";
88
124
  }
89
- /**
90
- * Walk the AST, collect every JSXElement/JSXFragment whose location encloses
91
- * the click point, ordered innermost-first.
92
- *
93
- * "Encloses" here is inclusive at the start (so a click on the opening `<`
94
- * counts as inside) and exclusive at the end. We deliberately don't trust
95
- * Babel's `_debugSource` line/column to be exact — HMR or upstream transforms
96
- * can shift it slightly — so we treat the click as a probe and pick the
97
- * tightest JSX container around it.
98
- */
99
125
  function findJsxAncestors(ast, line, column) {
100
126
  const hits = [];
101
- const walk = (node) => {
102
- if (!node || typeof node !== "object") return;
103
- if (Array.isArray(node)) {
104
- for (const c of node) walk(c);
105
- return;
106
- }
107
- const n = node;
108
- if (typeof n.type !== "string") return;
109
- if ((n.type === "JSXElement" || n.type === "JSXFragment") && n.loc) {
110
- const s = n.loc.start;
111
- const e = n.loc.end;
112
- const afterStart = line > s.line || line === s.line && column >= s.column;
113
- const beforeEnd = line < e.line || line === e.line && column < e.column;
114
- if (afterStart && beforeEnd) hits.push({
115
- node: n,
116
- size: n.end - n.start
117
- });
118
- }
119
- for (const key of Object.keys(n)) {
120
- if (key === "loc" || key === "start" || key === "end" || key === "type" || key === "extra" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
121
- walk(n[key]);
122
- }
123
- };
124
- walk(ast);
127
+ walkJsx(ast, (n) => {
128
+ if (!n.loc) return;
129
+ const s = n.loc.start;
130
+ const e = n.loc.end;
131
+ const afterStart = line > s.line || line === s.line && column >= s.column;
132
+ const beforeEnd = line < e.line || line === e.line && column < e.column;
133
+ if (afterStart && beforeEnd) hits.push({
134
+ node: n,
135
+ size: n.end - n.start
136
+ });
137
+ });
125
138
  hits.sort((a, b) => a.size - b.size);
126
139
  return hits.map((h) => h.node);
127
140
  }
@@ -146,16 +159,6 @@ function planInsertion(source, target) {
146
159
  }
147
160
  return null;
148
161
  }
149
- /**
150
- * Resolve a click on the slide page (line/col from React fiber's
151
- * `_debugSource`) to an in-source offset where we can safely splice a
152
- * `@slide-comment` marker.
153
- *
154
- * Strategy: parse the file, find every JSX container around the click, and
155
- * walk innermost → outermost looking for the first one we can insert *inside*
156
- * (i.e. not self-closing). Self-closing elements like `<img/>` get hoisted to
157
- * their nearest non-self-closing ancestor.
158
- */
159
162
  function findInsertion(source, line, column) {
160
163
  let ast;
161
164
  try {
@@ -181,6 +184,321 @@ function offsetToLine(source, offset) {
181
184
  for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
182
185
  return line;
183
186
  }
187
+ function parseSource(source) {
188
+ try {
189
+ return parse(source, {
190
+ sourceType: "module",
191
+ plugins: ["typescript", "jsx"],
192
+ errorRecovery: true
193
+ });
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+ function findInnermostJsxElement(source, line, column) {
199
+ const ast = parseSource(source);
200
+ if (!ast) return null;
201
+ const exact = findJsxByStart(ast, line, column);
202
+ if (exact) return exact;
203
+ const ancestors = findJsxAncestors(ast, line, column);
204
+ for (const n of ancestors) if (n.type === "JSXElement") return n;
205
+ return null;
206
+ }
207
+ function findJsxByStart(ast, line, column) {
208
+ let hit = null;
209
+ walkJsx(ast, (n) => {
210
+ if (n.type !== "JSXElement" || !n.loc) return;
211
+ const s = n.loc.start;
212
+ if (s.line === line && s.column === column) {
213
+ hit = n;
214
+ return "stop";
215
+ }
216
+ });
217
+ return hit;
218
+ }
219
+ function jsString(s) {
220
+ return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
221
+ }
222
+ function findStyleAttr(opening) {
223
+ const attrs = opening.attributes ?? [];
224
+ for (const attr of attrs) {
225
+ if (attr.type !== "JSXAttribute") continue;
226
+ const name = attr.name;
227
+ if (name?.type === "JSXIdentifier" && name.name === "style") return attr;
228
+ }
229
+ return null;
230
+ }
231
+ function buildStyleSplice(source, element, ops) {
232
+ const opening = element.openingElement;
233
+ if (!opening) return { error: "no opening element" };
234
+ const existing = findStyleAttr(opening);
235
+ const style = new Map();
236
+ if (existing) {
237
+ const value = existing.value;
238
+ if (!value || value.type !== "JSXExpressionContainer") return { error: "style attribute has unsupported form" };
239
+ const expr = value.expression;
240
+ if (expr.type !== "ObjectExpression") return { error: "style is not a literal object" };
241
+ const properties = expr.properties;
242
+ for (const prop of properties) {
243
+ if (prop.type !== "ObjectProperty") return { error: "style contains spread or method" };
244
+ const p = prop;
245
+ if (p.computed) return { error: "style has computed key" };
246
+ let keyName = null;
247
+ if (p.key.type === "Identifier" && p.key.name) keyName = p.key.name;
248
+ else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") keyName = p.key.value;
249
+ if (!keyName) return { error: "style has unsupported key" };
250
+ style.set(keyName, source.slice(p.value.start, p.value.end));
251
+ }
252
+ }
253
+ for (const op of ops) if (op.value === null) style.delete(op.key);
254
+ else style.set(op.key, jsString(op.value));
255
+ if (style.size === 0) {
256
+ if (!existing) return null;
257
+ let from = existing.start;
258
+ if (from > 0 && source[from - 1] === " ") from -= 1;
259
+ return {
260
+ from,
261
+ to: existing.end,
262
+ text: ""
263
+ };
264
+ }
265
+ const propsText = Array.from(style.entries()).map(([k, v]) => `${k}: ${v}`).join(", ");
266
+ const newAttr = `style={{ ${propsText} }}`;
267
+ if (existing) return {
268
+ from: existing.start,
269
+ to: existing.end,
270
+ text: newAttr
271
+ };
272
+ const name = opening.name;
273
+ return {
274
+ from: name.end,
275
+ to: name.end,
276
+ text: ` ${newAttr}`
277
+ };
278
+ }
279
+ function formatJsxText(value) {
280
+ if (/[{}<>]/.test(value) || /^\s|\s$/.test(value) || value === "") return `{${jsString(value)}}`;
281
+ return value;
282
+ }
283
+ function buildTextSplice(element, value) {
284
+ const children = element.children ?? [];
285
+ if (children.length === 0) return { error: "element has no children to edit" };
286
+ const meaningful = children.filter((c) => {
287
+ if (c.type === "JSXText") {
288
+ const v = c.value;
289
+ return v.trim() !== "";
290
+ }
291
+ return true;
292
+ });
293
+ if (meaningful.length !== 1) return { error: "element has complex children" };
294
+ const child = meaningful[0];
295
+ if (child.type === "JSXText") {
296
+ const first = children[0];
297
+ const last = children[children.length - 1];
298
+ return {
299
+ from: first.start,
300
+ to: last.end,
301
+ text: formatJsxText(value)
302
+ };
303
+ }
304
+ if (child.type === "JSXExpressionContainer") {
305
+ const expr = child.expression;
306
+ if (expr.type === "StringLiteral" || expr.type === "NumericLiteral") return {
307
+ from: child.start,
308
+ to: child.end,
309
+ text: `{${jsString(value)}}`
310
+ };
311
+ return { error: "element has dynamic expression child" };
312
+ }
313
+ return { error: "element has complex children" };
314
+ }
315
+ function findImports(ast) {
316
+ const body = ast.program?.body ?? [];
317
+ const out = [];
318
+ for (const node of body) {
319
+ if (node.type !== "ImportDeclaration") continue;
320
+ const src = node.source?.value;
321
+ if (typeof src !== "string") continue;
322
+ const specs = node.specifiers ?? [];
323
+ let def = null;
324
+ for (const spec of specs) if (spec.type === "ImportDefaultSpecifier") {
325
+ const local = spec.local?.name;
326
+ if (typeof local === "string") {
327
+ def = local;
328
+ break;
329
+ }
330
+ }
331
+ out.push({
332
+ node,
333
+ source: src,
334
+ defaultIdent: def
335
+ });
336
+ }
337
+ return out;
338
+ }
339
+ function collectTopLevelIdentifiers(ast) {
340
+ const names = new Set();
341
+ for (const imp of findImports(ast)) {
342
+ if (imp.defaultIdent) names.add(imp.defaultIdent);
343
+ const specs = imp.node.specifiers ?? [];
344
+ for (const spec of specs) if (spec.type !== "ImportDefaultSpecifier") {
345
+ const local = spec.local?.name;
346
+ if (typeof local === "string") names.add(local);
347
+ }
348
+ }
349
+ return names;
350
+ }
351
+ function safeAssetIdentifier(filename, taken) {
352
+ const stem = filename.replace(/\.[^.]+$/, "");
353
+ let camel = "";
354
+ let upper = false;
355
+ for (const ch of stem) if (/[A-Za-z0-9]/.test(ch)) {
356
+ camel += upper ? ch.toUpperCase() : ch;
357
+ upper = false;
358
+ } else upper = camel.length > 0;
359
+ let base = camel;
360
+ if (!base || !/^[A-Za-z_$]/.test(base)) base = `asset${base.charAt(0).toUpperCase()}${base.slice(1)}` || "asset";
361
+ base = base.charAt(0).toLowerCase() + base.slice(1);
362
+ let candidate = base;
363
+ let i = 2;
364
+ while (taken.has(candidate)) {
365
+ candidate = `${base}${i}`;
366
+ i += 1;
367
+ }
368
+ return candidate;
369
+ }
370
+ function findJsxAttr(opening, name) {
371
+ const attrs = opening.attributes ?? [];
372
+ for (const attr of attrs) {
373
+ if (attr.type !== "JSXAttribute") continue;
374
+ const n = attr.name;
375
+ if (n?.type === "JSXIdentifier" && n.name === name) return attr;
376
+ }
377
+ return null;
378
+ }
379
+ function planAssetAttr(ast, element, attr, assetPath) {
380
+ const opening = element.openingElement;
381
+ if (!opening) return { error: "no opening element" };
382
+ if (!attr || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(attr)) return { error: "invalid attribute name" };
383
+ if (!assetPath.startsWith("./assets/")) return { error: "asset path must start with ./assets/" };
384
+ const imports = findImports(ast);
385
+ let identifier = null;
386
+ for (const imp of imports) if (imp.source === assetPath && imp.defaultIdent) {
387
+ identifier = imp.defaultIdent;
388
+ break;
389
+ }
390
+ let importSplice = null;
391
+ if (!identifier) {
392
+ const filename = assetPath.slice(assetPath.lastIndexOf("/") + 1);
393
+ const taken = collectTopLevelIdentifiers(ast);
394
+ identifier = safeAssetIdentifier(filename, taken);
395
+ const importStmt = `import ${identifier} from '${assetPath.replace(/'/g, "\\'")}';\n`;
396
+ const insertAt = imports.length > 0 ? imports[imports.length - 1].node.end : 0;
397
+ const prefix = imports.length > 0 ? "\n" : "";
398
+ importSplice = {
399
+ from: insertAt,
400
+ to: insertAt,
401
+ text: prefix + importStmt
402
+ };
403
+ }
404
+ const newAttr = `${attr}={${identifier}}`;
405
+ const existing = findJsxAttr(opening, attr);
406
+ let attrSplice;
407
+ if (existing) attrSplice = {
408
+ from: existing.start,
409
+ to: existing.end,
410
+ text: newAttr
411
+ };
412
+ else {
413
+ const name = opening.name;
414
+ attrSplice = {
415
+ from: name.end,
416
+ to: name.end,
417
+ text: ` ${newAttr}`
418
+ };
419
+ }
420
+ return {
421
+ importSplice,
422
+ attrSplice
423
+ };
424
+ }
425
+ function applyEdit(source, line, column, ops) {
426
+ if (ops.length === 0) return {
427
+ ok: true,
428
+ source
429
+ };
430
+ const element = findInnermostJsxElement(source, line, column);
431
+ if (!element) return {
432
+ ok: false,
433
+ status: 422,
434
+ error: "no JSX element at location"
435
+ };
436
+ const splices = [];
437
+ const styleOps = ops.flatMap((op) => op.kind === "set-style" ? [{
438
+ key: op.key,
439
+ value: op.value
440
+ }] : []);
441
+ if (styleOps.length > 0) {
442
+ const result = buildStyleSplice(source, element, styleOps);
443
+ if (result && "error" in result) return {
444
+ ok: false,
445
+ status: 422,
446
+ error: result.error
447
+ };
448
+ if (result) splices.push(result);
449
+ }
450
+ for (const op of ops) {
451
+ if (op.kind !== "set-text") continue;
452
+ const result = buildTextSplice(element, op.value);
453
+ if ("error" in result) return {
454
+ ok: false,
455
+ status: 422,
456
+ error: result.error
457
+ };
458
+ splices.push(result);
459
+ }
460
+ const assetOps = ops.flatMap((op) => op.kind === "set-attr-asset" ? [op] : []);
461
+ if (assetOps.length > 0) {
462
+ const ast = parseSource(source);
463
+ if (!ast) return {
464
+ ok: false,
465
+ status: 422,
466
+ error: "could not parse source"
467
+ };
468
+ const importSplices = [];
469
+ for (const op of assetOps) {
470
+ const plan = planAssetAttr(ast, element, op.attr, op.assetPath);
471
+ if ("error" in plan) return {
472
+ ok: false,
473
+ status: 422,
474
+ error: plan.error
475
+ };
476
+ splices.push(plan.attrSplice);
477
+ if (plan.importSplice) importSplices.push(plan.importSplice);
478
+ }
479
+ if (importSplices.length > 0) {
480
+ const from = importSplices[0].from;
481
+ const to = importSplices[0].to;
482
+ const text = importSplices.map((s) => s.text).join("");
483
+ splices.push({
484
+ from,
485
+ to,
486
+ text
487
+ });
488
+ }
489
+ }
490
+ if (splices.length === 0) return {
491
+ ok: true,
492
+ source
493
+ };
494
+ splices.sort((a, b) => b.from - a.from);
495
+ let next = source;
496
+ for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
497
+ return {
498
+ ok: true,
499
+ source: next
500
+ };
501
+ }
184
502
  function commentsPlugin(opts) {
185
503
  const userCwd = opts.userCwd;
186
504
  const slidesDir = opts.slidesDir ?? "slides";
@@ -188,6 +506,77 @@ function commentsPlugin(opts) {
188
506
  name: "open-slide:comments",
189
507
  apply: "serve",
190
508
  configureServer(server) {
509
+ server.middlewares.use("/__edit", async (req, res, next) => {
510
+ const url = new URL(req.url ?? "/", "http://local");
511
+ const method = req.method ?? "GET";
512
+ if (method !== "POST") return next();
513
+ try {
514
+ if (url.pathname === "/") {
515
+ const body = await readBody$1(req);
516
+ const slideId = body.slideId ?? "";
517
+ const file = resolveSlidePath(userCwd, slidesDir, slideId);
518
+ if (!file) return json$1(res, 400, { error: "invalid slideId" });
519
+ if (!body.line || body.line < 1) return json$1(res, 400, { error: "invalid line" });
520
+ if (!Array.isArray(body.ops)) return json$1(res, 400, { error: "missing ops" });
521
+ let source;
522
+ try {
523
+ source = await fs.readFile(file, "utf8");
524
+ } catch {
525
+ return json$1(res, 404, { error: "slide not found" });
526
+ }
527
+ const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
528
+ if (!result.ok) return json$1(res, result.status, { error: result.error });
529
+ const changed = result.source !== source;
530
+ if (changed) await fs.writeFile(file, result.source, "utf8");
531
+ return json$1(res, 200, {
532
+ ok: true,
533
+ changed
534
+ });
535
+ }
536
+ if (url.pathname === "/batch") {
537
+ const body = await readBody$1(req);
538
+ const slideId = body.slideId ?? "";
539
+ const file = resolveSlidePath(userCwd, slidesDir, slideId);
540
+ if (!file) return json$1(res, 400, { error: "invalid slideId" });
541
+ if (!Array.isArray(body.edits)) return json$1(res, 400, { error: "missing edits" });
542
+ let source;
543
+ try {
544
+ source = await fs.readFile(file, "utf8");
545
+ } catch {
546
+ return json$1(res, 404, { error: "slide not found" });
547
+ }
548
+ const original = source;
549
+ const results = [];
550
+ for (const edit of body.edits) {
551
+ if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
552
+ results.push({
553
+ ok: false,
554
+ error: "invalid edit"
555
+ });
556
+ continue;
557
+ }
558
+ const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
559
+ if (r.ok) {
560
+ source = r.source;
561
+ results.push({ ok: true });
562
+ } else results.push({
563
+ ok: false,
564
+ error: r.error
565
+ });
566
+ }
567
+ const changed = source !== original;
568
+ if (changed) await fs.writeFile(file, source, "utf8");
569
+ return json$1(res, 200, {
570
+ ok: true,
571
+ changed,
572
+ results
573
+ });
574
+ }
575
+ return next();
576
+ } catch (err) {
577
+ json$1(res, 500, { error: String(err.message ?? err) });
578
+ }
579
+ });
191
580
  server.middlewares.use("/__comments", async (req, res, next) => {
192
581
  const url = new URL(req.url ?? "/", "http://local");
193
582
  const method = req.method ?? "GET";
@@ -268,6 +657,45 @@ function commentsPlugin(opts) {
268
657
  const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
269
658
  const SLIDE_ID_RE = /^[a-z0-9_-]+$/i;
270
659
  const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
660
+ const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
661
+ const ASSET_MAX_BYTES = 25 * 1024 * 1024;
662
+ const MIME_BY_EXT = {
663
+ png: "image/png",
664
+ jpg: "image/jpeg",
665
+ jpeg: "image/jpeg",
666
+ gif: "image/gif",
667
+ svg: "image/svg+xml",
668
+ webp: "image/webp",
669
+ avif: "image/avif",
670
+ ico: "image/x-icon",
671
+ mp4: "video/mp4",
672
+ webm: "video/webm",
673
+ mov: "video/quicktime",
674
+ woff: "font/woff",
675
+ woff2: "font/woff2",
676
+ ttf: "font/ttf",
677
+ otf: "font/otf",
678
+ json: "application/json",
679
+ txt: "text/plain; charset=utf-8",
680
+ md: "text/markdown; charset=utf-8"
681
+ };
682
+ function mimeForFilename(name) {
683
+ const dot = name.lastIndexOf(".");
684
+ if (dot < 0) return "application/octet-stream";
685
+ const ext = name.slice(dot + 1).toLowerCase();
686
+ return MIME_BY_EXT[ext] ?? "application/octet-stream";
687
+ }
688
+ function validateAssetName(v) {
689
+ if (typeof v !== "string") return null;
690
+ const trimmed = v.trim();
691
+ if (trimmed.length < 1 || trimmed.length > 120) return null;
692
+ if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
693
+ if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
694
+ if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
695
+ const dot = trimmed.lastIndexOf(".");
696
+ if (dot <= 0 || dot === trimmed.length - 1) return null;
697
+ return trimmed;
698
+ }
271
699
  async function readBody(req) {
272
700
  return await new Promise((resolve, reject) => {
273
701
  const chunks = [];
@@ -341,6 +769,22 @@ async function rmSlideDir(slidesRoot, slideId) {
341
769
  return false;
342
770
  }
343
771
  }
772
+ function resolveAssetsDir(slidesRoot, slideId) {
773
+ if (!SLIDE_ID_RE.test(slideId)) return null;
774
+ const slideDir = path.resolve(slidesRoot, slideId);
775
+ if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
776
+ const assetsDir = path.resolve(slideDir, "assets");
777
+ if (assetsDir !== path.join(slideDir, "assets")) return null;
778
+ return assetsDir;
779
+ }
780
+ function resolveAssetFile(slidesRoot, slideId, filename) {
781
+ const assetsDir = resolveAssetsDir(slidesRoot, slideId);
782
+ if (!assetsDir) return null;
783
+ if (!validateAssetName(filename)) return null;
784
+ const file = path.resolve(assetsDir, filename);
785
+ if (!file.startsWith(assetsDir + path.sep)) return null;
786
+ return file;
787
+ }
344
788
  function resolveSlideEntry(slidesRoot, slideId) {
345
789
  if (!SLIDE_ID_RE.test(slideId)) return null;
346
790
  const dir = path.resolve(slidesRoot, slideId);
@@ -441,6 +885,22 @@ function filesPlugin(opts) {
441
885
  event: "open-slide:files-changed"
442
886
  });
443
887
  });
888
+ const onAssetChange = (p) => {
889
+ if (!p.startsWith(slidesRoot + path.sep)) return;
890
+ const rel = p.slice(slidesRoot.length + 1);
891
+ const parts = rel.split(path.sep);
892
+ if (parts.length < 3 || parts[1] !== "assets") return;
893
+ const slideId = parts[0];
894
+ if (!SLIDE_ID_RE.test(slideId)) return;
895
+ server.ws.send({
896
+ type: "custom",
897
+ event: "open-slide:assets-changed",
898
+ data: { slideId }
899
+ });
900
+ };
901
+ server.watcher.on("add", onAssetChange);
902
+ server.watcher.on("change", onAssetChange);
903
+ server.watcher.on("unlink", onAssetChange);
444
904
  server.middlewares.use("/__slides", async (req, res, next) => {
445
905
  const url = new URL(req.url ?? "/", "http://local");
446
906
  const method = req.method ?? "GET";
@@ -484,6 +944,172 @@ function filesPlugin(opts) {
484
944
  json(res, 500, { error: String(err.message ?? err) });
485
945
  }
486
946
  });
947
+ server.middlewares.use("/__assets", async (req, res, next) => {
948
+ const url = new URL(req.url ?? "/", "http://local");
949
+ const method = req.method ?? "GET";
950
+ try {
951
+ const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
952
+ const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
953
+ if (listMatch && method === "GET") {
954
+ const slideId = listMatch[1];
955
+ const assetsDir = resolveAssetsDir(slidesRoot, slideId);
956
+ if (!assetsDir) return json(res, 400, { error: "invalid slideId" });
957
+ let entries;
958
+ try {
959
+ entries = await fs.readdir(assetsDir);
960
+ } catch (err) {
961
+ if (err.code === "ENOENT") return json(res, 200, { assets: [] });
962
+ throw err;
963
+ }
964
+ const assets = [];
965
+ for (const name of entries) {
966
+ if (!validateAssetName(name)) continue;
967
+ const stat = await fs.stat(path.join(assetsDir, name));
968
+ if (!stat.isFile()) continue;
969
+ assets.push({
970
+ name,
971
+ size: stat.size,
972
+ mtime: stat.mtimeMs,
973
+ mime: mimeForFilename(name),
974
+ url: `/__assets/${slideId}/${encodeURIComponent(name)}`
975
+ });
976
+ }
977
+ assets.sort((a, b) => a.name.localeCompare(b.name));
978
+ return json(res, 200, { assets });
979
+ }
980
+ if (fileMatch) {
981
+ const slideId = fileMatch[1];
982
+ const filename = decodeURIComponent(fileMatch[2]);
983
+ const file = resolveAssetFile(slidesRoot, slideId, filename);
984
+ if (!file) return json(res, 400, { error: "invalid path" });
985
+ if (method === "GET") try {
986
+ const buf = await fs.readFile(file);
987
+ res.statusCode = 200;
988
+ res.setHeader("content-type", mimeForFilename(filename));
989
+ res.setHeader("cache-control", "no-store");
990
+ res.end(buf);
991
+ return;
992
+ } catch (err) {
993
+ if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
994
+ throw err;
995
+ }
996
+ if (method === "POST") {
997
+ const overwrite = url.searchParams.get("overwrite") === "1";
998
+ const lenHeader = req.headers["content-length"];
999
+ const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
1000
+ if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json(res, 413, { error: "file too large" });
1001
+ if (!overwrite) try {
1002
+ await fs.access(file);
1003
+ return json(res, 409, { error: "asset exists" });
1004
+ } catch {}
1005
+ const assetsDir = resolveAssetsDir(slidesRoot, slideId);
1006
+ if (!assetsDir) return json(res, 400, { error: "invalid slideId" });
1007
+ await fs.mkdir(assetsDir, { recursive: true });
1008
+ const chunks = [];
1009
+ let total = 0;
1010
+ let oversized = false;
1011
+ await new Promise((resolve, reject) => {
1012
+ req.on("data", (c) => {
1013
+ total += c.length;
1014
+ if (total > ASSET_MAX_BYTES) {
1015
+ oversized = true;
1016
+ req.destroy();
1017
+ return;
1018
+ }
1019
+ chunks.push(c);
1020
+ });
1021
+ req.on("end", () => resolve());
1022
+ req.on("error", reject);
1023
+ });
1024
+ if (oversized) return json(res, 413, { error: "file too large" });
1025
+ await fs.writeFile(file, Buffer.concat(chunks));
1026
+ return json(res, 200, {
1027
+ ok: true,
1028
+ name: filename,
1029
+ size: total,
1030
+ mime: mimeForFilename(filename),
1031
+ url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
1032
+ });
1033
+ }
1034
+ if (method === "PATCH") {
1035
+ const body = await readBody(req);
1036
+ const target = validateAssetName(body.name);
1037
+ if (!target) return json(res, 400, { error: "invalid name" });
1038
+ if (target === filename) return json(res, 200, {
1039
+ ok: true,
1040
+ name: filename
1041
+ });
1042
+ const dest = resolveAssetFile(slidesRoot, slideId, target);
1043
+ if (!dest) return json(res, 400, { error: "invalid name" });
1044
+ try {
1045
+ await fs.access(dest);
1046
+ return json(res, 409, { error: "target exists" });
1047
+ } catch {}
1048
+ try {
1049
+ await fs.rename(file, dest);
1050
+ } catch (err) {
1051
+ if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
1052
+ throw err;
1053
+ }
1054
+ return json(res, 200, {
1055
+ ok: true,
1056
+ name: target
1057
+ });
1058
+ }
1059
+ if (method === "DELETE") {
1060
+ try {
1061
+ await fs.unlink(file);
1062
+ } catch (err) {
1063
+ if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
1064
+ throw err;
1065
+ }
1066
+ return json(res, 200, { ok: true });
1067
+ }
1068
+ }
1069
+ return next();
1070
+ } catch (err) {
1071
+ json(res, 500, { error: String(err.message ?? err) });
1072
+ }
1073
+ });
1074
+ server.middlewares.use("/__svgl", async (req, res, next) => {
1075
+ const reqUrl = new URL(req.url ?? "/", "http://local");
1076
+ const method = req.method ?? "GET";
1077
+ if (method !== "GET") return next();
1078
+ try {
1079
+ let target = null;
1080
+ if (reqUrl.pathname === "/search") {
1081
+ const params = new URLSearchParams();
1082
+ const q = reqUrl.searchParams.get("q");
1083
+ const limit = reqUrl.searchParams.get("limit");
1084
+ if (q) params.set("search", q);
1085
+ if (limit) params.set("limit", limit);
1086
+ const qs = params.toString();
1087
+ target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
1088
+ } else if (reqUrl.pathname === "/svg") {
1089
+ const u = reqUrl.searchParams.get("u");
1090
+ if (!u) return json(res, 400, { error: "missing u" });
1091
+ let parsed;
1092
+ try {
1093
+ parsed = new URL(u);
1094
+ } catch {
1095
+ return json(res, 400, { error: "invalid u" });
1096
+ }
1097
+ if (parsed.protocol !== "https:") return json(res, 400, { error: "https only" });
1098
+ const host = parsed.hostname.toLowerCase();
1099
+ if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json(res, 400, { error: "host not allowed" });
1100
+ target = parsed.toString();
1101
+ } else return next();
1102
+ const upstream = await fetch(target);
1103
+ const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
1104
+ res.statusCode = upstream.status;
1105
+ res.setHeader("content-type", ct);
1106
+ res.setHeader("cache-control", "no-store");
1107
+ const buf = Buffer.from(await upstream.arrayBuffer());
1108
+ res.end(buf);
1109
+ } catch (err) {
1110
+ json(res, 502, { error: String(err.message ?? err) });
1111
+ }
1112
+ });
487
1113
  server.middlewares.use("/__folders", async (req, res, next) => {
488
1114
  const url = new URL(req.url ?? "/", "http://local");
489
1115
  const method = req.method ?? "GET";
@@ -564,6 +1190,74 @@ function filesPlugin(opts) {
564
1190
  };
565
1191
  }
566
1192
 
1193
+ //#endregion
1194
+ //#region src/vite/loc-tags-plugin.ts
1195
+ function isHostJsxName(name) {
1196
+ if (!name || typeof name !== "object") return false;
1197
+ const n = name;
1198
+ return n.type === "JSXIdentifier" && typeof n.name === "string" && /^[a-z]/.test(n.name);
1199
+ }
1200
+ function alreadyTagged(opening) {
1201
+ const attrs = opening.attributes ?? [];
1202
+ for (const attr of attrs) {
1203
+ if (attr.type !== "JSXAttribute") continue;
1204
+ const name = attr.name;
1205
+ if (name?.type === "JSXIdentifier" && name.name === "data-slide-loc") return true;
1206
+ }
1207
+ return false;
1208
+ }
1209
+ function injectLocTags(code) {
1210
+ let ast;
1211
+ try {
1212
+ ast = parse(code, {
1213
+ sourceType: "module",
1214
+ plugins: ["typescript", "jsx"],
1215
+ errorRecovery: true
1216
+ });
1217
+ } catch {
1218
+ return null;
1219
+ }
1220
+ const insertions = [];
1221
+ walkJsx(ast, (node) => {
1222
+ if (node.type !== "JSXElement") return;
1223
+ const opening = node.openingElement;
1224
+ if (!opening) return;
1225
+ const name = opening.name;
1226
+ if (!isHostJsxName(name)) return;
1227
+ if (alreadyTagged(opening)) return;
1228
+ const loc = node.loc;
1229
+ if (!loc) return;
1230
+ insertions.push({
1231
+ offset: name.end,
1232
+ text: ` data-slide-loc="${loc.start.line}:${loc.start.column}"`
1233
+ });
1234
+ });
1235
+ if (insertions.length === 0) return null;
1236
+ insertions.sort((a, b) => b.offset - a.offset);
1237
+ let next = code;
1238
+ for (const ins of insertions) next = next.slice(0, ins.offset) + ins.text + next.slice(ins.offset);
1239
+ return next;
1240
+ }
1241
+ function locTagsPlugin(opts) {
1242
+ const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides");
1243
+ return {
1244
+ name: "open-slide:loc-tags",
1245
+ apply: "serve",
1246
+ enforce: "pre",
1247
+ transform(code, id) {
1248
+ const filePath = id.split("?")[0];
1249
+ if (!filePath.startsWith(slidesRoot + path.sep)) return null;
1250
+ if (!filePath.endsWith(`${path.sep}index.tsx`)) return null;
1251
+ const next = injectLocTags(code);
1252
+ if (next === null) return null;
1253
+ return {
1254
+ code: next,
1255
+ map: null
1256
+ };
1257
+ }
1258
+ };
1259
+ }
1260
+
567
1261
  //#endregion
568
1262
  //#region src/vite/open-slide-plugin.ts
569
1263
  const CONFIG_FILE = "open-slide.config.ts";
@@ -732,6 +1426,10 @@ async function createViteConfig(opts) {
732
1426
  root: APP_ROOT,
733
1427
  configFile: false,
734
1428
  plugins: [
1429
+ locTagsPlugin({
1430
+ userCwd,
1431
+ slidesDir
1432
+ }),
735
1433
  react(),
736
1434
  tailwindcss(),
737
1435
  openSlidePlugin({