@open-slide/core 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-1Rqivz0d.js → build-DZhbjQpQ.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-s0YUbmUe.d.ts → config-BQdTMho4.d.ts} +1 -1
- package/dist/{config-XZJnC_fu.js → config-iKjqaX08.js} +2045 -1631
- package/dist/{dev-0W8gYiSa.js → dev-BjLGk5nN.js} +1 -1
- package/dist/{en-7GU-DHbJ.js → en-DDGqyNaW.js} +14 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +43 -4
- package/dist/{preview-DT9hJvzM.js → preview-jwLWHWkQ.js} +1 -1
- package/dist/{types-QCpkHkiS.d.ts → types-Dpr8nbih.d.ts} +16 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +9 -2
- package/src/app/components/asset-view.tsx +75 -9
- package/src/app/components/inspector/inspector-panel.tsx +16 -1
- package/src/app/components/panel/panel-shell.tsx +5 -3
- package/src/app/components/present/laser-pointer.tsx +3 -4
- package/src/app/components/present/progress-bar.tsx +4 -4
- package/src/app/lib/assets.ts +21 -0
- package/src/app/lib/sdk.ts +2 -0
- package/src/app/lib/slides.ts +2 -0
- package/src/app/routes/home.tsx +101 -3
- package/src/app/routes/slide.tsx +45 -0
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +14 -1
- package/src/locale/ja.ts +14 -1
- package/src/locale/types.ts +16 -0
- package/src/locale/zh-cn.ts +14 -1
- package/src/locale/zh-tw.ts +14 -1
|
@@ -6,15 +6,17 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import tailwindcss from "@tailwindcss/vite";
|
|
8
8
|
import react from "@vitejs/plugin-react";
|
|
9
|
-
import
|
|
9
|
+
import * as t$4 from "@babel/types";
|
|
10
|
+
import * as t$3 from "@babel/types";
|
|
10
11
|
import * as t$2 from "@babel/types";
|
|
11
12
|
import * as t$1 from "@babel/types";
|
|
12
13
|
import * as t from "@babel/types";
|
|
13
14
|
import { isJSXElement, isJSXFragment } from "@babel/types";
|
|
15
|
+
import { parse } from "@babel/parser";
|
|
14
16
|
import fg from "fast-glob";
|
|
15
17
|
import { loadConfigFromFile, normalizePath } from "vite";
|
|
16
18
|
|
|
17
|
-
//#region src/
|
|
19
|
+
//#region src/editing/babel-walk.ts
|
|
18
20
|
const SKIP_KEYS = new Set([
|
|
19
21
|
"loc",
|
|
20
22
|
"start",
|
|
@@ -54,89 +56,106 @@ function walkJsx(ast, visit) {
|
|
|
54
56
|
function walkAll(ast, visit) {
|
|
55
57
|
walk(ast, visit, acceptAll);
|
|
56
58
|
}
|
|
59
|
+
function parseSource$2(source) {
|
|
60
|
+
try {
|
|
61
|
+
const ast = parse(source, {
|
|
62
|
+
sourceType: "module",
|
|
63
|
+
plugins: ["typescript", "jsx"],
|
|
64
|
+
errorRecovery: true
|
|
65
|
+
});
|
|
66
|
+
return ast.errors && ast.errors.length > 0 ? null : ast;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
57
71
|
|
|
58
72
|
//#endregion
|
|
59
|
-
//#region src/
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
function b64urlEncode(s) {
|
|
63
|
-
return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
73
|
+
//#region src/editing/edit-ops.ts
|
|
74
|
+
function jsString$1(s) {
|
|
75
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
64
76
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
function spliceRange(node, text) {
|
|
78
|
+
return {
|
|
79
|
+
from: node.start ?? 0,
|
|
80
|
+
to: node.end ?? 0,
|
|
81
|
+
text
|
|
82
|
+
};
|
|
68
83
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
req.on("data", (c) => chunks.push(c));
|
|
73
|
-
req.on("end", () => {
|
|
74
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
75
|
-
if (!raw) return resolve({});
|
|
76
|
-
try {
|
|
77
|
-
resolve(JSON.parse(raw));
|
|
78
|
-
} catch (e) {
|
|
79
|
-
reject(e);
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
req.on("error", reject);
|
|
83
|
-
});
|
|
84
|
+
function formatJsxAttrValue(value) {
|
|
85
|
+
if (/^[^"\\<>&{}\n\r]*$/.test(value)) return `"${value}"`;
|
|
86
|
+
return `{${jsString$1(value)}}`;
|
|
84
87
|
}
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
res.setHeader("content-type", "application/json");
|
|
88
|
-
res.end(JSON.stringify(body));
|
|
88
|
+
function jsxAttrName(attr) {
|
|
89
|
+
return t$4.isJSXIdentifier(attr.name) ? attr.name.name : null;
|
|
89
90
|
}
|
|
90
|
-
function
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
const full = path.resolve(slidesRoot, slideId, "index.tsx");
|
|
94
|
-
if (!full.startsWith(slidesRoot + path.sep)) return null;
|
|
95
|
-
return full;
|
|
91
|
+
function findJsxAttr(opening, name) {
|
|
92
|
+
for (const attr of opening.attributes) if (t$4.isJSXAttribute(attr) && jsxAttrName(attr) === name) return attr;
|
|
93
|
+
return null;
|
|
96
94
|
}
|
|
97
|
-
function
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (!m) continue;
|
|
105
|
-
const [, id, ts, textB64] = m;
|
|
106
|
-
try {
|
|
107
|
-
const payload = JSON.parse(b64urlDecode(textB64));
|
|
108
|
-
comments.push({
|
|
109
|
-
id,
|
|
110
|
-
line: i + 1,
|
|
111
|
-
ts,
|
|
112
|
-
note: payload.note,
|
|
113
|
-
hint: payload.hint
|
|
114
|
-
});
|
|
115
|
-
} catch {}
|
|
116
|
-
}
|
|
117
|
-
return comments;
|
|
95
|
+
function readJsxStringAttr(opening, name) {
|
|
96
|
+
const attr = findJsxAttr(opening, name);
|
|
97
|
+
const v = attr?.value;
|
|
98
|
+
if (!v) return null;
|
|
99
|
+
if (t$4.isStringLiteral(v)) return v.value;
|
|
100
|
+
if (t$4.isJSXExpressionContainer(v) && t$4.isStringLiteral(v.expression)) return v.expression.value;
|
|
101
|
+
return null;
|
|
118
102
|
}
|
|
119
|
-
function
|
|
120
|
-
|
|
103
|
+
function readJsxNumberAttr(opening, name) {
|
|
104
|
+
const attr = findJsxAttr(opening, name);
|
|
105
|
+
const v = attr?.value;
|
|
106
|
+
if (!v || !t$4.isJSXExpressionContainer(v)) return null;
|
|
107
|
+
if (!t$4.isNumericLiteral(v.expression)) return null;
|
|
108
|
+
const n = v.expression.value;
|
|
109
|
+
return Number.isFinite(n) ? n : null;
|
|
121
110
|
}
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
for (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
111
|
+
function findImports$1(ast) {
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const node of ast.program.body) {
|
|
114
|
+
if (!t$4.isImportDeclaration(node)) continue;
|
|
115
|
+
let def = null;
|
|
116
|
+
for (const spec of node.specifiers) if (t$4.isImportDefaultSpecifier(spec)) {
|
|
117
|
+
def = spec.local.name;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
out.push({
|
|
121
|
+
node,
|
|
122
|
+
source: node.source.value,
|
|
123
|
+
defaultIdent: def
|
|
124
|
+
});
|
|
128
125
|
}
|
|
129
|
-
return
|
|
126
|
+
return out;
|
|
130
127
|
}
|
|
131
|
-
function
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
128
|
+
function collectTopLevelIdentifiers(ast) {
|
|
129
|
+
const names = new Set();
|
|
130
|
+
for (const imp of findImports$1(ast)) {
|
|
131
|
+
if (imp.defaultIdent) names.add(imp.defaultIdent);
|
|
132
|
+
for (const spec of imp.node.specifiers) if (!t$4.isImportDefaultSpecifier(spec)) names.add(spec.local.name);
|
|
133
|
+
}
|
|
134
|
+
return names;
|
|
135
135
|
}
|
|
136
|
-
function
|
|
136
|
+
function safeAssetIdentifier(filename, taken) {
|
|
137
|
+
const stem = filename.replace(/\.[^.]+$/, "");
|
|
138
|
+
let camel = "";
|
|
139
|
+
let upper = false;
|
|
140
|
+
for (const ch of stem) if (/[A-Za-z0-9]/.test(ch)) {
|
|
141
|
+
camel += upper ? ch.toUpperCase() : ch;
|
|
142
|
+
upper = false;
|
|
143
|
+
} else upper = camel.length > 0;
|
|
144
|
+
let base = camel;
|
|
145
|
+
if (!base || !/^[A-Za-z_$]/.test(base)) base = `asset${base.charAt(0).toUpperCase()}${base.slice(1)}` || "asset";
|
|
146
|
+
base = base.charAt(0).toLowerCase() + base.slice(1);
|
|
147
|
+
let candidate = base;
|
|
148
|
+
let i = 2;
|
|
149
|
+
while (taken.has(candidate)) {
|
|
150
|
+
candidate = `${base}${i}`;
|
|
151
|
+
i += 1;
|
|
152
|
+
}
|
|
153
|
+
return candidate;
|
|
154
|
+
}
|
|
155
|
+
function findJsxAncestors$1(ast, line, column) {
|
|
137
156
|
const hits = [];
|
|
138
157
|
walkJsx(ast, (n) => {
|
|
139
|
-
if (!n.loc || !t$
|
|
158
|
+
if (!n.loc || !t$4.isJSXElement(n) && !t$4.isJSXFragment(n)) return;
|
|
140
159
|
const s = n.loc.start;
|
|
141
160
|
const e = n.loc.end;
|
|
142
161
|
const afterStart = line > s.line || line === s.line && column >= s.column;
|
|
@@ -149,64 +168,28 @@ function findJsxAncestors(ast, line, column) {
|
|
|
149
168
|
hits.sort((a, b) => a.size - b.size);
|
|
150
169
|
return hits.map((h) => h.node);
|
|
151
170
|
}
|
|
152
|
-
function
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (opening.selfClosing) return null;
|
|
164
|
-
const startLine = target.loc?.start.line ?? 1;
|
|
165
|
-
return {
|
|
166
|
-
offset: opening.end ?? 0,
|
|
167
|
-
indent: `${lineIndent(source, startLine)} `
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
function findInsertion(source, line, column) {
|
|
173
|
-
const ast = parseSource$2(source);
|
|
174
|
-
if (!ast) return null;
|
|
175
|
-
const col = column ?? 0;
|
|
176
|
-
const ancestors = findJsxAncestors(ast, line, col);
|
|
177
|
-
for (const node of ancestors) {
|
|
178
|
-
const plan = planInsertion(source, node);
|
|
179
|
-
if (plan) return plan;
|
|
180
|
-
}
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
function offsetToLine(source, offset) {
|
|
184
|
-
let line = 1;
|
|
185
|
-
for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
|
|
186
|
-
return line;
|
|
187
|
-
}
|
|
188
|
-
function parseSource$2(source) {
|
|
189
|
-
try {
|
|
190
|
-
const ast = parse(source, {
|
|
191
|
-
sourceType: "module",
|
|
192
|
-
plugins: ["typescript", "jsx"],
|
|
193
|
-
errorRecovery: true
|
|
194
|
-
});
|
|
195
|
-
return ast.errors && ast.errors.length > 0 ? null : ast;
|
|
196
|
-
} catch {
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
171
|
+
function findJsxByStart(ast, line, column) {
|
|
172
|
+
let hit = null;
|
|
173
|
+
walkJsx(ast, (n) => {
|
|
174
|
+
if (!t$4.isJSXElement(n) || !n.loc) return;
|
|
175
|
+
const s = n.loc.start;
|
|
176
|
+
if (s.line === line && s.column === column) {
|
|
177
|
+
hit = n;
|
|
178
|
+
return "stop";
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return hit;
|
|
199
182
|
}
|
|
200
183
|
function findInnermostJsxElement(ast, line, column) {
|
|
201
184
|
const exact = findJsxByStart(ast, line, column);
|
|
202
185
|
if (exact) return exact;
|
|
203
|
-
for (const n of findJsxAncestors(ast, line, column)) if (t$
|
|
186
|
+
for (const n of findJsxAncestors$1(ast, line, column)) if (t$4.isJSXElement(n)) return n;
|
|
204
187
|
return null;
|
|
205
188
|
}
|
|
206
189
|
function findUniqueElementByText(ast, prevText) {
|
|
207
190
|
const hits = [];
|
|
208
191
|
walkJsx(ast, (n) => {
|
|
209
|
-
if (!t$
|
|
192
|
+
if (!t$4.isJSXElement(n)) return;
|
|
210
193
|
const parts = [];
|
|
211
194
|
collectTextRangeParts(n, parts);
|
|
212
195
|
if (textRangeContent(parts) !== prevText) return;
|
|
@@ -248,28 +231,6 @@ function findElementForEdit(ast, line, column, ops) {
|
|
|
248
231
|
if (element && elementTextMatches(element, prevText)) return textMatch ?? element;
|
|
249
232
|
return textMatch ?? element;
|
|
250
233
|
}
|
|
251
|
-
function findJsxByStart(ast, line, column) {
|
|
252
|
-
let hit = null;
|
|
253
|
-
walkJsx(ast, (n) => {
|
|
254
|
-
if (!t$2.isJSXElement(n) || !n.loc) return;
|
|
255
|
-
const s = n.loc.start;
|
|
256
|
-
if (s.line === line && s.column === column) {
|
|
257
|
-
hit = n;
|
|
258
|
-
return "stop";
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
return hit;
|
|
262
|
-
}
|
|
263
|
-
function jsString$1(s) {
|
|
264
|
-
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
265
|
-
}
|
|
266
|
-
function jsxAttrName(attr) {
|
|
267
|
-
return t$2.isJSXIdentifier(attr.name) ? attr.name.name : null;
|
|
268
|
-
}
|
|
269
|
-
function findJsxAttr(opening, name) {
|
|
270
|
-
for (const attr of opening.attributes) if (t$2.isJSXAttribute(attr) && jsxAttrName(attr) === name) return attr;
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
234
|
function buildStyleSplice(source, element, ops) {
|
|
274
235
|
const opening = element.openingElement;
|
|
275
236
|
const existing = findJsxAttr(opening, "style");
|
|
@@ -277,19 +238,19 @@ function buildStyleSplice(source, element, ops) {
|
|
|
277
238
|
let hasRawEntry = false;
|
|
278
239
|
if (existing) {
|
|
279
240
|
const value = existing.value;
|
|
280
|
-
if (!value || !t$
|
|
241
|
+
if (!value || !t$4.isJSXExpressionContainer(value)) return { error: "style attribute has unsupported form" };
|
|
281
242
|
const expr = value.expression;
|
|
282
|
-
if (!t$
|
|
243
|
+
if (!t$4.isObjectExpression(expr)) {
|
|
283
244
|
if (typeof expr.start !== "number" || typeof expr.end !== "number") return { error: "style value missing source range" };
|
|
284
245
|
entries.push({
|
|
285
246
|
kind: "raw",
|
|
286
247
|
text: `...(${source.slice(expr.start, expr.end)})`
|
|
287
248
|
});
|
|
288
249
|
hasRawEntry = true;
|
|
289
|
-
} else for (const prop of expr.properties) if (t$
|
|
250
|
+
} else for (const prop of expr.properties) if (t$4.isObjectProperty(prop) && !prop.computed) {
|
|
290
251
|
let keyName = null;
|
|
291
|
-
if (t$
|
|
292
|
-
else if (t$
|
|
252
|
+
if (t$4.isIdentifier(prop.key)) keyName = prop.key.name;
|
|
253
|
+
else if (t$4.isStringLiteral(prop.key)) keyName = prop.key.value;
|
|
293
254
|
if (!keyName) return { error: "style has unsupported key" };
|
|
294
255
|
const v = prop.value;
|
|
295
256
|
if (typeof prop.key.start !== "number" || typeof prop.key.end !== "number" || typeof v.start !== "number" || typeof v.end !== "number") return { error: "style value missing source range" };
|
|
@@ -368,7 +329,7 @@ function formatJsxText(value) {
|
|
|
368
329
|
}
|
|
369
330
|
function meaningfulChildren(parent) {
|
|
370
331
|
return parent.children.filter((c) => {
|
|
371
|
-
if (t$
|
|
332
|
+
if (t$4.isJSXText(c)) return c.value.trim() !== "";
|
|
372
333
|
return true;
|
|
373
334
|
});
|
|
374
335
|
}
|
|
@@ -435,14 +396,14 @@ function cleanJsxTextWithOffsets(value) {
|
|
|
435
396
|
};
|
|
436
397
|
}
|
|
437
398
|
function isJsxBrElement(node) {
|
|
438
|
-
if (!t$
|
|
399
|
+
if (!t$4.isJSXElement(node)) return false;
|
|
439
400
|
const name = node.openingElement.name;
|
|
440
|
-
return t$
|
|
401
|
+
return t$4.isJSXIdentifier(name) && name.name.toLowerCase() === "br";
|
|
441
402
|
}
|
|
442
403
|
function collectTextCandidates(element, out) {
|
|
443
404
|
const meaningful = meaningfulChildren(element);
|
|
444
405
|
const isSole = meaningful.length === 1;
|
|
445
|
-
for (const child of meaningful) if (t$
|
|
406
|
+
for (const child of meaningful) if (t$4.isJSXText(child)) {
|
|
446
407
|
const current = child.value.trim();
|
|
447
408
|
if (!current) continue;
|
|
448
409
|
out.push({
|
|
@@ -453,9 +414,9 @@ function collectTextCandidates(element, out) {
|
|
|
453
414
|
text: formatJsxText(v)
|
|
454
415
|
}
|
|
455
416
|
});
|
|
456
|
-
} else if (t$
|
|
417
|
+
} else if (t$4.isJSXExpressionContainer(child)) {
|
|
457
418
|
const expr = child.expression;
|
|
458
|
-
if (t$
|
|
419
|
+
if (t$4.isStringLiteral(expr) || t$4.isNumericLiteral(expr)) {
|
|
459
420
|
const current = String(expr.value);
|
|
460
421
|
out.push({
|
|
461
422
|
current,
|
|
@@ -466,7 +427,7 @@ function collectTextCandidates(element, out) {
|
|
|
466
427
|
}
|
|
467
428
|
});
|
|
468
429
|
}
|
|
469
|
-
} else if (t$
|
|
430
|
+
} else if (t$4.isJSXElement(child) || t$4.isJSXFragment(child)) collectTextCandidates(child, out);
|
|
470
431
|
}
|
|
471
432
|
function collectTextRangeParts(element, out) {
|
|
472
433
|
const parts = [];
|
|
@@ -474,7 +435,7 @@ function collectTextRangeParts(element, out) {
|
|
|
474
435
|
out.push(...normalizeTextRangeParts(parts));
|
|
475
436
|
}
|
|
476
437
|
function collectTextRangePartsRaw(element, out) {
|
|
477
|
-
for (const child of element.children) if (t$
|
|
438
|
+
for (const child of element.children) if (t$4.isJSXText(child)) {
|
|
478
439
|
const { text: current, offsets } = cleanJsxTextWithOffsets(child.value);
|
|
479
440
|
if (current) out.push({
|
|
480
441
|
node: child,
|
|
@@ -484,9 +445,9 @@ function collectTextRangePartsRaw(element, out) {
|
|
|
484
445
|
text: formatJsxText,
|
|
485
446
|
offsets
|
|
486
447
|
});
|
|
487
|
-
} else if (t$
|
|
448
|
+
} else if (t$4.isJSXExpressionContainer(child)) {
|
|
488
449
|
const expression = child.expression;
|
|
489
|
-
if (t$
|
|
450
|
+
if (t$4.isStringLiteral(expression) || t$4.isNumericLiteral(expression)) {
|
|
490
451
|
const raw = String(expression.value);
|
|
491
452
|
const current = raw;
|
|
492
453
|
if (current) out.push({
|
|
@@ -502,7 +463,7 @@ function collectTextRangePartsRaw(element, out) {
|
|
|
502
463
|
node: child,
|
|
503
464
|
current: "\n"
|
|
504
465
|
});
|
|
505
|
-
else if (t$
|
|
466
|
+
else if (t$4.isJSXElement(child) || t$4.isJSXFragment(child)) collectTextRangePartsRaw(child, out);
|
|
506
467
|
}
|
|
507
468
|
function normalizeTextRangeParts(parts) {
|
|
508
469
|
return parts.flatMap((part, index) => {
|
|
@@ -665,7 +626,7 @@ function buildTextRangeStyleSplices(ast, source, element, start, end, op, prevTe
|
|
|
665
626
|
leafStart = leafEnd;
|
|
666
627
|
continue;
|
|
667
628
|
}
|
|
668
|
-
if (selectedStart === leafStart && selectedEnd === leafEnd && t$
|
|
629
|
+
if (selectedStart === leafStart && selectedEnd === leafEnd && t$4.isJSXElement(leaf.parent) && leaf.parent !== element && isOnlyMeaningfulChild(leaf.parent, leaf.node)) {
|
|
669
630
|
const result = buildStyleSplice(source, leaf.parent, [op]);
|
|
670
631
|
if (result && "error" in result) return result;
|
|
671
632
|
if (result) splices.push(result);
|
|
@@ -681,8 +642,8 @@ function buildTextRangeStyleSplices(ast, source, element, start, end, op, prevTe
|
|
|
681
642
|
const before = raw.slice(0, rawStart);
|
|
682
643
|
const selected = leaf.current.slice(localStart, localEnd);
|
|
683
644
|
const after = raw.slice(rawEnd);
|
|
684
|
-
const beforeText = t$
|
|
685
|
-
const afterText = t$
|
|
645
|
+
const beforeText = t$4.isJSXText(leaf.node) ? before : formatOptionalText(before, leaf.text);
|
|
646
|
+
const afterText = t$4.isJSXText(leaf.node) ? after : formatOptionalText(after, leaf.text);
|
|
686
647
|
splices.push(spliceRange(leaf.node, `${beforeText}${styleSpanForText(selected, op.key, op.value)}${afterText}`));
|
|
687
648
|
leafStart = leafEnd;
|
|
688
649
|
}
|
|
@@ -692,8 +653,8 @@ function propPassthroughName(element) {
|
|
|
692
653
|
const meaningful = meaningfulChildren(element);
|
|
693
654
|
if (meaningful.length !== 1) return null;
|
|
694
655
|
const child = meaningful[0];
|
|
695
|
-
if (!t$
|
|
696
|
-
return t$
|
|
656
|
+
if (!t$4.isJSXExpressionContainer(child)) return null;
|
|
657
|
+
return t$4.isIdentifier(child.expression) ? child.expression.name : null;
|
|
697
658
|
}
|
|
698
659
|
function findEnclosingComponent(ast, target) {
|
|
699
660
|
let best = null;
|
|
@@ -715,17 +676,17 @@ function findEnclosingComponent(ast, target) {
|
|
|
715
676
|
}
|
|
716
677
|
};
|
|
717
678
|
const visitDecl = (decl) => {
|
|
718
|
-
if (t$
|
|
719
|
-
else if (t$
|
|
720
|
-
if (!t$
|
|
721
|
-
if (t$
|
|
679
|
+
if (t$4.isFunctionDeclaration(decl) && decl.id) consider(decl.id.name, decl);
|
|
680
|
+
else if (t$4.isVariableDeclaration(decl)) for (const d of decl.declarations) {
|
|
681
|
+
if (!t$4.isVariableDeclarator(d) || !t$4.isIdentifier(d.id) || !d.init) continue;
|
|
682
|
+
if (t$4.isArrowFunctionExpression(d.init) || t$4.isFunctionExpression(d.init)) consider(d.id.name, d.init);
|
|
722
683
|
}
|
|
723
684
|
};
|
|
724
685
|
for (const decl of ast.program.body) {
|
|
725
686
|
visitDecl(decl);
|
|
726
|
-
if (t$
|
|
687
|
+
if (t$4.isExportNamedDeclaration(decl) || t$4.isExportDefaultDeclaration(decl)) {
|
|
727
688
|
const inner = decl.declaration;
|
|
728
|
-
if (inner && (t$
|
|
689
|
+
if (inner && (t$4.isStatement(inner) || t$4.isFunctionDeclaration(inner))) visitDecl(inner);
|
|
729
690
|
}
|
|
730
691
|
}
|
|
731
692
|
return best;
|
|
@@ -733,51 +694,40 @@ function findEnclosingComponent(ast, target) {
|
|
|
733
694
|
function componentDestructuresProp(fn, propName) {
|
|
734
695
|
if (fn.params.length === 0) return false;
|
|
735
696
|
let first = fn.params[0];
|
|
736
|
-
if (t$
|
|
737
|
-
if (!t$
|
|
697
|
+
if (t$4.isAssignmentPattern(first)) first = first.left;
|
|
698
|
+
if (!t$4.isObjectPattern(first)) return false;
|
|
738
699
|
for (const prop of first.properties) {
|
|
739
|
-
if (!t$
|
|
740
|
-
if (t$
|
|
741
|
-
if (t$
|
|
700
|
+
if (!t$4.isObjectProperty(prop)) continue;
|
|
701
|
+
if (t$4.isIdentifier(prop.key) && prop.key.name === propName) return true;
|
|
702
|
+
if (t$4.isStringLiteral(prop.key) && prop.key.value === propName) return true;
|
|
742
703
|
}
|
|
743
704
|
return false;
|
|
744
705
|
}
|
|
745
706
|
function collectCallSiteCandidates(ast, componentName) {
|
|
746
707
|
const out = [];
|
|
747
708
|
walkJsx(ast, (n) => {
|
|
748
|
-
if (!t$
|
|
709
|
+
if (!t$4.isJSXElement(n)) return;
|
|
749
710
|
const elName = n.openingElement.name;
|
|
750
|
-
if (t$
|
|
711
|
+
if (t$4.isJSXIdentifier(elName) && elName.name === componentName) collectTextCandidates(n, out);
|
|
751
712
|
});
|
|
752
713
|
return out;
|
|
753
714
|
}
|
|
754
|
-
function formatJsxAttrValue(value) {
|
|
755
|
-
if (/^[^"\\<>&{}\n\r]*$/.test(value)) return `"${value}"`;
|
|
756
|
-
return `{${jsString$1(value)}}`;
|
|
757
|
-
}
|
|
758
|
-
function spliceRange(node, text) {
|
|
759
|
-
return {
|
|
760
|
-
from: node.start ?? 0,
|
|
761
|
-
to: node.end ?? 0,
|
|
762
|
-
text
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
715
|
function collectPropCallSiteCandidates(ast, componentName, propName) {
|
|
766
716
|
const out = [];
|
|
767
717
|
walkJsx(ast, (n) => {
|
|
768
|
-
if (!t$
|
|
718
|
+
if (!t$4.isJSXElement(n)) return;
|
|
769
719
|
const elName = n.openingElement.name;
|
|
770
|
-
if (!t$
|
|
720
|
+
if (!t$4.isJSXIdentifier(elName) || elName.name !== componentName) return;
|
|
771
721
|
const attr = findJsxAttr(n.openingElement, propName);
|
|
772
722
|
if (!attr?.value) return;
|
|
773
723
|
const v = attr.value;
|
|
774
|
-
if (t$
|
|
724
|
+
if (t$4.isStringLiteral(v)) out.push({
|
|
775
725
|
current: v.value,
|
|
776
726
|
splice: (s) => spliceRange(v, formatJsxAttrValue(s))
|
|
777
727
|
});
|
|
778
|
-
else if (t$
|
|
728
|
+
else if (t$4.isJSXExpressionContainer(v)) {
|
|
779
729
|
const expr = v.expression;
|
|
780
|
-
if (t$
|
|
730
|
+
if (t$4.isStringLiteral(expr) || t$4.isNumericLiteral(expr)) out.push({
|
|
781
731
|
current: String(expr.value),
|
|
782
732
|
splice: (s) => spliceRange(v, formatJsxAttrValue(s))
|
|
783
733
|
});
|
|
@@ -790,17 +740,17 @@ function findEnclosingMapCallback(ast, target) {
|
|
|
790
740
|
const targetStart = target.start ?? 0;
|
|
791
741
|
const targetEnd = target.end ?? 0;
|
|
792
742
|
walkAll(ast, (node) => {
|
|
793
|
-
if (!t$
|
|
743
|
+
if (!t$4.isCallExpression(node)) return;
|
|
794
744
|
const callee = node.callee;
|
|
795
|
-
if (!t$
|
|
796
|
-
if (!t$
|
|
745
|
+
if (!t$4.isMemberExpression(callee) || callee.computed) return;
|
|
746
|
+
if (!t$4.isIdentifier(callee.property)) return;
|
|
797
747
|
if (callee.property.name !== "map" && callee.property.name !== "flatMap") return;
|
|
798
748
|
const fn = node.arguments[0];
|
|
799
|
-
if (!fn || !t$
|
|
749
|
+
if (!fn || !t$4.isArrowFunctionExpression(fn) && !t$4.isFunctionExpression(fn)) return;
|
|
800
750
|
const fnStart = fn.start ?? 0;
|
|
801
751
|
const fnEnd = fn.end ?? 0;
|
|
802
752
|
if (fnStart > targetStart || fnEnd < targetEnd) return;
|
|
803
|
-
if (!t$
|
|
753
|
+
if (!t$4.isExpression(callee.object)) return;
|
|
804
754
|
const size = fnEnd - fnStart;
|
|
805
755
|
if (!best || size < best.size) best = {
|
|
806
756
|
fn,
|
|
@@ -817,26 +767,26 @@ function findEnclosingMapCallback(ast, target) {
|
|
|
817
767
|
}
|
|
818
768
|
function resolveArrayLiteralElements(ast, expr) {
|
|
819
769
|
const dropHoles = (arr) => arr.elements.filter((e) => e != null);
|
|
820
|
-
if (t$
|
|
821
|
-
if (!t$
|
|
770
|
+
if (t$4.isArrayExpression(expr)) return dropHoles(expr);
|
|
771
|
+
if (!t$4.isIdentifier(expr)) return null;
|
|
822
772
|
const name = expr.name;
|
|
823
773
|
const useStart = expr.start ?? 0;
|
|
824
774
|
let init = null;
|
|
825
775
|
walkAll(ast, (node) => {
|
|
826
|
-
if (!t$
|
|
827
|
-
if (!t$
|
|
828
|
-
if (!node.init || !t$
|
|
776
|
+
if (!t$4.isVariableDeclarator(node)) return;
|
|
777
|
+
if (!t$4.isIdentifier(node.id) || node.id.name !== name) return;
|
|
778
|
+
if (!node.init || !t$4.isArrayExpression(node.init)) return;
|
|
829
779
|
if ((node.init.start ?? 0) > useStart) return;
|
|
830
780
|
init = node.init;
|
|
831
781
|
});
|
|
832
782
|
return init ? dropHoles(init) : null;
|
|
833
783
|
}
|
|
834
784
|
function findObjectProperty(obj, name) {
|
|
835
|
-
if (!t$
|
|
785
|
+
if (!t$4.isObjectExpression(obj)) return null;
|
|
836
786
|
for (const prop of obj.properties) {
|
|
837
|
-
if (!t$
|
|
838
|
-
if (t$
|
|
839
|
-
if (t$
|
|
787
|
+
if (!t$4.isObjectProperty(prop) || prop.computed) continue;
|
|
788
|
+
if (t$4.isIdentifier(prop.key) && prop.key.name === name) return prop;
|
|
789
|
+
if (t$4.isStringLiteral(prop.key) && prop.key.value === name) return prop;
|
|
840
790
|
}
|
|
841
791
|
return null;
|
|
842
792
|
}
|
|
@@ -844,22 +794,22 @@ function decodeMapPassthrough(element, callbackParam) {
|
|
|
844
794
|
const meaningful = meaningfulChildren(element);
|
|
845
795
|
if (meaningful.length !== 1) return null;
|
|
846
796
|
const child = meaningful[0];
|
|
847
|
-
if (!t$
|
|
797
|
+
if (!t$4.isJSXExpressionContainer(child)) return null;
|
|
848
798
|
const expr = child.expression;
|
|
849
|
-
if (t$
|
|
799
|
+
if (t$4.isMemberExpression(expr)) {
|
|
850
800
|
if (expr.computed) return null;
|
|
851
|
-
if (!t$
|
|
852
|
-
if (!callbackParam || !t$
|
|
801
|
+
if (!t$4.isIdentifier(expr.object) || !t$4.isIdentifier(expr.property)) return null;
|
|
802
|
+
if (!callbackParam || !t$4.isIdentifier(callbackParam)) return null;
|
|
853
803
|
if (callbackParam.name !== expr.object.name) return null;
|
|
854
804
|
return expr.property.name;
|
|
855
805
|
}
|
|
856
|
-
if (t$
|
|
806
|
+
if (t$4.isIdentifier(expr)) {
|
|
857
807
|
const fieldName = expr.name;
|
|
858
|
-
if (!callbackParam || !t$
|
|
808
|
+
if (!callbackParam || !t$4.isObjectPattern(callbackParam)) return null;
|
|
859
809
|
for (const prop of callbackParam.properties) {
|
|
860
|
-
if (!t$
|
|
861
|
-
if (!t$
|
|
862
|
-
return t$
|
|
810
|
+
if (!t$4.isObjectProperty(prop) || prop.computed) continue;
|
|
811
|
+
if (!t$4.isIdentifier(prop.key) || prop.key.name !== fieldName) continue;
|
|
812
|
+
return t$4.isIdentifier(prop.value) && prop.value.name === fieldName ? fieldName : null;
|
|
863
813
|
}
|
|
864
814
|
}
|
|
865
815
|
return null;
|
|
@@ -876,11 +826,11 @@ function collectArrayMapCandidates(ast, element) {
|
|
|
876
826
|
const prop = findObjectProperty(obj, fieldName);
|
|
877
827
|
if (!prop) continue;
|
|
878
828
|
const v = prop.value;
|
|
879
|
-
if (t$
|
|
829
|
+
if (t$4.isStringLiteral(v)) out.push({
|
|
880
830
|
current: v.value,
|
|
881
831
|
splice: (s) => spliceRange(v, jsString$1(s))
|
|
882
832
|
});
|
|
883
|
-
else if (t$
|
|
833
|
+
else if (t$4.isNumericLiteral(v)) out.push({
|
|
884
834
|
current: String(v.value),
|
|
885
835
|
splice: (s) => spliceRange(v, jsString$1(s))
|
|
886
836
|
});
|
|
@@ -914,68 +864,24 @@ function buildTextSplice(ast, element, value, prevText) {
|
|
|
914
864
|
if (matches.length > 1) return { error: "multiple text candidates share the same value; cannot disambiguate" };
|
|
915
865
|
return matches[0].splice(value);
|
|
916
866
|
}
|
|
917
|
-
function
|
|
918
|
-
const
|
|
919
|
-
for (const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const names = new Set();
|
|
936
|
-
for (const imp of findImports$1(ast)) {
|
|
937
|
-
if (imp.defaultIdent) names.add(imp.defaultIdent);
|
|
938
|
-
for (const spec of imp.node.specifiers) if (!t$2.isImportDefaultSpecifier(spec)) names.add(spec.local.name);
|
|
939
|
-
}
|
|
940
|
-
return names;
|
|
941
|
-
}
|
|
942
|
-
function safeAssetIdentifier(filename, taken) {
|
|
943
|
-
const stem = filename.replace(/\.[^.]+$/, "");
|
|
944
|
-
let camel = "";
|
|
945
|
-
let upper = false;
|
|
946
|
-
for (const ch of stem) if (/[A-Za-z0-9]/.test(ch)) {
|
|
947
|
-
camel += upper ? ch.toUpperCase() : ch;
|
|
948
|
-
upper = false;
|
|
949
|
-
} else upper = camel.length > 0;
|
|
950
|
-
let base = camel;
|
|
951
|
-
if (!base || !/^[A-Za-z_$]/.test(base)) base = `asset${base.charAt(0).toUpperCase()}${base.slice(1)}` || "asset";
|
|
952
|
-
base = base.charAt(0).toLowerCase() + base.slice(1);
|
|
953
|
-
let candidate = base;
|
|
954
|
-
let i = 2;
|
|
955
|
-
while (taken.has(candidate)) {
|
|
956
|
-
candidate = `${base}${i}`;
|
|
957
|
-
i += 1;
|
|
958
|
-
}
|
|
959
|
-
return candidate;
|
|
960
|
-
}
|
|
961
|
-
function planAssetImport(ast, assetPath) {
|
|
962
|
-
const imports = findImports$1(ast);
|
|
963
|
-
for (const imp of imports) if (imp.source === assetPath && imp.defaultIdent) return {
|
|
964
|
-
identifier: imp.defaultIdent,
|
|
965
|
-
importSplice: null
|
|
966
|
-
};
|
|
967
|
-
const filename = assetPath.slice(assetPath.lastIndexOf("/") + 1);
|
|
968
|
-
const identifier = safeAssetIdentifier(filename, collectTopLevelIdentifiers(ast));
|
|
969
|
-
const importStmt = `import ${identifier} from '${assetPath.replace(/'/g, "\\'")}';\n`;
|
|
970
|
-
const last = imports[imports.length - 1];
|
|
971
|
-
const insertAt = last ? last.node.end ?? 0 : 0;
|
|
972
|
-
const prefix = last ? "\n" : "";
|
|
973
|
-
return {
|
|
974
|
-
identifier,
|
|
975
|
-
importSplice: {
|
|
976
|
-
from: insertAt,
|
|
977
|
-
to: insertAt,
|
|
978
|
-
text: prefix + importStmt
|
|
867
|
+
function planAssetImport(ast, assetPath) {
|
|
868
|
+
const imports = findImports$1(ast);
|
|
869
|
+
for (const imp of imports) if (imp.source === assetPath && imp.defaultIdent) return {
|
|
870
|
+
identifier: imp.defaultIdent,
|
|
871
|
+
importSplice: null
|
|
872
|
+
};
|
|
873
|
+
const filename = assetPath.slice(assetPath.lastIndexOf("/") + 1);
|
|
874
|
+
const identifier = safeAssetIdentifier(filename, collectTopLevelIdentifiers(ast));
|
|
875
|
+
const importStmt = `import ${identifier} from '${assetPath.replace(/'/g, "\\'")}';\n`;
|
|
876
|
+
const last = imports[imports.length - 1];
|
|
877
|
+
const insertAt = last ? last.node.end ?? 0 : 0;
|
|
878
|
+
const prefix = last ? "\n" : "";
|
|
879
|
+
return {
|
|
880
|
+
identifier,
|
|
881
|
+
importSplice: {
|
|
882
|
+
from: insertAt,
|
|
883
|
+
to: insertAt,
|
|
884
|
+
text: prefix + importStmt
|
|
979
885
|
}
|
|
980
886
|
};
|
|
981
887
|
}
|
|
@@ -1000,25 +906,9 @@ function planAssetAttr(ast, element, attr, assetPath) {
|
|
|
1000
906
|
attrSplice
|
|
1001
907
|
};
|
|
1002
908
|
}
|
|
1003
|
-
function readJsxStringAttr(opening, name) {
|
|
1004
|
-
const attr = findJsxAttr(opening, name);
|
|
1005
|
-
const v = attr?.value;
|
|
1006
|
-
if (!v) return null;
|
|
1007
|
-
if (t$2.isStringLiteral(v)) return v.value;
|
|
1008
|
-
if (t$2.isJSXExpressionContainer(v) && t$2.isStringLiteral(v.expression)) return v.expression.value;
|
|
1009
|
-
return null;
|
|
1010
|
-
}
|
|
1011
|
-
function readJsxNumberAttr(opening, name) {
|
|
1012
|
-
const attr = findJsxAttr(opening, name);
|
|
1013
|
-
const v = attr?.value;
|
|
1014
|
-
if (!v || !t$2.isJSXExpressionContainer(v)) return null;
|
|
1015
|
-
if (!t$2.isNumericLiteral(v.expression)) return null;
|
|
1016
|
-
const n = v.expression.value;
|
|
1017
|
-
return Number.isFinite(n) ? n : null;
|
|
1018
|
-
}
|
|
1019
909
|
function planReplacePlaceholder(ast, element, assetPath) {
|
|
1020
910
|
const opening = element.openingElement;
|
|
1021
|
-
if (!t$
|
|
911
|
+
if (!t$4.isJSXIdentifier(opening.name) || opening.name.name !== "ImagePlaceholder") return { error: "not a placeholder" };
|
|
1022
912
|
if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
|
|
1023
913
|
const hint = readJsxStringAttr(opening, "hint") ?? "";
|
|
1024
914
|
const width = readJsxNumberAttr(opening, "width");
|
|
@@ -1158,267 +1048,267 @@ function applyEdit(source, line, column, ops) {
|
|
|
1158
1048
|
source: next
|
|
1159
1049
|
};
|
|
1160
1050
|
}
|
|
1161
|
-
function commentsPlugin(opts) {
|
|
1162
|
-
const userCwd = opts.userCwd;
|
|
1163
|
-
const slidesDir = opts.slidesDir ?? "slides";
|
|
1164
|
-
return {
|
|
1165
|
-
name: "open-slide:comments",
|
|
1166
|
-
apply: "serve",
|
|
1167
|
-
configureServer(server) {
|
|
1168
|
-
server.middlewares.use("/__edit", async (req, res, next) => {
|
|
1169
|
-
const url = new URL(req.url ?? "/", "http://local");
|
|
1170
|
-
const method = req.method ?? "GET";
|
|
1171
|
-
if (method !== "POST") return next();
|
|
1172
|
-
try {
|
|
1173
|
-
if (url.pathname === "/") {
|
|
1174
|
-
const body = await readBody$3(req);
|
|
1175
|
-
const slideId = body.slideId ?? "";
|
|
1176
|
-
const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
|
|
1177
|
-
if (!file) return json$3(res, 400, { error: "invalid slideId" });
|
|
1178
|
-
if (!body.line || body.line < 1) return json$3(res, 400, { error: "invalid line" });
|
|
1179
|
-
if (!Array.isArray(body.ops)) return json$3(res, 400, { error: "missing ops" });
|
|
1180
|
-
let source;
|
|
1181
|
-
try {
|
|
1182
|
-
source = await fs.readFile(file, "utf8");
|
|
1183
|
-
} catch {
|
|
1184
|
-
return json$3(res, 404, { error: "slide not found" });
|
|
1185
|
-
}
|
|
1186
|
-
const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
|
|
1187
|
-
if (!result.ok) return json$3(res, result.status, { error: result.error });
|
|
1188
|
-
const changed = result.source !== source;
|
|
1189
|
-
if (changed) await fs.writeFile(file, result.source, "utf8");
|
|
1190
|
-
return json$3(res, 200, {
|
|
1191
|
-
ok: true,
|
|
1192
|
-
changed
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
|
-
if (url.pathname === "/batch") {
|
|
1196
|
-
const body = await readBody$3(req);
|
|
1197
|
-
const slideId = body.slideId ?? "";
|
|
1198
|
-
const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
|
|
1199
|
-
if (!file) return json$3(res, 400, { error: "invalid slideId" });
|
|
1200
|
-
if (!Array.isArray(body.edits)) return json$3(res, 400, { error: "missing edits" });
|
|
1201
|
-
let source;
|
|
1202
|
-
try {
|
|
1203
|
-
source = await fs.readFile(file, "utf8");
|
|
1204
|
-
} catch {
|
|
1205
|
-
return json$3(res, 404, { error: "slide not found" });
|
|
1206
|
-
}
|
|
1207
|
-
const original = source;
|
|
1208
|
-
const results = [];
|
|
1209
|
-
for (const edit of body.edits) {
|
|
1210
|
-
if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
|
|
1211
|
-
results.push({
|
|
1212
|
-
ok: false,
|
|
1213
|
-
error: "invalid edit"
|
|
1214
|
-
});
|
|
1215
|
-
continue;
|
|
1216
|
-
}
|
|
1217
|
-
const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
|
|
1218
|
-
if (r.ok) {
|
|
1219
|
-
source = r.source;
|
|
1220
|
-
results.push({ ok: true });
|
|
1221
|
-
} else results.push({
|
|
1222
|
-
ok: false,
|
|
1223
|
-
error: r.error
|
|
1224
|
-
});
|
|
1225
|
-
}
|
|
1226
|
-
const changed = source !== original;
|
|
1227
|
-
if (changed) await fs.writeFile(file, source, "utf8");
|
|
1228
|
-
return json$3(res, 200, {
|
|
1229
|
-
ok: true,
|
|
1230
|
-
changed,
|
|
1231
|
-
results
|
|
1232
|
-
});
|
|
1233
|
-
}
|
|
1234
|
-
return next();
|
|
1235
|
-
} catch (err) {
|
|
1236
|
-
json$3(res, 500, { error: String(err.message ?? err) });
|
|
1237
|
-
}
|
|
1238
|
-
});
|
|
1239
|
-
server.middlewares.use("/__comments", async (req, res, next) => {
|
|
1240
|
-
const url = new URL(req.url ?? "/", "http://local");
|
|
1241
|
-
const method = req.method ?? "GET";
|
|
1242
|
-
try {
|
|
1243
|
-
if (method === "GET" && url.pathname === "/") {
|
|
1244
|
-
const slideId = url.searchParams.get("slideId") ?? "";
|
|
1245
|
-
const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
|
|
1246
|
-
if (!file) return json$3(res, 400, { error: "invalid slideId" });
|
|
1247
|
-
let source;
|
|
1248
|
-
try {
|
|
1249
|
-
source = await fs.readFile(file, "utf8");
|
|
1250
|
-
} catch {
|
|
1251
|
-
return json$3(res, 404, { error: "slide not found" });
|
|
1252
|
-
}
|
|
1253
|
-
return json$3(res, 200, { comments: parseMarkers(source) });
|
|
1254
|
-
}
|
|
1255
|
-
if (method === "POST" && url.pathname === "/add") {
|
|
1256
|
-
const body = await readBody$3(req);
|
|
1257
|
-
const slideId = body.slideId ?? "";
|
|
1258
|
-
const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
|
|
1259
|
-
if (!file) return json$3(res, 400, { error: "invalid slideId" });
|
|
1260
|
-
if (!body.line || body.line < 1) return json$3(res, 400, { error: "invalid line" });
|
|
1261
|
-
if (!body.text || typeof body.text !== "string") return json$3(res, 400, { error: "missing text" });
|
|
1262
|
-
let source;
|
|
1263
|
-
try {
|
|
1264
|
-
source = await fs.readFile(file, "utf8");
|
|
1265
|
-
} catch {
|
|
1266
|
-
return json$3(res, 404, { error: "slide not found" });
|
|
1267
|
-
}
|
|
1268
|
-
const plan = findInsertion(source, body.line, body.column);
|
|
1269
|
-
if (!plan) return json$3(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
|
|
1270
|
-
const id = newId();
|
|
1271
|
-
const ts = new Date().toISOString();
|
|
1272
|
-
const payload = b64urlEncode(JSON.stringify({
|
|
1273
|
-
note: body.text,
|
|
1274
|
-
hint: body.hint
|
|
1275
|
-
}));
|
|
1276
|
-
const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
|
|
1277
|
-
const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
|
|
1278
|
-
await fs.writeFile(file, next$1, "utf8");
|
|
1279
|
-
const markerLine = offsetToLine(next$1, plan.offset + 1);
|
|
1280
|
-
return json$3(res, 200, {
|
|
1281
|
-
id,
|
|
1282
|
-
line: markerLine
|
|
1283
|
-
});
|
|
1284
|
-
}
|
|
1285
|
-
if (method === "DELETE" && url.pathname.startsWith("/")) {
|
|
1286
|
-
const id = url.pathname.slice(1);
|
|
1287
|
-
if (!/^c-[a-f0-9]+$/.test(id)) return json$3(res, 400, { error: "invalid id" });
|
|
1288
|
-
const slideId = url.searchParams.get("slideId") ?? "";
|
|
1289
|
-
const file = resolveSlidePath$2(userCwd, slidesDir, slideId);
|
|
1290
|
-
if (!file) return json$3(res, 400, { error: "invalid slideId" });
|
|
1291
|
-
let source;
|
|
1292
|
-
try {
|
|
1293
|
-
source = await fs.readFile(file, "utf8");
|
|
1294
|
-
} catch {
|
|
1295
|
-
return json$3(res, 404, { error: "slide not found" });
|
|
1296
|
-
}
|
|
1297
|
-
const lines = source.split("\n");
|
|
1298
|
-
const idRe = new RegExp(`\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`);
|
|
1299
|
-
const hit = lines.findIndex((l) => idRe.test(l));
|
|
1300
|
-
if (hit === -1) return json$3(res, 404, { error: "marker not found" });
|
|
1301
|
-
lines.splice(hit, 1);
|
|
1302
|
-
await fs.writeFile(file, lines.join("\n"), "utf8");
|
|
1303
|
-
return json$3(res, 200, { ok: true });
|
|
1304
|
-
}
|
|
1305
|
-
next();
|
|
1306
|
-
} catch (err) {
|
|
1307
|
-
json$3(res, 500, { error: String(err.message ?? err) });
|
|
1308
|
-
}
|
|
1309
|
-
});
|
|
1310
|
-
}
|
|
1311
|
-
};
|
|
1312
|
-
}
|
|
1313
1051
|
|
|
1314
1052
|
//#endregion
|
|
1315
|
-
//#region src/
|
|
1316
|
-
|
|
1317
|
-
const
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1053
|
+
//#region src/editing/revert-asset.ts
|
|
1054
|
+
function collectImgSrcUses(ast, identifier) {
|
|
1055
|
+
const uses = [];
|
|
1056
|
+
walkJsx(ast, (n) => {
|
|
1057
|
+
if (!t$3.isJSXElement(n)) return;
|
|
1058
|
+
const opening = n.openingElement;
|
|
1059
|
+
if (!t$3.isJSXIdentifier(opening.name) || opening.name.name !== "img") return;
|
|
1060
|
+
const src = findJsxAttr(opening, "src");
|
|
1061
|
+
if (!src?.value) return;
|
|
1062
|
+
if (!t$3.isJSXExpressionContainer(src.value)) return;
|
|
1063
|
+
const expr = src.value.expression;
|
|
1064
|
+
if (!t$3.isIdentifier(expr) || expr.name !== identifier) return;
|
|
1065
|
+
uses.push({
|
|
1066
|
+
element: n,
|
|
1067
|
+
identNode: expr
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
return uses;
|
|
1071
|
+
}
|
|
1072
|
+
function readStyleNumericDim(opening, key) {
|
|
1073
|
+
const style = findJsxAttr(opening, "style");
|
|
1074
|
+
if (!style?.value) return null;
|
|
1075
|
+
if (!t$3.isJSXExpressionContainer(style.value)) return null;
|
|
1076
|
+
const obj = style.value.expression;
|
|
1077
|
+
if (!t$3.isObjectExpression(obj)) return null;
|
|
1078
|
+
for (const prop of obj.properties) {
|
|
1079
|
+
if (!t$3.isObjectProperty(prop)) continue;
|
|
1080
|
+
if (prop.computed) continue;
|
|
1081
|
+
const k = prop.key;
|
|
1082
|
+
const keyName = t$3.isIdentifier(k) ? k.name : t$3.isStringLiteral(k) ? k.value : null;
|
|
1083
|
+
if (keyName !== key) continue;
|
|
1084
|
+
const v = prop.value;
|
|
1085
|
+
if (t$3.isNumericLiteral(v) && Number.isFinite(v.value)) return v.value;
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
function buildPlaceholderReplacement(hint, width, height) {
|
|
1091
|
+
const parts = [`hint=${formatJsxAttrValue(hint)}`];
|
|
1092
|
+
if (width != null) parts.push(`width={${width}}`);
|
|
1093
|
+
if (height != null) parts.push(`height={${height}}`);
|
|
1094
|
+
return `<ImagePlaceholder ${parts.join(" ")} />`;
|
|
1095
|
+
}
|
|
1096
|
+
function planEnsureImagePlaceholderImport(ast) {
|
|
1097
|
+
const readKind = (n) => n.importKind === "type";
|
|
1098
|
+
const imports = findImports$1(ast);
|
|
1099
|
+
let valueImport = null;
|
|
1100
|
+
for (const imp of imports) {
|
|
1101
|
+
if (imp.source !== "@open-slide/core") continue;
|
|
1102
|
+
const declIsTypeOnly = readKind(imp.node);
|
|
1103
|
+
for (const spec of imp.node.specifiers) {
|
|
1104
|
+
if (!t$3.isImportSpecifier(spec)) continue;
|
|
1105
|
+
const imported = spec.imported;
|
|
1106
|
+
const name = t$3.isIdentifier(imported) ? imported.name : imported.value;
|
|
1107
|
+
if (name !== "ImagePlaceholder") continue;
|
|
1108
|
+
const specIsTypeOnly = readKind(spec) || declIsTypeOnly;
|
|
1109
|
+
if (!specIsTypeOnly) return null;
|
|
1110
|
+
}
|
|
1111
|
+
if (!declIsTypeOnly && !valueImport) valueImport = imp;
|
|
1112
|
+
}
|
|
1113
|
+
if (valueImport) {
|
|
1114
|
+
const node = valueImport.node;
|
|
1115
|
+
const lastSpec = node.specifiers[node.specifiers.length - 1];
|
|
1116
|
+
if (lastSpec && t$3.isImportSpecifier(lastSpec)) {
|
|
1117
|
+
const insertAt$1 = lastSpec.end ?? 0;
|
|
1118
|
+
return {
|
|
1119
|
+
from: insertAt$1,
|
|
1120
|
+
to: insertAt$1,
|
|
1121
|
+
text: ", ImagePlaceholder"
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
if (lastSpec && t$3.isImportDefaultSpecifier(lastSpec)) {
|
|
1125
|
+
const insertAt$1 = lastSpec.end ?? 0;
|
|
1126
|
+
return {
|
|
1127
|
+
from: insertAt$1,
|
|
1128
|
+
to: insertAt$1,
|
|
1129
|
+
text: ", { ImagePlaceholder }"
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
const insertAt = (node.source.start ?? 0) - 5;
|
|
1133
|
+
return {
|
|
1134
|
+
from: insertAt,
|
|
1135
|
+
to: insertAt,
|
|
1136
|
+
text: "{ ImagePlaceholder } "
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1325
1139
|
return {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
text
|
|
1140
|
+
from: 0,
|
|
1141
|
+
to: 0,
|
|
1142
|
+
text: "import { ImagePlaceholder } from '@open-slide/core';\n"
|
|
1330
1143
|
};
|
|
1331
1144
|
}
|
|
1332
|
-
function
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1335
|
-
const
|
|
1336
|
-
const
|
|
1337
|
-
|
|
1338
|
-
|
|
1145
|
+
function findAssetUsages(source, assetPath) {
|
|
1146
|
+
const ast = parseSource$2(source);
|
|
1147
|
+
if (!ast) return 0;
|
|
1148
|
+
const imports = findImports$1(ast);
|
|
1149
|
+
const target = imports.find((imp) => imp.source === assetPath && imp.defaultIdent);
|
|
1150
|
+
if (!target?.defaultIdent) return 0;
|
|
1151
|
+
return collectImgSrcUses(ast, target.defaultIdent).length;
|
|
1152
|
+
}
|
|
1153
|
+
function applyRevertAsset(source, assetPath) {
|
|
1154
|
+
const ast = parseSource$2(source);
|
|
1155
|
+
if (!ast) return {
|
|
1156
|
+
ok: false,
|
|
1157
|
+
status: 422,
|
|
1158
|
+
error: "could not parse source"
|
|
1159
|
+
};
|
|
1160
|
+
const imports = findImports$1(ast);
|
|
1161
|
+
const target = imports.find((imp) => imp.source === assetPath && imp.defaultIdent);
|
|
1162
|
+
if (!target?.defaultIdent) return {
|
|
1163
|
+
ok: true,
|
|
1164
|
+
source
|
|
1165
|
+
};
|
|
1166
|
+
const identifier = target.defaultIdent;
|
|
1167
|
+
const importLocal = (() => {
|
|
1168
|
+
for (const spec of target.node.specifiers) if (t$3.isImportDefaultSpecifier(spec) && spec.local.name === identifier) return spec.local;
|
|
1169
|
+
return null;
|
|
1170
|
+
})();
|
|
1171
|
+
const imgUses = collectImgSrcUses(ast, identifier);
|
|
1172
|
+
const allowed = new Set(imgUses.map((u) => u.identNode));
|
|
1173
|
+
if (importLocal) allowed.add(importLocal);
|
|
1174
|
+
let foreign = false;
|
|
1175
|
+
walkAll(ast, (n) => {
|
|
1176
|
+
if (!t$3.isIdentifier(n) || n.name !== identifier) return;
|
|
1177
|
+
if (!allowed.has(n)) foreign = true;
|
|
1178
|
+
});
|
|
1179
|
+
if (foreign) return {
|
|
1180
|
+
ok: false,
|
|
1181
|
+
status: 422,
|
|
1182
|
+
error: `cannot revert: '${identifier}' is referenced outside <img src={${identifier}}>`
|
|
1183
|
+
};
|
|
1184
|
+
const splices = [];
|
|
1185
|
+
for (const use of imgUses) {
|
|
1186
|
+
const opening = use.element.openingElement;
|
|
1187
|
+
const hint = readJsxStringAttr(opening, "alt") ?? "";
|
|
1188
|
+
const width = readStyleNumericDim(opening, "width");
|
|
1189
|
+
const height = readStyleNumericDim(opening, "height");
|
|
1190
|
+
splices.push(spliceRange(use.element, buildPlaceholderReplacement(hint, width, height)));
|
|
1191
|
+
}
|
|
1192
|
+
const importNode = target.node;
|
|
1193
|
+
const importFrom = importNode.start ?? 0;
|
|
1194
|
+
let importTo = importNode.end ?? 0;
|
|
1195
|
+
if (source[importTo] === "\n") importTo += 1;
|
|
1196
|
+
splices.push({
|
|
1197
|
+
from: importFrom,
|
|
1198
|
+
to: importTo,
|
|
1199
|
+
text: ""
|
|
1200
|
+
});
|
|
1201
|
+
const ensureSplice = planEnsureImagePlaceholderImport(ast);
|
|
1202
|
+
if (ensureSplice) splices.push(ensureSplice);
|
|
1203
|
+
if (splices.length === 0) return {
|
|
1204
|
+
ok: true,
|
|
1205
|
+
source
|
|
1206
|
+
};
|
|
1207
|
+
splices.sort((a, b) => b.from - a.from);
|
|
1208
|
+
let next = source;
|
|
1209
|
+
for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
|
|
1210
|
+
if (!parseSource$2(next)) return {
|
|
1211
|
+
ok: false,
|
|
1212
|
+
status: 422,
|
|
1213
|
+
error: "edit would produce invalid source"
|
|
1214
|
+
};
|
|
1339
1215
|
return {
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
configureServer(server) {
|
|
1343
|
-
server.ws.on("open-slide:current", async (raw) => {
|
|
1344
|
-
const next = cached ? { ...cached } : {
|
|
1345
|
-
slideId: "",
|
|
1346
|
-
pageIndex: 0,
|
|
1347
|
-
pageNumber: 1,
|
|
1348
|
-
totalPages: 1,
|
|
1349
|
-
slideTitle: "",
|
|
1350
|
-
view: "slides",
|
|
1351
|
-
pagePath: "",
|
|
1352
|
-
selection: null
|
|
1353
|
-
};
|
|
1354
|
-
if (typeof raw?.slideId === "string") {
|
|
1355
|
-
if (!SLIDE_ID_RE$3.test(raw.slideId)) return;
|
|
1356
|
-
const totalPages = typeof raw.totalPages === "number" && Number.isFinite(raw.totalPages) && raw.totalPages > 0 ? Math.floor(raw.totalPages) : 1;
|
|
1357
|
-
const rawIndex = typeof raw.pageIndex === "number" && Number.isFinite(raw.pageIndex) ? Math.floor(raw.pageIndex) : 0;
|
|
1358
|
-
const pageIndex = Math.max(0, Math.min(totalPages - 1, rawIndex));
|
|
1359
|
-
const slideTitle = typeof raw.slideTitle === "string" ? raw.slideTitle : raw.slideId;
|
|
1360
|
-
const view = raw.view === "assets" ? "assets" : "slides";
|
|
1361
|
-
const pagePath = path.join(slidesDir, raw.slideId, "index.tsx").split(path.sep).join("/");
|
|
1362
|
-
if (cached?.slideId !== raw.slideId || cached?.pageIndex !== pageIndex) next.selection = null;
|
|
1363
|
-
next.slideId = raw.slideId;
|
|
1364
|
-
next.pageIndex = pageIndex;
|
|
1365
|
-
next.pageNumber = pageIndex + 1;
|
|
1366
|
-
next.totalPages = totalPages;
|
|
1367
|
-
next.slideTitle = slideTitle;
|
|
1368
|
-
next.view = view;
|
|
1369
|
-
next.pagePath = pagePath;
|
|
1370
|
-
}
|
|
1371
|
-
if ("selection" in raw) next.selection = parseSelection(raw.selection);
|
|
1372
|
-
if (!next.slideId) return;
|
|
1373
|
-
cached = next;
|
|
1374
|
-
const body = {
|
|
1375
|
-
...next,
|
|
1376
|
-
updatedAt: new Date().toISOString()
|
|
1377
|
-
};
|
|
1378
|
-
try {
|
|
1379
|
-
await fs.mkdir(outDir, { recursive: true });
|
|
1380
|
-
await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
1381
|
-
await fs.rename(tmpFile, outFile);
|
|
1382
|
-
} catch {}
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1216
|
+
ok: true,
|
|
1217
|
+
source: next
|
|
1385
1218
|
};
|
|
1386
1219
|
}
|
|
1387
1220
|
|
|
1388
1221
|
//#endregion
|
|
1389
|
-
//#region src/
|
|
1390
|
-
const SLIDE_ID_RE$
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1397
|
-
if (!raw) return resolve({});
|
|
1398
|
-
try {
|
|
1399
|
-
resolve(JSON.parse(raw));
|
|
1400
|
-
} catch (e) {
|
|
1401
|
-
reject(e);
|
|
1402
|
-
}
|
|
1403
|
-
});
|
|
1404
|
-
req.on("error", reject);
|
|
1405
|
-
});
|
|
1222
|
+
//#region src/editing/slide-ops.ts
|
|
1223
|
+
const SLIDE_ID_RE$3 = /^[a-z0-9_-]+$/i;
|
|
1224
|
+
function validateSlideName(v) {
|
|
1225
|
+
if (typeof v !== "string") return null;
|
|
1226
|
+
const trimmed = v.trim();
|
|
1227
|
+
if (trimmed.length < 1 || trimmed.length > 80) return null;
|
|
1228
|
+
return trimmed;
|
|
1406
1229
|
}
|
|
1407
|
-
function
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1230
|
+
async function rmSlideDir(slidesRoot, slideId) {
|
|
1231
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return false;
|
|
1232
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
1233
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return false;
|
|
1234
|
+
try {
|
|
1235
|
+
await fs.rm(dir, {
|
|
1236
|
+
recursive: true,
|
|
1237
|
+
force: true
|
|
1238
|
+
});
|
|
1239
|
+
return true;
|
|
1240
|
+
} catch {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1411
1243
|
}
|
|
1412
|
-
function
|
|
1413
|
-
if (!SLIDE_ID_RE$
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
return full;
|
|
1244
|
+
function resolveSlideEntry(slidesRoot, slideId) {
|
|
1245
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return null;
|
|
1246
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
1247
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return null;
|
|
1248
|
+
return path.join(dir, "index.tsx");
|
|
1418
1249
|
}
|
|
1419
|
-
function
|
|
1250
|
+
function escapeSingleQuoted(s) {
|
|
1251
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Rewrite (or insert) the `title` field in the slide module's `export const meta`.
|
|
1255
|
+
*
|
|
1256
|
+
* Strategy:
|
|
1257
|
+
* 1. Find `export const meta` and brace-match its object literal.
|
|
1258
|
+
* 2. If the object already has a `title: '...'` entry, replace the literal.
|
|
1259
|
+
* 3. If the object exists but has no title, inject a new `title: '...'` line
|
|
1260
|
+
* as the first property (preserving the author's surrounding indentation).
|
|
1261
|
+
* 4. If there is no `meta` export at all, insert a fresh one right before
|
|
1262
|
+
* `export default`.
|
|
1263
|
+
*
|
|
1264
|
+
* Returns the rewritten source, or `null` if the file shape was too surprising
|
|
1265
|
+
* to touch safely (e.g. `export default` missing when we'd need to inject meta).
|
|
1266
|
+
*/
|
|
1267
|
+
function updateMetaTitleInSource(source, title) {
|
|
1268
|
+
const newLiteral = `'${escapeSingleQuoted(title)}'`;
|
|
1269
|
+
const metaStart = source.search(/export\s+const\s+meta\b/);
|
|
1270
|
+
if (metaStart !== -1) {
|
|
1271
|
+
const eqIdx = source.indexOf("=", metaStart);
|
|
1272
|
+
if (eqIdx === -1) return null;
|
|
1273
|
+
const openBrace = source.indexOf("{", eqIdx);
|
|
1274
|
+
if (openBrace === -1) return null;
|
|
1275
|
+
let depth = 0;
|
|
1276
|
+
let closeBrace = -1;
|
|
1277
|
+
for (let i = openBrace; i < source.length; i++) {
|
|
1278
|
+
const ch = source[i];
|
|
1279
|
+
if (ch === "{") depth++;
|
|
1280
|
+
else if (ch === "}") {
|
|
1281
|
+
depth--;
|
|
1282
|
+
if (depth === 0) {
|
|
1283
|
+
closeBrace = i;
|
|
1284
|
+
break;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
if (closeBrace === -1) return null;
|
|
1289
|
+
const body = source.slice(openBrace + 1, closeBrace);
|
|
1290
|
+
const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
|
|
1291
|
+
const match = body.match(titleRe);
|
|
1292
|
+
if (match) {
|
|
1293
|
+
const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
|
|
1294
|
+
return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
|
|
1295
|
+
}
|
|
1296
|
+
const firstIndentMatch = body.match(/\n([ \t]+)\S/);
|
|
1297
|
+
const indent$1 = firstIndentMatch ? firstIndentMatch[1] : " ";
|
|
1298
|
+
const trimmedBody = body.replace(/^\s*\n?/, "");
|
|
1299
|
+
const needsSeparator = trimmedBody.trim().length > 0;
|
|
1300
|
+
const insertion$1 = `\n${indent$1}title: ${newLiteral}${needsSeparator ? "," : ""}`;
|
|
1301
|
+
return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
|
|
1302
|
+
}
|
|
1303
|
+
const exportDefaultIdx = source.search(/export\s+default\b/);
|
|
1304
|
+
if (exportDefaultIdx === -1) return null;
|
|
1305
|
+
const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
|
|
1306
|
+
return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
|
|
1307
|
+
}
|
|
1308
|
+
function findDefaultExportArray(source) {
|
|
1309
|
+
let ast;
|
|
1420
1310
|
try {
|
|
1421
|
-
|
|
1311
|
+
ast = parse(source, {
|
|
1422
1312
|
sourceType: "module",
|
|
1423
1313
|
plugins: ["typescript", "jsx"],
|
|
1424
1314
|
errorRecovery: true
|
|
@@ -1426,439 +1316,386 @@ function parseSource$1(source) {
|
|
|
1426
1316
|
} catch {
|
|
1427
1317
|
return null;
|
|
1428
1318
|
}
|
|
1429
|
-
}
|
|
1430
|
-
function findDesignDecl(ast) {
|
|
1431
1319
|
const body = ast.program?.body ?? [];
|
|
1432
1320
|
for (const node of body) {
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1321
|
+
if (node.type !== "ExportDefaultDeclaration") continue;
|
|
1322
|
+
let inner = node.declaration;
|
|
1323
|
+
while (inner && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression")) inner = inner.expression;
|
|
1324
|
+
if (!inner || inner.type !== "ArrayExpression") return null;
|
|
1325
|
+
const arrayStart = inner.start;
|
|
1326
|
+
const arrayEnd = inner.end;
|
|
1327
|
+
const rawElements = inner.elements ?? [];
|
|
1328
|
+
const elements = [];
|
|
1329
|
+
for (const el of rawElements) {
|
|
1330
|
+
if (!el || typeof el.start !== "number" || typeof el.end !== "number") return null;
|
|
1331
|
+
elements.push({
|
|
1332
|
+
start: el.start,
|
|
1333
|
+
end: el.end
|
|
1334
|
+
});
|
|
1438
1335
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1336
|
+
return {
|
|
1337
|
+
elements,
|
|
1338
|
+
arrayStart,
|
|
1339
|
+
arrayEnd
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Rewrite `export default [...]` so its elements appear in the requested order.
|
|
1346
|
+
*
|
|
1347
|
+
* `order[i]` is the original index that should land at new position `i`. The
|
|
1348
|
+
* function preserves each element's exact source slice (including any inline
|
|
1349
|
+
* comments that hug an identifier) and keeps the inter-element separator slots
|
|
1350
|
+
* in their original positions, so a 3-page array `[A, B, C]` reordered to
|
|
1351
|
+
* `[2, 0, 1]` becomes `[C, A, B]` with the same indentation and trailing
|
|
1352
|
+
* commas the author wrote.
|
|
1353
|
+
*
|
|
1354
|
+
* Returns `null` when the file's default export isn't an array literal, or the
|
|
1355
|
+
* order is not a valid permutation of `[0, n-1]`.
|
|
1356
|
+
*/
|
|
1357
|
+
function reorderDefaultExportPagesInSource(source, order) {
|
|
1358
|
+
const found = findDefaultExportArray(source);
|
|
1359
|
+
if (!found) return null;
|
|
1360
|
+
const { elements, arrayStart, arrayEnd } = found;
|
|
1361
|
+
const n = elements.length;
|
|
1362
|
+
if (order.length !== n) return null;
|
|
1363
|
+
const seen = new Set();
|
|
1364
|
+
for (const idx of order) {
|
|
1365
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= n) return null;
|
|
1366
|
+
if (seen.has(idx)) return null;
|
|
1367
|
+
seen.add(idx);
|
|
1368
|
+
}
|
|
1369
|
+
if (n === 0) return source;
|
|
1370
|
+
let identity = true;
|
|
1371
|
+
for (let i = 0; i < n; i++) if (order[i] !== i) {
|
|
1372
|
+
identity = false;
|
|
1373
|
+
break;
|
|
1374
|
+
}
|
|
1375
|
+
if (identity) return source;
|
|
1376
|
+
const prefix = source.slice(arrayStart, elements[0].start);
|
|
1377
|
+
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
1378
|
+
const separators = [];
|
|
1379
|
+
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
1380
|
+
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
1381
|
+
let rebuilt = prefix + elementText[order[0]];
|
|
1382
|
+
for (let i = 1; i < n; i++) rebuilt += separators[i - 1] + elementText[order[i]];
|
|
1383
|
+
rebuilt += suffix;
|
|
1384
|
+
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
1385
|
+
}
|
|
1386
|
+
function findNotesArray(source) {
|
|
1387
|
+
let ast;
|
|
1388
|
+
try {
|
|
1389
|
+
ast = parse(source, {
|
|
1390
|
+
sourceType: "module",
|
|
1391
|
+
plugins: ["typescript", "jsx"],
|
|
1392
|
+
errorRecovery: true
|
|
1393
|
+
});
|
|
1394
|
+
} catch {
|
|
1395
|
+
return "invalid";
|
|
1396
|
+
}
|
|
1397
|
+
const body = ast.program?.body ?? [];
|
|
1398
|
+
for (const stmt of body) {
|
|
1399
|
+
if (stmt.type !== "ExportNamedDeclaration") continue;
|
|
1400
|
+
const decl = stmt.declaration;
|
|
1401
|
+
if (!decl || decl.type !== "VariableDeclaration") continue;
|
|
1402
|
+
const declarations = decl.declarations ?? [];
|
|
1441
1403
|
for (const d of declarations) {
|
|
1442
1404
|
const id = d.id;
|
|
1443
|
-
if (!id || id.type !== "Identifier" || id.name !== "
|
|
1405
|
+
if (!id || id.type !== "Identifier" || id.name !== "notes") continue;
|
|
1444
1406
|
const init = d.init;
|
|
1445
|
-
if (!init) return
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1407
|
+
if (!init || init.type !== "ArrayExpression") return "invalid";
|
|
1408
|
+
const arrayStart = init.start;
|
|
1409
|
+
const arrayEnd = init.end;
|
|
1410
|
+
if (typeof arrayStart !== "number" || typeof arrayEnd !== "number") return "invalid";
|
|
1411
|
+
const rawElements = init.elements ?? [];
|
|
1412
|
+
const elementTexts = [];
|
|
1413
|
+
for (const el of rawElements) {
|
|
1414
|
+
if (el === null) {
|
|
1415
|
+
elementTexts.push("undefined");
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
if (el.type === "SpreadElement") return "invalid";
|
|
1419
|
+
const start = el.start;
|
|
1420
|
+
const end = el.end;
|
|
1421
|
+
if (typeof start !== "number" || typeof end !== "number") return "invalid";
|
|
1422
|
+
elementTexts.push(source.slice(start, end));
|
|
1450
1423
|
}
|
|
1451
|
-
if (inner.type !== "ObjectExpression") return null;
|
|
1452
1424
|
return {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
objectEnd: inner.end
|
|
1425
|
+
arrayStart,
|
|
1426
|
+
arrayEnd,
|
|
1427
|
+
elementTexts
|
|
1457
1428
|
};
|
|
1458
1429
|
}
|
|
1459
1430
|
}
|
|
1460
1431
|
return null;
|
|
1461
1432
|
}
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1433
|
+
/**
|
|
1434
|
+
* Reorder `export const notes = [...]` to follow the page-array reorder.
|
|
1435
|
+
*
|
|
1436
|
+
* `order[i]` is the original page index that should land at new position `i`.
|
|
1437
|
+
* The notes array is index-aligned with the pages array but may be shorter
|
|
1438
|
+
* (trailing `undefined` slots are routinely trimmed). Missing elements are
|
|
1439
|
+
* treated as `undefined`, and trailing `undefined` is trimmed again after
|
|
1440
|
+
* reordering to keep the file tidy.
|
|
1441
|
+
*
|
|
1442
|
+
* Returns the rewritten source, the original source if no `notes` export
|
|
1443
|
+
* exists or the reorder is a no-op, or `null` if the `notes` export's shape
|
|
1444
|
+
* is too surprising to touch safely.
|
|
1445
|
+
*/
|
|
1446
|
+
function reorderNotesArrayInSource(source, order) {
|
|
1447
|
+
for (const idx of order) if (!Number.isInteger(idx) || idx < 0) return null;
|
|
1448
|
+
const found = findNotesArray(source);
|
|
1449
|
+
if (found === "invalid") return null;
|
|
1450
|
+
if (found === null) return source;
|
|
1451
|
+
const { arrayStart, arrayEnd, elementTexts } = found;
|
|
1452
|
+
const pick = (i) => i >= 0 && i < elementTexts.length ? elementTexts[i] : "undefined";
|
|
1453
|
+
const reordered = order.map(pick);
|
|
1454
|
+
while (reordered.length > 0 && reordered[reordered.length - 1] === "undefined") reordered.pop();
|
|
1455
|
+
const replacement = reordered.length === 0 ? "[]" : `[\n${reordered.map((s) => ` ${s},`).join("\n")}\n]`;
|
|
1456
|
+
if (replacement === source.slice(arrayStart, arrayEnd)) return source;
|
|
1457
|
+
return source.slice(0, arrayStart) + replacement + source.slice(arrayEnd);
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Remove the element at `index` from `export default [...]`.
|
|
1461
|
+
*
|
|
1462
|
+
* Preserves the source slice of every other element, dropping the separator
|
|
1463
|
+
* immediately following the removed element (or the preceding one when the
|
|
1464
|
+
* removed element is the last). Returns `null` when the default export isn't
|
|
1465
|
+
* an array literal or `index` is out of range.
|
|
1466
|
+
*/
|
|
1467
|
+
function removePageFromDefaultExportInSource(source, index) {
|
|
1468
|
+
const found = findDefaultExportArray(source);
|
|
1469
|
+
if (!found) return null;
|
|
1470
|
+
const { elements, arrayStart, arrayEnd } = found;
|
|
1471
|
+
const n = elements.length;
|
|
1472
|
+
if (!Number.isInteger(index) || index < 0 || index >= n) return null;
|
|
1473
|
+
if (n === 1) return `${source.slice(0, arrayStart)}[]${source.slice(arrayEnd)}`;
|
|
1474
|
+
const prefix = source.slice(arrayStart, elements[0].start);
|
|
1475
|
+
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
1476
|
+
const separators = [];
|
|
1477
|
+
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
1478
|
+
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
1479
|
+
const keptElements = [];
|
|
1480
|
+
const keptSeparators = [];
|
|
1481
|
+
for (let i = 0; i < n; i++) {
|
|
1482
|
+
if (i === index) continue;
|
|
1483
|
+
keptElements.push(elementText[i]);
|
|
1484
|
+
}
|
|
1485
|
+
for (let i = 0; i < n - 1; i++) {
|
|
1486
|
+
if (index === n - 1 ? i === n - 2 : i === index) continue;
|
|
1487
|
+
keptSeparators.push(separators[i]);
|
|
1488
|
+
}
|
|
1489
|
+
let rebuilt = prefix + keptElements[0];
|
|
1490
|
+
for (let i = 1; i < keptElements.length; i++) rebuilt += keptSeparators[i - 1] + keptElements[i];
|
|
1491
|
+
rebuilt += suffix;
|
|
1492
|
+
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
1493
|
+
}
|
|
1494
|
+
function chooseInsertSeparator(prefix, existingSeparators) {
|
|
1495
|
+
const sample = existingSeparators.find((s) => s.includes(","));
|
|
1496
|
+
if (sample) return sample;
|
|
1497
|
+
if (prefix.includes("\n")) {
|
|
1498
|
+
const m = prefix.match(/\n([ \t]*)$/);
|
|
1499
|
+
const indent$1 = m ? m[1] : " ";
|
|
1500
|
+
return `,\n${indent$1}`;
|
|
1501
|
+
}
|
|
1502
|
+
return ", ";
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Duplicate the element at `index` in `export default [...]`, inserting the
|
|
1506
|
+
* copy immediately after the original. Reuses an existing inter-element
|
|
1507
|
+
* separator when one is available so the cloned entry matches the surrounding
|
|
1508
|
+
* indentation. Returns `null` when the default export isn't an array literal
|
|
1509
|
+
* or `index` is out of range.
|
|
1510
|
+
*/
|
|
1511
|
+
function duplicatePageInDefaultExportInSource(source, index) {
|
|
1512
|
+
const found = findDefaultExportArray(source);
|
|
1513
|
+
if (!found) return null;
|
|
1514
|
+
const { elements, arrayStart, arrayEnd } = found;
|
|
1515
|
+
const n = elements.length;
|
|
1516
|
+
if (!Number.isInteger(index) || index < 0 || index >= n) return null;
|
|
1517
|
+
const prefix = source.slice(arrayStart, elements[0].start);
|
|
1518
|
+
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
1519
|
+
const separators = [];
|
|
1520
|
+
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
1521
|
+
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
1522
|
+
const insertSep = chooseInsertSeparator(prefix, separators);
|
|
1523
|
+
const newElements = [];
|
|
1524
|
+
const newSeparators = [];
|
|
1525
|
+
for (let i = 0; i < n; i++) {
|
|
1526
|
+
newElements.push(elementText[i]);
|
|
1527
|
+
if (i === index) {
|
|
1528
|
+
newElements.push(elementText[i]);
|
|
1529
|
+
newSeparators.push(insertSep);
|
|
1503
1530
|
}
|
|
1504
|
-
|
|
1531
|
+
if (i < n - 1) newSeparators.push(separators[i]);
|
|
1505
1532
|
}
|
|
1533
|
+
let rebuilt = prefix + newElements[0];
|
|
1534
|
+
for (let i = 1; i < newElements.length; i++) rebuilt += newSeparators[i - 1] + newElements[i];
|
|
1535
|
+
rebuilt += suffix;
|
|
1536
|
+
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
1506
1537
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1538
|
+
|
|
1539
|
+
//#endregion
|
|
1540
|
+
//#region src/files/assets.ts
|
|
1541
|
+
const GLOBAL_SCOPE = "@global";
|
|
1542
|
+
const ASSET_MAX_BYTES = 25 * 1024 * 1024;
|
|
1543
|
+
const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
|
|
1544
|
+
const MIME_BY_EXT = {
|
|
1545
|
+
png: "image/png",
|
|
1546
|
+
jpg: "image/jpeg",
|
|
1547
|
+
jpeg: "image/jpeg",
|
|
1548
|
+
gif: "image/gif",
|
|
1549
|
+
svg: "image/svg+xml",
|
|
1550
|
+
webp: "image/webp",
|
|
1551
|
+
avif: "image/avif",
|
|
1552
|
+
ico: "image/x-icon",
|
|
1553
|
+
mp4: "video/mp4",
|
|
1554
|
+
webm: "video/webm",
|
|
1555
|
+
mov: "video/quicktime",
|
|
1556
|
+
woff: "font/woff",
|
|
1557
|
+
woff2: "font/woff2",
|
|
1558
|
+
ttf: "font/ttf",
|
|
1559
|
+
otf: "font/otf",
|
|
1560
|
+
json: "application/json",
|
|
1561
|
+
txt: "text/plain; charset=utf-8",
|
|
1562
|
+
md: "text/markdown; charset=utf-8"
|
|
1563
|
+
};
|
|
1564
|
+
function mimeForFilename(name) {
|
|
1565
|
+
const dot = name.lastIndexOf(".");
|
|
1566
|
+
if (dot < 0) return "application/octet-stream";
|
|
1567
|
+
const ext = name.slice(dot + 1).toLowerCase();
|
|
1568
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
1509
1569
|
}
|
|
1510
|
-
function
|
|
1511
|
-
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
if (
|
|
1517
|
-
|
|
1570
|
+
function validateAssetName(v) {
|
|
1571
|
+
if (typeof v !== "string") return null;
|
|
1572
|
+
const trimmed = v.trim();
|
|
1573
|
+
if (trimmed.length < 1 || trimmed.length > 120) return null;
|
|
1574
|
+
if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
|
|
1575
|
+
if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
|
|
1576
|
+
if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
|
|
1577
|
+
const dot = trimmed.lastIndexOf(".");
|
|
1578
|
+
if (dot <= 0 || dot === trimmed.length - 1) return null;
|
|
1579
|
+
return trimmed;
|
|
1518
1580
|
}
|
|
1519
|
-
function
|
|
1520
|
-
|
|
1581
|
+
function resolveAssetsDir(slidesRoot, slideId) {
|
|
1582
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return null;
|
|
1583
|
+
const slideDir = path.resolve(slidesRoot, slideId);
|
|
1584
|
+
if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
|
|
1585
|
+
const assetsDir = path.resolve(slideDir, "assets");
|
|
1586
|
+
if (assetsDir !== path.join(slideDir, "assets")) return null;
|
|
1587
|
+
return assetsDir;
|
|
1521
1588
|
}
|
|
1522
|
-
function
|
|
1523
|
-
|
|
1589
|
+
function resolveAssetFile(slidesRoot, slideId, filename) {
|
|
1590
|
+
const assetsDir = resolveAssetsDir(slidesRoot, slideId);
|
|
1591
|
+
if (!assetsDir) return null;
|
|
1592
|
+
if (!validateAssetName(filename)) return null;
|
|
1593
|
+
const file = path.resolve(assetsDir, filename);
|
|
1594
|
+
if (!file.startsWith(assetsDir + path.sep)) return null;
|
|
1595
|
+
return file;
|
|
1524
1596
|
}
|
|
1525
|
-
function
|
|
1526
|
-
|
|
1597
|
+
function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
|
|
1598
|
+
if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
|
|
1599
|
+
return resolveAssetsDir(slidesRoot, scope);
|
|
1527
1600
|
}
|
|
1528
|
-
function
|
|
1529
|
-
if (
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
if (!
|
|
1533
|
-
return
|
|
1534
|
-
}
|
|
1535
|
-
if (typeof value === "boolean") return value ? "true" : "false";
|
|
1536
|
-
if (Array.isArray(value)) {
|
|
1537
|
-
if (value.length === 0) return "[]";
|
|
1538
|
-
const inner = value.map((el) => serializeValue(el, level + 1)).join(", ");
|
|
1539
|
-
return `[${inner}]`;
|
|
1540
|
-
}
|
|
1541
|
-
if (isPlainObject(value)) {
|
|
1542
|
-
const entries = Object.entries(value);
|
|
1543
|
-
if (entries.length === 0) return "{}";
|
|
1544
|
-
const childIndent = indent(level + 1);
|
|
1545
|
-
const lines = entries.map(([k, v]) => {
|
|
1546
|
-
const key = isValidIdentifier(k) ? k : jsString(k);
|
|
1547
|
-
return `${childIndent}${key}: ${serializeValue(v, level + 1)},`;
|
|
1548
|
-
});
|
|
1549
|
-
return `{\n${lines.join("\n")}\n${indent(level)}}`;
|
|
1601
|
+
function resolveScopedAssetFile(slidesRoot, globalAssetsRoot, scope, filename) {
|
|
1602
|
+
if (scope === GLOBAL_SCOPE) {
|
|
1603
|
+
if (!validateAssetName(filename)) return null;
|
|
1604
|
+
const file = path.resolve(globalAssetsRoot, filename);
|
|
1605
|
+
if (!file.startsWith(globalAssetsRoot + path.sep)) return null;
|
|
1606
|
+
return file;
|
|
1550
1607
|
}
|
|
1551
|
-
|
|
1608
|
+
return resolveAssetFile(slidesRoot, scope, filename);
|
|
1552
1609
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1610
|
+
|
|
1611
|
+
//#endregion
|
|
1612
|
+
//#region src/http/request-guard.ts
|
|
1613
|
+
function firstHeaderValue(value) {
|
|
1614
|
+
if (Array.isArray(value)) return value[0] ?? null;
|
|
1615
|
+
return value ?? null;
|
|
1555
1616
|
}
|
|
1556
|
-
function
|
|
1557
|
-
|
|
1558
|
-
|
|
1617
|
+
function headerValue(req, name) {
|
|
1618
|
+
return firstHeaderValue(req.headers[name.toLowerCase()])?.trim() ?? null;
|
|
1619
|
+
}
|
|
1620
|
+
function firstCommaToken(value) {
|
|
1621
|
+
if (!value) return null;
|
|
1622
|
+
const [first] = value.split(",", 1);
|
|
1623
|
+
return first?.trim() || null;
|
|
1624
|
+
}
|
|
1625
|
+
function requestProto(req) {
|
|
1626
|
+
const forwarded = firstCommaToken(headerValue(req, "x-forwarded-proto"))?.toLowerCase();
|
|
1627
|
+
if (forwarded === "http" || forwarded === "https") return forwarded;
|
|
1628
|
+
return "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
|
|
1629
|
+
}
|
|
1630
|
+
function normalizedOrigin(origin) {
|
|
1631
|
+
try {
|
|
1632
|
+
const url = new URL(origin);
|
|
1633
|
+
return `${url.protocol}//${url.host}`.toLowerCase();
|
|
1634
|
+
} catch {
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
function validateMutationRequest(req, opts = {}) {
|
|
1639
|
+
if (opts.requireJsonBody) {
|
|
1640
|
+
const contentType = headerValue(req, "content-type")?.toLowerCase();
|
|
1641
|
+
if (!contentType || !contentType.startsWith("application/json")) return {
|
|
1642
|
+
ok: false,
|
|
1643
|
+
status: 415,
|
|
1644
|
+
error: "content-type must be application/json"
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
const fetchSite = firstCommaToken(headerValue(req, "sec-fetch-site"))?.toLowerCase();
|
|
1648
|
+
if (fetchSite === "cross-site") return {
|
|
1559
1649
|
ok: false,
|
|
1560
|
-
|
|
1561
|
-
error: "
|
|
1650
|
+
status: 403,
|
|
1651
|
+
error: "cross-site request blocked"
|
|
1562
1652
|
};
|
|
1563
|
-
const
|
|
1564
|
-
if (!
|
|
1653
|
+
const originRaw = headerValue(req, "origin");
|
|
1654
|
+
if (!originRaw) return { ok: true };
|
|
1655
|
+
if (originRaw.toLowerCase() === "null") return {
|
|
1565
1656
|
ok: false,
|
|
1566
|
-
|
|
1657
|
+
status: 403,
|
|
1658
|
+
error: "opaque origin is not allowed"
|
|
1567
1659
|
};
|
|
1568
|
-
const
|
|
1569
|
-
if (!
|
|
1660
|
+
const actualOrigin = normalizedOrigin(originRaw);
|
|
1661
|
+
if (!actualOrigin) return {
|
|
1570
1662
|
ok: false,
|
|
1571
|
-
|
|
1572
|
-
error: "
|
|
1663
|
+
status: 403,
|
|
1664
|
+
error: "invalid origin header"
|
|
1573
1665
|
};
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
return {
|
|
1586
|
-
ok: true,
|
|
1587
|
-
design: merged,
|
|
1588
|
-
loc
|
|
1666
|
+
const host = firstCommaToken(headerValue(req, "x-forwarded-host")) ?? headerValue(req, "host");
|
|
1667
|
+
if (!host) return {
|
|
1668
|
+
ok: false,
|
|
1669
|
+
status: 400,
|
|
1670
|
+
error: "missing host header"
|
|
1671
|
+
};
|
|
1672
|
+
const expectedOrigin = `${requestProto(req)}://${host}`.toLowerCase();
|
|
1673
|
+
if (actualOrigin !== expectedOrigin) return {
|
|
1674
|
+
ok: false,
|
|
1675
|
+
status: 403,
|
|
1676
|
+
error: "origin mismatch"
|
|
1589
1677
|
};
|
|
1678
|
+
return { ok: true };
|
|
1590
1679
|
}
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
if (node.type === "VariableDeclaration") varDecl = node;
|
|
1596
|
-
else if (node.type === "ExportNamedDeclaration") {
|
|
1597
|
-
const decl = node.declaration;
|
|
1598
|
-
if (decl?.type === "VariableDeclaration") varDecl = decl;
|
|
1599
|
-
}
|
|
1600
|
-
if (!varDecl) continue;
|
|
1601
|
-
const declarations = varDecl.declarations ?? [];
|
|
1602
|
-
for (const d of declarations) {
|
|
1603
|
-
const id = d.id;
|
|
1604
|
-
if (!id || id.type !== "Identifier" || id.name !== "design") continue;
|
|
1605
|
-
const init = d.init;
|
|
1606
|
-
if (!init) return null;
|
|
1607
|
-
let inner = init;
|
|
1608
|
-
if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
|
|
1609
|
-
const expr = inner.expression;
|
|
1610
|
-
if (expr) inner = expr;
|
|
1611
|
-
}
|
|
1612
|
-
if (inner.type !== "ObjectExpression") return null;
|
|
1613
|
-
return inner;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
return null;
|
|
1617
|
-
}
|
|
1618
|
-
function findImports(ast) {
|
|
1619
|
-
const body = ast.program?.body ?? [];
|
|
1620
|
-
const out = [];
|
|
1621
|
-
for (const node of body) {
|
|
1622
|
-
if (node.type !== "ImportDeclaration") continue;
|
|
1623
|
-
const src = node.source?.value;
|
|
1624
|
-
if (typeof src !== "string") continue;
|
|
1625
|
-
const specs = node.specifiers ?? [];
|
|
1626
|
-
out.push({
|
|
1627
|
-
node,
|
|
1628
|
-
source: src,
|
|
1629
|
-
specifiers: specs
|
|
1630
|
-
});
|
|
1631
|
-
}
|
|
1632
|
-
return out;
|
|
1633
|
-
}
|
|
1634
|
-
function ensureDesignSystemImport(source, ast) {
|
|
1635
|
-
const imports = findImports(ast);
|
|
1636
|
-
const coreImport = imports.find((imp) => imp.source === "@open-slide/core");
|
|
1637
|
-
if (coreImport) {
|
|
1638
|
-
const hasDesignSystem = coreImport.specifiers.some((spec) => {
|
|
1639
|
-
if (spec.type !== "ImportSpecifier") return false;
|
|
1640
|
-
const imported = spec.imported;
|
|
1641
|
-
return imported?.name === "DesignSystem";
|
|
1642
|
-
});
|
|
1643
|
-
if (hasDesignSystem) return {
|
|
1644
|
-
source,
|
|
1645
|
-
offsetShift: 0
|
|
1646
|
-
};
|
|
1647
|
-
const node = coreImport.node;
|
|
1648
|
-
const importText = source.slice(node.start, node.end);
|
|
1649
|
-
const braceClose = importText.lastIndexOf("}");
|
|
1650
|
-
if (braceClose === -1) return {
|
|
1651
|
-
source,
|
|
1652
|
-
offsetShift: 0
|
|
1653
|
-
};
|
|
1654
|
-
const absoluteBrace = node.start + braceClose;
|
|
1655
|
-
const insertText = coreImport.specifiers.length > 0 ? ", type DesignSystem" : "type DesignSystem";
|
|
1656
|
-
const next$1 = `${source.slice(0, absoluteBrace)}${insertText}${source.slice(absoluteBrace)}`;
|
|
1657
|
-
return {
|
|
1658
|
-
source: next$1,
|
|
1659
|
-
offsetShift: insertText.length
|
|
1660
|
-
};
|
|
1661
|
-
}
|
|
1662
|
-
const stmt = `import type { DesignSystem } from '@open-slide/core';\n`;
|
|
1663
|
-
if (imports.length > 0) {
|
|
1664
|
-
const last = imports[imports.length - 1];
|
|
1665
|
-
const insertAt = last.node.end;
|
|
1666
|
-
const trail = source[insertAt] === "\n" ? "" : "\n";
|
|
1667
|
-
const next$1 = `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
|
|
1668
|
-
return {
|
|
1669
|
-
source: next$1,
|
|
1670
|
-
offsetShift: 1 + stmt.length - (trail ? 0 : 1)
|
|
1671
|
-
};
|
|
1672
|
-
}
|
|
1673
|
-
const next = `${stmt}\n${source}`;
|
|
1674
|
-
return {
|
|
1675
|
-
source: next,
|
|
1676
|
-
offsetShift: stmt.length + 1
|
|
1677
|
-
};
|
|
1678
|
-
}
|
|
1679
|
-
function findInsertionPoint(source, ast) {
|
|
1680
|
-
const imports = findImports(ast);
|
|
1681
|
-
if (imports.length === 0) return 0;
|
|
1682
|
-
const last = imports[imports.length - 1];
|
|
1683
|
-
let off = last.node.end;
|
|
1684
|
-
while (off < source.length && source[off] !== "\n") off++;
|
|
1685
|
-
if (off < source.length) off++;
|
|
1686
|
-
return off;
|
|
1687
|
-
}
|
|
1688
|
-
function applyDesignWrite(source, next) {
|
|
1689
|
-
let body;
|
|
1690
|
-
try {
|
|
1691
|
-
body = serializeDesign(next);
|
|
1692
|
-
} catch (err) {
|
|
1693
|
-
return {
|
|
1694
|
-
ok: false,
|
|
1695
|
-
status: 422,
|
|
1696
|
-
error: `serialize failed: ${err.message}`
|
|
1697
|
-
};
|
|
1698
|
-
}
|
|
1699
|
-
const ast = parseSource$1(source);
|
|
1700
|
-
if (!ast) return {
|
|
1701
|
-
ok: false,
|
|
1702
|
-
status: 422,
|
|
1703
|
-
error: "could not parse slide source"
|
|
1704
|
-
};
|
|
1705
|
-
const loc = findDesignDecl(ast);
|
|
1706
|
-
if (loc) {
|
|
1707
|
-
const out$1 = source.slice(0, loc.objectStart) + body + source.slice(loc.objectEnd);
|
|
1708
|
-
return {
|
|
1709
|
-
ok: true,
|
|
1710
|
-
source: out$1,
|
|
1711
|
-
created: false
|
|
1712
|
-
};
|
|
1713
|
-
}
|
|
1714
|
-
const withImport = ensureDesignSystemImport(source, ast);
|
|
1715
|
-
const ast2 = parseSource$1(withImport.source);
|
|
1716
|
-
if (!ast2) return {
|
|
1717
|
-
ok: false,
|
|
1718
|
-
status: 422,
|
|
1719
|
-
error: "failed to re-parse after adding import"
|
|
1720
|
-
};
|
|
1721
|
-
const insertAt = findInsertionPoint(withImport.source, ast2);
|
|
1722
|
-
const block = `\nconst design: DesignSystem = ${body};\n`;
|
|
1723
|
-
const out = withImport.source.slice(0, insertAt) + block + withImport.source.slice(insertAt);
|
|
1724
|
-
return {
|
|
1725
|
-
ok: true,
|
|
1726
|
-
source: out,
|
|
1727
|
-
created: true
|
|
1728
|
-
};
|
|
1729
|
-
}
|
|
1730
|
-
function designPlugin(opts) {
|
|
1680
|
+
|
|
1681
|
+
//#endregion
|
|
1682
|
+
//#region src/vite/routes/context.ts
|
|
1683
|
+
function makeContext(opts) {
|
|
1731
1684
|
const userCwd = opts.userCwd;
|
|
1732
1685
|
const slidesDir = opts.slidesDir ?? "slides";
|
|
1686
|
+
const assetsDir = opts.assetsDir ?? "assets";
|
|
1687
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
1688
|
+
const globalAssetsRoot = path.resolve(userCwd, assetsDir);
|
|
1689
|
+
const manifestPath = path.join(slidesRoot, ".folders.json");
|
|
1733
1690
|
return {
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
const method = req.method ?? "GET";
|
|
1740
|
-
const slideId = url.searchParams.get("slideId") ?? "";
|
|
1741
|
-
const file = resolveSlidePath$1(userCwd, slidesDir, slideId);
|
|
1742
|
-
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
1743
|
-
try {
|
|
1744
|
-
if (method === "GET" && url.pathname === "/") {
|
|
1745
|
-
let source;
|
|
1746
|
-
try {
|
|
1747
|
-
source = await fs.readFile(file, "utf8");
|
|
1748
|
-
} catch {
|
|
1749
|
-
return json$2(res, 404, { error: "slide not found" });
|
|
1750
|
-
}
|
|
1751
|
-
const parsed = parseSlideDesign(source);
|
|
1752
|
-
if (parsed.ok) return json$2(res, 200, {
|
|
1753
|
-
design: parsed.design,
|
|
1754
|
-
exists: true,
|
|
1755
|
-
warning: null
|
|
1756
|
-
});
|
|
1757
|
-
if (parsed.exists === false) return json$2(res, 200, {
|
|
1758
|
-
design: defaultDesign,
|
|
1759
|
-
exists: false,
|
|
1760
|
-
warning: null
|
|
1761
|
-
});
|
|
1762
|
-
return json$2(res, 200, {
|
|
1763
|
-
design: defaultDesign,
|
|
1764
|
-
exists: true,
|
|
1765
|
-
warning: parsed.error
|
|
1766
|
-
});
|
|
1767
|
-
}
|
|
1768
|
-
if (method === "PUT" && url.pathname === "/") {
|
|
1769
|
-
const body = await readBody$2(req);
|
|
1770
|
-
const patch = body.patch;
|
|
1771
|
-
if (!patch || typeof patch !== "object") return json$2(res, 400, { error: "missing patch object" });
|
|
1772
|
-
let source;
|
|
1773
|
-
try {
|
|
1774
|
-
source = await fs.readFile(file, "utf8");
|
|
1775
|
-
} catch {
|
|
1776
|
-
return json$2(res, 404, { error: "slide not found" });
|
|
1777
|
-
}
|
|
1778
|
-
const parsed = parseSlideDesign(source);
|
|
1779
|
-
const baseDesign = parsed.ok ? parsed.design : defaultDesign;
|
|
1780
|
-
if (!parsed.ok && parsed.exists) return json$2(res, 422, { error: parsed.error });
|
|
1781
|
-
const merged = mergeDesign(baseDesign, patch);
|
|
1782
|
-
const written = applyDesignWrite(source, merged);
|
|
1783
|
-
if (!written.ok) return json$2(res, written.status, { error: written.error });
|
|
1784
|
-
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
1785
|
-
return json$2(res, 200, {
|
|
1786
|
-
ok: true,
|
|
1787
|
-
design: merged,
|
|
1788
|
-
created: written.created
|
|
1789
|
-
});
|
|
1790
|
-
}
|
|
1791
|
-
if (method === "POST" && url.pathname === "/reset") {
|
|
1792
|
-
let source;
|
|
1793
|
-
try {
|
|
1794
|
-
source = await fs.readFile(file, "utf8");
|
|
1795
|
-
} catch {
|
|
1796
|
-
return json$2(res, 404, { error: "slide not found" });
|
|
1797
|
-
}
|
|
1798
|
-
const written = applyDesignWrite(source, defaultDesign);
|
|
1799
|
-
if (!written.ok) return json$2(res, written.status, { error: written.error });
|
|
1800
|
-
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
1801
|
-
return json$2(res, 200, {
|
|
1802
|
-
ok: true,
|
|
1803
|
-
design: defaultDesign,
|
|
1804
|
-
created: written.created
|
|
1805
|
-
});
|
|
1806
|
-
}
|
|
1807
|
-
return next();
|
|
1808
|
-
} catch (err) {
|
|
1809
|
-
json$2(res, 500, { error: String(err.message ?? err) });
|
|
1810
|
-
}
|
|
1811
|
-
});
|
|
1812
|
-
}
|
|
1691
|
+
userCwd,
|
|
1692
|
+
slidesDir,
|
|
1693
|
+
slidesRoot,
|
|
1694
|
+
globalAssetsRoot,
|
|
1695
|
+
manifestPath
|
|
1813
1696
|
};
|
|
1814
1697
|
}
|
|
1815
|
-
|
|
1816
|
-
//#endregion
|
|
1817
|
-
//#region src/vite/files-plugin.ts
|
|
1818
|
-
const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
|
|
1819
|
-
const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
|
|
1820
|
-
const GLOBAL_SCOPE = "@global";
|
|
1821
|
-
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
1822
|
-
const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
|
|
1823
|
-
const ASSET_MAX_BYTES = 25 * 1024 * 1024;
|
|
1824
|
-
const MIME_BY_EXT = {
|
|
1825
|
-
png: "image/png",
|
|
1826
|
-
jpg: "image/jpeg",
|
|
1827
|
-
jpeg: "image/jpeg",
|
|
1828
|
-
gif: "image/gif",
|
|
1829
|
-
svg: "image/svg+xml",
|
|
1830
|
-
webp: "image/webp",
|
|
1831
|
-
avif: "image/avif",
|
|
1832
|
-
ico: "image/x-icon",
|
|
1833
|
-
mp4: "video/mp4",
|
|
1834
|
-
webm: "video/webm",
|
|
1835
|
-
mov: "video/quicktime",
|
|
1836
|
-
woff: "font/woff",
|
|
1837
|
-
woff2: "font/woff2",
|
|
1838
|
-
ttf: "font/ttf",
|
|
1839
|
-
otf: "font/otf",
|
|
1840
|
-
json: "application/json",
|
|
1841
|
-
txt: "text/plain; charset=utf-8",
|
|
1842
|
-
md: "text/markdown; charset=utf-8"
|
|
1843
|
-
};
|
|
1844
|
-
function mimeForFilename(name) {
|
|
1845
|
-
const dot = name.lastIndexOf(".");
|
|
1846
|
-
if (dot < 0) return "application/octet-stream";
|
|
1847
|
-
const ext = name.slice(dot + 1).toLowerCase();
|
|
1848
|
-
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
1849
|
-
}
|
|
1850
|
-
function validateAssetName(v) {
|
|
1851
|
-
if (typeof v !== "string") return null;
|
|
1852
|
-
const trimmed = v.trim();
|
|
1853
|
-
if (trimmed.length < 1 || trimmed.length > 120) return null;
|
|
1854
|
-
if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
|
|
1855
|
-
if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
|
|
1856
|
-
if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
|
|
1857
|
-
const dot = trimmed.lastIndexOf(".");
|
|
1858
|
-
if (dot <= 0 || dot === trimmed.length - 1) return null;
|
|
1859
|
-
return trimmed;
|
|
1860
|
-
}
|
|
1861
|
-
async function readBody$1(req) {
|
|
1698
|
+
async function readBody$2(req) {
|
|
1862
1699
|
return await new Promise((resolve, reject) => {
|
|
1863
1700
|
const chunks = [];
|
|
1864
1701
|
req.on("data", (c) => chunks.push(c));
|
|
@@ -1874,11 +1711,489 @@ async function readBody$1(req) {
|
|
|
1874
1711
|
req.on("error", reject);
|
|
1875
1712
|
});
|
|
1876
1713
|
}
|
|
1877
|
-
function json$
|
|
1714
|
+
function json$2(res, status, body) {
|
|
1878
1715
|
res.statusCode = status;
|
|
1879
1716
|
res.setHeader("content-type", "application/json");
|
|
1880
1717
|
res.end(JSON.stringify(body));
|
|
1881
1718
|
}
|
|
1719
|
+
function resolveSlideEntryPath(ctx, slideId) {
|
|
1720
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return null;
|
|
1721
|
+
const full = path.resolve(ctx.slidesRoot, slideId, "index.tsx");
|
|
1722
|
+
if (!full.startsWith(ctx.slidesRoot + path.sep)) return null;
|
|
1723
|
+
return full;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
//#endregion
|
|
1727
|
+
//#region src/vite/routes/assets.ts
|
|
1728
|
+
function registerAssetRoutes(server, ctx) {
|
|
1729
|
+
server.middlewares.use("/__assets", async (req, res, next) => {
|
|
1730
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
1731
|
+
const method = req.method ?? "GET";
|
|
1732
|
+
try {
|
|
1733
|
+
const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
|
|
1734
|
+
const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
|
|
1735
|
+
const usagesMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)\/usages$/);
|
|
1736
|
+
if (usagesMatch && method === "GET") {
|
|
1737
|
+
const scope = usagesMatch[1];
|
|
1738
|
+
const filename = decodeURIComponent(usagesMatch[2]);
|
|
1739
|
+
if (!validateAssetName(filename)) return json$2(res, 400, { error: "invalid path" });
|
|
1740
|
+
const isGlobal = scope === GLOBAL_SCOPE;
|
|
1741
|
+
const assetPath = isGlobal ? `@assets/${filename}` : `./assets/${filename}`;
|
|
1742
|
+
let slideIds;
|
|
1743
|
+
if (isGlobal) try {
|
|
1744
|
+
const entries = await fs.readdir(ctx.slidesRoot, { withFileTypes: true });
|
|
1745
|
+
slideIds = entries.filter((e) => e.isDirectory() && SLIDE_ID_RE$3.test(e.name)).map((e) => e.name);
|
|
1746
|
+
} catch {
|
|
1747
|
+
slideIds = [];
|
|
1748
|
+
}
|
|
1749
|
+
else {
|
|
1750
|
+
if (!SLIDE_ID_RE$3.test(scope)) return json$2(res, 400, { error: "invalid slideId" });
|
|
1751
|
+
slideIds = [scope];
|
|
1752
|
+
}
|
|
1753
|
+
const usages = [];
|
|
1754
|
+
let totalCount = 0;
|
|
1755
|
+
for (const sid of slideIds) {
|
|
1756
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, sid);
|
|
1757
|
+
if (!entry) continue;
|
|
1758
|
+
let source;
|
|
1759
|
+
try {
|
|
1760
|
+
source = await fs.readFile(entry, "utf8");
|
|
1761
|
+
} catch {
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
const count = findAssetUsages(source, assetPath);
|
|
1765
|
+
if (count > 0) {
|
|
1766
|
+
usages.push({
|
|
1767
|
+
slideId: sid,
|
|
1768
|
+
count
|
|
1769
|
+
});
|
|
1770
|
+
totalCount += count;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
return json$2(res, 200, {
|
|
1774
|
+
usages,
|
|
1775
|
+
totalCount
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
if (listMatch && method === "GET") {
|
|
1779
|
+
const slideId = listMatch[1];
|
|
1780
|
+
const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
|
|
1781
|
+
if (!scopedDir) return json$2(res, 400, { error: "invalid slideId" });
|
|
1782
|
+
let entries;
|
|
1783
|
+
try {
|
|
1784
|
+
entries = await fs.readdir(scopedDir);
|
|
1785
|
+
} catch (err) {
|
|
1786
|
+
if (err.code === "ENOENT") return json$2(res, 200, { assets: [] });
|
|
1787
|
+
throw err;
|
|
1788
|
+
}
|
|
1789
|
+
const assets = [];
|
|
1790
|
+
for (const name of entries) {
|
|
1791
|
+
if (!validateAssetName(name)) continue;
|
|
1792
|
+
const stat = await fs.stat(path.join(scopedDir, name));
|
|
1793
|
+
if (!stat.isFile()) continue;
|
|
1794
|
+
assets.push({
|
|
1795
|
+
name,
|
|
1796
|
+
size: stat.size,
|
|
1797
|
+
mtime: stat.mtimeMs,
|
|
1798
|
+
mime: mimeForFilename(name),
|
|
1799
|
+
url: `/__assets/${slideId}/${encodeURIComponent(name)}`
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
assets.sort((a, b) => a.name.localeCompare(b.name));
|
|
1803
|
+
return json$2(res, 200, { assets });
|
|
1804
|
+
}
|
|
1805
|
+
if (fileMatch) {
|
|
1806
|
+
const slideId = fileMatch[1];
|
|
1807
|
+
const filename = decodeURIComponent(fileMatch[2]);
|
|
1808
|
+
const file = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, filename);
|
|
1809
|
+
if (!file) return json$2(res, 400, { error: "invalid path" });
|
|
1810
|
+
if (method === "GET") try {
|
|
1811
|
+
const buf = await fs.readFile(file);
|
|
1812
|
+
res.statusCode = 200;
|
|
1813
|
+
res.setHeader("content-type", mimeForFilename(filename));
|
|
1814
|
+
res.setHeader("cache-control", "no-store");
|
|
1815
|
+
res.end(buf);
|
|
1816
|
+
return;
|
|
1817
|
+
} catch (err) {
|
|
1818
|
+
if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
|
|
1819
|
+
throw err;
|
|
1820
|
+
}
|
|
1821
|
+
if (method === "POST") {
|
|
1822
|
+
const requestCheck = validateMutationRequest(req);
|
|
1823
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
1824
|
+
const overwrite = url.searchParams.get("overwrite") === "1";
|
|
1825
|
+
const lenHeader = req.headers["content-length"];
|
|
1826
|
+
const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
|
|
1827
|
+
if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json$2(res, 413, { error: "file too large" });
|
|
1828
|
+
if (!overwrite) try {
|
|
1829
|
+
await fs.access(file);
|
|
1830
|
+
return json$2(res, 409, { error: "asset exists" });
|
|
1831
|
+
} catch {}
|
|
1832
|
+
const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
|
|
1833
|
+
if (!scopedDir) return json$2(res, 400, { error: "invalid slideId" });
|
|
1834
|
+
await fs.mkdir(scopedDir, { recursive: true });
|
|
1835
|
+
const chunks = [];
|
|
1836
|
+
let total = 0;
|
|
1837
|
+
let oversized = false;
|
|
1838
|
+
await new Promise((resolve, reject) => {
|
|
1839
|
+
req.on("data", (c) => {
|
|
1840
|
+
total += c.length;
|
|
1841
|
+
if (total > ASSET_MAX_BYTES) {
|
|
1842
|
+
oversized = true;
|
|
1843
|
+
req.destroy();
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
chunks.push(c);
|
|
1847
|
+
});
|
|
1848
|
+
req.on("end", () => resolve());
|
|
1849
|
+
req.on("error", reject);
|
|
1850
|
+
});
|
|
1851
|
+
if (oversized) return json$2(res, 413, { error: "file too large" });
|
|
1852
|
+
await fs.writeFile(file, Buffer.concat(chunks));
|
|
1853
|
+
return json$2(res, 200, {
|
|
1854
|
+
ok: true,
|
|
1855
|
+
name: filename,
|
|
1856
|
+
size: total,
|
|
1857
|
+
mime: mimeForFilename(filename),
|
|
1858
|
+
url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
if (method === "PATCH") {
|
|
1862
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
1863
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
1864
|
+
const body = await readBody$2(req);
|
|
1865
|
+
const target = validateAssetName(body.name);
|
|
1866
|
+
if (!target) return json$2(res, 400, { error: "invalid name" });
|
|
1867
|
+
if (target === filename) return json$2(res, 200, {
|
|
1868
|
+
ok: true,
|
|
1869
|
+
name: filename
|
|
1870
|
+
});
|
|
1871
|
+
const dest = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, target);
|
|
1872
|
+
if (!dest) return json$2(res, 400, { error: "invalid name" });
|
|
1873
|
+
try {
|
|
1874
|
+
await fs.access(dest);
|
|
1875
|
+
return json$2(res, 409, { error: "target exists" });
|
|
1876
|
+
} catch {}
|
|
1877
|
+
try {
|
|
1878
|
+
await fs.rename(file, dest);
|
|
1879
|
+
} catch (err) {
|
|
1880
|
+
if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
|
|
1881
|
+
throw err;
|
|
1882
|
+
}
|
|
1883
|
+
return json$2(res, 200, {
|
|
1884
|
+
ok: true,
|
|
1885
|
+
name: target
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
if (method === "DELETE") {
|
|
1889
|
+
const requestCheck = validateMutationRequest(req);
|
|
1890
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
1891
|
+
try {
|
|
1892
|
+
await fs.unlink(file);
|
|
1893
|
+
} catch (err) {
|
|
1894
|
+
if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
|
|
1895
|
+
throw err;
|
|
1896
|
+
}
|
|
1897
|
+
return json$2(res, 200, { ok: true });
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
return next();
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
//#endregion
|
|
1908
|
+
//#region src/editing/comments.ts
|
|
1909
|
+
const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
|
|
1910
|
+
function b64urlEncode(s) {
|
|
1911
|
+
return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1912
|
+
}
|
|
1913
|
+
function b64urlDecode(s) {
|
|
1914
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
|
|
1915
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
|
|
1916
|
+
}
|
|
1917
|
+
function parseMarkers(source) {
|
|
1918
|
+
const comments = [];
|
|
1919
|
+
const lines = source.split("\n");
|
|
1920
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1921
|
+
const line = lines[i];
|
|
1922
|
+
MARKER_RE.lastIndex = 0;
|
|
1923
|
+
const m = MARKER_RE.exec(line);
|
|
1924
|
+
if (!m) continue;
|
|
1925
|
+
const [, id, ts, textB64] = m;
|
|
1926
|
+
try {
|
|
1927
|
+
const payload = JSON.parse(b64urlDecode(textB64));
|
|
1928
|
+
comments.push({
|
|
1929
|
+
id,
|
|
1930
|
+
line: i + 1,
|
|
1931
|
+
ts,
|
|
1932
|
+
note: payload.note,
|
|
1933
|
+
hint: payload.hint
|
|
1934
|
+
});
|
|
1935
|
+
} catch {}
|
|
1936
|
+
}
|
|
1937
|
+
return comments;
|
|
1938
|
+
}
|
|
1939
|
+
function newCommentId() {
|
|
1940
|
+
return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
1941
|
+
}
|
|
1942
|
+
function markerDeleteRegex(id) {
|
|
1943
|
+
return new RegExp(`\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`);
|
|
1944
|
+
}
|
|
1945
|
+
function lineToOffset(source, line) {
|
|
1946
|
+
let off = 0;
|
|
1947
|
+
for (let l = 1; l < line; l++) {
|
|
1948
|
+
const nl = source.indexOf("\n", off);
|
|
1949
|
+
if (nl === -1) return source.length;
|
|
1950
|
+
off = nl + 1;
|
|
1951
|
+
}
|
|
1952
|
+
return off;
|
|
1953
|
+
}
|
|
1954
|
+
function lineIndent(source, lineNumber) {
|
|
1955
|
+
const start = lineToOffset(source, lineNumber);
|
|
1956
|
+
const m = source.slice(start, start + 200).match(/^[ \t]*/);
|
|
1957
|
+
return m?.[0] ?? "";
|
|
1958
|
+
}
|
|
1959
|
+
function findJsxAncestors(ast, line, column) {
|
|
1960
|
+
const hits = [];
|
|
1961
|
+
walkJsx(ast, (n) => {
|
|
1962
|
+
if (!n.loc || !t$2.isJSXElement(n) && !t$2.isJSXFragment(n)) return;
|
|
1963
|
+
const s = n.loc.start;
|
|
1964
|
+
const e = n.loc.end;
|
|
1965
|
+
const afterStart = line > s.line || line === s.line && column >= s.column;
|
|
1966
|
+
const beforeEnd = line < e.line || line === e.line && column < e.column;
|
|
1967
|
+
if (afterStart && beforeEnd) hits.push({
|
|
1968
|
+
node: n,
|
|
1969
|
+
size: (n.end ?? 0) - (n.start ?? 0)
|
|
1970
|
+
});
|
|
1971
|
+
});
|
|
1972
|
+
hits.sort((a, b) => a.size - b.size);
|
|
1973
|
+
return hits.map((h) => h.node);
|
|
1974
|
+
}
|
|
1975
|
+
function planInsertion(source, target) {
|
|
1976
|
+
if (t$2.isJSXFragment(target)) {
|
|
1977
|
+
const opening = target.openingFragment;
|
|
1978
|
+
const startLine = target.loc?.start.line ?? 1;
|
|
1979
|
+
return {
|
|
1980
|
+
offset: opening.end ?? 0,
|
|
1981
|
+
indent: `${lineIndent(source, startLine)} `
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
if (t$2.isJSXElement(target)) {
|
|
1985
|
+
const opening = target.openingElement;
|
|
1986
|
+
if (opening.selfClosing) return null;
|
|
1987
|
+
const startLine = target.loc?.start.line ?? 1;
|
|
1988
|
+
return {
|
|
1989
|
+
offset: opening.end ?? 0,
|
|
1990
|
+
indent: `${lineIndent(source, startLine)} `
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
return null;
|
|
1994
|
+
}
|
|
1995
|
+
function findInsertion(source, line, column) {
|
|
1996
|
+
const ast = parseSource$2(source);
|
|
1997
|
+
if (!ast) return null;
|
|
1998
|
+
const col = column ?? 0;
|
|
1999
|
+
const ancestors = findJsxAncestors(ast, line, col);
|
|
2000
|
+
for (const node of ancestors) {
|
|
2001
|
+
const plan = planInsertion(source, node);
|
|
2002
|
+
if (plan) return plan;
|
|
2003
|
+
}
|
|
2004
|
+
return null;
|
|
2005
|
+
}
|
|
2006
|
+
function offsetToLine(source, offset) {
|
|
2007
|
+
let line = 1;
|
|
2008
|
+
for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
|
|
2009
|
+
return line;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
//#endregion
|
|
2013
|
+
//#region src/vite/routes/comments.ts
|
|
2014
|
+
function registerCommentRoutes(server, ctx) {
|
|
2015
|
+
server.middlewares.use("/__comments", async (req, res, next) => {
|
|
2016
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2017
|
+
const method = req.method ?? "GET";
|
|
2018
|
+
try {
|
|
2019
|
+
if (method === "GET" && url.pathname === "/") {
|
|
2020
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
2021
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2022
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2023
|
+
let source;
|
|
2024
|
+
try {
|
|
2025
|
+
source = await fs.readFile(file, "utf8");
|
|
2026
|
+
} catch {
|
|
2027
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2028
|
+
}
|
|
2029
|
+
return json$2(res, 200, { comments: parseMarkers(source) });
|
|
2030
|
+
}
|
|
2031
|
+
if (method === "POST" && url.pathname === "/add") {
|
|
2032
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2033
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2034
|
+
const body = await readBody$2(req);
|
|
2035
|
+
const slideId = body.slideId ?? "";
|
|
2036
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2037
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2038
|
+
if (!body.line || body.line < 1) return json$2(res, 400, { error: "invalid line" });
|
|
2039
|
+
if (!body.text || typeof body.text !== "string") return json$2(res, 400, { error: "missing text" });
|
|
2040
|
+
let source;
|
|
2041
|
+
try {
|
|
2042
|
+
source = await fs.readFile(file, "utf8");
|
|
2043
|
+
} catch {
|
|
2044
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2045
|
+
}
|
|
2046
|
+
const plan = findInsertion(source, body.line, body.column);
|
|
2047
|
+
if (!plan) return json$2(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
|
|
2048
|
+
const id = newCommentId();
|
|
2049
|
+
const ts = new Date().toISOString();
|
|
2050
|
+
const payload = b64urlEncode(JSON.stringify({
|
|
2051
|
+
note: body.text,
|
|
2052
|
+
hint: body.hint
|
|
2053
|
+
}));
|
|
2054
|
+
const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
|
|
2055
|
+
const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
|
|
2056
|
+
await fs.writeFile(file, next$1, "utf8");
|
|
2057
|
+
const markerLine = offsetToLine(next$1, plan.offset + 1);
|
|
2058
|
+
return json$2(res, 200, {
|
|
2059
|
+
id,
|
|
2060
|
+
line: markerLine
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
if (method === "DELETE" && url.pathname.startsWith("/")) {
|
|
2064
|
+
const requestCheck = validateMutationRequest(req);
|
|
2065
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2066
|
+
const id = url.pathname.slice(1);
|
|
2067
|
+
if (!/^c-[a-f0-9]+$/.test(id)) return json$2(res, 400, { error: "invalid id" });
|
|
2068
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
2069
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2070
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2071
|
+
let source;
|
|
2072
|
+
try {
|
|
2073
|
+
source = await fs.readFile(file, "utf8");
|
|
2074
|
+
} catch {
|
|
2075
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2076
|
+
}
|
|
2077
|
+
const lines = source.split("\n");
|
|
2078
|
+
const idRe = markerDeleteRegex(id);
|
|
2079
|
+
const hit = lines.findIndex((l) => idRe.test(l));
|
|
2080
|
+
if (hit === -1) return json$2(res, 404, { error: "marker not found" });
|
|
2081
|
+
lines.splice(hit, 1);
|
|
2082
|
+
await fs.writeFile(file, lines.join("\n"), "utf8");
|
|
2083
|
+
return json$2(res, 200, { ok: true });
|
|
2084
|
+
}
|
|
2085
|
+
next();
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
//#endregion
|
|
2093
|
+
//#region src/vite/routes/edit.ts
|
|
2094
|
+
function registerEditRoutes(server, ctx) {
|
|
2095
|
+
server.middlewares.use("/__edit", async (req, res, next) => {
|
|
2096
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2097
|
+
const method = req.method ?? "GET";
|
|
2098
|
+
if (method !== "POST") return next();
|
|
2099
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2100
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2101
|
+
try {
|
|
2102
|
+
if (url.pathname === "/") {
|
|
2103
|
+
const body = await readBody$2(req);
|
|
2104
|
+
const slideId = body.slideId ?? "";
|
|
2105
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2106
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2107
|
+
if (!body.line || body.line < 1) return json$2(res, 400, { error: "invalid line" });
|
|
2108
|
+
if (!Array.isArray(body.ops)) return json$2(res, 400, { error: "missing ops" });
|
|
2109
|
+
let source;
|
|
2110
|
+
try {
|
|
2111
|
+
source = await fs.readFile(file, "utf8");
|
|
2112
|
+
} catch {
|
|
2113
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2114
|
+
}
|
|
2115
|
+
const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
|
|
2116
|
+
if (!result.ok) return json$2(res, result.status, { error: result.error });
|
|
2117
|
+
const changed = result.source !== source;
|
|
2118
|
+
if (changed) await fs.writeFile(file, result.source, "utf8");
|
|
2119
|
+
return json$2(res, 200, {
|
|
2120
|
+
ok: true,
|
|
2121
|
+
changed
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
if (url.pathname === "/revert-asset") {
|
|
2125
|
+
const body = await readBody$2(req);
|
|
2126
|
+
const slideId = body.slideId ?? "";
|
|
2127
|
+
const assetPath = body.assetPath;
|
|
2128
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2129
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2130
|
+
if (typeof assetPath !== "string" || !assetPath) return json$2(res, 400, { error: "missing assetPath" });
|
|
2131
|
+
if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return json$2(res, 400, { error: "asset path must start with ./assets/ or @assets/" });
|
|
2132
|
+
let source;
|
|
2133
|
+
try {
|
|
2134
|
+
source = await fs.readFile(file, "utf8");
|
|
2135
|
+
} catch {
|
|
2136
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2137
|
+
}
|
|
2138
|
+
const result = applyRevertAsset(source, assetPath);
|
|
2139
|
+
if (!result.ok) return json$2(res, result.status, { error: result.error });
|
|
2140
|
+
const changed = result.source !== source;
|
|
2141
|
+
if (changed) await fs.writeFile(file, result.source, "utf8");
|
|
2142
|
+
return json$2(res, 200, {
|
|
2143
|
+
ok: true,
|
|
2144
|
+
changed
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
if (url.pathname === "/batch") {
|
|
2148
|
+
const body = await readBody$2(req);
|
|
2149
|
+
const slideId = body.slideId ?? "";
|
|
2150
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2151
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2152
|
+
if (!Array.isArray(body.edits)) return json$2(res, 400, { error: "missing edits" });
|
|
2153
|
+
let source;
|
|
2154
|
+
try {
|
|
2155
|
+
source = await fs.readFile(file, "utf8");
|
|
2156
|
+
} catch {
|
|
2157
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2158
|
+
}
|
|
2159
|
+
const original = source;
|
|
2160
|
+
const results = [];
|
|
2161
|
+
for (const edit of body.edits) {
|
|
2162
|
+
if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
|
|
2163
|
+
results.push({
|
|
2164
|
+
ok: false,
|
|
2165
|
+
error: "invalid edit"
|
|
2166
|
+
});
|
|
2167
|
+
continue;
|
|
2168
|
+
}
|
|
2169
|
+
const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
|
|
2170
|
+
if (r.ok) {
|
|
2171
|
+
source = r.source;
|
|
2172
|
+
results.push({ ok: true });
|
|
2173
|
+
} else results.push({
|
|
2174
|
+
ok: false,
|
|
2175
|
+
error: r.error
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
const changed = source !== original;
|
|
2179
|
+
if (changed) await fs.writeFile(file, source, "utf8");
|
|
2180
|
+
return json$2(res, 200, {
|
|
2181
|
+
ok: true,
|
|
2182
|
+
changed,
|
|
2183
|
+
results
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
return next();
|
|
2187
|
+
} catch (err) {
|
|
2188
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
2189
|
+
}
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
//#endregion
|
|
2194
|
+
//#region src/files/folders.ts
|
|
2195
|
+
const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
|
|
2196
|
+
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
1882
2197
|
function emptyManifest() {
|
|
1883
2198
|
return {
|
|
1884
2199
|
folders: [],
|
|
@@ -1911,123 +2226,437 @@ function validateName(v) {
|
|
|
1911
2226
|
if (trimmed.length < 1 || trimmed.length > 40) return null;
|
|
1912
2227
|
return trimmed;
|
|
1913
2228
|
}
|
|
1914
|
-
function
|
|
1915
|
-
if (typeof v !== "
|
|
1916
|
-
const
|
|
1917
|
-
if (
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
try {
|
|
1925
|
-
await fs.rm(dir, {
|
|
1926
|
-
recursive: true,
|
|
1927
|
-
force: true
|
|
1928
|
-
});
|
|
1929
|
-
return true;
|
|
1930
|
-
} catch {
|
|
1931
|
-
return false;
|
|
2229
|
+
function validateIcon(v) {
|
|
2230
|
+
if (!v || typeof v !== "object") return null;
|
|
2231
|
+
const icon = v;
|
|
2232
|
+
if (icon.type === "emoji") {
|
|
2233
|
+
if (typeof icon.value !== "string") return null;
|
|
2234
|
+
if (icon.value.length < 1 || icon.value.length > 8) return null;
|
|
2235
|
+
return {
|
|
2236
|
+
type: "emoji",
|
|
2237
|
+
value: icon.value
|
|
2238
|
+
};
|
|
1932
2239
|
}
|
|
2240
|
+
if (icon.type === "color") {
|
|
2241
|
+
if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
|
|
2242
|
+
return {
|
|
2243
|
+
type: "color",
|
|
2244
|
+
value: icon.value
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
return null;
|
|
1933
2248
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
}
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
|
|
2249
|
+
|
|
2250
|
+
//#endregion
|
|
2251
|
+
//#region src/vite/routes/folders.ts
|
|
2252
|
+
function registerFolderRoutes(server, ctx) {
|
|
2253
|
+
server.middlewares.use("/__folders", async (req, res, next) => {
|
|
2254
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2255
|
+
const method = req.method ?? "GET";
|
|
2256
|
+
try {
|
|
2257
|
+
if (method === "GET" && url.pathname === "/") {
|
|
2258
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2259
|
+
return json$2(res, 200, manifest);
|
|
2260
|
+
}
|
|
2261
|
+
if (method === "POST" && url.pathname === "/") {
|
|
2262
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2263
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2264
|
+
const body = await readBody$2(req);
|
|
2265
|
+
const name = validateName(body.name);
|
|
2266
|
+
if (!name) return json$2(res, 400, { error: "invalid name" });
|
|
2267
|
+
const icon = validateIcon(body.icon);
|
|
2268
|
+
if (!icon) return json$2(res, 400, { error: "invalid icon" });
|
|
2269
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2270
|
+
const folder = {
|
|
2271
|
+
id: newFolderId(),
|
|
2272
|
+
name,
|
|
2273
|
+
icon
|
|
2274
|
+
};
|
|
2275
|
+
manifest.folders.push(folder);
|
|
2276
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2277
|
+
return json$2(res, 200, folder);
|
|
2278
|
+
}
|
|
2279
|
+
if (method === "PUT" && url.pathname === "/assign") {
|
|
2280
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2281
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2282
|
+
const body = await readBody$2(req);
|
|
2283
|
+
if (typeof body.slideId !== "string" || !SLIDE_ID_RE$3.test(body.slideId)) return json$2(res, 400, { error: "invalid slideId" });
|
|
2284
|
+
const slideId = body.slideId;
|
|
2285
|
+
let folderId;
|
|
2286
|
+
if (body.folderId === null) folderId = null;
|
|
2287
|
+
else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
|
|
2288
|
+
else return json$2(res, 400, { error: "invalid folderId" });
|
|
2289
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2290
|
+
if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json$2(res, 404, { error: "folder not found" });
|
|
2291
|
+
if (folderId === null) delete manifest.assignments[slideId];
|
|
2292
|
+
else manifest.assignments[slideId] = folderId;
|
|
2293
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2294
|
+
return json$2(res, 200, { ok: true });
|
|
2295
|
+
}
|
|
2296
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
2297
|
+
if (idMatch) {
|
|
2298
|
+
const id = idMatch[1];
|
|
2299
|
+
if (!FOLDER_ID_RE.test(id)) return json$2(res, 400, { error: "invalid id" });
|
|
2300
|
+
if (method === "PATCH") {
|
|
2301
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2302
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2303
|
+
const body = await readBody$2(req);
|
|
2304
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2305
|
+
const folder = manifest.folders.find((f) => f.id === id);
|
|
2306
|
+
if (!folder) return json$2(res, 404, { error: "folder not found" });
|
|
2307
|
+
if (body.name !== void 0) {
|
|
2308
|
+
const name = validateName(body.name);
|
|
2309
|
+
if (!name) return json$2(res, 400, { error: "invalid name" });
|
|
2310
|
+
folder.name = name;
|
|
2311
|
+
}
|
|
2312
|
+
if (body.icon !== void 0) {
|
|
2313
|
+
const icon = validateIcon(body.icon);
|
|
2314
|
+
if (!icon) return json$2(res, 400, { error: "invalid icon" });
|
|
2315
|
+
folder.icon = icon;
|
|
2316
|
+
}
|
|
2317
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2318
|
+
return json$2(res, 200, folder);
|
|
2319
|
+
}
|
|
2320
|
+
if (method === "DELETE") {
|
|
2321
|
+
const requestCheck = validateMutationRequest(req);
|
|
2322
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2323
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2324
|
+
const before = manifest.folders.length;
|
|
2325
|
+
manifest.folders = manifest.folders.filter((f) => f.id !== id);
|
|
2326
|
+
if (manifest.folders.length === before) return json$2(res, 404, { error: "folder not found" });
|
|
2327
|
+
for (const [slideId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[slideId];
|
|
2328
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2329
|
+
return json$2(res, 200, { ok: true });
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
next();
|
|
2333
|
+
} catch (err) {
|
|
2334
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
2335
|
+
}
|
|
2336
|
+
});
|
|
1971
2337
|
}
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2338
|
+
|
|
2339
|
+
//#endregion
|
|
2340
|
+
//#region src/vite/routes/slides.ts
|
|
2341
|
+
function registerSlideRoutes(server, ctx) {
|
|
2342
|
+
server.middlewares.use("/__slides", async (req, res, next) => {
|
|
2343
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2344
|
+
const method = req.method ?? "GET";
|
|
2345
|
+
try {
|
|
2346
|
+
const reorderMatch = url.pathname.match(/^\/([^/]+)\/reorder$/);
|
|
2347
|
+
if (reorderMatch && method === "PUT") {
|
|
2348
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2349
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2350
|
+
const slideId$1 = reorderMatch[1];
|
|
2351
|
+
if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
|
|
2352
|
+
const body = await readBody$2(req);
|
|
2353
|
+
if (!Array.isArray(body.order)) return json$2(res, 400, { error: "invalid order" });
|
|
2354
|
+
const order = [];
|
|
2355
|
+
for (const v of body.order) {
|
|
2356
|
+
if (!Number.isInteger(v)) return json$2(res, 400, { error: "invalid order" });
|
|
2357
|
+
order.push(v);
|
|
2358
|
+
}
|
|
2359
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
|
|
2360
|
+
if (!entry) return json$2(res, 400, { error: "invalid slideId" });
|
|
2361
|
+
let source;
|
|
2362
|
+
try {
|
|
2363
|
+
source = await fs.readFile(entry, "utf8");
|
|
2364
|
+
} catch {
|
|
2365
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2366
|
+
}
|
|
2367
|
+
const reordered = reorderDefaultExportPagesInSource(source, order);
|
|
2368
|
+
if (reordered === null) return json$2(res, 422, { error: "could not reorder pages — order must be a permutation of the existing array" });
|
|
2369
|
+
const withNotes = reorderNotesArrayInSource(reordered, order);
|
|
2370
|
+
if (withNotes === null) return json$2(res, 422, { error: "could not reorder pages — `notes` export has an unexpected shape" });
|
|
2371
|
+
if (withNotes !== source) await fs.writeFile(entry, withNotes, "utf8");
|
|
2372
|
+
return json$2(res, 200, {
|
|
2373
|
+
ok: true,
|
|
2374
|
+
slideId: slideId$1,
|
|
2375
|
+
order
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
const pageOpMatch = url.pathname.match(/^\/([^/]+)\/pages\/(\d+)(?:\/([a-z]+))?$/);
|
|
2379
|
+
if (pageOpMatch) {
|
|
2380
|
+
const slideId$1 = pageOpMatch[1];
|
|
2381
|
+
const pageIndex = Number.parseInt(pageOpMatch[2], 10);
|
|
2382
|
+
const op = pageOpMatch[3];
|
|
2383
|
+
if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
|
|
2384
|
+
if (!Number.isInteger(pageIndex) || pageIndex < 0) return json$2(res, 400, { error: "invalid page index" });
|
|
2385
|
+
const isDelete = method === "DELETE" && !op;
|
|
2386
|
+
const isDuplicate = method === "POST" && op === "duplicate";
|
|
2387
|
+
if (!isDelete && !isDuplicate) return next();
|
|
2388
|
+
const requestCheck = validateMutationRequest(req);
|
|
2389
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2390
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
|
|
2391
|
+
if (!entry) return json$2(res, 400, { error: "invalid slideId" });
|
|
2392
|
+
let source;
|
|
2393
|
+
try {
|
|
2394
|
+
source = await fs.readFile(entry, "utf8");
|
|
2395
|
+
} catch {
|
|
2396
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2397
|
+
}
|
|
2398
|
+
const updated = isDelete ? removePageFromDefaultExportInSource(source, pageIndex) : duplicatePageInDefaultExportInSource(source, pageIndex);
|
|
2399
|
+
if (updated === null) return json$2(res, 422, { error: isDelete ? "could not delete page — index out of range or default export is not an array" : "could not duplicate page — index out of range or default export is not an array" });
|
|
2400
|
+
if (updated !== source) await fs.writeFile(entry, updated, "utf8");
|
|
2401
|
+
return json$2(res, 200, {
|
|
2402
|
+
ok: true,
|
|
2403
|
+
slideId: slideId$1,
|
|
2404
|
+
index: pageIndex
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
2408
|
+
if (!idMatch) return next();
|
|
2409
|
+
const slideId = idMatch[1];
|
|
2410
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return json$2(res, 400, { error: "invalid slideId" });
|
|
2411
|
+
if (method === "PATCH") {
|
|
2412
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2413
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2414
|
+
const body = await readBody$2(req);
|
|
2415
|
+
const name = validateSlideName(body.name);
|
|
2416
|
+
if (!name) return json$2(res, 400, { error: "invalid name" });
|
|
2417
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId);
|
|
2418
|
+
if (!entry) return json$2(res, 400, { error: "invalid slideId" });
|
|
2419
|
+
let source;
|
|
2420
|
+
try {
|
|
2421
|
+
source = await fs.readFile(entry, "utf8");
|
|
2422
|
+
} catch {
|
|
2423
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2004
2424
|
}
|
|
2425
|
+
const updated = updateMetaTitleInSource(source, name);
|
|
2426
|
+
if (updated === null) return json$2(res, 422, { error: "could not locate a safe place to write meta.title in index.tsx" });
|
|
2427
|
+
if (updated !== source) await fs.writeFile(entry, updated, "utf8");
|
|
2428
|
+
server.ws.send({ type: "full-reload" });
|
|
2429
|
+
return json$2(res, 200, {
|
|
2430
|
+
ok: true,
|
|
2431
|
+
slideId,
|
|
2432
|
+
name
|
|
2433
|
+
});
|
|
2005
2434
|
}
|
|
2435
|
+
if (method === "DELETE") {
|
|
2436
|
+
const requestCheck = validateMutationRequest(req);
|
|
2437
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2438
|
+
const removed = await rmSlideDir(ctx.slidesRoot, slideId);
|
|
2439
|
+
if (!removed) return json$2(res, 404, { error: "slide not found" });
|
|
2440
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2441
|
+
delete manifest.assignments[slideId];
|
|
2442
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2443
|
+
return json$2(res, 200, { ok: true });
|
|
2444
|
+
}
|
|
2445
|
+
return next();
|
|
2446
|
+
} catch (err) {
|
|
2447
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
2006
2448
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
//#endregion
|
|
2453
|
+
//#region src/vite/routes/svgl.ts
|
|
2454
|
+
function registerSvglRoutes(server) {
|
|
2455
|
+
server.middlewares.use("/__svgl", async (req, res, next) => {
|
|
2456
|
+
const reqUrl = new URL(req.url ?? "/", "http://local");
|
|
2457
|
+
const method = req.method ?? "GET";
|
|
2458
|
+
if (method !== "GET") return next();
|
|
2459
|
+
try {
|
|
2460
|
+
let target = null;
|
|
2461
|
+
if (reqUrl.pathname === "/search") {
|
|
2462
|
+
const params = new URLSearchParams();
|
|
2463
|
+
const q = reqUrl.searchParams.get("q");
|
|
2464
|
+
const limit = reqUrl.searchParams.get("limit");
|
|
2465
|
+
if (q) params.set("search", q);
|
|
2466
|
+
if (limit) params.set("limit", limit);
|
|
2467
|
+
const qs = params.toString();
|
|
2468
|
+
target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
|
|
2469
|
+
} else if (reqUrl.pathname === "/svg") {
|
|
2470
|
+
const u = reqUrl.searchParams.get("u");
|
|
2471
|
+
if (!u) return json$2(res, 400, { error: "missing u" });
|
|
2472
|
+
let parsed;
|
|
2473
|
+
try {
|
|
2474
|
+
parsed = new URL(u);
|
|
2475
|
+
} catch {
|
|
2476
|
+
return json$2(res, 400, { error: "invalid u" });
|
|
2477
|
+
}
|
|
2478
|
+
if (parsed.protocol !== "https:") return json$2(res, 400, { error: "https only" });
|
|
2479
|
+
const host = parsed.hostname.toLowerCase();
|
|
2480
|
+
if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json$2(res, 400, { error: "host not allowed" });
|
|
2481
|
+
target = parsed.toString();
|
|
2482
|
+
} else return next();
|
|
2483
|
+
const upstream = await fetch(target);
|
|
2484
|
+
const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
|
|
2485
|
+
res.statusCode = upstream.status;
|
|
2486
|
+
res.setHeader("content-type", ct);
|
|
2487
|
+
res.setHeader("cache-control", "no-store");
|
|
2488
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
2489
|
+
res.end(buf);
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
json$2(res, 502, { error: String(err.message ?? err) });
|
|
2014
2492
|
}
|
|
2015
|
-
|
|
2016
|
-
const indent$1 = firstIndentMatch ? firstIndentMatch[1] : " ";
|
|
2017
|
-
const trimmedBody = body.replace(/^\s*\n?/, "");
|
|
2018
|
-
const needsSeparator = trimmedBody.trim().length > 0;
|
|
2019
|
-
const insertion$1 = `\n${indent$1}title: ${newLiteral}${needsSeparator ? "," : ""}`;
|
|
2020
|
-
return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
|
|
2021
|
-
}
|
|
2022
|
-
const exportDefaultIdx = source.search(/export\s+default\b/);
|
|
2023
|
-
if (exportDefaultIdx === -1) return null;
|
|
2024
|
-
const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
|
|
2025
|
-
return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
|
|
2493
|
+
});
|
|
2026
2494
|
}
|
|
2027
|
-
|
|
2028
|
-
|
|
2495
|
+
|
|
2496
|
+
//#endregion
|
|
2497
|
+
//#region src/vite/routes/watchers.ts
|
|
2498
|
+
function registerWatchers(server, ctx) {
|
|
2499
|
+
server.watcher.add(ctx.manifestPath);
|
|
2500
|
+
server.watcher.on("change", (p) => {
|
|
2501
|
+
if (p === ctx.manifestPath) server.ws.send({
|
|
2502
|
+
type: "custom",
|
|
2503
|
+
event: "open-slide:files-changed"
|
|
2504
|
+
});
|
|
2505
|
+
});
|
|
2506
|
+
server.watcher.add(ctx.globalAssetsRoot);
|
|
2507
|
+
const onAssetChange = (p) => {
|
|
2508
|
+
if (p.startsWith(ctx.globalAssetsRoot + path.sep) || p === ctx.globalAssetsRoot) {
|
|
2509
|
+
server.ws.send({
|
|
2510
|
+
type: "custom",
|
|
2511
|
+
event: "open-slide:assets-changed",
|
|
2512
|
+
data: { slideId: GLOBAL_SCOPE }
|
|
2513
|
+
});
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
if (!p.startsWith(ctx.slidesRoot + path.sep)) return;
|
|
2517
|
+
const rel = p.slice(ctx.slidesRoot.length + 1);
|
|
2518
|
+
const parts = rel.split(path.sep);
|
|
2519
|
+
if (parts.length < 3 || parts[1] !== "assets") return;
|
|
2520
|
+
const slideId = parts[0];
|
|
2521
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return;
|
|
2522
|
+
server.ws.send({
|
|
2523
|
+
type: "custom",
|
|
2524
|
+
event: "open-slide:assets-changed",
|
|
2525
|
+
data: { slideId }
|
|
2526
|
+
});
|
|
2527
|
+
};
|
|
2528
|
+
server.watcher.on("add", onAssetChange);
|
|
2529
|
+
server.watcher.on("change", onAssetChange);
|
|
2530
|
+
server.watcher.on("unlink", onAssetChange);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
//#endregion
|
|
2534
|
+
//#region src/vite/api-plugin.ts
|
|
2535
|
+
function apiPlugin(opts) {
|
|
2536
|
+
return {
|
|
2537
|
+
name: "open-slide:api",
|
|
2538
|
+
apply: "serve",
|
|
2539
|
+
configureServer(server) {
|
|
2540
|
+
const ctx = makeContext(opts);
|
|
2541
|
+
registerWatchers(server, ctx);
|
|
2542
|
+
registerEditRoutes(server, ctx);
|
|
2543
|
+
registerCommentRoutes(server, ctx);
|
|
2544
|
+
registerSlideRoutes(server, ctx);
|
|
2545
|
+
registerAssetRoutes(server, ctx);
|
|
2546
|
+
registerSvglRoutes(server);
|
|
2547
|
+
registerFolderRoutes(server, ctx);
|
|
2548
|
+
}
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
//#endregion
|
|
2553
|
+
//#region src/vite/current-plugin.ts
|
|
2554
|
+
const SLIDE_ID_RE$2 = /^[a-z0-9_-]+$/i;
|
|
2555
|
+
const TEXT_SNIPPET_MAX = 120;
|
|
2556
|
+
function parseSelection(raw) {
|
|
2557
|
+
if (raw == null || typeof raw !== "object") return null;
|
|
2558
|
+
const sel = raw;
|
|
2559
|
+
if (typeof sel.line !== "number" || !Number.isFinite(sel.line)) return null;
|
|
2560
|
+
if (typeof sel.column !== "number" || !Number.isFinite(sel.column)) return null;
|
|
2561
|
+
const tagName = typeof sel.tagName === "string" ? sel.tagName.toLowerCase().slice(0, 32) : "unknown";
|
|
2562
|
+
const text = typeof sel.text === "string" ? sel.text.replace(/\s+/g, " ").trim().slice(0, TEXT_SNIPPET_MAX) : "";
|
|
2563
|
+
return {
|
|
2564
|
+
line: Math.max(1, Math.floor(sel.line)),
|
|
2565
|
+
column: Math.max(0, Math.floor(sel.column)),
|
|
2566
|
+
tagName,
|
|
2567
|
+
text
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
function currentPlugin(opts) {
|
|
2571
|
+
const userCwd = opts.userCwd;
|
|
2572
|
+
const slidesDir = opts.slidesDir ?? "slides";
|
|
2573
|
+
const outDir = path.join(userCwd, "node_modules", ".open-slide");
|
|
2574
|
+
const outFile = path.join(outDir, "current.json");
|
|
2575
|
+
const tmpFile = `${outFile}.tmp`;
|
|
2576
|
+
let cached = null;
|
|
2577
|
+
return {
|
|
2578
|
+
name: "open-slide:current",
|
|
2579
|
+
apply: "serve",
|
|
2580
|
+
configureServer(server) {
|
|
2581
|
+
server.ws.on("open-slide:current", async (raw) => {
|
|
2582
|
+
const next = cached ? { ...cached } : {
|
|
2583
|
+
slideId: "",
|
|
2584
|
+
pageIndex: 0,
|
|
2585
|
+
pageNumber: 1,
|
|
2586
|
+
totalPages: 1,
|
|
2587
|
+
slideTitle: "",
|
|
2588
|
+
view: "slides",
|
|
2589
|
+
pagePath: "",
|
|
2590
|
+
selection: null
|
|
2591
|
+
};
|
|
2592
|
+
if (typeof raw?.slideId === "string") {
|
|
2593
|
+
if (!SLIDE_ID_RE$2.test(raw.slideId)) return;
|
|
2594
|
+
const totalPages = typeof raw.totalPages === "number" && Number.isFinite(raw.totalPages) && raw.totalPages > 0 ? Math.floor(raw.totalPages) : 1;
|
|
2595
|
+
const rawIndex = typeof raw.pageIndex === "number" && Number.isFinite(raw.pageIndex) ? Math.floor(raw.pageIndex) : 0;
|
|
2596
|
+
const pageIndex = Math.max(0, Math.min(totalPages - 1, rawIndex));
|
|
2597
|
+
const slideTitle = typeof raw.slideTitle === "string" ? raw.slideTitle : raw.slideId;
|
|
2598
|
+
const view = raw.view === "assets" ? "assets" : "slides";
|
|
2599
|
+
const pagePath = path.join(slidesDir, raw.slideId, "index.tsx").split(path.sep).join("/");
|
|
2600
|
+
if (cached?.slideId !== raw.slideId || cached?.pageIndex !== pageIndex) next.selection = null;
|
|
2601
|
+
next.slideId = raw.slideId;
|
|
2602
|
+
next.pageIndex = pageIndex;
|
|
2603
|
+
next.pageNumber = pageIndex + 1;
|
|
2604
|
+
next.totalPages = totalPages;
|
|
2605
|
+
next.slideTitle = slideTitle;
|
|
2606
|
+
next.view = view;
|
|
2607
|
+
next.pagePath = pagePath;
|
|
2608
|
+
}
|
|
2609
|
+
if ("selection" in raw) next.selection = parseSelection(raw.selection);
|
|
2610
|
+
if (!next.slideId) return;
|
|
2611
|
+
cached = next;
|
|
2612
|
+
const body = {
|
|
2613
|
+
...next,
|
|
2614
|
+
updatedAt: new Date().toISOString()
|
|
2615
|
+
};
|
|
2616
|
+
try {
|
|
2617
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
2618
|
+
await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
2619
|
+
await fs.rename(tmpFile, outFile);
|
|
2620
|
+
} catch {}
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
//#endregion
|
|
2627
|
+
//#region src/vite/design-plugin.ts
|
|
2628
|
+
const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
|
|
2629
|
+
async function readBody$1(req) {
|
|
2630
|
+
return await new Promise((resolve, reject) => {
|
|
2631
|
+
const chunks = [];
|
|
2632
|
+
req.on("data", (c) => chunks.push(c));
|
|
2633
|
+
req.on("end", () => {
|
|
2634
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
2635
|
+
if (!raw) return resolve({});
|
|
2636
|
+
try {
|
|
2637
|
+
resolve(JSON.parse(raw));
|
|
2638
|
+
} catch (e) {
|
|
2639
|
+
reject(e);
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
req.on("error", reject);
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
function json$1(res, status, body) {
|
|
2646
|
+
res.statusCode = status;
|
|
2647
|
+
res.setHeader("content-type", "application/json");
|
|
2648
|
+
res.end(JSON.stringify(body));
|
|
2649
|
+
}
|
|
2650
|
+
function resolveSlidePath$1(userCwd, slidesDir, slideId) {
|
|
2651
|
+
if (!SLIDE_ID_RE$1.test(slideId)) return null;
|
|
2652
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
2653
|
+
const full = path.resolve(slidesRoot, slideId, "index.tsx");
|
|
2654
|
+
if (!full.startsWith(`${slidesRoot}${path.sep}`)) return null;
|
|
2655
|
+
return full;
|
|
2656
|
+
}
|
|
2657
|
+
function parseSource$1(source) {
|
|
2029
2658
|
try {
|
|
2030
|
-
|
|
2659
|
+
return parse(source, {
|
|
2031
2660
|
sourceType: "module",
|
|
2032
2661
|
plugins: ["typescript", "jsx"],
|
|
2033
2662
|
errorRecovery: true
|
|
@@ -2035,630 +2664,393 @@ function findDefaultExportArray(source) {
|
|
|
2035
2664
|
} catch {
|
|
2036
2665
|
return null;
|
|
2037
2666
|
}
|
|
2667
|
+
}
|
|
2668
|
+
function findDesignDecl(ast) {
|
|
2038
2669
|
const body = ast.program?.body ?? [];
|
|
2039
2670
|
for (const node of body) {
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
const
|
|
2048
|
-
for (const
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2671
|
+
let varDecl = null;
|
|
2672
|
+
if (node.type === "VariableDeclaration") varDecl = node;
|
|
2673
|
+
else if (node.type === "ExportNamedDeclaration") {
|
|
2674
|
+
const decl = node.declaration;
|
|
2675
|
+
if (decl?.type === "VariableDeclaration") varDecl = decl;
|
|
2676
|
+
}
|
|
2677
|
+
if (!varDecl) continue;
|
|
2678
|
+
const declarations = varDecl.declarations ?? [];
|
|
2679
|
+
for (const d of declarations) {
|
|
2680
|
+
const id = d.id;
|
|
2681
|
+
if (!id || id.type !== "Identifier" || id.name !== "design") continue;
|
|
2682
|
+
const init = d.init;
|
|
2683
|
+
if (!init) return null;
|
|
2684
|
+
let inner = init;
|
|
2685
|
+
if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
|
|
2686
|
+
const expr = inner.expression;
|
|
2687
|
+
if (expr) inner = expr;
|
|
2688
|
+
}
|
|
2689
|
+
if (inner.type !== "ObjectExpression") return null;
|
|
2690
|
+
return {
|
|
2691
|
+
declStart: node.start,
|
|
2692
|
+
declEnd: node.end,
|
|
2693
|
+
objectStart: inner.start,
|
|
2694
|
+
objectEnd: inner.end
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
return null;
|
|
2699
|
+
}
|
|
2700
|
+
function literalToValue(node) {
|
|
2701
|
+
switch (node.type) {
|
|
2702
|
+
case "StringLiteral": return node.value;
|
|
2703
|
+
case "NumericLiteral": return node.value;
|
|
2704
|
+
case "BooleanLiteral": return node.value;
|
|
2705
|
+
case "NullLiteral": return null;
|
|
2706
|
+
case "UnaryExpression": {
|
|
2707
|
+
const op = node.operator;
|
|
2708
|
+
const arg = node.argument;
|
|
2709
|
+
const v = literalToValue(arg);
|
|
2710
|
+
if (op === "-" && typeof v === "number") return -v;
|
|
2711
|
+
if (op === "+" && typeof v === "number") return v;
|
|
2712
|
+
throw new Error(`unsupported unary operator ${op}`);
|
|
2713
|
+
}
|
|
2714
|
+
case "TemplateLiteral": {
|
|
2715
|
+
const quasis = node.quasis;
|
|
2716
|
+
const expressions = node.expressions;
|
|
2717
|
+
if (expressions.length > 0) throw new Error("template literal has expressions");
|
|
2718
|
+
return quasis[0].value.cooked ?? quasis[0].value.raw;
|
|
2719
|
+
}
|
|
2720
|
+
case "ArrayExpression": {
|
|
2721
|
+
const elements = node.elements;
|
|
2722
|
+
return elements.map((el) => {
|
|
2723
|
+
if (!el) throw new Error("array has hole");
|
|
2724
|
+
return literalToValue(el);
|
|
2053
2725
|
});
|
|
2054
2726
|
}
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2727
|
+
case "ObjectExpression": {
|
|
2728
|
+
const properties = node.properties;
|
|
2729
|
+
const out = {};
|
|
2730
|
+
for (const prop of properties) {
|
|
2731
|
+
if (prop.type !== "ObjectProperty") throw new Error("object has spread or method");
|
|
2732
|
+
const p = prop;
|
|
2733
|
+
if (p.computed) throw new Error("object has computed key");
|
|
2734
|
+
let key;
|
|
2735
|
+
if (p.key.type === "Identifier" && typeof p.key.name === "string") key = p.key.name;
|
|
2736
|
+
else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") key = p.key.value;
|
|
2737
|
+
else throw new Error("unsupported object key");
|
|
2738
|
+
out[key] = literalToValue(p.value);
|
|
2739
|
+
}
|
|
2740
|
+
return out;
|
|
2741
|
+
}
|
|
2742
|
+
default: throw new Error(`unsupported node type ${node.type}`);
|
|
2060
2743
|
}
|
|
2061
|
-
return null;
|
|
2062
2744
|
}
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2745
|
+
function isPlainObject(v) {
|
|
2746
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2747
|
+
}
|
|
2748
|
+
function mergeDesign(base, patch) {
|
|
2749
|
+
const out = JSON.parse(JSON.stringify(base));
|
|
2750
|
+
const apply = (target, src) => {
|
|
2751
|
+
for (const [k, v] of Object.entries(src)) if (isPlainObject(v) && isPlainObject(target[k])) apply(target[k], v);
|
|
2752
|
+
else target[k] = v;
|
|
2753
|
+
};
|
|
2754
|
+
if (isPlainObject(patch)) apply(out, patch);
|
|
2755
|
+
return out;
|
|
2756
|
+
}
|
|
2757
|
+
function indent(level) {
|
|
2758
|
+
return " ".repeat(level);
|
|
2759
|
+
}
|
|
2760
|
+
function jsString(s) {
|
|
2761
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
2762
|
+
}
|
|
2763
|
+
function isValidIdentifier(name) {
|
|
2764
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
2765
|
+
}
|
|
2766
|
+
function serializeValue(value, level) {
|
|
2767
|
+
if (value === null) return "null";
|
|
2768
|
+
if (typeof value === "string") return jsString(value);
|
|
2769
|
+
if (typeof value === "number") {
|
|
2770
|
+
if (!Number.isFinite(value)) throw new Error("non-finite number");
|
|
2771
|
+
return String(value);
|
|
2087
2772
|
}
|
|
2088
|
-
if (
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2773
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
2774
|
+
if (Array.isArray(value)) {
|
|
2775
|
+
if (value.length === 0) return "[]";
|
|
2776
|
+
const inner = value.map((el) => serializeValue(el, level + 1)).join(", ");
|
|
2777
|
+
return `[${inner}]`;
|
|
2093
2778
|
}
|
|
2094
|
-
if (
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2779
|
+
if (isPlainObject(value)) {
|
|
2780
|
+
const entries = Object.entries(value);
|
|
2781
|
+
if (entries.length === 0) return "{}";
|
|
2782
|
+
const childIndent = indent(level + 1);
|
|
2783
|
+
const lines = entries.map(([k, v]) => {
|
|
2784
|
+
const key = isValidIdentifier(k) ? k : jsString(k);
|
|
2785
|
+
return `${childIndent}${key}: ${serializeValue(v, level + 1)},`;
|
|
2786
|
+
});
|
|
2787
|
+
return `{\n${lines.join("\n")}\n${indent(level)}}`;
|
|
2788
|
+
}
|
|
2789
|
+
throw new Error(`unsupported value type ${typeof value}`);
|
|
2104
2790
|
}
|
|
2105
|
-
function
|
|
2106
|
-
|
|
2791
|
+
function serializeDesign(design) {
|
|
2792
|
+
return serializeValue(design, 0);
|
|
2793
|
+
}
|
|
2794
|
+
function parseSlideDesign(source) {
|
|
2795
|
+
const ast = parseSource$1(source);
|
|
2796
|
+
if (!ast) return {
|
|
2797
|
+
ok: false,
|
|
2798
|
+
exists: true,
|
|
2799
|
+
error: "could not parse slide source"
|
|
2800
|
+
};
|
|
2801
|
+
const loc = findDesignDecl(ast);
|
|
2802
|
+
if (!loc) return {
|
|
2803
|
+
ok: false,
|
|
2804
|
+
exists: false
|
|
2805
|
+
};
|
|
2806
|
+
const objectNode = findDesignObjectNode(ast);
|
|
2807
|
+
if (!objectNode) return {
|
|
2808
|
+
ok: false,
|
|
2809
|
+
exists: true,
|
|
2810
|
+
error: "design has unsupported initializer"
|
|
2811
|
+
};
|
|
2812
|
+
let value;
|
|
2107
2813
|
try {
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2814
|
+
value = literalToValue(objectNode);
|
|
2815
|
+
} catch (err) {
|
|
2816
|
+
return {
|
|
2817
|
+
ok: false,
|
|
2818
|
+
exists: true,
|
|
2819
|
+
error: err.message
|
|
2820
|
+
};
|
|
2115
2821
|
}
|
|
2822
|
+
const merged = mergeDesign(defaultDesign, value);
|
|
2823
|
+
return {
|
|
2824
|
+
ok: true,
|
|
2825
|
+
design: merged,
|
|
2826
|
+
loc
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
function findDesignObjectNode(ast) {
|
|
2116
2830
|
const body = ast.program?.body ?? [];
|
|
2117
|
-
for (const
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
if (
|
|
2121
|
-
|
|
2831
|
+
for (const node of body) {
|
|
2832
|
+
let varDecl = null;
|
|
2833
|
+
if (node.type === "VariableDeclaration") varDecl = node;
|
|
2834
|
+
else if (node.type === "ExportNamedDeclaration") {
|
|
2835
|
+
const decl = node.declaration;
|
|
2836
|
+
if (decl?.type === "VariableDeclaration") varDecl = decl;
|
|
2837
|
+
}
|
|
2838
|
+
if (!varDecl) continue;
|
|
2839
|
+
const declarations = varDecl.declarations ?? [];
|
|
2122
2840
|
for (const d of declarations) {
|
|
2123
2841
|
const id = d.id;
|
|
2124
|
-
if (!id || id.type !== "Identifier" || id.name !== "
|
|
2842
|
+
if (!id || id.type !== "Identifier" || id.name !== "design") continue;
|
|
2125
2843
|
const init = d.init;
|
|
2126
|
-
if (!init
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
const elementTexts = [];
|
|
2132
|
-
for (const el of rawElements) {
|
|
2133
|
-
if (el === null) {
|
|
2134
|
-
elementTexts.push("undefined");
|
|
2135
|
-
continue;
|
|
2136
|
-
}
|
|
2137
|
-
if (el.type === "SpreadElement") return "invalid";
|
|
2138
|
-
const start = el.start;
|
|
2139
|
-
const end = el.end;
|
|
2140
|
-
if (typeof start !== "number" || typeof end !== "number") return "invalid";
|
|
2141
|
-
elementTexts.push(source.slice(start, end));
|
|
2844
|
+
if (!init) return null;
|
|
2845
|
+
let inner = init;
|
|
2846
|
+
if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
|
|
2847
|
+
const expr = inner.expression;
|
|
2848
|
+
if (expr) inner = expr;
|
|
2142
2849
|
}
|
|
2143
|
-
return
|
|
2144
|
-
|
|
2145
|
-
arrayEnd,
|
|
2146
|
-
elementTexts
|
|
2147
|
-
};
|
|
2850
|
+
if (inner.type !== "ObjectExpression") return null;
|
|
2851
|
+
return inner;
|
|
2148
2852
|
}
|
|
2149
2853
|
}
|
|
2150
2854
|
return null;
|
|
2151
2855
|
}
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
function reorderNotesArrayInSource(source, order) {
|
|
2166
|
-
for (const idx of order) if (!Number.isInteger(idx) || idx < 0) return null;
|
|
2167
|
-
const found = findNotesArray(source);
|
|
2168
|
-
if (found === "invalid") return null;
|
|
2169
|
-
if (found === null) return source;
|
|
2170
|
-
const { arrayStart, arrayEnd, elementTexts } = found;
|
|
2171
|
-
const pick = (i) => i >= 0 && i < elementTexts.length ? elementTexts[i] : "undefined";
|
|
2172
|
-
const reordered = order.map(pick);
|
|
2173
|
-
while (reordered.length > 0 && reordered[reordered.length - 1] === "undefined") reordered.pop();
|
|
2174
|
-
const replacement = reordered.length === 0 ? "[]" : `[\n${reordered.map((s) => ` ${s},`).join("\n")}\n]`;
|
|
2175
|
-
if (replacement === source.slice(arrayStart, arrayEnd)) return source;
|
|
2176
|
-
return source.slice(0, arrayStart) + replacement + source.slice(arrayEnd);
|
|
2177
|
-
}
|
|
2178
|
-
/**
|
|
2179
|
-
* Remove the element at `index` from `export default [...]`.
|
|
2180
|
-
*
|
|
2181
|
-
* Preserves the source slice of every other element, dropping the separator
|
|
2182
|
-
* immediately following the removed element (or the preceding one when the
|
|
2183
|
-
* removed element is the last). Returns `null` when the default export isn't
|
|
2184
|
-
* an array literal or `index` is out of range.
|
|
2185
|
-
*/
|
|
2186
|
-
function removePageFromDefaultExportInSource(source, index) {
|
|
2187
|
-
const found = findDefaultExportArray(source);
|
|
2188
|
-
if (!found) return null;
|
|
2189
|
-
const { elements, arrayStart, arrayEnd } = found;
|
|
2190
|
-
const n = elements.length;
|
|
2191
|
-
if (!Number.isInteger(index) || index < 0 || index >= n) return null;
|
|
2192
|
-
if (n === 1) return `${source.slice(0, arrayStart)}[]${source.slice(arrayEnd)}`;
|
|
2193
|
-
const prefix = source.slice(arrayStart, elements[0].start);
|
|
2194
|
-
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
2195
|
-
const separators = [];
|
|
2196
|
-
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
2197
|
-
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
2198
|
-
const keptElements = [];
|
|
2199
|
-
const keptSeparators = [];
|
|
2200
|
-
for (let i = 0; i < n; i++) {
|
|
2201
|
-
if (i === index) continue;
|
|
2202
|
-
keptElements.push(elementText[i]);
|
|
2203
|
-
}
|
|
2204
|
-
for (let i = 0; i < n - 1; i++) {
|
|
2205
|
-
if (index === n - 1 ? i === n - 2 : i === index) continue;
|
|
2206
|
-
keptSeparators.push(separators[i]);
|
|
2207
|
-
}
|
|
2208
|
-
let rebuilt = prefix + keptElements[0];
|
|
2209
|
-
for (let i = 1; i < keptElements.length; i++) rebuilt += keptSeparators[i - 1] + keptElements[i];
|
|
2210
|
-
rebuilt += suffix;
|
|
2211
|
-
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
2212
|
-
}
|
|
2213
|
-
function chooseInsertSeparator(prefix, existingSeparators) {
|
|
2214
|
-
const sample = existingSeparators.find((s) => s.includes(","));
|
|
2215
|
-
if (sample) return sample;
|
|
2216
|
-
if (prefix.includes("\n")) {
|
|
2217
|
-
const m = prefix.match(/\n([ \t]*)$/);
|
|
2218
|
-
const indent$1 = m ? m[1] : " ";
|
|
2219
|
-
return `,\n${indent$1}`;
|
|
2856
|
+
function findImports(ast) {
|
|
2857
|
+
const body = ast.program?.body ?? [];
|
|
2858
|
+
const out = [];
|
|
2859
|
+
for (const node of body) {
|
|
2860
|
+
if (node.type !== "ImportDeclaration") continue;
|
|
2861
|
+
const src = node.source?.value;
|
|
2862
|
+
if (typeof src !== "string") continue;
|
|
2863
|
+
const specs = node.specifiers ?? [];
|
|
2864
|
+
out.push({
|
|
2865
|
+
node,
|
|
2866
|
+
source: src,
|
|
2867
|
+
specifiers: specs
|
|
2868
|
+
});
|
|
2220
2869
|
}
|
|
2221
|
-
return
|
|
2870
|
+
return out;
|
|
2222
2871
|
}
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
}
|
|
2250
|
-
if (i < n - 1) newSeparators.push(separators[i]);
|
|
2872
|
+
function ensureDesignSystemImport(source, ast) {
|
|
2873
|
+
const imports = findImports(ast);
|
|
2874
|
+
const coreImport = imports.find((imp) => imp.source === "@open-slide/core");
|
|
2875
|
+
if (coreImport) {
|
|
2876
|
+
const hasDesignSystem = coreImport.specifiers.some((spec) => {
|
|
2877
|
+
if (spec.type !== "ImportSpecifier") return false;
|
|
2878
|
+
const imported = spec.imported;
|
|
2879
|
+
return imported?.name === "DesignSystem";
|
|
2880
|
+
});
|
|
2881
|
+
if (hasDesignSystem) return {
|
|
2882
|
+
source,
|
|
2883
|
+
offsetShift: 0
|
|
2884
|
+
};
|
|
2885
|
+
const node = coreImport.node;
|
|
2886
|
+
const importText = source.slice(node.start, node.end);
|
|
2887
|
+
const braceClose = importText.lastIndexOf("}");
|
|
2888
|
+
if (braceClose === -1) return {
|
|
2889
|
+
source,
|
|
2890
|
+
offsetShift: 0
|
|
2891
|
+
};
|
|
2892
|
+
const absoluteBrace = node.start + braceClose;
|
|
2893
|
+
const insertText = coreImport.specifiers.length > 0 ? ", type DesignSystem" : "type DesignSystem";
|
|
2894
|
+
const next$1 = `${source.slice(0, absoluteBrace)}${insertText}${source.slice(absoluteBrace)}`;
|
|
2895
|
+
return {
|
|
2896
|
+
source: next$1,
|
|
2897
|
+
offsetShift: insertText.length
|
|
2898
|
+
};
|
|
2251
2899
|
}
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2900
|
+
const stmt = `import type { DesignSystem } from '@open-slide/core';\n`;
|
|
2901
|
+
if (imports.length > 0) {
|
|
2902
|
+
const last = imports[imports.length - 1];
|
|
2903
|
+
const insertAt = last.node.end;
|
|
2904
|
+
const trail = source[insertAt] === "\n" ? "" : "\n";
|
|
2905
|
+
const next$1 = `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
|
|
2906
|
+
return {
|
|
2907
|
+
source: next$1,
|
|
2908
|
+
offsetShift: 1 + stmt.length - (trail ? 0 : 1)
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
const next = `${stmt}\n${source}`;
|
|
2912
|
+
return {
|
|
2913
|
+
source: next,
|
|
2914
|
+
offsetShift: stmt.length + 1
|
|
2915
|
+
};
|
|
2256
2916
|
}
|
|
2257
|
-
function
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2917
|
+
function findInsertionPoint(source, ast) {
|
|
2918
|
+
const imports = findImports(ast);
|
|
2919
|
+
if (imports.length === 0) return 0;
|
|
2920
|
+
const last = imports[imports.length - 1];
|
|
2921
|
+
let off = last.node.end;
|
|
2922
|
+
while (off < source.length && source[off] !== "\n") off++;
|
|
2923
|
+
if (off < source.length) off++;
|
|
2924
|
+
return off;
|
|
2925
|
+
}
|
|
2926
|
+
function applyDesignWrite(source, next) {
|
|
2927
|
+
let body;
|
|
2928
|
+
try {
|
|
2929
|
+
body = serializeDesign(next);
|
|
2930
|
+
} catch (err) {
|
|
2263
2931
|
return {
|
|
2264
|
-
|
|
2265
|
-
|
|
2932
|
+
ok: false,
|
|
2933
|
+
status: 422,
|
|
2934
|
+
error: `serialize failed: ${err.message}`
|
|
2266
2935
|
};
|
|
2267
2936
|
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2937
|
+
const ast = parseSource$1(source);
|
|
2938
|
+
if (!ast) return {
|
|
2939
|
+
ok: false,
|
|
2940
|
+
status: 422,
|
|
2941
|
+
error: "could not parse slide source"
|
|
2942
|
+
};
|
|
2943
|
+
const loc = findDesignDecl(ast);
|
|
2944
|
+
if (loc) {
|
|
2945
|
+
const out$1 = source.slice(0, loc.objectStart) + body + source.slice(loc.objectEnd);
|
|
2270
2946
|
return {
|
|
2271
|
-
|
|
2272
|
-
|
|
2947
|
+
ok: true,
|
|
2948
|
+
source: out$1,
|
|
2949
|
+
created: false
|
|
2273
2950
|
};
|
|
2274
2951
|
}
|
|
2275
|
-
|
|
2952
|
+
const withImport = ensureDesignSystemImport(source, ast);
|
|
2953
|
+
const ast2 = parseSource$1(withImport.source);
|
|
2954
|
+
if (!ast2) return {
|
|
2955
|
+
ok: false,
|
|
2956
|
+
status: 422,
|
|
2957
|
+
error: "failed to re-parse after adding import"
|
|
2958
|
+
};
|
|
2959
|
+
const insertAt = findInsertionPoint(withImport.source, ast2);
|
|
2960
|
+
const block = `\nconst design: DesignSystem = ${body};\n`;
|
|
2961
|
+
const out = withImport.source.slice(0, insertAt) + block + withImport.source.slice(insertAt);
|
|
2962
|
+
return {
|
|
2963
|
+
ok: true,
|
|
2964
|
+
source: out,
|
|
2965
|
+
created: true
|
|
2966
|
+
};
|
|
2276
2967
|
}
|
|
2277
|
-
function
|
|
2968
|
+
function designPlugin(opts) {
|
|
2278
2969
|
const userCwd = opts.userCwd;
|
|
2279
2970
|
const slidesDir = opts.slidesDir ?? "slides";
|
|
2280
|
-
const assetsDir = opts.assetsDir ?? "assets";
|
|
2281
|
-
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
2282
|
-
const globalAssetsRoot = path.resolve(userCwd, assetsDir);
|
|
2283
|
-
const manifestPath = path.join(slidesRoot, ".folders.json");
|
|
2284
2971
|
return {
|
|
2285
|
-
name: "open-slide:
|
|
2972
|
+
name: "open-slide:design",
|
|
2286
2973
|
apply: "serve",
|
|
2287
2974
|
configureServer(server) {
|
|
2288
|
-
server.
|
|
2289
|
-
server.watcher.on("change", (p) => {
|
|
2290
|
-
if (p === manifestPath) server.ws.send({
|
|
2291
|
-
type: "custom",
|
|
2292
|
-
event: "open-slide:files-changed"
|
|
2293
|
-
});
|
|
2294
|
-
});
|
|
2295
|
-
server.watcher.add(globalAssetsRoot);
|
|
2296
|
-
const onAssetChange = (p) => {
|
|
2297
|
-
if (p.startsWith(globalAssetsRoot + path.sep) || p === globalAssetsRoot) {
|
|
2298
|
-
server.ws.send({
|
|
2299
|
-
type: "custom",
|
|
2300
|
-
event: "open-slide:assets-changed",
|
|
2301
|
-
data: { slideId: GLOBAL_SCOPE }
|
|
2302
|
-
});
|
|
2303
|
-
return;
|
|
2304
|
-
}
|
|
2305
|
-
if (!p.startsWith(slidesRoot + path.sep)) return;
|
|
2306
|
-
const rel = p.slice(slidesRoot.length + 1);
|
|
2307
|
-
const parts = rel.split(path.sep);
|
|
2308
|
-
if (parts.length < 3 || parts[1] !== "assets") return;
|
|
2309
|
-
const slideId = parts[0];
|
|
2310
|
-
if (!SLIDE_ID_RE$1.test(slideId)) return;
|
|
2311
|
-
server.ws.send({
|
|
2312
|
-
type: "custom",
|
|
2313
|
-
event: "open-slide:assets-changed",
|
|
2314
|
-
data: { slideId }
|
|
2315
|
-
});
|
|
2316
|
-
};
|
|
2317
|
-
server.watcher.on("add", onAssetChange);
|
|
2318
|
-
server.watcher.on("change", onAssetChange);
|
|
2319
|
-
server.watcher.on("unlink", onAssetChange);
|
|
2320
|
-
server.middlewares.use("/__slides", async (req, res, next) => {
|
|
2975
|
+
server.middlewares.use("/__design", async (req, res, next) => {
|
|
2321
2976
|
const url = new URL(req.url ?? "/", "http://local");
|
|
2322
2977
|
const method = req.method ?? "GET";
|
|
2978
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
2979
|
+
const file = resolveSlidePath$1(userCwd, slidesDir, slideId);
|
|
2980
|
+
if (!file) return json$1(res, 400, { error: "invalid slideId" });
|
|
2323
2981
|
try {
|
|
2324
|
-
|
|
2325
|
-
if (reorderMatch && method === "PUT") {
|
|
2326
|
-
const slideId$1 = reorderMatch[1];
|
|
2327
|
-
if (!SLIDE_ID_RE$1.test(slideId$1)) return json$1(res, 400, { error: "invalid slideId" });
|
|
2328
|
-
const body = await readBody$1(req);
|
|
2329
|
-
if (!Array.isArray(body.order)) return json$1(res, 400, { error: "invalid order" });
|
|
2330
|
-
const order = [];
|
|
2331
|
-
for (const v of body.order) {
|
|
2332
|
-
if (!Number.isInteger(v)) return json$1(res, 400, { error: "invalid order" });
|
|
2333
|
-
order.push(v);
|
|
2334
|
-
}
|
|
2335
|
-
const entry = resolveSlideEntry(slidesRoot, slideId$1);
|
|
2336
|
-
if (!entry) return json$1(res, 400, { error: "invalid slideId" });
|
|
2982
|
+
if (method === "GET" && url.pathname === "/") {
|
|
2337
2983
|
let source;
|
|
2338
2984
|
try {
|
|
2339
|
-
source = await fs.readFile(
|
|
2985
|
+
source = await fs.readFile(file, "utf8");
|
|
2340
2986
|
} catch {
|
|
2341
2987
|
return json$1(res, 404, { error: "slide not found" });
|
|
2342
2988
|
}
|
|
2343
|
-
const
|
|
2344
|
-
if (
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2989
|
+
const parsed = parseSlideDesign(source);
|
|
2990
|
+
if (parsed.ok) return json$1(res, 200, {
|
|
2991
|
+
design: parsed.design,
|
|
2992
|
+
exists: true,
|
|
2993
|
+
warning: null
|
|
2994
|
+
});
|
|
2995
|
+
if (parsed.exists === false) return json$1(res, 200, {
|
|
2996
|
+
design: defaultDesign,
|
|
2997
|
+
exists: false,
|
|
2998
|
+
warning: null
|
|
2999
|
+
});
|
|
2348
3000
|
return json$1(res, 200, {
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
3001
|
+
design: defaultDesign,
|
|
3002
|
+
exists: true,
|
|
3003
|
+
warning: parsed.error
|
|
2352
3004
|
});
|
|
2353
3005
|
}
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
const
|
|
2358
|
-
const
|
|
2359
|
-
if (!
|
|
2360
|
-
if (!Number.isInteger(pageIndex) || pageIndex < 0) return json$1(res, 400, { error: "invalid page index" });
|
|
2361
|
-
const isDelete = method === "DELETE" && !op;
|
|
2362
|
-
const isDuplicate = method === "POST" && op === "duplicate";
|
|
2363
|
-
if (!isDelete && !isDuplicate) return next();
|
|
2364
|
-
const entry = resolveSlideEntry(slidesRoot, slideId$1);
|
|
2365
|
-
if (!entry) return json$1(res, 400, { error: "invalid slideId" });
|
|
3006
|
+
if (method === "PUT" && url.pathname === "/") {
|
|
3007
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
3008
|
+
if (!requestCheck.ok) return json$1(res, requestCheck.status, { error: requestCheck.error });
|
|
3009
|
+
const body = await readBody$1(req);
|
|
3010
|
+
const patch = body.patch;
|
|
3011
|
+
if (!patch || typeof patch !== "object") return json$1(res, 400, { error: "missing patch object" });
|
|
2366
3012
|
let source;
|
|
2367
3013
|
try {
|
|
2368
|
-
source = await fs.readFile(
|
|
3014
|
+
source = await fs.readFile(file, "utf8");
|
|
2369
3015
|
} catch {
|
|
2370
3016
|
return json$1(res, 404, { error: "slide not found" });
|
|
2371
3017
|
}
|
|
2372
|
-
const
|
|
2373
|
-
|
|
2374
|
-
if (
|
|
3018
|
+
const parsed = parseSlideDesign(source);
|
|
3019
|
+
const baseDesign = parsed.ok ? parsed.design : defaultDesign;
|
|
3020
|
+
if (!parsed.ok && parsed.exists) return json$1(res, 422, { error: parsed.error });
|
|
3021
|
+
const merged = mergeDesign(baseDesign, patch);
|
|
3022
|
+
const written = applyDesignWrite(source, merged);
|
|
3023
|
+
if (!written.ok) return json$1(res, written.status, { error: written.error });
|
|
3024
|
+
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
2375
3025
|
return json$1(res, 200, {
|
|
2376
3026
|
ok: true,
|
|
2377
|
-
|
|
2378
|
-
|
|
3027
|
+
design: merged,
|
|
3028
|
+
created: written.created
|
|
2379
3029
|
});
|
|
2380
3030
|
}
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
if (!SLIDE_ID_RE$1.test(slideId)) return json$1(res, 400, { error: "invalid slideId" });
|
|
2385
|
-
if (method === "PATCH") {
|
|
2386
|
-
const body = await readBody$1(req);
|
|
2387
|
-
const name = validateSlideName(body.name);
|
|
2388
|
-
if (!name) return json$1(res, 400, { error: "invalid name" });
|
|
2389
|
-
const entry = resolveSlideEntry(slidesRoot, slideId);
|
|
2390
|
-
if (!entry) return json$1(res, 400, { error: "invalid slideId" });
|
|
3031
|
+
if (method === "POST" && url.pathname === "/reset") {
|
|
3032
|
+
const requestCheck = validateMutationRequest(req);
|
|
3033
|
+
if (!requestCheck.ok) return json$1(res, requestCheck.status, { error: requestCheck.error });
|
|
2391
3034
|
let source;
|
|
2392
3035
|
try {
|
|
2393
|
-
source = await fs.readFile(
|
|
3036
|
+
source = await fs.readFile(file, "utf8");
|
|
2394
3037
|
} catch {
|
|
2395
3038
|
return json$1(res, 404, { error: "slide not found" });
|
|
2396
3039
|
}
|
|
2397
|
-
const
|
|
2398
|
-
if (
|
|
2399
|
-
if (
|
|
2400
|
-
server.ws.send({ type: "full-reload" });
|
|
3040
|
+
const written = applyDesignWrite(source, defaultDesign);
|
|
3041
|
+
if (!written.ok) return json$1(res, written.status, { error: written.error });
|
|
3042
|
+
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
2401
3043
|
return json$1(res, 200, {
|
|
2402
3044
|
ok: true,
|
|
2403
|
-
|
|
2404
|
-
|
|
3045
|
+
design: defaultDesign,
|
|
3046
|
+
created: written.created
|
|
2405
3047
|
});
|
|
2406
3048
|
}
|
|
2407
|
-
if (method === "DELETE") {
|
|
2408
|
-
const removed = await rmSlideDir(slidesRoot, slideId);
|
|
2409
|
-
if (!removed) return json$1(res, 404, { error: "slide not found" });
|
|
2410
|
-
const manifest = await readManifest(manifestPath);
|
|
2411
|
-
delete manifest.assignments[slideId];
|
|
2412
|
-
await writeManifest(manifestPath, manifest);
|
|
2413
|
-
return json$1(res, 200, { ok: true });
|
|
2414
|
-
}
|
|
2415
3049
|
return next();
|
|
2416
3050
|
} catch (err) {
|
|
2417
3051
|
json$1(res, 500, { error: String(err.message ?? err) });
|
|
2418
3052
|
}
|
|
2419
3053
|
});
|
|
2420
|
-
server.middlewares.use("/__assets", async (req, res, next) => {
|
|
2421
|
-
const url = new URL(req.url ?? "/", "http://local");
|
|
2422
|
-
const method = req.method ?? "GET";
|
|
2423
|
-
try {
|
|
2424
|
-
const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
|
|
2425
|
-
const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
|
|
2426
|
-
if (listMatch && method === "GET") {
|
|
2427
|
-
const slideId = listMatch[1];
|
|
2428
|
-
const assetsDir$1 = resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, slideId);
|
|
2429
|
-
if (!assetsDir$1) return json$1(res, 400, { error: "invalid slideId" });
|
|
2430
|
-
let entries;
|
|
2431
|
-
try {
|
|
2432
|
-
entries = await fs.readdir(assetsDir$1);
|
|
2433
|
-
} catch (err) {
|
|
2434
|
-
if (err.code === "ENOENT") return json$1(res, 200, { assets: [] });
|
|
2435
|
-
throw err;
|
|
2436
|
-
}
|
|
2437
|
-
const assets = [];
|
|
2438
|
-
for (const name of entries) {
|
|
2439
|
-
if (!validateAssetName(name)) continue;
|
|
2440
|
-
const stat = await fs.stat(path.join(assetsDir$1, name));
|
|
2441
|
-
if (!stat.isFile()) continue;
|
|
2442
|
-
assets.push({
|
|
2443
|
-
name,
|
|
2444
|
-
size: stat.size,
|
|
2445
|
-
mtime: stat.mtimeMs,
|
|
2446
|
-
mime: mimeForFilename(name),
|
|
2447
|
-
url: `/__assets/${slideId}/${encodeURIComponent(name)}`
|
|
2448
|
-
});
|
|
2449
|
-
}
|
|
2450
|
-
assets.sort((a, b) => a.name.localeCompare(b.name));
|
|
2451
|
-
return json$1(res, 200, { assets });
|
|
2452
|
-
}
|
|
2453
|
-
if (fileMatch) {
|
|
2454
|
-
const slideId = fileMatch[1];
|
|
2455
|
-
const filename = decodeURIComponent(fileMatch[2]);
|
|
2456
|
-
const file = resolveScopedAssetFile(slidesRoot, globalAssetsRoot, slideId, filename);
|
|
2457
|
-
if (!file) return json$1(res, 400, { error: "invalid path" });
|
|
2458
|
-
if (method === "GET") try {
|
|
2459
|
-
const buf = await fs.readFile(file);
|
|
2460
|
-
res.statusCode = 200;
|
|
2461
|
-
res.setHeader("content-type", mimeForFilename(filename));
|
|
2462
|
-
res.setHeader("cache-control", "no-store");
|
|
2463
|
-
res.end(buf);
|
|
2464
|
-
return;
|
|
2465
|
-
} catch (err) {
|
|
2466
|
-
if (err.code === "ENOENT") return json$1(res, 404, { error: "asset not found" });
|
|
2467
|
-
throw err;
|
|
2468
|
-
}
|
|
2469
|
-
if (method === "POST") {
|
|
2470
|
-
const overwrite = url.searchParams.get("overwrite") === "1";
|
|
2471
|
-
const lenHeader = req.headers["content-length"];
|
|
2472
|
-
const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
|
|
2473
|
-
if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json$1(res, 413, { error: "file too large" });
|
|
2474
|
-
if (!overwrite) try {
|
|
2475
|
-
await fs.access(file);
|
|
2476
|
-
return json$1(res, 409, { error: "asset exists" });
|
|
2477
|
-
} catch {}
|
|
2478
|
-
const assetsDir$1 = resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, slideId);
|
|
2479
|
-
if (!assetsDir$1) return json$1(res, 400, { error: "invalid slideId" });
|
|
2480
|
-
await fs.mkdir(assetsDir$1, { recursive: true });
|
|
2481
|
-
const chunks = [];
|
|
2482
|
-
let total = 0;
|
|
2483
|
-
let oversized = false;
|
|
2484
|
-
await new Promise((resolve, reject) => {
|
|
2485
|
-
req.on("data", (c) => {
|
|
2486
|
-
total += c.length;
|
|
2487
|
-
if (total > ASSET_MAX_BYTES) {
|
|
2488
|
-
oversized = true;
|
|
2489
|
-
req.destroy();
|
|
2490
|
-
return;
|
|
2491
|
-
}
|
|
2492
|
-
chunks.push(c);
|
|
2493
|
-
});
|
|
2494
|
-
req.on("end", () => resolve());
|
|
2495
|
-
req.on("error", reject);
|
|
2496
|
-
});
|
|
2497
|
-
if (oversized) return json$1(res, 413, { error: "file too large" });
|
|
2498
|
-
await fs.writeFile(file, Buffer.concat(chunks));
|
|
2499
|
-
return json$1(res, 200, {
|
|
2500
|
-
ok: true,
|
|
2501
|
-
name: filename,
|
|
2502
|
-
size: total,
|
|
2503
|
-
mime: mimeForFilename(filename),
|
|
2504
|
-
url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
|
|
2505
|
-
});
|
|
2506
|
-
}
|
|
2507
|
-
if (method === "PATCH") {
|
|
2508
|
-
const body = await readBody$1(req);
|
|
2509
|
-
const target = validateAssetName(body.name);
|
|
2510
|
-
if (!target) return json$1(res, 400, { error: "invalid name" });
|
|
2511
|
-
if (target === filename) return json$1(res, 200, {
|
|
2512
|
-
ok: true,
|
|
2513
|
-
name: filename
|
|
2514
|
-
});
|
|
2515
|
-
const dest = resolveScopedAssetFile(slidesRoot, globalAssetsRoot, slideId, target);
|
|
2516
|
-
if (!dest) return json$1(res, 400, { error: "invalid name" });
|
|
2517
|
-
try {
|
|
2518
|
-
await fs.access(dest);
|
|
2519
|
-
return json$1(res, 409, { error: "target exists" });
|
|
2520
|
-
} catch {}
|
|
2521
|
-
try {
|
|
2522
|
-
await fs.rename(file, dest);
|
|
2523
|
-
} catch (err) {
|
|
2524
|
-
if (err.code === "ENOENT") return json$1(res, 404, { error: "asset not found" });
|
|
2525
|
-
throw err;
|
|
2526
|
-
}
|
|
2527
|
-
return json$1(res, 200, {
|
|
2528
|
-
ok: true,
|
|
2529
|
-
name: target
|
|
2530
|
-
});
|
|
2531
|
-
}
|
|
2532
|
-
if (method === "DELETE") {
|
|
2533
|
-
try {
|
|
2534
|
-
await fs.unlink(file);
|
|
2535
|
-
} catch (err) {
|
|
2536
|
-
if (err.code === "ENOENT") return json$1(res, 404, { error: "asset not found" });
|
|
2537
|
-
throw err;
|
|
2538
|
-
}
|
|
2539
|
-
return json$1(res, 200, { ok: true });
|
|
2540
|
-
}
|
|
2541
|
-
}
|
|
2542
|
-
return next();
|
|
2543
|
-
} catch (err) {
|
|
2544
|
-
json$1(res, 500, { error: String(err.message ?? err) });
|
|
2545
|
-
}
|
|
2546
|
-
});
|
|
2547
|
-
server.middlewares.use("/__svgl", async (req, res, next) => {
|
|
2548
|
-
const reqUrl = new URL(req.url ?? "/", "http://local");
|
|
2549
|
-
const method = req.method ?? "GET";
|
|
2550
|
-
if (method !== "GET") return next();
|
|
2551
|
-
try {
|
|
2552
|
-
let target = null;
|
|
2553
|
-
if (reqUrl.pathname === "/search") {
|
|
2554
|
-
const params = new URLSearchParams();
|
|
2555
|
-
const q = reqUrl.searchParams.get("q");
|
|
2556
|
-
const limit = reqUrl.searchParams.get("limit");
|
|
2557
|
-
if (q) params.set("search", q);
|
|
2558
|
-
if (limit) params.set("limit", limit);
|
|
2559
|
-
const qs = params.toString();
|
|
2560
|
-
target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
|
|
2561
|
-
} else if (reqUrl.pathname === "/svg") {
|
|
2562
|
-
const u = reqUrl.searchParams.get("u");
|
|
2563
|
-
if (!u) return json$1(res, 400, { error: "missing u" });
|
|
2564
|
-
let parsed;
|
|
2565
|
-
try {
|
|
2566
|
-
parsed = new URL(u);
|
|
2567
|
-
} catch {
|
|
2568
|
-
return json$1(res, 400, { error: "invalid u" });
|
|
2569
|
-
}
|
|
2570
|
-
if (parsed.protocol !== "https:") return json$1(res, 400, { error: "https only" });
|
|
2571
|
-
const host = parsed.hostname.toLowerCase();
|
|
2572
|
-
if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json$1(res, 400, { error: "host not allowed" });
|
|
2573
|
-
target = parsed.toString();
|
|
2574
|
-
} else return next();
|
|
2575
|
-
const upstream = await fetch(target);
|
|
2576
|
-
const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
|
|
2577
|
-
res.statusCode = upstream.status;
|
|
2578
|
-
res.setHeader("content-type", ct);
|
|
2579
|
-
res.setHeader("cache-control", "no-store");
|
|
2580
|
-
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
2581
|
-
res.end(buf);
|
|
2582
|
-
} catch (err) {
|
|
2583
|
-
json$1(res, 502, { error: String(err.message ?? err) });
|
|
2584
|
-
}
|
|
2585
|
-
});
|
|
2586
|
-
server.middlewares.use("/__folders", async (req, res, next) => {
|
|
2587
|
-
const url = new URL(req.url ?? "/", "http://local");
|
|
2588
|
-
const method = req.method ?? "GET";
|
|
2589
|
-
try {
|
|
2590
|
-
if (method === "GET" && url.pathname === "/") {
|
|
2591
|
-
const manifest = await readManifest(manifestPath);
|
|
2592
|
-
return json$1(res, 200, manifest);
|
|
2593
|
-
}
|
|
2594
|
-
if (method === "POST" && url.pathname === "/") {
|
|
2595
|
-
const body = await readBody$1(req);
|
|
2596
|
-
const name = validateName(body.name);
|
|
2597
|
-
if (!name) return json$1(res, 400, { error: "invalid name" });
|
|
2598
|
-
const icon = validateIcon(body.icon);
|
|
2599
|
-
if (!icon) return json$1(res, 400, { error: "invalid icon" });
|
|
2600
|
-
const manifest = await readManifest(manifestPath);
|
|
2601
|
-
const folder = {
|
|
2602
|
-
id: newFolderId(),
|
|
2603
|
-
name,
|
|
2604
|
-
icon
|
|
2605
|
-
};
|
|
2606
|
-
manifest.folders.push(folder);
|
|
2607
|
-
await writeManifest(manifestPath, manifest);
|
|
2608
|
-
return json$1(res, 200, folder);
|
|
2609
|
-
}
|
|
2610
|
-
if (method === "PUT" && url.pathname === "/assign") {
|
|
2611
|
-
const body = await readBody$1(req);
|
|
2612
|
-
if (typeof body.slideId !== "string" || !SLIDE_ID_RE$1.test(body.slideId)) return json$1(res, 400, { error: "invalid slideId" });
|
|
2613
|
-
const slideId = body.slideId;
|
|
2614
|
-
let folderId;
|
|
2615
|
-
if (body.folderId === null) folderId = null;
|
|
2616
|
-
else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
|
|
2617
|
-
else return json$1(res, 400, { error: "invalid folderId" });
|
|
2618
|
-
const manifest = await readManifest(manifestPath);
|
|
2619
|
-
if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json$1(res, 404, { error: "folder not found" });
|
|
2620
|
-
if (folderId === null) delete manifest.assignments[slideId];
|
|
2621
|
-
else manifest.assignments[slideId] = folderId;
|
|
2622
|
-
await writeManifest(manifestPath, manifest);
|
|
2623
|
-
return json$1(res, 200, { ok: true });
|
|
2624
|
-
}
|
|
2625
|
-
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
2626
|
-
if (idMatch) {
|
|
2627
|
-
const id = idMatch[1];
|
|
2628
|
-
if (!FOLDER_ID_RE.test(id)) return json$1(res, 400, { error: "invalid id" });
|
|
2629
|
-
if (method === "PATCH") {
|
|
2630
|
-
const body = await readBody$1(req);
|
|
2631
|
-
const manifest = await readManifest(manifestPath);
|
|
2632
|
-
const folder = manifest.folders.find((f) => f.id === id);
|
|
2633
|
-
if (!folder) return json$1(res, 404, { error: "folder not found" });
|
|
2634
|
-
if (body.name !== void 0) {
|
|
2635
|
-
const name = validateName(body.name);
|
|
2636
|
-
if (!name) return json$1(res, 400, { error: "invalid name" });
|
|
2637
|
-
folder.name = name;
|
|
2638
|
-
}
|
|
2639
|
-
if (body.icon !== void 0) {
|
|
2640
|
-
const icon = validateIcon(body.icon);
|
|
2641
|
-
if (!icon) return json$1(res, 400, { error: "invalid icon" });
|
|
2642
|
-
folder.icon = icon;
|
|
2643
|
-
}
|
|
2644
|
-
await writeManifest(manifestPath, manifest);
|
|
2645
|
-
return json$1(res, 200, folder);
|
|
2646
|
-
}
|
|
2647
|
-
if (method === "DELETE") {
|
|
2648
|
-
const manifest = await readManifest(manifestPath);
|
|
2649
|
-
const before = manifest.folders.length;
|
|
2650
|
-
manifest.folders = manifest.folders.filter((f) => f.id !== id);
|
|
2651
|
-
if (manifest.folders.length === before) return json$1(res, 404, { error: "folder not found" });
|
|
2652
|
-
for (const [slideId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[slideId];
|
|
2653
|
-
await writeManifest(manifestPath, manifest);
|
|
2654
|
-
return json$1(res, 200, { ok: true });
|
|
2655
|
-
}
|
|
2656
|
-
}
|
|
2657
|
-
next();
|
|
2658
|
-
} catch (err) {
|
|
2659
|
-
json$1(res, 500, { error: String(err.message ?? err) });
|
|
2660
|
-
}
|
|
2661
|
-
});
|
|
2662
3054
|
}
|
|
2663
3055
|
};
|
|
2664
3056
|
}
|
|
@@ -2887,6 +3279,8 @@ function notesPlugin(opts) {
|
|
|
2887
3279
|
const url = new URL(req.url ?? "/", "http://local");
|
|
2888
3280
|
const method = req.method ?? "GET";
|
|
2889
3281
|
if (method !== "PUT" || url.pathname !== "/") return next();
|
|
3282
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
3283
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2890
3284
|
try {
|
|
2891
3285
|
const body = await readBody(req);
|
|
2892
3286
|
const slideId = body.slideId ?? "";
|
|
@@ -2959,13 +3353,18 @@ function toId(absFile, slidesRoot) {
|
|
|
2959
3353
|
return rel.split(path.sep)[0];
|
|
2960
3354
|
}
|
|
2961
3355
|
const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
|
|
2962
|
-
|
|
3356
|
+
const META_CREATED_AT_RE = /(?:^|[\s,{])createdAt\s*:\s*['"]([^'"]+)['"]/;
|
|
3357
|
+
function extractMeta(src) {
|
|
3358
|
+
const empty = {
|
|
3359
|
+
theme: null,
|
|
3360
|
+
createdAt: null
|
|
3361
|
+
};
|
|
2963
3362
|
const metaStart = src.search(/export\s+const\s+meta\b/);
|
|
2964
|
-
if (metaStart === -1) return
|
|
3363
|
+
if (metaStart === -1) return empty;
|
|
2965
3364
|
const eqIdx = src.indexOf("=", metaStart);
|
|
2966
|
-
if (eqIdx === -1) return
|
|
3365
|
+
if (eqIdx === -1) return empty;
|
|
2967
3366
|
const openBrace = src.indexOf("{", eqIdx);
|
|
2968
|
-
if (openBrace === -1) return
|
|
3367
|
+
if (openBrace === -1) return empty;
|
|
2969
3368
|
let depth = 0;
|
|
2970
3369
|
let closeBrace = -1;
|
|
2971
3370
|
for (let i = openBrace; i < src.length; i++) {
|
|
@@ -2979,34 +3378,52 @@ function extractMetaTheme(src) {
|
|
|
2979
3378
|
}
|
|
2980
3379
|
}
|
|
2981
3380
|
}
|
|
2982
|
-
if (closeBrace === -1) return
|
|
3381
|
+
if (closeBrace === -1) return empty;
|
|
2983
3382
|
const body = src.slice(openBrace + 1, closeBrace);
|
|
2984
|
-
const
|
|
2985
|
-
|
|
3383
|
+
const themeMatch = body.match(META_THEME_RE);
|
|
3384
|
+
const createdAtMatch = body.match(META_CREATED_AT_RE);
|
|
3385
|
+
return {
|
|
3386
|
+
theme: themeMatch ? themeMatch[1] : null,
|
|
3387
|
+
createdAt: createdAtMatch ? createdAtMatch[1] : null
|
|
3388
|
+
};
|
|
2986
3389
|
}
|
|
2987
|
-
async function
|
|
3390
|
+
async function readSlideMeta(abs) {
|
|
2988
3391
|
try {
|
|
2989
3392
|
const src = await fs.readFile(abs, "utf8");
|
|
2990
|
-
return
|
|
3393
|
+
return extractMeta(src);
|
|
2991
3394
|
} catch {
|
|
2992
|
-
return
|
|
3395
|
+
return {
|
|
3396
|
+
theme: null,
|
|
3397
|
+
createdAt: null
|
|
3398
|
+
};
|
|
2993
3399
|
}
|
|
2994
3400
|
}
|
|
3401
|
+
function parseCreatedAtMs(iso) {
|
|
3402
|
+
if (!iso) return null;
|
|
3403
|
+
const ms = Date.parse(iso);
|
|
3404
|
+
return Number.isFinite(ms) ? ms : null;
|
|
3405
|
+
}
|
|
2995
3406
|
async function generateSlidesModule(files, slidesRoot, isDev) {
|
|
2996
3407
|
const entries = await Promise.all(files.map(async (abs) => {
|
|
2997
3408
|
const id = toId(abs, slidesRoot);
|
|
2998
3409
|
const importPath = isDev ? `/@fs/${abs.replace(/^\/+/, "")}` : abs;
|
|
2999
|
-
const
|
|
3410
|
+
const meta = await readSlideMeta(abs);
|
|
3000
3411
|
return {
|
|
3001
3412
|
id,
|
|
3002
3413
|
importPath,
|
|
3003
|
-
theme
|
|
3414
|
+
theme: meta.theme,
|
|
3415
|
+
createdAt: parseCreatedAtMs(meta.createdAt)
|
|
3004
3416
|
};
|
|
3005
3417
|
}));
|
|
3006
3418
|
const ids = JSON.stringify(entries.map((e) => e.id).sort());
|
|
3007
3419
|
const themesMap = {};
|
|
3008
|
-
|
|
3420
|
+
const createdAtMap = {};
|
|
3421
|
+
for (const e of entries) {
|
|
3422
|
+
if (e.theme) themesMap[e.id] = e.theme;
|
|
3423
|
+
if (e.createdAt !== null) createdAtMap[e.id] = e.createdAt;
|
|
3424
|
+
}
|
|
3009
3425
|
const themesJson = JSON.stringify(themesMap);
|
|
3426
|
+
const createdAtJson = JSON.stringify(createdAtMap);
|
|
3010
3427
|
const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
|
|
3011
3428
|
const devRuntime = isDev ? `
|
|
3012
3429
|
const slideImportTokens = ${importTokens};
|
|
@@ -3027,6 +3444,7 @@ if (import.meta.hot) {
|
|
|
3027
3444
|
return `// virtual:open-slide/slides — generated
|
|
3028
3445
|
export const slideIds = ${ids};
|
|
3029
3446
|
export const slideThemes = ${themesJson};
|
|
3447
|
+
export const slideCreatedAt = ${createdAtJson};
|
|
3030
3448
|
${devRuntime}
|
|
3031
3449
|
|
|
3032
3450
|
export async function loadSlide(id) {
|
|
@@ -3229,18 +3647,18 @@ async function readTheme(mdAbs, themesRoot) {
|
|
|
3229
3647
|
};
|
|
3230
3648
|
}
|
|
3231
3649
|
function generateThemesModule(themes, isDev) {
|
|
3232
|
-
const meta = themes.map((t$
|
|
3233
|
-
id: t$
|
|
3234
|
-
name: t$
|
|
3235
|
-
description: t$
|
|
3236
|
-
body: t$
|
|
3237
|
-
hasDemo: t$
|
|
3650
|
+
const meta = themes.map((t$5) => ({
|
|
3651
|
+
id: t$5.id,
|
|
3652
|
+
name: t$5.frontmatter.name,
|
|
3653
|
+
description: t$5.frontmatter.description,
|
|
3654
|
+
body: t$5.body,
|
|
3655
|
+
hasDemo: t$5.demoAbs !== null
|
|
3238
3656
|
}));
|
|
3239
|
-
const cases = themes.flatMap((t$
|
|
3240
|
-
const abs = t$
|
|
3657
|
+
const cases = themes.flatMap((t$5) => {
|
|
3658
|
+
const abs = t$5.demoAbs;
|
|
3241
3659
|
if (!abs) return [];
|
|
3242
3660
|
const importPath = isDev ? `/@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
|
|
3243
|
-
return [` case ${JSON.stringify(t$
|
|
3661
|
+
return [` case ${JSON.stringify(t$5.id)}: return import(${JSON.stringify(importPath)});`];
|
|
3244
3662
|
}).join("\n");
|
|
3245
3663
|
return `// virtual:open-slide/themes — generated
|
|
3246
3664
|
export const themes = ${JSON.stringify(meta)};
|
|
@@ -3345,19 +3763,15 @@ async function createViteConfig(opts) {
|
|
|
3345
3763
|
config
|
|
3346
3764
|
}),
|
|
3347
3765
|
designPlugin({ userCwd }),
|
|
3348
|
-
|
|
3766
|
+
apiPlugin({
|
|
3349
3767
|
userCwd,
|
|
3350
|
-
slidesDir
|
|
3768
|
+
slidesDir,
|
|
3769
|
+
assetsDir
|
|
3351
3770
|
}),
|
|
3352
3771
|
notesPlugin({
|
|
3353
3772
|
userCwd,
|
|
3354
3773
|
slidesDir
|
|
3355
3774
|
}),
|
|
3356
|
-
filesPlugin({
|
|
3357
|
-
userCwd,
|
|
3358
|
-
slidesDir,
|
|
3359
|
-
assetsDir
|
|
3360
|
-
}),
|
|
3361
3775
|
currentPlugin({
|
|
3362
3776
|
userCwd,
|
|
3363
3777
|
slidesDir
|