@open-slide/core 0.0.9 → 0.0.11

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.
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-CtwxMYv9.js";
1
+ import { createViteConfig } from "./config-LZM903FE.js";
2
2
  import path from "node:path";
3
3
  import { build as build$1, mergeConfig } from "vite";
4
4
 
package/dist/cli/bin.js CHANGED
@@ -22,15 +22,15 @@ async function run(argv) {
22
22
  const program = new Command();
23
23
  program.name("open-slide").description("Author slides — we handle the Vite/React stack.").version(version, "-v, --version", "print version").helpOption("-h, --help", "show help").showHelpAfterError(chalk.dim("(run `open-slide --help` for usage)"));
24
24
  program.command("dev").description("Start the dev server").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
25
- const { dev } = await import("../dev-CJX97uiy.js");
25
+ const { dev } = await import("../dev-B3JzCYn7.js");
26
26
  await dev(flags);
27
27
  });
28
28
  program.command("build").description("Build a static site").option("--out-dir <dir>", "output directory (defaults to `dist`)").action(async (flags) => {
29
- const { build } = await import("../build-pqF4W1Yi.js");
29
+ const { build } = await import("../build-DHiRlpjn.js");
30
30
  await build(flags);
31
31
  });
32
32
  program.command("preview").description("Preview the production build").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
33
- const { preview } = await import("../preview-IuLPcL5y.js");
33
+ const { preview } = await import("../preview-UikovHEt.js");
34
34
  await preview(flags);
35
35
  });
36
36
  await program.parseAsync(argv, { from: "user" });
@@ -312,6 +312,116 @@ function buildTextSplice(element, value) {
312
312
  }
313
313
  return { error: "element has complex children" };
314
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
+ }
315
425
  function applyEdit(source, line, column, ops) {
316
426
  if (ops.length === 0) return {
317
427
  ok: true,
@@ -347,6 +457,36 @@ function applyEdit(source, line, column, ops) {
347
457
  };
348
458
  splices.push(result);
349
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
+ }
350
490
  if (splices.length === 0) return {
351
491
  ok: true,
352
492
  source
@@ -517,6 +657,45 @@ function commentsPlugin(opts) {
517
657
  const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
518
658
  const SLIDE_ID_RE = /^[a-z0-9_-]+$/i;
519
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
+ }
520
699
  async function readBody(req) {
521
700
  return await new Promise((resolve, reject) => {
522
701
  const chunks = [];
@@ -590,6 +769,22 @@ async function rmSlideDir(slidesRoot, slideId) {
590
769
  return false;
591
770
  }
592
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
+ }
593
788
  function resolveSlideEntry(slidesRoot, slideId) {
594
789
  if (!SLIDE_ID_RE.test(slideId)) return null;
595
790
  const dir = path.resolve(slidesRoot, slideId);
@@ -690,6 +885,22 @@ function filesPlugin(opts) {
690
885
  event: "open-slide:files-changed"
691
886
  });
692
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);
693
904
  server.middlewares.use("/__slides", async (req, res, next) => {
694
905
  const url = new URL(req.url ?? "/", "http://local");
695
906
  const method = req.method ?? "GET";
@@ -733,6 +944,172 @@ function filesPlugin(opts) {
733
944
  json(res, 500, { error: String(err.message ?? err) });
734
945
  }
735
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
+ });
736
1113
  server.middlewares.use("/__folders", async (req, res, next) => {
737
1114
  const url = new URL(req.url ?? "/", "http://local");
738
1115
  const method = req.method ?? "GET";
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-CtwxMYv9.js";
1
+ import { createViteConfig } from "./config-LZM903FE.js";
2
2
  import { createServer, mergeConfig } from "vite";
3
3
 
4
4
  //#region src/cli/dev.ts
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-CtwxMYv9.js";
1
+ import { createViteConfig } from "./config-LZM903FE.js";
2
2
  import { mergeConfig, preview as preview$1 } from "vite";
3
3
 
4
4
  //#region src/cli/preview.ts
@@ -1,3 +1,3 @@
1
- import { createViteConfig } from "../config-CtwxMYv9.js";
1
+ import { createViteConfig } from "../config-LZM903FE.js";
2
2
 
3
3
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {