@open-slide/core 1.4.0 → 1.6.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-tLrkKUHr.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-s0YUbmUe.d.ts → config-CfMThYN9.d.ts} +1 -1
- package/dist/{config-XZJnC_fu.js → config-PwUHqZ_X.js} +2312 -1654
- package/dist/{dev-0W8gYiSa.js → dev-DpCIRbhT.js} +1 -1
- package/dist/{en-7GU-DHbJ.js → en-BDnM5zKJ.js} +18 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.js +20 -4
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +55 -4
- package/dist/{preview-DT9hJvzM.js → preview-BSGlM6Se.js} +1 -1
- package/dist/{types-QCpkHkiS.d.ts → types-B-KrjgX8.d.ts} +21 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +30 -22
- package/skills/slide-authoring/SKILL.md +26 -2
- package/src/app/components/asset-view.tsx +83 -10
- package/src/app/components/inspector/inspector-panel.tsx +16 -1
- package/src/app/components/panel/panel-shell.tsx +5 -3
- package/src/app/components/player.tsx +6 -1
- package/src/app/components/present/laser-pointer.tsx +3 -4
- package/src/app/components/present/overview-grid.tsx +4 -1
- package/src/app/components/present/progress-bar.tsx +4 -4
- package/src/app/components/themes/theme-detail.tsx +7 -2
- package/src/app/components/themes/themes-gallery.tsx +4 -1
- package/src/app/components/thumbnail-rail.tsx +10 -2
- package/src/app/lib/assets.ts +23 -0
- package/src/app/lib/export-html.ts +7 -2
- package/src/app/lib/export-pdf.ts +34 -2
- package/src/app/lib/folders.ts +35 -1
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/sdk.ts +2 -0
- package/src/app/lib/slides.ts +2 -0
- package/src/app/lib/use-wheel-page-navigation.ts +7 -0
- package/src/app/routes/home-shell.tsx +13 -2
- package/src/app/routes/home.tsx +129 -5
- package/src/app/routes/presenter.tsx +7 -2
- package/src/app/routes/slide.tsx +49 -1
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +18 -1
- package/src/locale/ja.ts +18 -1
- package/src/locale/types.ts +21 -0
- package/src/locale/zh-cn.ts +18 -1
- package/src/locale/zh-tw.ts +18 -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,706 +1048,1825 @@ 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 findReferencedAssets(source, assetPaths) {
|
|
1154
|
+
const referenced = new Set();
|
|
1155
|
+
if (assetPaths.length === 0) return referenced;
|
|
1156
|
+
const ast = parseSource$2(source);
|
|
1157
|
+
if (!ast) return referenced;
|
|
1158
|
+
const wanted = new Set(assetPaths);
|
|
1159
|
+
const identToPath = new Map();
|
|
1160
|
+
for (const imp of findImports$1(ast)) {
|
|
1161
|
+
if (!imp.defaultIdent) continue;
|
|
1162
|
+
if (wanted.has(imp.source)) identToPath.set(imp.defaultIdent, imp.source);
|
|
1163
|
+
}
|
|
1164
|
+
if (identToPath.size === 0) return referenced;
|
|
1165
|
+
walkJsx(ast, (n) => {
|
|
1166
|
+
if (!t$3.isJSXElement(n)) return;
|
|
1167
|
+
const opening = n.openingElement;
|
|
1168
|
+
if (!t$3.isJSXIdentifier(opening.name) || opening.name.name !== "img") return;
|
|
1169
|
+
const src = findJsxAttr(opening, "src");
|
|
1170
|
+
if (!src?.value || !t$3.isJSXExpressionContainer(src.value)) return;
|
|
1171
|
+
const expr = src.value.expression;
|
|
1172
|
+
if (!t$3.isIdentifier(expr)) return;
|
|
1173
|
+
const p = identToPath.get(expr.name);
|
|
1174
|
+
if (p) referenced.add(p);
|
|
1175
|
+
});
|
|
1176
|
+
return referenced;
|
|
1177
|
+
}
|
|
1178
|
+
function applyRevertAsset(source, assetPath) {
|
|
1179
|
+
const ast = parseSource$2(source);
|
|
1180
|
+
if (!ast) return {
|
|
1181
|
+
ok: false,
|
|
1182
|
+
status: 422,
|
|
1183
|
+
error: "could not parse source"
|
|
1184
|
+
};
|
|
1185
|
+
const imports = findImports$1(ast);
|
|
1186
|
+
const target = imports.find((imp) => imp.source === assetPath && imp.defaultIdent);
|
|
1187
|
+
if (!target?.defaultIdent) return {
|
|
1188
|
+
ok: true,
|
|
1189
|
+
source
|
|
1190
|
+
};
|
|
1191
|
+
const identifier = target.defaultIdent;
|
|
1192
|
+
const importLocal = (() => {
|
|
1193
|
+
for (const spec of target.node.specifiers) if (t$3.isImportDefaultSpecifier(spec) && spec.local.name === identifier) return spec.local;
|
|
1194
|
+
return null;
|
|
1195
|
+
})();
|
|
1196
|
+
const imgUses = collectImgSrcUses(ast, identifier);
|
|
1197
|
+
const allowed = new Set(imgUses.map((u) => u.identNode));
|
|
1198
|
+
if (importLocal) allowed.add(importLocal);
|
|
1199
|
+
let foreign = false;
|
|
1200
|
+
walkAll(ast, (n) => {
|
|
1201
|
+
if (!t$3.isIdentifier(n) || n.name !== identifier) return;
|
|
1202
|
+
if (!allowed.has(n)) foreign = true;
|
|
1203
|
+
});
|
|
1204
|
+
if (foreign) return {
|
|
1205
|
+
ok: false,
|
|
1206
|
+
status: 422,
|
|
1207
|
+
error: `cannot revert: '${identifier}' is referenced outside <img src={${identifier}}>`
|
|
1208
|
+
};
|
|
1209
|
+
const splices = [];
|
|
1210
|
+
for (const use of imgUses) {
|
|
1211
|
+
const opening = use.element.openingElement;
|
|
1212
|
+
const hint = readJsxStringAttr(opening, "alt") ?? "";
|
|
1213
|
+
const width = readStyleNumericDim(opening, "width");
|
|
1214
|
+
const height = readStyleNumericDim(opening, "height");
|
|
1215
|
+
splices.push(spliceRange(use.element, buildPlaceholderReplacement(hint, width, height)));
|
|
1216
|
+
}
|
|
1217
|
+
const importNode = target.node;
|
|
1218
|
+
const importFrom = importNode.start ?? 0;
|
|
1219
|
+
let importTo = importNode.end ?? 0;
|
|
1220
|
+
if (source[importTo] === "\n") importTo += 1;
|
|
1221
|
+
splices.push({
|
|
1222
|
+
from: importFrom,
|
|
1223
|
+
to: importTo,
|
|
1224
|
+
text: ""
|
|
1225
|
+
});
|
|
1226
|
+
const ensureSplice = planEnsureImagePlaceholderImport(ast);
|
|
1227
|
+
if (ensureSplice) splices.push(ensureSplice);
|
|
1228
|
+
if (splices.length === 0) return {
|
|
1229
|
+
ok: true,
|
|
1230
|
+
source
|
|
1231
|
+
};
|
|
1232
|
+
splices.sort((a, b) => b.from - a.from);
|
|
1233
|
+
let next = source;
|
|
1234
|
+
for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
|
|
1235
|
+
if (!parseSource$2(next)) return {
|
|
1236
|
+
ok: false,
|
|
1237
|
+
status: 422,
|
|
1238
|
+
error: "edit would produce invalid source"
|
|
1239
|
+
};
|
|
1339
1240
|
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
|
-
}
|
|
1241
|
+
ok: true,
|
|
1242
|
+
source: next
|
|
1385
1243
|
};
|
|
1386
1244
|
}
|
|
1387
1245
|
|
|
1388
1246
|
//#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
|
-
});
|
|
1406
|
-
}
|
|
1407
|
-
function json$2(res, status, body) {
|
|
1408
|
-
res.statusCode = status;
|
|
1409
|
-
res.setHeader("content-type", "application/json");
|
|
1410
|
-
res.end(JSON.stringify(body));
|
|
1247
|
+
//#region src/editing/slide-ops.ts
|
|
1248
|
+
const SLIDE_ID_RE$3 = /^[a-z0-9_-]+$/i;
|
|
1249
|
+
function validateSlideName(v) {
|
|
1250
|
+
if (typeof v !== "string") return null;
|
|
1251
|
+
const trimmed = v.trim();
|
|
1252
|
+
if (trimmed.length < 1 || trimmed.length > 80) return null;
|
|
1253
|
+
return trimmed;
|
|
1411
1254
|
}
|
|
1412
|
-
function
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
if (!full.startsWith(`${slidesRoot}${path.sep}`)) return null;
|
|
1417
|
-
return full;
|
|
1255
|
+
function unwrapExpression(node) {
|
|
1256
|
+
let current = node;
|
|
1257
|
+
while (current && (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression")) current = current.expression;
|
|
1258
|
+
return current;
|
|
1418
1259
|
}
|
|
1419
|
-
function
|
|
1260
|
+
function readMetaTitleInSource(source) {
|
|
1261
|
+
let ast;
|
|
1420
1262
|
try {
|
|
1421
|
-
|
|
1263
|
+
ast = parse(source, {
|
|
1422
1264
|
sourceType: "module",
|
|
1423
1265
|
plugins: ["typescript", "jsx"],
|
|
1424
1266
|
errorRecovery: true
|
|
1425
1267
|
});
|
|
1426
1268
|
} catch {
|
|
1427
|
-
return
|
|
1269
|
+
return { kind: "unsupported" };
|
|
1428
1270
|
}
|
|
1429
|
-
}
|
|
1430
|
-
function findDesignDecl(ast) {
|
|
1431
1271
|
const body = ast.program?.body ?? [];
|
|
1432
|
-
for (const
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
if (decl?.type === "VariableDeclaration") varDecl = decl;
|
|
1438
|
-
}
|
|
1439
|
-
if (!varDecl) continue;
|
|
1440
|
-
const declarations = varDecl.declarations ?? [];
|
|
1272
|
+
for (const stmt of body) {
|
|
1273
|
+
if (stmt.type !== "ExportNamedDeclaration") continue;
|
|
1274
|
+
const decl = stmt.declaration;
|
|
1275
|
+
if (!decl || decl.type !== "VariableDeclaration") continue;
|
|
1276
|
+
const declarations = decl.declarations ?? [];
|
|
1441
1277
|
for (const d of declarations) {
|
|
1442
1278
|
const id = d.id;
|
|
1443
|
-
if (!id || id.type !== "Identifier" || id.name !== "
|
|
1444
|
-
const init = d.init;
|
|
1445
|
-
if (!init) return
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
const op = node.operator;
|
|
1470
|
-
const arg = node.argument;
|
|
1471
|
-
const v = literalToValue(arg);
|
|
1472
|
-
if (op === "-" && typeof v === "number") return -v;
|
|
1473
|
-
if (op === "+" && typeof v === "number") return v;
|
|
1474
|
-
throw new Error(`unsupported unary operator ${op}`);
|
|
1475
|
-
}
|
|
1476
|
-
case "TemplateLiteral": {
|
|
1477
|
-
const quasis = node.quasis;
|
|
1478
|
-
const expressions = node.expressions;
|
|
1479
|
-
if (expressions.length > 0) throw new Error("template literal has expressions");
|
|
1480
|
-
return quasis[0].value.cooked ?? quasis[0].value.raw;
|
|
1481
|
-
}
|
|
1482
|
-
case "ArrayExpression": {
|
|
1483
|
-
const elements = node.elements;
|
|
1484
|
-
return elements.map((el) => {
|
|
1485
|
-
if (!el) throw new Error("array has hole");
|
|
1486
|
-
return literalToValue(el);
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
case "ObjectExpression": {
|
|
1490
|
-
const properties = node.properties;
|
|
1491
|
-
const out = {};
|
|
1492
|
-
for (const prop of properties) {
|
|
1493
|
-
if (prop.type !== "ObjectProperty") throw new Error("object has spread or method");
|
|
1494
|
-
const p = prop;
|
|
1495
|
-
if (p.computed) throw new Error("object has computed key");
|
|
1496
|
-
let key;
|
|
1497
|
-
if (p.key.type === "Identifier" && typeof p.key.name === "string") key = p.key.name;
|
|
1498
|
-
else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") key = p.key.value;
|
|
1499
|
-
else throw new Error("unsupported object key");
|
|
1500
|
-
out[key] = literalToValue(p.value);
|
|
1279
|
+
if (!id || id.type !== "Identifier" || id.name !== "meta") continue;
|
|
1280
|
+
const init = unwrapExpression(d.init);
|
|
1281
|
+
if (!init || init.type !== "ObjectExpression") return { kind: "unsupported" };
|
|
1282
|
+
const properties = init.properties ?? [];
|
|
1283
|
+
for (const property of properties) {
|
|
1284
|
+
if (property.type !== "ObjectProperty" || property.computed) continue;
|
|
1285
|
+
const key = property.key;
|
|
1286
|
+
const keyName = key?.type === "Identifier" ? key.name : key?.type === "StringLiteral" ? key.value : void 0;
|
|
1287
|
+
if (keyName !== "title") continue;
|
|
1288
|
+
const value = property.value;
|
|
1289
|
+
if (value?.type === "StringLiteral" && typeof value.value === "string") return {
|
|
1290
|
+
kind: "found",
|
|
1291
|
+
title: value.value
|
|
1292
|
+
};
|
|
1293
|
+
if (value?.type === "TemplateLiteral") {
|
|
1294
|
+
const expressions = value.expressions ?? [];
|
|
1295
|
+
const quasis = value.quasis ?? [];
|
|
1296
|
+
const firstValue = quasis[0]?.value;
|
|
1297
|
+
const cooked = firstValue?.cooked;
|
|
1298
|
+
const raw = firstValue?.raw;
|
|
1299
|
+
if (expressions.length === 0 && typeof (cooked ?? raw) === "string") return {
|
|
1300
|
+
kind: "found",
|
|
1301
|
+
title: cooked ?? raw
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
return { kind: "unsupported" };
|
|
1501
1305
|
}
|
|
1502
|
-
return
|
|
1306
|
+
return { kind: "missing" };
|
|
1503
1307
|
}
|
|
1504
|
-
default: throw new Error(`unsupported node type ${node.type}`);
|
|
1505
1308
|
}
|
|
1309
|
+
return { kind: "missing" };
|
|
1506
1310
|
}
|
|
1507
|
-
function
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
};
|
|
1516
|
-
if (isPlainObject(patch)) apply(out, patch);
|
|
1517
|
-
return out;
|
|
1518
|
-
}
|
|
1519
|
-
function indent(level) {
|
|
1520
|
-
return " ".repeat(level);
|
|
1521
|
-
}
|
|
1522
|
-
function jsString(s) {
|
|
1523
|
-
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
1524
|
-
}
|
|
1525
|
-
function isValidIdentifier(name) {
|
|
1526
|
-
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
1527
|
-
}
|
|
1528
|
-
function serializeValue(value, level) {
|
|
1529
|
-
if (value === null) return "null";
|
|
1530
|
-
if (typeof value === "string") return jsString(value);
|
|
1531
|
-
if (typeof value === "number") {
|
|
1532
|
-
if (!Number.isFinite(value)) throw new Error("non-finite number");
|
|
1533
|
-
return String(value);
|
|
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)},`;
|
|
1311
|
+
async function rmSlideDir(slidesRoot, slideId) {
|
|
1312
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return false;
|
|
1313
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
1314
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return false;
|
|
1315
|
+
try {
|
|
1316
|
+
await fs.rm(dir, {
|
|
1317
|
+
recursive: true,
|
|
1318
|
+
force: true
|
|
1548
1319
|
});
|
|
1549
|
-
return
|
|
1320
|
+
return true;
|
|
1321
|
+
} catch {
|
|
1322
|
+
return false;
|
|
1550
1323
|
}
|
|
1551
|
-
throw new Error(`unsupported value type ${typeof value}`);
|
|
1552
1324
|
}
|
|
1553
|
-
function
|
|
1554
|
-
|
|
1555
|
-
}
|
|
1556
|
-
function parseSlideDesign(source) {
|
|
1557
|
-
const ast = parseSource$1(source);
|
|
1558
|
-
if (!ast) return {
|
|
1559
|
-
ok: false,
|
|
1560
|
-
exists: true,
|
|
1561
|
-
error: "could not parse slide source"
|
|
1562
|
-
};
|
|
1563
|
-
const loc = findDesignDecl(ast);
|
|
1564
|
-
if (!loc) return {
|
|
1325
|
+
async function duplicateSlideDir(slidesRoot, slideId, desiredId) {
|
|
1326
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return {
|
|
1565
1327
|
ok: false,
|
|
1566
|
-
|
|
1328
|
+
status: 400,
|
|
1329
|
+
error: "invalid slideId"
|
|
1567
1330
|
};
|
|
1568
|
-
const
|
|
1569
|
-
|
|
1331
|
+
const root = path.resolve(slidesRoot);
|
|
1332
|
+
const srcDir = path.resolve(root, slideId);
|
|
1333
|
+
if (!srcDir.startsWith(root + path.sep)) return {
|
|
1570
1334
|
ok: false,
|
|
1571
|
-
|
|
1572
|
-
error: "
|
|
1335
|
+
status: 400,
|
|
1336
|
+
error: "invalid slideId"
|
|
1573
1337
|
};
|
|
1574
|
-
let value;
|
|
1575
1338
|
try {
|
|
1576
|
-
|
|
1577
|
-
} catch
|
|
1339
|
+
await fs.access(path.join(srcDir, "index.tsx"));
|
|
1340
|
+
} catch {
|
|
1578
1341
|
return {
|
|
1579
1342
|
ok: false,
|
|
1580
|
-
|
|
1581
|
-
error:
|
|
1343
|
+
status: 404,
|
|
1344
|
+
error: "slide not found"
|
|
1582
1345
|
};
|
|
1583
1346
|
}
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1347
|
+
let newId;
|
|
1348
|
+
if (desiredId !== void 0) {
|
|
1349
|
+
if (!SLIDE_ID_RE$3.test(desiredId)) return {
|
|
1350
|
+
ok: false,
|
|
1351
|
+
status: 400,
|
|
1352
|
+
error: "invalid newId"
|
|
1353
|
+
};
|
|
1354
|
+
newId = desiredId;
|
|
1355
|
+
const dstDir$1 = path.resolve(root, newId);
|
|
1356
|
+
if (!dstDir$1.startsWith(root + path.sep)) return {
|
|
1357
|
+
ok: false,
|
|
1358
|
+
status: 400,
|
|
1359
|
+
error: "invalid newId"
|
|
1360
|
+
};
|
|
1361
|
+
try {
|
|
1362
|
+
await fs.access(dstDir$1);
|
|
1363
|
+
return {
|
|
1364
|
+
ok: false,
|
|
1365
|
+
status: 409,
|
|
1366
|
+
error: "slide already exists"
|
|
1367
|
+
};
|
|
1368
|
+
} catch {}
|
|
1369
|
+
} else {
|
|
1370
|
+
let suffix = 1;
|
|
1371
|
+
while (true) {
|
|
1372
|
+
newId = suffix === 1 ? `${slideId}-copy` : `${slideId}-copy-${suffix}`;
|
|
1373
|
+
try {
|
|
1374
|
+
await fs.access(path.resolve(root, newId));
|
|
1375
|
+
suffix++;
|
|
1376
|
+
} catch {
|
|
1377
|
+
break;
|
|
1611
1378
|
}
|
|
1612
|
-
if (inner.type !== "ObjectExpression") return null;
|
|
1613
|
-
return inner;
|
|
1614
1379
|
}
|
|
1615
1380
|
}
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
const
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1381
|
+
const dstDir = path.resolve(root, newId);
|
|
1382
|
+
if (!dstDir.startsWith(root + path.sep)) return {
|
|
1383
|
+
ok: false,
|
|
1384
|
+
status: 400,
|
|
1385
|
+
error: "invalid newId"
|
|
1386
|
+
};
|
|
1387
|
+
const srcEntry = path.join(srcDir, "index.tsx");
|
|
1388
|
+
let copiedEntrySource;
|
|
1389
|
+
try {
|
|
1390
|
+
const source = await fs.readFile(srcEntry, "utf8");
|
|
1391
|
+
const metaTitle = readMetaTitleInSource(source);
|
|
1392
|
+
if (metaTitle.kind === "unsupported") return {
|
|
1393
|
+
ok: false,
|
|
1394
|
+
status: 422,
|
|
1395
|
+
error: "could not update copied slide title"
|
|
1396
|
+
};
|
|
1397
|
+
const title = metaTitle.kind === "found" ? metaTitle.title : slideId;
|
|
1398
|
+
const updated = updateMetaTitleInSource(source, `${title} (copy)`);
|
|
1399
|
+
if (updated === null) return {
|
|
1400
|
+
ok: false,
|
|
1401
|
+
status: 422,
|
|
1402
|
+
error: "could not update copied slide title"
|
|
1403
|
+
};
|
|
1404
|
+
copiedEntrySource = updated;
|
|
1405
|
+
} catch {
|
|
1406
|
+
return {
|
|
1407
|
+
ok: false,
|
|
1408
|
+
status: 404,
|
|
1409
|
+
error: "slide not found"
|
|
1410
|
+
};
|
|
1631
1411
|
}
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1412
|
+
try {
|
|
1413
|
+
await fs.cp(srcDir, dstDir, {
|
|
1414
|
+
recursive: true,
|
|
1415
|
+
errorOnExist: true,
|
|
1416
|
+
force: false
|
|
1417
|
+
});
|
|
1418
|
+
await fs.writeFile(path.join(dstDir, "index.tsx"), copiedEntrySource, "utf8");
|
|
1419
|
+
return {
|
|
1420
|
+
ok: true,
|
|
1421
|
+
slideId: newId
|
|
1422
|
+
};
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
if (err.code === "EEXIST") return {
|
|
1425
|
+
ok: false,
|
|
1426
|
+
status: 409,
|
|
1427
|
+
error: "slide already exists"
|
|
1428
|
+
};
|
|
1429
|
+
return {
|
|
1430
|
+
ok: false,
|
|
1431
|
+
status: 500,
|
|
1432
|
+
error: String(err.message ?? err)
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
function resolveSlideEntry(slidesRoot, slideId) {
|
|
1437
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return null;
|
|
1438
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
1439
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return null;
|
|
1440
|
+
return path.join(dir, "index.tsx");
|
|
1441
|
+
}
|
|
1442
|
+
function escapeSingleQuoted(s) {
|
|
1443
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Rewrite (or insert) the `title` field in the slide module's `export const meta`.
|
|
1447
|
+
*
|
|
1448
|
+
* Strategy:
|
|
1449
|
+
* 1. Find `export const meta` and brace-match its object literal.
|
|
1450
|
+
* 2. If the object already has a `title: '...'` entry, replace the literal.
|
|
1451
|
+
* 3. If the object exists but has no title, inject a new `title: '...'` line
|
|
1452
|
+
* as the first property (preserving the author's surrounding indentation).
|
|
1453
|
+
* 4. If there is no `meta` export at all, insert a fresh one right before
|
|
1454
|
+
* `export default`.
|
|
1455
|
+
*
|
|
1456
|
+
* Returns the rewritten source, or `null` if the file shape was too surprising
|
|
1457
|
+
* to touch safely (e.g. `export default` missing when we'd need to inject meta).
|
|
1458
|
+
*/
|
|
1459
|
+
function updateMetaTitleInSource(source, title) {
|
|
1460
|
+
const newLiteral = `'${escapeSingleQuoted(title)}'`;
|
|
1461
|
+
const metaStart = source.search(/export\s+const\s+meta\b/);
|
|
1462
|
+
if (metaStart !== -1) {
|
|
1463
|
+
const eqIdx = source.indexOf("=", metaStart);
|
|
1464
|
+
if (eqIdx === -1) return null;
|
|
1465
|
+
const openBrace = source.indexOf("{", eqIdx);
|
|
1466
|
+
if (openBrace === -1) return null;
|
|
1467
|
+
let depth = 0;
|
|
1468
|
+
let closeBrace = -1;
|
|
1469
|
+
for (let i = openBrace; i < source.length; i++) {
|
|
1470
|
+
const ch = source[i];
|
|
1471
|
+
if (ch === "{") depth++;
|
|
1472
|
+
else if (ch === "}") {
|
|
1473
|
+
depth--;
|
|
1474
|
+
if (depth === 0) {
|
|
1475
|
+
closeBrace = i;
|
|
1476
|
+
break;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (closeBrace === -1) return null;
|
|
1481
|
+
const body = source.slice(openBrace + 1, closeBrace);
|
|
1482
|
+
const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
|
|
1483
|
+
const match = body.match(titleRe);
|
|
1484
|
+
if (match) {
|
|
1485
|
+
const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
|
|
1486
|
+
return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
|
|
1487
|
+
}
|
|
1488
|
+
const firstIndentMatch = body.match(/\n([ \t]+)\S/);
|
|
1489
|
+
const indent$1 = firstIndentMatch ? firstIndentMatch[1] : " ";
|
|
1490
|
+
const trimmedBody = body.replace(/^\s*\n?/, "");
|
|
1491
|
+
const needsSeparator = trimmedBody.trim().length > 0;
|
|
1492
|
+
const insertion$1 = `\n${indent$1}title: ${newLiteral}${needsSeparator ? "," : ""}`;
|
|
1493
|
+
return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
|
|
1494
|
+
}
|
|
1495
|
+
const exportDefaultIdx = source.search(/export\s+default\b/);
|
|
1496
|
+
if (exportDefaultIdx === -1) return null;
|
|
1497
|
+
const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
|
|
1498
|
+
return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
|
|
1499
|
+
}
|
|
1500
|
+
function findDefaultExportArray(source) {
|
|
1501
|
+
let ast;
|
|
1502
|
+
try {
|
|
1503
|
+
ast = parse(source, {
|
|
1504
|
+
sourceType: "module",
|
|
1505
|
+
plugins: ["typescript", "jsx"],
|
|
1506
|
+
errorRecovery: true
|
|
1507
|
+
});
|
|
1508
|
+
} catch {
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
const body = ast.program?.body ?? [];
|
|
1512
|
+
for (const node of body) {
|
|
1513
|
+
if (node.type !== "ExportDefaultDeclaration") continue;
|
|
1514
|
+
let inner = node.declaration;
|
|
1515
|
+
while (inner && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression")) inner = inner.expression;
|
|
1516
|
+
if (!inner || inner.type !== "ArrayExpression") return null;
|
|
1517
|
+
const arrayStart = inner.start;
|
|
1518
|
+
const arrayEnd = inner.end;
|
|
1519
|
+
const rawElements = inner.elements ?? [];
|
|
1520
|
+
const elements = [];
|
|
1521
|
+
for (const el of rawElements) {
|
|
1522
|
+
if (!el || typeof el.start !== "number" || typeof el.end !== "number") return null;
|
|
1523
|
+
elements.push({
|
|
1524
|
+
start: el.start,
|
|
1525
|
+
end: el.end
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
return {
|
|
1529
|
+
elements,
|
|
1530
|
+
arrayStart,
|
|
1531
|
+
arrayEnd
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Rewrite `export default [...]` so its elements appear in the requested order.
|
|
1538
|
+
*
|
|
1539
|
+
* `order[i]` is the original index that should land at new position `i`. The
|
|
1540
|
+
* function preserves each element's exact source slice (including any inline
|
|
1541
|
+
* comments that hug an identifier) and keeps the inter-element separator slots
|
|
1542
|
+
* in their original positions, so a 3-page array `[A, B, C]` reordered to
|
|
1543
|
+
* `[2, 0, 1]` becomes `[C, A, B]` with the same indentation and trailing
|
|
1544
|
+
* commas the author wrote.
|
|
1545
|
+
*
|
|
1546
|
+
* Returns `null` when the file's default export isn't an array literal, or the
|
|
1547
|
+
* order is not a valid permutation of `[0, n-1]`.
|
|
1548
|
+
*/
|
|
1549
|
+
function reorderDefaultExportPagesInSource(source, order) {
|
|
1550
|
+
const found = findDefaultExportArray(source);
|
|
1551
|
+
if (!found) return null;
|
|
1552
|
+
const { elements, arrayStart, arrayEnd } = found;
|
|
1553
|
+
const n = elements.length;
|
|
1554
|
+
if (order.length !== n) return null;
|
|
1555
|
+
const seen = new Set();
|
|
1556
|
+
for (const idx of order) {
|
|
1557
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= n) return null;
|
|
1558
|
+
if (seen.has(idx)) return null;
|
|
1559
|
+
seen.add(idx);
|
|
1560
|
+
}
|
|
1561
|
+
if (n === 0) return source;
|
|
1562
|
+
let identity = true;
|
|
1563
|
+
for (let i = 0; i < n; i++) if (order[i] !== i) {
|
|
1564
|
+
identity = false;
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
if (identity) return source;
|
|
1568
|
+
const prefix = source.slice(arrayStart, elements[0].start);
|
|
1569
|
+
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
1570
|
+
const separators = [];
|
|
1571
|
+
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
1572
|
+
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
1573
|
+
let rebuilt = prefix + elementText[order[0]];
|
|
1574
|
+
for (let i = 1; i < n; i++) rebuilt += separators[i - 1] + elementText[order[i]];
|
|
1575
|
+
rebuilt += suffix;
|
|
1576
|
+
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
1577
|
+
}
|
|
1578
|
+
function findNotesArray(source) {
|
|
1579
|
+
let ast;
|
|
1580
|
+
try {
|
|
1581
|
+
ast = parse(source, {
|
|
1582
|
+
sourceType: "module",
|
|
1583
|
+
plugins: ["typescript", "jsx"],
|
|
1584
|
+
errorRecovery: true
|
|
1585
|
+
});
|
|
1586
|
+
} catch {
|
|
1587
|
+
return "invalid";
|
|
1588
|
+
}
|
|
1589
|
+
const body = ast.program?.body ?? [];
|
|
1590
|
+
for (const stmt of body) {
|
|
1591
|
+
if (stmt.type !== "ExportNamedDeclaration") continue;
|
|
1592
|
+
const decl = stmt.declaration;
|
|
1593
|
+
if (!decl || decl.type !== "VariableDeclaration") continue;
|
|
1594
|
+
const declarations = decl.declarations ?? [];
|
|
1595
|
+
for (const d of declarations) {
|
|
1596
|
+
const id = d.id;
|
|
1597
|
+
if (!id || id.type !== "Identifier" || id.name !== "notes") continue;
|
|
1598
|
+
const init = d.init;
|
|
1599
|
+
if (!init || init.type !== "ArrayExpression") return "invalid";
|
|
1600
|
+
const arrayStart = init.start;
|
|
1601
|
+
const arrayEnd = init.end;
|
|
1602
|
+
if (typeof arrayStart !== "number" || typeof arrayEnd !== "number") return "invalid";
|
|
1603
|
+
const rawElements = init.elements ?? [];
|
|
1604
|
+
const elementTexts = [];
|
|
1605
|
+
for (const el of rawElements) {
|
|
1606
|
+
if (el === null) {
|
|
1607
|
+
elementTexts.push("undefined");
|
|
1608
|
+
continue;
|
|
1609
|
+
}
|
|
1610
|
+
if (el.type === "SpreadElement") return "invalid";
|
|
1611
|
+
const start = el.start;
|
|
1612
|
+
const end = el.end;
|
|
1613
|
+
if (typeof start !== "number" || typeof end !== "number") return "invalid";
|
|
1614
|
+
elementTexts.push(source.slice(start, end));
|
|
1615
|
+
}
|
|
1616
|
+
return {
|
|
1617
|
+
arrayStart,
|
|
1618
|
+
arrayEnd,
|
|
1619
|
+
elementTexts
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return null;
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Reorder `export const notes = [...]` to follow the page-array reorder.
|
|
1627
|
+
*
|
|
1628
|
+
* `order[i]` is the original page index that should land at new position `i`.
|
|
1629
|
+
* The notes array is index-aligned with the pages array but may be shorter
|
|
1630
|
+
* (trailing `undefined` slots are routinely trimmed). Missing elements are
|
|
1631
|
+
* treated as `undefined`, and trailing `undefined` is trimmed again after
|
|
1632
|
+
* reordering to keep the file tidy.
|
|
1633
|
+
*
|
|
1634
|
+
* Returns the rewritten source, the original source if no `notes` export
|
|
1635
|
+
* exists or the reorder is a no-op, or `null` if the `notes` export's shape
|
|
1636
|
+
* is too surprising to touch safely.
|
|
1637
|
+
*/
|
|
1638
|
+
function reorderNotesArrayInSource(source, order) {
|
|
1639
|
+
for (const idx of order) if (!Number.isInteger(idx) || idx < 0) return null;
|
|
1640
|
+
const found = findNotesArray(source);
|
|
1641
|
+
if (found === "invalid") return null;
|
|
1642
|
+
if (found === null) return source;
|
|
1643
|
+
const { arrayStart, arrayEnd, elementTexts } = found;
|
|
1644
|
+
const pick = (i) => i >= 0 && i < elementTexts.length ? elementTexts[i] : "undefined";
|
|
1645
|
+
const reordered = order.map(pick);
|
|
1646
|
+
while (reordered.length > 0 && reordered[reordered.length - 1] === "undefined") reordered.pop();
|
|
1647
|
+
const replacement = reordered.length === 0 ? "[]" : `[\n${reordered.map((s) => ` ${s},`).join("\n")}\n]`;
|
|
1648
|
+
if (replacement === source.slice(arrayStart, arrayEnd)) return source;
|
|
1649
|
+
return source.slice(0, arrayStart) + replacement + source.slice(arrayEnd);
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Remove the element at `index` from `export default [...]`.
|
|
1653
|
+
*
|
|
1654
|
+
* Preserves the source slice of every other element, dropping the separator
|
|
1655
|
+
* immediately following the removed element (or the preceding one when the
|
|
1656
|
+
* removed element is the last). Returns `null` when the default export isn't
|
|
1657
|
+
* an array literal or `index` is out of range.
|
|
1658
|
+
*/
|
|
1659
|
+
function removePageFromDefaultExportInSource(source, index) {
|
|
1660
|
+
const found = findDefaultExportArray(source);
|
|
1661
|
+
if (!found) return null;
|
|
1662
|
+
const { elements, arrayStart, arrayEnd } = found;
|
|
1663
|
+
const n = elements.length;
|
|
1664
|
+
if (!Number.isInteger(index) || index < 0 || index >= n) return null;
|
|
1665
|
+
if (n === 1) return `${source.slice(0, arrayStart)}[]${source.slice(arrayEnd)}`;
|
|
1666
|
+
const prefix = source.slice(arrayStart, elements[0].start);
|
|
1667
|
+
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
1668
|
+
const separators = [];
|
|
1669
|
+
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
1670
|
+
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
1671
|
+
const keptElements = [];
|
|
1672
|
+
const keptSeparators = [];
|
|
1673
|
+
for (let i = 0; i < n; i++) {
|
|
1674
|
+
if (i === index) continue;
|
|
1675
|
+
keptElements.push(elementText[i]);
|
|
1676
|
+
}
|
|
1677
|
+
for (let i = 0; i < n - 1; i++) {
|
|
1678
|
+
if (index === n - 1 ? i === n - 2 : i === index) continue;
|
|
1679
|
+
keptSeparators.push(separators[i]);
|
|
1680
|
+
}
|
|
1681
|
+
let rebuilt = prefix + keptElements[0];
|
|
1682
|
+
for (let i = 1; i < keptElements.length; i++) rebuilt += keptSeparators[i - 1] + keptElements[i];
|
|
1683
|
+
rebuilt += suffix;
|
|
1684
|
+
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
1685
|
+
}
|
|
1686
|
+
function chooseInsertSeparator(prefix, existingSeparators) {
|
|
1687
|
+
const sample = existingSeparators.find((s) => s.includes(","));
|
|
1688
|
+
if (sample) return sample;
|
|
1689
|
+
if (prefix.includes("\n")) {
|
|
1690
|
+
const m = prefix.match(/\n([ \t]*)$/);
|
|
1691
|
+
const indent$1 = m ? m[1] : " ";
|
|
1692
|
+
return `,\n${indent$1}`;
|
|
1693
|
+
}
|
|
1694
|
+
return ", ";
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Duplicate the element at `index` in `export default [...]`, inserting the
|
|
1698
|
+
* copy immediately after the original. Reuses an existing inter-element
|
|
1699
|
+
* separator when one is available so the cloned entry matches the surrounding
|
|
1700
|
+
* indentation. Returns `null` when the default export isn't an array literal
|
|
1701
|
+
* or `index` is out of range.
|
|
1702
|
+
*/
|
|
1703
|
+
function duplicatePageInDefaultExportInSource(source, index) {
|
|
1704
|
+
const found = findDefaultExportArray(source);
|
|
1705
|
+
if (!found) return null;
|
|
1706
|
+
const { elements, arrayStart, arrayEnd } = found;
|
|
1707
|
+
const n = elements.length;
|
|
1708
|
+
if (!Number.isInteger(index) || index < 0 || index >= n) return null;
|
|
1709
|
+
const prefix = source.slice(arrayStart, elements[0].start);
|
|
1710
|
+
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
1711
|
+
const separators = [];
|
|
1712
|
+
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
1713
|
+
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
1714
|
+
const insertSep = chooseInsertSeparator(prefix, separators);
|
|
1715
|
+
const newElements = [];
|
|
1716
|
+
const newSeparators = [];
|
|
1717
|
+
for (let i = 0; i < n; i++) {
|
|
1718
|
+
newElements.push(elementText[i]);
|
|
1719
|
+
if (i === index) {
|
|
1720
|
+
newElements.push(elementText[i]);
|
|
1721
|
+
newSeparators.push(insertSep);
|
|
1722
|
+
}
|
|
1723
|
+
if (i < n - 1) newSeparators.push(separators[i]);
|
|
1724
|
+
}
|
|
1725
|
+
let rebuilt = prefix + newElements[0];
|
|
1726
|
+
for (let i = 1; i < newElements.length; i++) rebuilt += newSeparators[i - 1] + newElements[i];
|
|
1727
|
+
rebuilt += suffix;
|
|
1728
|
+
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
//#endregion
|
|
1732
|
+
//#region src/files/assets.ts
|
|
1733
|
+
const GLOBAL_SCOPE = "@global";
|
|
1734
|
+
const ASSET_MAX_BYTES = 25 * 1024 * 1024;
|
|
1735
|
+
const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
|
|
1736
|
+
const MIME_BY_EXT = {
|
|
1737
|
+
png: "image/png",
|
|
1738
|
+
jpg: "image/jpeg",
|
|
1739
|
+
jpeg: "image/jpeg",
|
|
1740
|
+
gif: "image/gif",
|
|
1741
|
+
svg: "image/svg+xml",
|
|
1742
|
+
webp: "image/webp",
|
|
1743
|
+
avif: "image/avif",
|
|
1744
|
+
ico: "image/x-icon",
|
|
1745
|
+
mp4: "video/mp4",
|
|
1746
|
+
webm: "video/webm",
|
|
1747
|
+
mov: "video/quicktime",
|
|
1748
|
+
woff: "font/woff",
|
|
1749
|
+
woff2: "font/woff2",
|
|
1750
|
+
ttf: "font/ttf",
|
|
1751
|
+
otf: "font/otf",
|
|
1752
|
+
json: "application/json",
|
|
1753
|
+
txt: "text/plain; charset=utf-8",
|
|
1754
|
+
md: "text/markdown; charset=utf-8"
|
|
1755
|
+
};
|
|
1756
|
+
function mimeForFilename(name) {
|
|
1757
|
+
const dot = name.lastIndexOf(".");
|
|
1758
|
+
if (dot < 0) return "application/octet-stream";
|
|
1759
|
+
const ext = name.slice(dot + 1).toLowerCase();
|
|
1760
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
1761
|
+
}
|
|
1762
|
+
function validateAssetName(v) {
|
|
1763
|
+
if (typeof v !== "string") return null;
|
|
1764
|
+
const trimmed = v.trim();
|
|
1765
|
+
if (trimmed.length < 1 || trimmed.length > 120) return null;
|
|
1766
|
+
if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
|
|
1767
|
+
if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
|
|
1768
|
+
if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
|
|
1769
|
+
const dot = trimmed.lastIndexOf(".");
|
|
1770
|
+
if (dot <= 0 || dot === trimmed.length - 1) return null;
|
|
1771
|
+
return trimmed;
|
|
1772
|
+
}
|
|
1773
|
+
function resolveAssetsDir(slidesRoot, slideId) {
|
|
1774
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return null;
|
|
1775
|
+
const slideDir = path.resolve(slidesRoot, slideId);
|
|
1776
|
+
if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
|
|
1777
|
+
const assetsDir = path.resolve(slideDir, "assets");
|
|
1778
|
+
if (assetsDir !== path.join(slideDir, "assets")) return null;
|
|
1779
|
+
return assetsDir;
|
|
1780
|
+
}
|
|
1781
|
+
function resolveAssetFile(slidesRoot, slideId, filename) {
|
|
1782
|
+
const assetsDir = resolveAssetsDir(slidesRoot, slideId);
|
|
1783
|
+
if (!assetsDir) return null;
|
|
1784
|
+
if (!validateAssetName(filename)) return null;
|
|
1785
|
+
const file = path.resolve(assetsDir, filename);
|
|
1786
|
+
if (!file.startsWith(assetsDir + path.sep)) return null;
|
|
1787
|
+
return file;
|
|
1788
|
+
}
|
|
1789
|
+
function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
|
|
1790
|
+
if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
|
|
1791
|
+
return resolveAssetsDir(slidesRoot, scope);
|
|
1792
|
+
}
|
|
1793
|
+
function resolveScopedAssetFile(slidesRoot, globalAssetsRoot, scope, filename) {
|
|
1794
|
+
if (scope === GLOBAL_SCOPE) {
|
|
1795
|
+
if (!validateAssetName(filename)) return null;
|
|
1796
|
+
const file = path.resolve(globalAssetsRoot, filename);
|
|
1797
|
+
if (!file.startsWith(globalAssetsRoot + path.sep)) return null;
|
|
1798
|
+
return file;
|
|
1799
|
+
}
|
|
1800
|
+
return resolveAssetFile(slidesRoot, scope, filename);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
//#endregion
|
|
1804
|
+
//#region src/http/request-guard.ts
|
|
1805
|
+
function firstHeaderValue(value) {
|
|
1806
|
+
if (Array.isArray(value)) return value[0] ?? null;
|
|
1807
|
+
return value ?? null;
|
|
1808
|
+
}
|
|
1809
|
+
function headerValue(req, name) {
|
|
1810
|
+
return firstHeaderValue(req.headers[name.toLowerCase()])?.trim() ?? null;
|
|
1811
|
+
}
|
|
1812
|
+
function firstCommaToken(value) {
|
|
1813
|
+
if (!value) return null;
|
|
1814
|
+
const [first] = value.split(",", 1);
|
|
1815
|
+
return first?.trim() || null;
|
|
1816
|
+
}
|
|
1817
|
+
function requestProto(req) {
|
|
1818
|
+
const forwarded = firstCommaToken(headerValue(req, "x-forwarded-proto"))?.toLowerCase();
|
|
1819
|
+
if (forwarded === "http" || forwarded === "https") return forwarded;
|
|
1820
|
+
return "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
|
|
1821
|
+
}
|
|
1822
|
+
function normalizedOrigin(origin) {
|
|
1823
|
+
try {
|
|
1824
|
+
const url = new URL(origin);
|
|
1825
|
+
return `${url.protocol}//${url.host}`.toLowerCase();
|
|
1826
|
+
} catch {
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
function validateMutationRequest(req, opts = {}) {
|
|
1831
|
+
if (opts.requireJsonBody) {
|
|
1832
|
+
const contentType = headerValue(req, "content-type")?.toLowerCase();
|
|
1833
|
+
if (!contentType || !contentType.startsWith("application/json")) return {
|
|
1834
|
+
ok: false,
|
|
1835
|
+
status: 415,
|
|
1836
|
+
error: "content-type must be application/json"
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
const fetchSite = firstCommaToken(headerValue(req, "sec-fetch-site"))?.toLowerCase();
|
|
1840
|
+
if (fetchSite === "cross-site") return {
|
|
1841
|
+
ok: false,
|
|
1842
|
+
status: 403,
|
|
1843
|
+
error: "cross-site request blocked"
|
|
1844
|
+
};
|
|
1845
|
+
const originRaw = headerValue(req, "origin");
|
|
1846
|
+
if (!originRaw) return { ok: true };
|
|
1847
|
+
if (originRaw.toLowerCase() === "null") return {
|
|
1848
|
+
ok: false,
|
|
1849
|
+
status: 403,
|
|
1850
|
+
error: "opaque origin is not allowed"
|
|
1851
|
+
};
|
|
1852
|
+
const actualOrigin = normalizedOrigin(originRaw);
|
|
1853
|
+
if (!actualOrigin) return {
|
|
1854
|
+
ok: false,
|
|
1855
|
+
status: 403,
|
|
1856
|
+
error: "invalid origin header"
|
|
1857
|
+
};
|
|
1858
|
+
const host = firstCommaToken(headerValue(req, "x-forwarded-host")) ?? headerValue(req, "host");
|
|
1859
|
+
if (!host) return {
|
|
1860
|
+
ok: false,
|
|
1861
|
+
status: 400,
|
|
1862
|
+
error: "missing host header"
|
|
1863
|
+
};
|
|
1864
|
+
const expectedOrigin = `${requestProto(req)}://${host}`.toLowerCase();
|
|
1865
|
+
if (actualOrigin !== expectedOrigin) return {
|
|
1866
|
+
ok: false,
|
|
1867
|
+
status: 403,
|
|
1868
|
+
error: "origin mismatch"
|
|
1869
|
+
};
|
|
1870
|
+
return { ok: true };
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
//#endregion
|
|
1874
|
+
//#region src/vite/routes/context.ts
|
|
1875
|
+
function makeContext(opts) {
|
|
1876
|
+
const userCwd = opts.userCwd;
|
|
1877
|
+
const slidesDir = opts.slidesDir ?? "slides";
|
|
1878
|
+
const assetsDir = opts.assetsDir ?? "assets";
|
|
1879
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
1880
|
+
const globalAssetsRoot = path.resolve(userCwd, assetsDir);
|
|
1881
|
+
const manifestPath = path.join(slidesRoot, ".folders.json");
|
|
1882
|
+
return {
|
|
1883
|
+
userCwd,
|
|
1884
|
+
slidesDir,
|
|
1885
|
+
slidesRoot,
|
|
1886
|
+
globalAssetsRoot,
|
|
1887
|
+
manifestPath
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
async function readBody$2(req) {
|
|
1891
|
+
return await new Promise((resolve, reject) => {
|
|
1892
|
+
const chunks = [];
|
|
1893
|
+
req.on("data", (c) => chunks.push(c));
|
|
1894
|
+
req.on("end", () => {
|
|
1895
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1896
|
+
if (!raw) return resolve({});
|
|
1897
|
+
try {
|
|
1898
|
+
resolve(JSON.parse(raw));
|
|
1899
|
+
} catch (e) {
|
|
1900
|
+
reject(e);
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
req.on("error", reject);
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
function json$2(res, status, body) {
|
|
1907
|
+
res.statusCode = status;
|
|
1908
|
+
res.setHeader("content-type", "application/json");
|
|
1909
|
+
res.end(JSON.stringify(body));
|
|
1910
|
+
}
|
|
1911
|
+
function resolveSlideEntryPath(ctx, slideId) {
|
|
1912
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return null;
|
|
1913
|
+
const full = path.resolve(ctx.slidesRoot, slideId, "index.tsx");
|
|
1914
|
+
if (!full.startsWith(ctx.slidesRoot + path.sep)) return null;
|
|
1915
|
+
return full;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
//#endregion
|
|
1919
|
+
//#region src/vite/routes/assets.ts
|
|
1920
|
+
function registerAssetRoutes(server, ctx) {
|
|
1921
|
+
server.middlewares.use("/__assets", async (req, res, next) => {
|
|
1922
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
1923
|
+
const method = req.method ?? "GET";
|
|
1924
|
+
try {
|
|
1925
|
+
const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
|
|
1926
|
+
const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
|
|
1927
|
+
const usagesMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)\/usages$/);
|
|
1928
|
+
if (usagesMatch && method === "GET") {
|
|
1929
|
+
const scope = usagesMatch[1];
|
|
1930
|
+
const filename = decodeURIComponent(usagesMatch[2]);
|
|
1931
|
+
if (!validateAssetName(filename)) return json$2(res, 400, { error: "invalid path" });
|
|
1932
|
+
const isGlobal = scope === GLOBAL_SCOPE;
|
|
1933
|
+
const assetPath = isGlobal ? `@assets/${filename}` : `./assets/${filename}`;
|
|
1934
|
+
let slideIds;
|
|
1935
|
+
if (isGlobal) try {
|
|
1936
|
+
const entries = await fs.readdir(ctx.slidesRoot, { withFileTypes: true });
|
|
1937
|
+
slideIds = entries.filter((e) => e.isDirectory() && SLIDE_ID_RE$3.test(e.name)).map((e) => e.name);
|
|
1938
|
+
} catch {
|
|
1939
|
+
slideIds = [];
|
|
1940
|
+
}
|
|
1941
|
+
else {
|
|
1942
|
+
if (!SLIDE_ID_RE$3.test(scope)) return json$2(res, 400, { error: "invalid slideId" });
|
|
1943
|
+
slideIds = [scope];
|
|
1944
|
+
}
|
|
1945
|
+
const usages = [];
|
|
1946
|
+
let totalCount = 0;
|
|
1947
|
+
for (const sid of slideIds) {
|
|
1948
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, sid);
|
|
1949
|
+
if (!entry) continue;
|
|
1950
|
+
let source;
|
|
1951
|
+
try {
|
|
1952
|
+
source = await fs.readFile(entry, "utf8");
|
|
1953
|
+
} catch {
|
|
1954
|
+
continue;
|
|
1955
|
+
}
|
|
1956
|
+
const count = findAssetUsages(source, assetPath);
|
|
1957
|
+
if (count > 0) {
|
|
1958
|
+
usages.push({
|
|
1959
|
+
slideId: sid,
|
|
1960
|
+
count
|
|
1961
|
+
});
|
|
1962
|
+
totalCount += count;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
return json$2(res, 200, {
|
|
1966
|
+
usages,
|
|
1967
|
+
totalCount
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
if (listMatch && method === "GET") {
|
|
1971
|
+
const slideId = listMatch[1];
|
|
1972
|
+
const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
|
|
1973
|
+
if (!scopedDir) return json$2(res, 400, { error: "invalid slideId" });
|
|
1974
|
+
let entries;
|
|
1975
|
+
try {
|
|
1976
|
+
entries = await fs.readdir(scopedDir);
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
if (err.code === "ENOENT") return json$2(res, 200, { assets: [] });
|
|
1979
|
+
throw err;
|
|
1980
|
+
}
|
|
1981
|
+
const assets = [];
|
|
1982
|
+
for (const name of entries) {
|
|
1983
|
+
if (!validateAssetName(name)) continue;
|
|
1984
|
+
const stat = await fs.stat(path.join(scopedDir, name));
|
|
1985
|
+
if (!stat.isFile()) continue;
|
|
1986
|
+
assets.push({
|
|
1987
|
+
name,
|
|
1988
|
+
size: stat.size,
|
|
1989
|
+
mtime: stat.mtimeMs,
|
|
1990
|
+
mime: mimeForFilename(name),
|
|
1991
|
+
url: `/__assets/${slideId}/${encodeURIComponent(name)}`,
|
|
1992
|
+
unused: true
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
assets.sort((a, b) => a.name.localeCompare(b.name));
|
|
1996
|
+
if (assets.length > 0) {
|
|
1997
|
+
const isGlobal = slideId === GLOBAL_SCOPE;
|
|
1998
|
+
let scanIds;
|
|
1999
|
+
if (isGlobal) try {
|
|
2000
|
+
const dirs = await fs.readdir(ctx.slidesRoot, { withFileTypes: true });
|
|
2001
|
+
scanIds = dirs.filter((e) => e.isDirectory() && SLIDE_ID_RE$3.test(e.name)).map((e) => e.name);
|
|
2002
|
+
} catch {
|
|
2003
|
+
scanIds = [];
|
|
2004
|
+
}
|
|
2005
|
+
else scanIds = SLIDE_ID_RE$3.test(slideId) ? [slideId] : [];
|
|
2006
|
+
const paths = assets.map((a) => isGlobal ? `@assets/${a.name}` : `./assets/${a.name}`);
|
|
2007
|
+
const pathToAsset = new Map(paths.map((p, i) => [p, assets[i]]));
|
|
2008
|
+
for (const sid of scanIds) {
|
|
2009
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, sid);
|
|
2010
|
+
if (!entry) continue;
|
|
2011
|
+
let source;
|
|
2012
|
+
try {
|
|
2013
|
+
source = await fs.readFile(entry, "utf8");
|
|
2014
|
+
} catch {
|
|
2015
|
+
continue;
|
|
2016
|
+
}
|
|
2017
|
+
for (const p of findReferencedAssets(source, paths)) {
|
|
2018
|
+
const a = pathToAsset.get(p);
|
|
2019
|
+
if (a) a.unused = false;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
return json$2(res, 200, { assets });
|
|
2024
|
+
}
|
|
2025
|
+
if (fileMatch) {
|
|
2026
|
+
const slideId = fileMatch[1];
|
|
2027
|
+
const filename = decodeURIComponent(fileMatch[2]);
|
|
2028
|
+
const file = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, filename);
|
|
2029
|
+
if (!file) return json$2(res, 400, { error: "invalid path" });
|
|
2030
|
+
if (method === "GET") try {
|
|
2031
|
+
const buf = await fs.readFile(file);
|
|
2032
|
+
res.statusCode = 200;
|
|
2033
|
+
res.setHeader("content-type", mimeForFilename(filename));
|
|
2034
|
+
res.setHeader("cache-control", "no-store");
|
|
2035
|
+
res.end(buf);
|
|
2036
|
+
return;
|
|
2037
|
+
} catch (err) {
|
|
2038
|
+
if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
|
|
2039
|
+
throw err;
|
|
2040
|
+
}
|
|
2041
|
+
if (method === "POST") {
|
|
2042
|
+
const requestCheck = validateMutationRequest(req);
|
|
2043
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2044
|
+
const overwrite = url.searchParams.get("overwrite") === "1";
|
|
2045
|
+
const lenHeader = req.headers["content-length"];
|
|
2046
|
+
const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
|
|
2047
|
+
if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json$2(res, 413, { error: "file too large" });
|
|
2048
|
+
if (!overwrite) try {
|
|
2049
|
+
await fs.access(file);
|
|
2050
|
+
return json$2(res, 409, { error: "asset exists" });
|
|
2051
|
+
} catch {}
|
|
2052
|
+
const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
|
|
2053
|
+
if (!scopedDir) return json$2(res, 400, { error: "invalid slideId" });
|
|
2054
|
+
await fs.mkdir(scopedDir, { recursive: true });
|
|
2055
|
+
const chunks = [];
|
|
2056
|
+
let total = 0;
|
|
2057
|
+
let oversized = false;
|
|
2058
|
+
await new Promise((resolve, reject) => {
|
|
2059
|
+
req.on("data", (c) => {
|
|
2060
|
+
total += c.length;
|
|
2061
|
+
if (total > ASSET_MAX_BYTES) {
|
|
2062
|
+
oversized = true;
|
|
2063
|
+
req.destroy();
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
chunks.push(c);
|
|
2067
|
+
});
|
|
2068
|
+
req.on("end", () => resolve());
|
|
2069
|
+
req.on("error", reject);
|
|
2070
|
+
});
|
|
2071
|
+
if (oversized) return json$2(res, 413, { error: "file too large" });
|
|
2072
|
+
await fs.writeFile(file, Buffer.concat(chunks));
|
|
2073
|
+
return json$2(res, 200, {
|
|
2074
|
+
ok: true,
|
|
2075
|
+
name: filename,
|
|
2076
|
+
size: total,
|
|
2077
|
+
mime: mimeForFilename(filename),
|
|
2078
|
+
url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
if (method === "PATCH") {
|
|
2082
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2083
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2084
|
+
const body = await readBody$2(req);
|
|
2085
|
+
const target = validateAssetName(body.name);
|
|
2086
|
+
if (!target) return json$2(res, 400, { error: "invalid name" });
|
|
2087
|
+
if (target === filename) return json$2(res, 200, {
|
|
2088
|
+
ok: true,
|
|
2089
|
+
name: filename
|
|
2090
|
+
});
|
|
2091
|
+
const dest = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, target);
|
|
2092
|
+
if (!dest) return json$2(res, 400, { error: "invalid name" });
|
|
2093
|
+
try {
|
|
2094
|
+
await fs.access(dest);
|
|
2095
|
+
return json$2(res, 409, { error: "target exists" });
|
|
2096
|
+
} catch {}
|
|
2097
|
+
try {
|
|
2098
|
+
await fs.rename(file, dest);
|
|
2099
|
+
} catch (err) {
|
|
2100
|
+
if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
|
|
2101
|
+
throw err;
|
|
2102
|
+
}
|
|
2103
|
+
return json$2(res, 200, {
|
|
2104
|
+
ok: true,
|
|
2105
|
+
name: target
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
if (method === "DELETE") {
|
|
2109
|
+
const requestCheck = validateMutationRequest(req);
|
|
2110
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2111
|
+
try {
|
|
2112
|
+
await fs.unlink(file);
|
|
2113
|
+
} catch (err) {
|
|
2114
|
+
if (err.code === "ENOENT") return json$2(res, 404, { error: "asset not found" });
|
|
2115
|
+
throw err;
|
|
2116
|
+
}
|
|
2117
|
+
return json$2(res, 200, { ok: true });
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
return next();
|
|
2121
|
+
} catch (err) {
|
|
2122
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
//#endregion
|
|
2128
|
+
//#region src/editing/comments.ts
|
|
2129
|
+
const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
|
|
2130
|
+
function b64urlEncode(s) {
|
|
2131
|
+
return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2132
|
+
}
|
|
2133
|
+
function b64urlDecode(s) {
|
|
2134
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
|
|
2135
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
|
|
2136
|
+
}
|
|
2137
|
+
function parseMarkers(source) {
|
|
2138
|
+
const comments = [];
|
|
2139
|
+
const lines = source.split("\n");
|
|
2140
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2141
|
+
const line = lines[i];
|
|
2142
|
+
MARKER_RE.lastIndex = 0;
|
|
2143
|
+
const m = MARKER_RE.exec(line);
|
|
2144
|
+
if (!m) continue;
|
|
2145
|
+
const [, id, ts, textB64] = m;
|
|
2146
|
+
try {
|
|
2147
|
+
const payload = JSON.parse(b64urlDecode(textB64));
|
|
2148
|
+
comments.push({
|
|
2149
|
+
id,
|
|
2150
|
+
line: i + 1,
|
|
2151
|
+
ts,
|
|
2152
|
+
note: payload.note,
|
|
2153
|
+
hint: payload.hint
|
|
2154
|
+
});
|
|
2155
|
+
} catch {}
|
|
2156
|
+
}
|
|
2157
|
+
return comments;
|
|
2158
|
+
}
|
|
2159
|
+
function newCommentId() {
|
|
2160
|
+
return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
2161
|
+
}
|
|
2162
|
+
function markerDeleteRegex(id) {
|
|
2163
|
+
return new RegExp(`\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`);
|
|
2164
|
+
}
|
|
2165
|
+
function lineToOffset(source, line) {
|
|
2166
|
+
let off = 0;
|
|
2167
|
+
for (let l = 1; l < line; l++) {
|
|
2168
|
+
const nl = source.indexOf("\n", off);
|
|
2169
|
+
if (nl === -1) return source.length;
|
|
2170
|
+
off = nl + 1;
|
|
2171
|
+
}
|
|
2172
|
+
return off;
|
|
2173
|
+
}
|
|
2174
|
+
function lineIndent(source, lineNumber) {
|
|
2175
|
+
const start = lineToOffset(source, lineNumber);
|
|
2176
|
+
const m = source.slice(start, start + 200).match(/^[ \t]*/);
|
|
2177
|
+
return m?.[0] ?? "";
|
|
2178
|
+
}
|
|
2179
|
+
function findJsxAncestors(ast, line, column) {
|
|
2180
|
+
const hits = [];
|
|
2181
|
+
walkJsx(ast, (n) => {
|
|
2182
|
+
if (!n.loc || !t$2.isJSXElement(n) && !t$2.isJSXFragment(n)) return;
|
|
2183
|
+
const s = n.loc.start;
|
|
2184
|
+
const e = n.loc.end;
|
|
2185
|
+
const afterStart = line > s.line || line === s.line && column >= s.column;
|
|
2186
|
+
const beforeEnd = line < e.line || line === e.line && column < e.column;
|
|
2187
|
+
if (afterStart && beforeEnd) hits.push({
|
|
2188
|
+
node: n,
|
|
2189
|
+
size: (n.end ?? 0) - (n.start ?? 0)
|
|
1642
2190
|
});
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
const
|
|
1650
|
-
|
|
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)}`;
|
|
2191
|
+
});
|
|
2192
|
+
hits.sort((a, b) => a.size - b.size);
|
|
2193
|
+
return hits.map((h) => h.node);
|
|
2194
|
+
}
|
|
2195
|
+
function planInsertion(source, target) {
|
|
2196
|
+
if (t$2.isJSXFragment(target)) {
|
|
2197
|
+
const opening = target.openingFragment;
|
|
2198
|
+
const startLine = target.loc?.start.line ?? 1;
|
|
1657
2199
|
return {
|
|
1658
|
-
|
|
1659
|
-
|
|
2200
|
+
offset: opening.end ?? 0,
|
|
2201
|
+
indent: `${lineIndent(source, startLine)} `
|
|
1660
2202
|
};
|
|
1661
2203
|
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
const
|
|
1666
|
-
const trail = source[insertAt] === "\n" ? "" : "\n";
|
|
1667
|
-
const next$1 = `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
|
|
2204
|
+
if (t$2.isJSXElement(target)) {
|
|
2205
|
+
const opening = target.openingElement;
|
|
2206
|
+
if (opening.selfClosing) return null;
|
|
2207
|
+
const startLine = target.loc?.start.line ?? 1;
|
|
1668
2208
|
return {
|
|
1669
|
-
|
|
1670
|
-
|
|
2209
|
+
offset: opening.end ?? 0,
|
|
2210
|
+
indent: `${lineIndent(source, startLine)} `
|
|
1671
2211
|
};
|
|
1672
2212
|
}
|
|
1673
|
-
|
|
2213
|
+
return null;
|
|
2214
|
+
}
|
|
2215
|
+
function findInsertion(source, line, column) {
|
|
2216
|
+
const ast = parseSource$2(source);
|
|
2217
|
+
if (!ast) return null;
|
|
2218
|
+
const col = column ?? 0;
|
|
2219
|
+
const ancestors = findJsxAncestors(ast, line, col);
|
|
2220
|
+
for (const node of ancestors) {
|
|
2221
|
+
const plan = planInsertion(source, node);
|
|
2222
|
+
if (plan) return plan;
|
|
2223
|
+
}
|
|
2224
|
+
return null;
|
|
2225
|
+
}
|
|
2226
|
+
function offsetToLine(source, offset) {
|
|
2227
|
+
let line = 1;
|
|
2228
|
+
for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
|
|
2229
|
+
return line;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
//#endregion
|
|
2233
|
+
//#region src/vite/routes/comments.ts
|
|
2234
|
+
function registerCommentRoutes(server, ctx) {
|
|
2235
|
+
server.middlewares.use("/__comments", async (req, res, next) => {
|
|
2236
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2237
|
+
const method = req.method ?? "GET";
|
|
2238
|
+
try {
|
|
2239
|
+
if (method === "GET" && url.pathname === "/") {
|
|
2240
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
2241
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2242
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2243
|
+
let source;
|
|
2244
|
+
try {
|
|
2245
|
+
source = await fs.readFile(file, "utf8");
|
|
2246
|
+
} catch {
|
|
2247
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2248
|
+
}
|
|
2249
|
+
return json$2(res, 200, { comments: parseMarkers(source) });
|
|
2250
|
+
}
|
|
2251
|
+
if (method === "POST" && url.pathname === "/add") {
|
|
2252
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2253
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2254
|
+
const body = await readBody$2(req);
|
|
2255
|
+
const slideId = body.slideId ?? "";
|
|
2256
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2257
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2258
|
+
if (!body.line || body.line < 1) return json$2(res, 400, { error: "invalid line" });
|
|
2259
|
+
if (!body.text || typeof body.text !== "string") return json$2(res, 400, { error: "missing text" });
|
|
2260
|
+
let source;
|
|
2261
|
+
try {
|
|
2262
|
+
source = await fs.readFile(file, "utf8");
|
|
2263
|
+
} catch {
|
|
2264
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2265
|
+
}
|
|
2266
|
+
const plan = findInsertion(source, body.line, body.column);
|
|
2267
|
+
if (!plan) return json$2(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
|
|
2268
|
+
const id = newCommentId();
|
|
2269
|
+
const ts = new Date().toISOString();
|
|
2270
|
+
const payload = b64urlEncode(JSON.stringify({
|
|
2271
|
+
note: body.text,
|
|
2272
|
+
hint: body.hint
|
|
2273
|
+
}));
|
|
2274
|
+
const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
|
|
2275
|
+
const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
|
|
2276
|
+
await fs.writeFile(file, next$1, "utf8");
|
|
2277
|
+
const markerLine = offsetToLine(next$1, plan.offset + 1);
|
|
2278
|
+
return json$2(res, 200, {
|
|
2279
|
+
id,
|
|
2280
|
+
line: markerLine
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
if (method === "DELETE" && url.pathname.startsWith("/")) {
|
|
2284
|
+
const requestCheck = validateMutationRequest(req);
|
|
2285
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2286
|
+
const id = url.pathname.slice(1);
|
|
2287
|
+
if (!/^c-[a-f0-9]+$/.test(id)) return json$2(res, 400, { error: "invalid id" });
|
|
2288
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
2289
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2290
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2291
|
+
let source;
|
|
2292
|
+
try {
|
|
2293
|
+
source = await fs.readFile(file, "utf8");
|
|
2294
|
+
} catch {
|
|
2295
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2296
|
+
}
|
|
2297
|
+
const lines = source.split("\n");
|
|
2298
|
+
const idRe = markerDeleteRegex(id);
|
|
2299
|
+
const hit = lines.findIndex((l) => idRe.test(l));
|
|
2300
|
+
if (hit === -1) return json$2(res, 404, { error: "marker not found" });
|
|
2301
|
+
lines.splice(hit, 1);
|
|
2302
|
+
await fs.writeFile(file, lines.join("\n"), "utf8");
|
|
2303
|
+
return json$2(res, 200, { ok: true });
|
|
2304
|
+
}
|
|
2305
|
+
next();
|
|
2306
|
+
} catch (err) {
|
|
2307
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
2308
|
+
}
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
//#endregion
|
|
2313
|
+
//#region src/vite/routes/edit.ts
|
|
2314
|
+
function registerEditRoutes(server, ctx) {
|
|
2315
|
+
server.middlewares.use("/__edit", async (req, res, next) => {
|
|
2316
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2317
|
+
const method = req.method ?? "GET";
|
|
2318
|
+
if (method !== "POST") return next();
|
|
2319
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2320
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2321
|
+
try {
|
|
2322
|
+
if (url.pathname === "/") {
|
|
2323
|
+
const body = await readBody$2(req);
|
|
2324
|
+
const slideId = body.slideId ?? "";
|
|
2325
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2326
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2327
|
+
if (!body.line || body.line < 1) return json$2(res, 400, { error: "invalid line" });
|
|
2328
|
+
if (!Array.isArray(body.ops)) return json$2(res, 400, { error: "missing ops" });
|
|
2329
|
+
let source;
|
|
2330
|
+
try {
|
|
2331
|
+
source = await fs.readFile(file, "utf8");
|
|
2332
|
+
} catch {
|
|
2333
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2334
|
+
}
|
|
2335
|
+
const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
|
|
2336
|
+
if (!result.ok) return json$2(res, result.status, { error: result.error });
|
|
2337
|
+
const changed = result.source !== source;
|
|
2338
|
+
if (changed) await fs.writeFile(file, result.source, "utf8");
|
|
2339
|
+
return json$2(res, 200, {
|
|
2340
|
+
ok: true,
|
|
2341
|
+
changed
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
if (url.pathname === "/revert-asset") {
|
|
2345
|
+
const body = await readBody$2(req);
|
|
2346
|
+
const slideId = body.slideId ?? "";
|
|
2347
|
+
const assetPath = body.assetPath;
|
|
2348
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2349
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2350
|
+
if (typeof assetPath !== "string" || !assetPath) return json$2(res, 400, { error: "missing assetPath" });
|
|
2351
|
+
if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return json$2(res, 400, { error: "asset path must start with ./assets/ or @assets/" });
|
|
2352
|
+
let source;
|
|
2353
|
+
try {
|
|
2354
|
+
source = await fs.readFile(file, "utf8");
|
|
2355
|
+
} catch {
|
|
2356
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2357
|
+
}
|
|
2358
|
+
const result = applyRevertAsset(source, assetPath);
|
|
2359
|
+
if (!result.ok) return json$2(res, result.status, { error: result.error });
|
|
2360
|
+
const changed = result.source !== source;
|
|
2361
|
+
if (changed) await fs.writeFile(file, result.source, "utf8");
|
|
2362
|
+
return json$2(res, 200, {
|
|
2363
|
+
ok: true,
|
|
2364
|
+
changed
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
if (url.pathname === "/batch") {
|
|
2368
|
+
const body = await readBody$2(req);
|
|
2369
|
+
const slideId = body.slideId ?? "";
|
|
2370
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2371
|
+
if (!file) return json$2(res, 400, { error: "invalid slideId" });
|
|
2372
|
+
if (!Array.isArray(body.edits)) return json$2(res, 400, { error: "missing edits" });
|
|
2373
|
+
let source;
|
|
2374
|
+
try {
|
|
2375
|
+
source = await fs.readFile(file, "utf8");
|
|
2376
|
+
} catch {
|
|
2377
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2378
|
+
}
|
|
2379
|
+
const original = source;
|
|
2380
|
+
const results = [];
|
|
2381
|
+
for (const edit of body.edits) {
|
|
2382
|
+
if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
|
|
2383
|
+
results.push({
|
|
2384
|
+
ok: false,
|
|
2385
|
+
error: "invalid edit"
|
|
2386
|
+
});
|
|
2387
|
+
continue;
|
|
2388
|
+
}
|
|
2389
|
+
const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
|
|
2390
|
+
if (r.ok) {
|
|
2391
|
+
source = r.source;
|
|
2392
|
+
results.push({ ok: true });
|
|
2393
|
+
} else results.push({
|
|
2394
|
+
ok: false,
|
|
2395
|
+
error: r.error
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
const changed = source !== original;
|
|
2399
|
+
if (changed) await fs.writeFile(file, source, "utf8");
|
|
2400
|
+
return json$2(res, 200, {
|
|
2401
|
+
ok: true,
|
|
2402
|
+
changed,
|
|
2403
|
+
results
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
2406
|
+
return next();
|
|
2407
|
+
} catch (err) {
|
|
2408
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
2409
|
+
}
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
//#endregion
|
|
2414
|
+
//#region src/files/folders.ts
|
|
2415
|
+
const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
|
|
2416
|
+
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
2417
|
+
function emptyManifest() {
|
|
1674
2418
|
return {
|
|
1675
|
-
|
|
1676
|
-
|
|
2419
|
+
folders: [],
|
|
2420
|
+
assignments: {}
|
|
1677
2421
|
};
|
|
1678
2422
|
}
|
|
1679
|
-
function
|
|
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;
|
|
2423
|
+
async function readManifest(file) {
|
|
1690
2424
|
try {
|
|
1691
|
-
|
|
2425
|
+
const raw = await fs.readFile(file, "utf8");
|
|
2426
|
+
const parsed = JSON.parse(raw);
|
|
2427
|
+
return {
|
|
2428
|
+
folders: Array.isArray(parsed.folders) ? parsed.folders : [],
|
|
2429
|
+
assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
|
|
2430
|
+
};
|
|
1692
2431
|
} catch (err) {
|
|
2432
|
+
if (err.code === "ENOENT") return emptyManifest();
|
|
2433
|
+
throw err;
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
async function writeManifest(file, manifest) {
|
|
2437
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
2438
|
+
await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
2439
|
+
}
|
|
2440
|
+
function newFolderId() {
|
|
2441
|
+
return `f-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
2442
|
+
}
|
|
2443
|
+
function validateName(v) {
|
|
2444
|
+
if (typeof v !== "string") return null;
|
|
2445
|
+
const trimmed = v.trim();
|
|
2446
|
+
if (trimmed.length < 1 || trimmed.length > 40) return null;
|
|
2447
|
+
return trimmed;
|
|
2448
|
+
}
|
|
2449
|
+
function validateIcon(v) {
|
|
2450
|
+
if (!v || typeof v !== "object") return null;
|
|
2451
|
+
const icon = v;
|
|
2452
|
+
if (icon.type === "emoji") {
|
|
2453
|
+
if (typeof icon.value !== "string") return null;
|
|
2454
|
+
if (icon.value.length < 1 || icon.value.length > 8) return null;
|
|
1693
2455
|
return {
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
error: `serialize failed: ${err.message}`
|
|
2456
|
+
type: "emoji",
|
|
2457
|
+
value: icon.value
|
|
1697
2458
|
};
|
|
1698
2459
|
}
|
|
1699
|
-
|
|
1700
|
-
|
|
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);
|
|
2460
|
+
if (icon.type === "color") {
|
|
2461
|
+
if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
|
|
1708
2462
|
return {
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
created: false
|
|
2463
|
+
type: "color",
|
|
2464
|
+
value: icon.value
|
|
1712
2465
|
};
|
|
1713
2466
|
}
|
|
1714
|
-
|
|
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
|
-
};
|
|
2467
|
+
return null;
|
|
1729
2468
|
}
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
const
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
2469
|
+
|
|
2470
|
+
//#endregion
|
|
2471
|
+
//#region src/vite/routes/folders.ts
|
|
2472
|
+
function registerFolderRoutes(server, ctx) {
|
|
2473
|
+
server.middlewares.use("/__folders", async (req, res, next) => {
|
|
2474
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2475
|
+
const method = req.method ?? "GET";
|
|
2476
|
+
try {
|
|
2477
|
+
if (method === "GET" && url.pathname === "/") {
|
|
2478
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2479
|
+
return json$2(res, 200, manifest);
|
|
2480
|
+
}
|
|
2481
|
+
if (method === "POST" && url.pathname === "/") {
|
|
2482
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2483
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2484
|
+
const body = await readBody$2(req);
|
|
2485
|
+
const name = validateName(body.name);
|
|
2486
|
+
if (!name) return json$2(res, 400, { error: "invalid name" });
|
|
2487
|
+
const icon = validateIcon(body.icon);
|
|
2488
|
+
if (!icon) return json$2(res, 400, { error: "invalid icon" });
|
|
2489
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2490
|
+
const folder = {
|
|
2491
|
+
id: newFolderId(),
|
|
2492
|
+
name,
|
|
2493
|
+
icon
|
|
2494
|
+
};
|
|
2495
|
+
manifest.folders.push(folder);
|
|
2496
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2497
|
+
return json$2(res, 200, folder);
|
|
2498
|
+
}
|
|
2499
|
+
if (method === "PUT" && url.pathname === "/assign") {
|
|
2500
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2501
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2502
|
+
const body = await readBody$2(req);
|
|
2503
|
+
if (typeof body.slideId !== "string" || !SLIDE_ID_RE$3.test(body.slideId)) return json$2(res, 400, { error: "invalid slideId" });
|
|
2504
|
+
const slideId = body.slideId;
|
|
2505
|
+
let folderId;
|
|
2506
|
+
if (body.folderId === null) folderId = null;
|
|
2507
|
+
else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
|
|
2508
|
+
else return json$2(res, 400, { error: "invalid folderId" });
|
|
2509
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2510
|
+
if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json$2(res, 404, { error: "folder not found" });
|
|
2511
|
+
if (folderId === null) delete manifest.assignments[slideId];
|
|
2512
|
+
else manifest.assignments[slideId] = folderId;
|
|
2513
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2514
|
+
return json$2(res, 200, { ok: true });
|
|
2515
|
+
}
|
|
2516
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
2517
|
+
if (idMatch) {
|
|
2518
|
+
const id = idMatch[1];
|
|
2519
|
+
if (!FOLDER_ID_RE.test(id)) return json$2(res, 400, { error: "invalid id" });
|
|
2520
|
+
if (method === "PATCH") {
|
|
2521
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2522
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2523
|
+
const body = await readBody$2(req);
|
|
2524
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2525
|
+
const folder = manifest.folders.find((f) => f.id === id);
|
|
2526
|
+
if (!folder) return json$2(res, 404, { error: "folder not found" });
|
|
2527
|
+
if (body.name !== void 0) {
|
|
2528
|
+
const name = validateName(body.name);
|
|
2529
|
+
if (!name) return json$2(res, 400, { error: "invalid name" });
|
|
2530
|
+
folder.name = name;
|
|
1790
2531
|
}
|
|
1791
|
-
if (
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
-
});
|
|
2532
|
+
if (body.icon !== void 0) {
|
|
2533
|
+
const icon = validateIcon(body.icon);
|
|
2534
|
+
if (!icon) return json$2(res, 400, { error: "invalid icon" });
|
|
2535
|
+
folder.icon = icon;
|
|
1806
2536
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
2537
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2538
|
+
return json$2(res, 200, folder);
|
|
2539
|
+
}
|
|
2540
|
+
if (method === "DELETE") {
|
|
2541
|
+
const requestCheck = validateMutationRequest(req);
|
|
2542
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2543
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2544
|
+
const before = manifest.folders.length;
|
|
2545
|
+
manifest.folders = manifest.folders.filter((f) => f.id !== id);
|
|
2546
|
+
if (manifest.folders.length === before) return json$2(res, 404, { error: "folder not found" });
|
|
2547
|
+
for (const [slideId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[slideId];
|
|
2548
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2549
|
+
return json$2(res, 200, { ok: true });
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
next();
|
|
2553
|
+
} catch (err) {
|
|
2554
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
//#endregion
|
|
2560
|
+
//#region src/vite/routes/slides.ts
|
|
2561
|
+
function registerSlideRoutes(server, ctx) {
|
|
2562
|
+
server.middlewares.use("/__slides", async (req, res, next) => {
|
|
2563
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2564
|
+
const method = req.method ?? "GET";
|
|
2565
|
+
try {
|
|
2566
|
+
const reorderMatch = url.pathname.match(/^\/([^/]+)\/reorder$/);
|
|
2567
|
+
if (reorderMatch && method === "PUT") {
|
|
2568
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2569
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2570
|
+
const slideId$1 = reorderMatch[1];
|
|
2571
|
+
if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
|
|
2572
|
+
const body = await readBody$2(req);
|
|
2573
|
+
if (!Array.isArray(body.order)) return json$2(res, 400, { error: "invalid order" });
|
|
2574
|
+
const order = [];
|
|
2575
|
+
for (const v of body.order) {
|
|
2576
|
+
if (!Number.isInteger(v)) return json$2(res, 400, { error: "invalid order" });
|
|
2577
|
+
order.push(v);
|
|
2578
|
+
}
|
|
2579
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
|
|
2580
|
+
if (!entry) return json$2(res, 400, { error: "invalid slideId" });
|
|
2581
|
+
let source;
|
|
2582
|
+
try {
|
|
2583
|
+
source = await fs.readFile(entry, "utf8");
|
|
2584
|
+
} catch {
|
|
2585
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2586
|
+
}
|
|
2587
|
+
const reordered = reorderDefaultExportPagesInSource(source, order);
|
|
2588
|
+
if (reordered === null) return json$2(res, 422, { error: "could not reorder pages — order must be a permutation of the existing array" });
|
|
2589
|
+
const withNotes = reorderNotesArrayInSource(reordered, order);
|
|
2590
|
+
if (withNotes === null) return json$2(res, 422, { error: "could not reorder pages — `notes` export has an unexpected shape" });
|
|
2591
|
+
if (withNotes !== source) await fs.writeFile(entry, withNotes, "utf8");
|
|
2592
|
+
return json$2(res, 200, {
|
|
2593
|
+
ok: true,
|
|
2594
|
+
slideId: slideId$1,
|
|
2595
|
+
order
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
const pageOpMatch = url.pathname.match(/^\/([^/]+)\/pages\/(\d+)(?:\/([a-z]+))?$/);
|
|
2599
|
+
if (pageOpMatch) {
|
|
2600
|
+
const slideId$1 = pageOpMatch[1];
|
|
2601
|
+
const pageIndex = Number.parseInt(pageOpMatch[2], 10);
|
|
2602
|
+
const op = pageOpMatch[3];
|
|
2603
|
+
if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
|
|
2604
|
+
if (!Number.isInteger(pageIndex) || pageIndex < 0) return json$2(res, 400, { error: "invalid page index" });
|
|
2605
|
+
const isDelete = method === "DELETE" && !op;
|
|
2606
|
+
const isDuplicate = method === "POST" && op === "duplicate";
|
|
2607
|
+
if (!isDelete && !isDuplicate) return next();
|
|
2608
|
+
const requestCheck = validateMutationRequest(req);
|
|
2609
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2610
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
|
|
2611
|
+
if (!entry) return json$2(res, 400, { error: "invalid slideId" });
|
|
2612
|
+
let source;
|
|
2613
|
+
try {
|
|
2614
|
+
source = await fs.readFile(entry, "utf8");
|
|
2615
|
+
} catch {
|
|
2616
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2617
|
+
}
|
|
2618
|
+
const updated = isDelete ? removePageFromDefaultExportInSource(source, pageIndex) : duplicatePageInDefaultExportInSource(source, pageIndex);
|
|
2619
|
+
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" });
|
|
2620
|
+
if (updated !== source) await fs.writeFile(entry, updated, "utf8");
|
|
2621
|
+
return json$2(res, 200, {
|
|
2622
|
+
ok: true,
|
|
2623
|
+
slideId: slideId$1,
|
|
2624
|
+
index: pageIndex
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
const duplicateMatch = url.pathname.match(/^\/([^/]+)\/duplicate$/);
|
|
2628
|
+
if (duplicateMatch && method === "POST") {
|
|
2629
|
+
const requestCheck = validateMutationRequest(req);
|
|
2630
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2631
|
+
const slideId$1 = duplicateMatch[1];
|
|
2632
|
+
if (!SLIDE_ID_RE$3.test(slideId$1)) return json$2(res, 400, { error: "invalid slideId" });
|
|
2633
|
+
const body = await readBody$2(req);
|
|
2634
|
+
if (body.newId !== void 0 && typeof body.newId !== "string") return json$2(res, 400, { error: "invalid newId" });
|
|
2635
|
+
const duplicated = await duplicateSlideDir(ctx.slidesRoot, slideId$1, body.newId);
|
|
2636
|
+
if (!duplicated.ok) return json$2(res, duplicated.status, { error: duplicated.error });
|
|
2637
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2638
|
+
const folderId = manifest.assignments[slideId$1];
|
|
2639
|
+
if (folderId) {
|
|
2640
|
+
manifest.assignments[duplicated.slideId] = folderId;
|
|
2641
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2642
|
+
}
|
|
2643
|
+
return json$2(res, 200, {
|
|
2644
|
+
ok: true,
|
|
2645
|
+
slideId: duplicated.slideId
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
2649
|
+
if (!idMatch) return next();
|
|
2650
|
+
const slideId = idMatch[1];
|
|
2651
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return json$2(res, 400, { error: "invalid slideId" });
|
|
2652
|
+
if (method === "PATCH") {
|
|
2653
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2654
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2655
|
+
const body = await readBody$2(req);
|
|
2656
|
+
const name = validateSlideName(body.name);
|
|
2657
|
+
if (!name) return json$2(res, 400, { error: "invalid name" });
|
|
2658
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId);
|
|
2659
|
+
if (!entry) return json$2(res, 400, { error: "invalid slideId" });
|
|
2660
|
+
let source;
|
|
2661
|
+
try {
|
|
2662
|
+
source = await fs.readFile(entry, "utf8");
|
|
2663
|
+
} catch {
|
|
2664
|
+
return json$2(res, 404, { error: "slide not found" });
|
|
2665
|
+
}
|
|
2666
|
+
const updated = updateMetaTitleInSource(source, name);
|
|
2667
|
+
if (updated === null) return json$2(res, 422, { error: "could not locate a safe place to write meta.title in index.tsx" });
|
|
2668
|
+
if (updated !== source) await fs.writeFile(entry, updated, "utf8");
|
|
2669
|
+
server.ws.send({ type: "full-reload" });
|
|
2670
|
+
return json$2(res, 200, {
|
|
2671
|
+
ok: true,
|
|
2672
|
+
slideId,
|
|
2673
|
+
name
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
if (method === "DELETE") {
|
|
2677
|
+
const requestCheck = validateMutationRequest(req);
|
|
2678
|
+
if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
|
|
2679
|
+
const removed = await rmSlideDir(ctx.slidesRoot, slideId);
|
|
2680
|
+
if (!removed) return json$2(res, 404, { error: "slide not found" });
|
|
2681
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2682
|
+
delete manifest.assignments[slideId];
|
|
2683
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2684
|
+
return json$2(res, 200, { ok: true });
|
|
2685
|
+
}
|
|
2686
|
+
return next();
|
|
2687
|
+
} catch (err) {
|
|
2688
|
+
json$2(res, 500, { error: String(err.message ?? err) });
|
|
2689
|
+
}
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
//#endregion
|
|
2694
|
+
//#region src/vite/routes/svgl.ts
|
|
2695
|
+
function registerSvglRoutes(server) {
|
|
2696
|
+
server.middlewares.use("/__svgl", async (req, res, next) => {
|
|
2697
|
+
const reqUrl = new URL(req.url ?? "/", "http://local");
|
|
2698
|
+
const method = req.method ?? "GET";
|
|
2699
|
+
if (method !== "GET") return next();
|
|
2700
|
+
try {
|
|
2701
|
+
let target = null;
|
|
2702
|
+
if (reqUrl.pathname === "/search") {
|
|
2703
|
+
const params = new URLSearchParams();
|
|
2704
|
+
const q = reqUrl.searchParams.get("q");
|
|
2705
|
+
const limit = reqUrl.searchParams.get("limit");
|
|
2706
|
+
if (q) params.set("search", q);
|
|
2707
|
+
if (limit) params.set("limit", limit);
|
|
2708
|
+
const qs = params.toString();
|
|
2709
|
+
target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
|
|
2710
|
+
} else if (reqUrl.pathname === "/svg") {
|
|
2711
|
+
const u = reqUrl.searchParams.get("u");
|
|
2712
|
+
if (!u) return json$2(res, 400, { error: "missing u" });
|
|
2713
|
+
let parsed;
|
|
2714
|
+
try {
|
|
2715
|
+
parsed = new URL(u);
|
|
2716
|
+
} catch {
|
|
2717
|
+
return json$2(res, 400, { error: "invalid u" });
|
|
1810
2718
|
}
|
|
2719
|
+
if (parsed.protocol !== "https:") return json$2(res, 400, { error: "https only" });
|
|
2720
|
+
const host = parsed.hostname.toLowerCase();
|
|
2721
|
+
if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json$2(res, 400, { error: "host not allowed" });
|
|
2722
|
+
target = parsed.toString();
|
|
2723
|
+
} else return next();
|
|
2724
|
+
const upstream = await fetch(target);
|
|
2725
|
+
const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
|
|
2726
|
+
res.statusCode = upstream.status;
|
|
2727
|
+
res.setHeader("content-type", ct);
|
|
2728
|
+
res.setHeader("cache-control", "no-store");
|
|
2729
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
2730
|
+
res.end(buf);
|
|
2731
|
+
} catch (err) {
|
|
2732
|
+
json$2(res, 502, { error: String(err.message ?? err) });
|
|
2733
|
+
}
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
//#endregion
|
|
2738
|
+
//#region src/vite/routes/watchers.ts
|
|
2739
|
+
function registerWatchers(server, ctx) {
|
|
2740
|
+
server.watcher.add(ctx.manifestPath);
|
|
2741
|
+
server.watcher.on("change", (p) => {
|
|
2742
|
+
if (p === ctx.manifestPath) server.ws.send({
|
|
2743
|
+
type: "custom",
|
|
2744
|
+
event: "open-slide:files-changed"
|
|
2745
|
+
});
|
|
2746
|
+
});
|
|
2747
|
+
server.watcher.add(ctx.globalAssetsRoot);
|
|
2748
|
+
const onAssetChange = (p) => {
|
|
2749
|
+
if (p.startsWith(ctx.globalAssetsRoot + path.sep) || p === ctx.globalAssetsRoot) {
|
|
2750
|
+
server.ws.send({
|
|
2751
|
+
type: "custom",
|
|
2752
|
+
event: "open-slide:assets-changed",
|
|
2753
|
+
data: { slideId: GLOBAL_SCOPE }
|
|
1811
2754
|
});
|
|
2755
|
+
return;
|
|
1812
2756
|
}
|
|
2757
|
+
if (!p.startsWith(ctx.slidesRoot + path.sep)) return;
|
|
2758
|
+
const rel = p.slice(ctx.slidesRoot.length + 1);
|
|
2759
|
+
const parts = rel.split(path.sep);
|
|
2760
|
+
if (parts.length < 3 || parts[1] !== "assets") return;
|
|
2761
|
+
const slideId = parts[0];
|
|
2762
|
+
if (!SLIDE_ID_RE$3.test(slideId)) return;
|
|
2763
|
+
server.ws.send({
|
|
2764
|
+
type: "custom",
|
|
2765
|
+
event: "open-slide:assets-changed",
|
|
2766
|
+
data: { slideId }
|
|
2767
|
+
});
|
|
1813
2768
|
};
|
|
2769
|
+
server.watcher.on("add", onAssetChange);
|
|
2770
|
+
server.watcher.on("change", onAssetChange);
|
|
2771
|
+
server.watcher.on("unlink", onAssetChange);
|
|
1814
2772
|
}
|
|
1815
2773
|
|
|
1816
2774
|
//#endregion
|
|
1817
|
-
//#region src/vite/
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
const
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
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";
|
|
2775
|
+
//#region src/vite/api-plugin.ts
|
|
2776
|
+
function apiPlugin(opts) {
|
|
2777
|
+
return {
|
|
2778
|
+
name: "open-slide:api",
|
|
2779
|
+
apply: "serve",
|
|
2780
|
+
configureServer(server) {
|
|
2781
|
+
const ctx = makeContext(opts);
|
|
2782
|
+
registerWatchers(server, ctx);
|
|
2783
|
+
registerEditRoutes(server, ctx);
|
|
2784
|
+
registerCommentRoutes(server, ctx);
|
|
2785
|
+
registerSlideRoutes(server, ctx);
|
|
2786
|
+
registerAssetRoutes(server, ctx);
|
|
2787
|
+
registerSvglRoutes(server);
|
|
2788
|
+
registerFolderRoutes(server, ctx);
|
|
2789
|
+
}
|
|
2790
|
+
};
|
|
1849
2791
|
}
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
if (
|
|
1857
|
-
const
|
|
1858
|
-
if (
|
|
1859
|
-
return
|
|
2792
|
+
|
|
2793
|
+
//#endregion
|
|
2794
|
+
//#region src/vite/current-plugin.ts
|
|
2795
|
+
const SLIDE_ID_RE$2 = /^[a-z0-9_-]+$/i;
|
|
2796
|
+
const TEXT_SNIPPET_MAX = 120;
|
|
2797
|
+
function parseSelection(raw) {
|
|
2798
|
+
if (raw == null || typeof raw !== "object") return null;
|
|
2799
|
+
const sel = raw;
|
|
2800
|
+
if (typeof sel.line !== "number" || !Number.isFinite(sel.line)) return null;
|
|
2801
|
+
if (typeof sel.column !== "number" || !Number.isFinite(sel.column)) return null;
|
|
2802
|
+
const tagName = typeof sel.tagName === "string" ? sel.tagName.toLowerCase().slice(0, 32) : "unknown";
|
|
2803
|
+
const text = typeof sel.text === "string" ? sel.text.replace(/\s+/g, " ").trim().slice(0, TEXT_SNIPPET_MAX) : "";
|
|
2804
|
+
return {
|
|
2805
|
+
line: Math.max(1, Math.floor(sel.line)),
|
|
2806
|
+
column: Math.max(0, Math.floor(sel.column)),
|
|
2807
|
+
tagName,
|
|
2808
|
+
text
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
function currentPlugin(opts) {
|
|
2812
|
+
const userCwd = opts.userCwd;
|
|
2813
|
+
const slidesDir = opts.slidesDir ?? "slides";
|
|
2814
|
+
const outDir = path.join(userCwd, "node_modules", ".open-slide");
|
|
2815
|
+
const outFile = path.join(outDir, "current.json");
|
|
2816
|
+
const tmpFile = `${outFile}.tmp`;
|
|
2817
|
+
let cached = null;
|
|
2818
|
+
return {
|
|
2819
|
+
name: "open-slide:current",
|
|
2820
|
+
apply: "serve",
|
|
2821
|
+
configureServer(server) {
|
|
2822
|
+
server.ws.on("open-slide:current", async (raw) => {
|
|
2823
|
+
const next = cached ? { ...cached } : {
|
|
2824
|
+
slideId: "",
|
|
2825
|
+
pageIndex: 0,
|
|
2826
|
+
pageNumber: 1,
|
|
2827
|
+
totalPages: 1,
|
|
2828
|
+
slideTitle: "",
|
|
2829
|
+
view: "slides",
|
|
2830
|
+
pagePath: "",
|
|
2831
|
+
selection: null
|
|
2832
|
+
};
|
|
2833
|
+
if (typeof raw?.slideId === "string") {
|
|
2834
|
+
if (!SLIDE_ID_RE$2.test(raw.slideId)) return;
|
|
2835
|
+
const totalPages = typeof raw.totalPages === "number" && Number.isFinite(raw.totalPages) && raw.totalPages > 0 ? Math.floor(raw.totalPages) : 1;
|
|
2836
|
+
const rawIndex = typeof raw.pageIndex === "number" && Number.isFinite(raw.pageIndex) ? Math.floor(raw.pageIndex) : 0;
|
|
2837
|
+
const pageIndex = Math.max(0, Math.min(totalPages - 1, rawIndex));
|
|
2838
|
+
const slideTitle = typeof raw.slideTitle === "string" ? raw.slideTitle : raw.slideId;
|
|
2839
|
+
const view = raw.view === "assets" ? "assets" : "slides";
|
|
2840
|
+
const pagePath = path.join(slidesDir, raw.slideId, "index.tsx").split(path.sep).join("/");
|
|
2841
|
+
if (cached?.slideId !== raw.slideId || cached?.pageIndex !== pageIndex) next.selection = null;
|
|
2842
|
+
next.slideId = raw.slideId;
|
|
2843
|
+
next.pageIndex = pageIndex;
|
|
2844
|
+
next.pageNumber = pageIndex + 1;
|
|
2845
|
+
next.totalPages = totalPages;
|
|
2846
|
+
next.slideTitle = slideTitle;
|
|
2847
|
+
next.view = view;
|
|
2848
|
+
next.pagePath = pagePath;
|
|
2849
|
+
}
|
|
2850
|
+
if ("selection" in raw) next.selection = parseSelection(raw.selection);
|
|
2851
|
+
if (!next.slideId) return;
|
|
2852
|
+
cached = next;
|
|
2853
|
+
const body = {
|
|
2854
|
+
...next,
|
|
2855
|
+
updatedAt: new Date().toISOString()
|
|
2856
|
+
};
|
|
2857
|
+
try {
|
|
2858
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
2859
|
+
await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
2860
|
+
await fs.rename(tmpFile, outFile);
|
|
2861
|
+
} catch {}
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
1860
2865
|
}
|
|
2866
|
+
|
|
2867
|
+
//#endregion
|
|
2868
|
+
//#region src/vite/design-plugin.ts
|
|
2869
|
+
const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
|
|
1861
2870
|
async function readBody$1(req) {
|
|
1862
2871
|
return await new Promise((resolve, reject) => {
|
|
1863
2872
|
const chunks = [];
|
|
@@ -1879,155 +2888,16 @@ function json$1(res, status, body) {
|
|
|
1879
2888
|
res.setHeader("content-type", "application/json");
|
|
1880
2889
|
res.end(JSON.stringify(body));
|
|
1881
2890
|
}
|
|
1882
|
-
function
|
|
1883
|
-
return {
|
|
1884
|
-
folders: [],
|
|
1885
|
-
assignments: {}
|
|
1886
|
-
};
|
|
1887
|
-
}
|
|
1888
|
-
async function readManifest(file) {
|
|
1889
|
-
try {
|
|
1890
|
-
const raw = await fs.readFile(file, "utf8");
|
|
1891
|
-
const parsed = JSON.parse(raw);
|
|
1892
|
-
return {
|
|
1893
|
-
folders: Array.isArray(parsed.folders) ? parsed.folders : [],
|
|
1894
|
-
assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
|
|
1895
|
-
};
|
|
1896
|
-
} catch (err) {
|
|
1897
|
-
if (err.code === "ENOENT") return emptyManifest();
|
|
1898
|
-
throw err;
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
async function writeManifest(file, manifest) {
|
|
1902
|
-
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
1903
|
-
await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
1904
|
-
}
|
|
1905
|
-
function newFolderId() {
|
|
1906
|
-
return `f-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
1907
|
-
}
|
|
1908
|
-
function validateName(v) {
|
|
1909
|
-
if (typeof v !== "string") return null;
|
|
1910
|
-
const trimmed = v.trim();
|
|
1911
|
-
if (trimmed.length < 1 || trimmed.length > 40) return null;
|
|
1912
|
-
return trimmed;
|
|
1913
|
-
}
|
|
1914
|
-
function validateSlideName(v) {
|
|
1915
|
-
if (typeof v !== "string") return null;
|
|
1916
|
-
const trimmed = v.trim();
|
|
1917
|
-
if (trimmed.length < 1 || trimmed.length > 80) return null;
|
|
1918
|
-
return trimmed;
|
|
1919
|
-
}
|
|
1920
|
-
async function rmSlideDir(slidesRoot, slideId) {
|
|
1921
|
-
if (!SLIDE_ID_RE$1.test(slideId)) return false;
|
|
1922
|
-
const dir = path.resolve(slidesRoot, slideId);
|
|
1923
|
-
if (!dir.startsWith(slidesRoot + path.sep)) return false;
|
|
1924
|
-
try {
|
|
1925
|
-
await fs.rm(dir, {
|
|
1926
|
-
recursive: true,
|
|
1927
|
-
force: true
|
|
1928
|
-
});
|
|
1929
|
-
return true;
|
|
1930
|
-
} catch {
|
|
1931
|
-
return false;
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
function resolveAssetsDir(slidesRoot, slideId) {
|
|
1935
|
-
if (!SLIDE_ID_RE$1.test(slideId)) return null;
|
|
1936
|
-
const slideDir = path.resolve(slidesRoot, slideId);
|
|
1937
|
-
if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
|
|
1938
|
-
const assetsDir = path.resolve(slideDir, "assets");
|
|
1939
|
-
if (assetsDir !== path.join(slideDir, "assets")) return null;
|
|
1940
|
-
return assetsDir;
|
|
1941
|
-
}
|
|
1942
|
-
function resolveAssetFile(slidesRoot, slideId, filename) {
|
|
1943
|
-
const assetsDir = resolveAssetsDir(slidesRoot, slideId);
|
|
1944
|
-
if (!assetsDir) return null;
|
|
1945
|
-
if (!validateAssetName(filename)) return null;
|
|
1946
|
-
const file = path.resolve(assetsDir, filename);
|
|
1947
|
-
if (!file.startsWith(assetsDir + path.sep)) return null;
|
|
1948
|
-
return file;
|
|
1949
|
-
}
|
|
1950
|
-
function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
|
|
1951
|
-
if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
|
|
1952
|
-
return resolveAssetsDir(slidesRoot, scope);
|
|
1953
|
-
}
|
|
1954
|
-
function resolveScopedAssetFile(slidesRoot, globalAssetsRoot, scope, filename) {
|
|
1955
|
-
if (scope === GLOBAL_SCOPE) {
|
|
1956
|
-
if (!validateAssetName(filename)) return null;
|
|
1957
|
-
const file = path.resolve(globalAssetsRoot, filename);
|
|
1958
|
-
if (!file.startsWith(globalAssetsRoot + path.sep)) return null;
|
|
1959
|
-
return file;
|
|
1960
|
-
}
|
|
1961
|
-
return resolveAssetFile(slidesRoot, scope, filename);
|
|
1962
|
-
}
|
|
1963
|
-
function resolveSlideEntry(slidesRoot, slideId) {
|
|
2891
|
+
function resolveSlidePath$1(userCwd, slidesDir, slideId) {
|
|
1964
2892
|
if (!SLIDE_ID_RE$1.test(slideId)) return null;
|
|
1965
|
-
const
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
function escapeSingleQuoted(s) {
|
|
1970
|
-
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1971
|
-
}
|
|
1972
|
-
/**
|
|
1973
|
-
* Rewrite (or insert) the `title` field in the slide module's `export const meta`.
|
|
1974
|
-
*
|
|
1975
|
-
* Strategy:
|
|
1976
|
-
* 1. Find `export const meta` and brace-match its object literal.
|
|
1977
|
-
* 2. If the object already has a `title: '...'` entry, replace the literal.
|
|
1978
|
-
* 3. If the object exists but has no title, inject a new `title: '...'` line
|
|
1979
|
-
* as the first property (preserving the author's surrounding indentation).
|
|
1980
|
-
* 4. If there is no `meta` export at all, insert a fresh one right before
|
|
1981
|
-
* `export default`.
|
|
1982
|
-
*
|
|
1983
|
-
* Returns the rewritten source, or `null` if the file shape was too surprising
|
|
1984
|
-
* to touch safely (e.g. `export default` missing when we'd need to inject meta).
|
|
1985
|
-
*/
|
|
1986
|
-
function updateMetaTitleInSource(source, title) {
|
|
1987
|
-
const newLiteral = `'${escapeSingleQuoted(title)}'`;
|
|
1988
|
-
const metaStart = source.search(/export\s+const\s+meta\b/);
|
|
1989
|
-
if (metaStart !== -1) {
|
|
1990
|
-
const eqIdx = source.indexOf("=", metaStart);
|
|
1991
|
-
if (eqIdx === -1) return null;
|
|
1992
|
-
const openBrace = source.indexOf("{", eqIdx);
|
|
1993
|
-
if (openBrace === -1) return null;
|
|
1994
|
-
let depth = 0;
|
|
1995
|
-
let closeBrace = -1;
|
|
1996
|
-
for (let i = openBrace; i < source.length; i++) {
|
|
1997
|
-
const ch = source[i];
|
|
1998
|
-
if (ch === "{") depth++;
|
|
1999
|
-
else if (ch === "}") {
|
|
2000
|
-
depth--;
|
|
2001
|
-
if (depth === 0) {
|
|
2002
|
-
closeBrace = i;
|
|
2003
|
-
break;
|
|
2004
|
-
}
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2007
|
-
if (closeBrace === -1) return null;
|
|
2008
|
-
const body = source.slice(openBrace + 1, closeBrace);
|
|
2009
|
-
const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
|
|
2010
|
-
const match = body.match(titleRe);
|
|
2011
|
-
if (match) {
|
|
2012
|
-
const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
|
|
2013
|
-
return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
|
|
2014
|
-
}
|
|
2015
|
-
const firstIndentMatch = body.match(/\n([ \t]+)\S/);
|
|
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);
|
|
2893
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
2894
|
+
const full = path.resolve(slidesRoot, slideId, "index.tsx");
|
|
2895
|
+
if (!full.startsWith(`${slidesRoot}${path.sep}`)) return null;
|
|
2896
|
+
return full;
|
|
2026
2897
|
}
|
|
2027
|
-
function
|
|
2028
|
-
let ast;
|
|
2898
|
+
function parseSource$1(source) {
|
|
2029
2899
|
try {
|
|
2030
|
-
|
|
2900
|
+
return parse(source, {
|
|
2031
2901
|
sourceType: "module",
|
|
2032
2902
|
plugins: ["typescript", "jsx"],
|
|
2033
2903
|
errorRecovery: true
|
|
@@ -2035,630 +2905,393 @@ function findDefaultExportArray(source) {
|
|
|
2035
2905
|
} catch {
|
|
2036
2906
|
return null;
|
|
2037
2907
|
}
|
|
2908
|
+
}
|
|
2909
|
+
function findDesignDecl(ast) {
|
|
2038
2910
|
const body = ast.program?.body ?? [];
|
|
2039
2911
|
for (const node of body) {
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
const
|
|
2048
|
-
for (const
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2912
|
+
let varDecl = null;
|
|
2913
|
+
if (node.type === "VariableDeclaration") varDecl = node;
|
|
2914
|
+
else if (node.type === "ExportNamedDeclaration") {
|
|
2915
|
+
const decl = node.declaration;
|
|
2916
|
+
if (decl?.type === "VariableDeclaration") varDecl = decl;
|
|
2917
|
+
}
|
|
2918
|
+
if (!varDecl) continue;
|
|
2919
|
+
const declarations = varDecl.declarations ?? [];
|
|
2920
|
+
for (const d of declarations) {
|
|
2921
|
+
const id = d.id;
|
|
2922
|
+
if (!id || id.type !== "Identifier" || id.name !== "design") continue;
|
|
2923
|
+
const init = d.init;
|
|
2924
|
+
if (!init) return null;
|
|
2925
|
+
let inner = init;
|
|
2926
|
+
if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
|
|
2927
|
+
const expr = inner.expression;
|
|
2928
|
+
if (expr) inner = expr;
|
|
2929
|
+
}
|
|
2930
|
+
if (inner.type !== "ObjectExpression") return null;
|
|
2931
|
+
return {
|
|
2932
|
+
declStart: node.start,
|
|
2933
|
+
declEnd: node.end,
|
|
2934
|
+
objectStart: inner.start,
|
|
2935
|
+
objectEnd: inner.end
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
return null;
|
|
2940
|
+
}
|
|
2941
|
+
function literalToValue(node) {
|
|
2942
|
+
switch (node.type) {
|
|
2943
|
+
case "StringLiteral": return node.value;
|
|
2944
|
+
case "NumericLiteral": return node.value;
|
|
2945
|
+
case "BooleanLiteral": return node.value;
|
|
2946
|
+
case "NullLiteral": return null;
|
|
2947
|
+
case "UnaryExpression": {
|
|
2948
|
+
const op = node.operator;
|
|
2949
|
+
const arg = node.argument;
|
|
2950
|
+
const v = literalToValue(arg);
|
|
2951
|
+
if (op === "-" && typeof v === "number") return -v;
|
|
2952
|
+
if (op === "+" && typeof v === "number") return v;
|
|
2953
|
+
throw new Error(`unsupported unary operator ${op}`);
|
|
2954
|
+
}
|
|
2955
|
+
case "TemplateLiteral": {
|
|
2956
|
+
const quasis = node.quasis;
|
|
2957
|
+
const expressions = node.expressions;
|
|
2958
|
+
if (expressions.length > 0) throw new Error("template literal has expressions");
|
|
2959
|
+
return quasis[0].value.cooked ?? quasis[0].value.raw;
|
|
2960
|
+
}
|
|
2961
|
+
case "ArrayExpression": {
|
|
2962
|
+
const elements = node.elements;
|
|
2963
|
+
return elements.map((el) => {
|
|
2964
|
+
if (!el) throw new Error("array has hole");
|
|
2965
|
+
return literalToValue(el);
|
|
2053
2966
|
});
|
|
2054
2967
|
}
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2968
|
+
case "ObjectExpression": {
|
|
2969
|
+
const properties = node.properties;
|
|
2970
|
+
const out = {};
|
|
2971
|
+
for (const prop of properties) {
|
|
2972
|
+
if (prop.type !== "ObjectProperty") throw new Error("object has spread or method");
|
|
2973
|
+
const p = prop;
|
|
2974
|
+
if (p.computed) throw new Error("object has computed key");
|
|
2975
|
+
let key;
|
|
2976
|
+
if (p.key.type === "Identifier" && typeof p.key.name === "string") key = p.key.name;
|
|
2977
|
+
else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") key = p.key.value;
|
|
2978
|
+
else throw new Error("unsupported object key");
|
|
2979
|
+
out[key] = literalToValue(p.value);
|
|
2980
|
+
}
|
|
2981
|
+
return out;
|
|
2982
|
+
}
|
|
2983
|
+
default: throw new Error(`unsupported node type ${node.type}`);
|
|
2060
2984
|
}
|
|
2061
|
-
return null;
|
|
2062
2985
|
}
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2986
|
+
function isPlainObject(v) {
|
|
2987
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2988
|
+
}
|
|
2989
|
+
function mergeDesign(base, patch) {
|
|
2990
|
+
const out = JSON.parse(JSON.stringify(base));
|
|
2991
|
+
const apply = (target, src) => {
|
|
2992
|
+
for (const [k, v] of Object.entries(src)) if (isPlainObject(v) && isPlainObject(target[k])) apply(target[k], v);
|
|
2993
|
+
else target[k] = v;
|
|
2994
|
+
};
|
|
2995
|
+
if (isPlainObject(patch)) apply(out, patch);
|
|
2996
|
+
return out;
|
|
2997
|
+
}
|
|
2998
|
+
function indent(level) {
|
|
2999
|
+
return " ".repeat(level);
|
|
3000
|
+
}
|
|
3001
|
+
function jsString(s) {
|
|
3002
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
3003
|
+
}
|
|
3004
|
+
function isValidIdentifier(name) {
|
|
3005
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
3006
|
+
}
|
|
3007
|
+
function serializeValue(value, level) {
|
|
3008
|
+
if (value === null) return "null";
|
|
3009
|
+
if (typeof value === "string") return jsString(value);
|
|
3010
|
+
if (typeof value === "number") {
|
|
3011
|
+
if (!Number.isFinite(value)) throw new Error("non-finite number");
|
|
3012
|
+
return String(value);
|
|
2087
3013
|
}
|
|
2088
|
-
if (
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
3014
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
3015
|
+
if (Array.isArray(value)) {
|
|
3016
|
+
if (value.length === 0) return "[]";
|
|
3017
|
+
const inner = value.map((el) => serializeValue(el, level + 1)).join(", ");
|
|
3018
|
+
return `[${inner}]`;
|
|
2093
3019
|
}
|
|
2094
|
-
if (
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
3020
|
+
if (isPlainObject(value)) {
|
|
3021
|
+
const entries = Object.entries(value);
|
|
3022
|
+
if (entries.length === 0) return "{}";
|
|
3023
|
+
const childIndent = indent(level + 1);
|
|
3024
|
+
const lines = entries.map(([k, v]) => {
|
|
3025
|
+
const key = isValidIdentifier(k) ? k : jsString(k);
|
|
3026
|
+
return `${childIndent}${key}: ${serializeValue(v, level + 1)},`;
|
|
3027
|
+
});
|
|
3028
|
+
return `{\n${lines.join("\n")}\n${indent(level)}}`;
|
|
3029
|
+
}
|
|
3030
|
+
throw new Error(`unsupported value type ${typeof value}`);
|
|
2104
3031
|
}
|
|
2105
|
-
function
|
|
2106
|
-
|
|
3032
|
+
function serializeDesign(design) {
|
|
3033
|
+
return serializeValue(design, 0);
|
|
3034
|
+
}
|
|
3035
|
+
function parseSlideDesign(source) {
|
|
3036
|
+
const ast = parseSource$1(source);
|
|
3037
|
+
if (!ast) return {
|
|
3038
|
+
ok: false,
|
|
3039
|
+
exists: true,
|
|
3040
|
+
error: "could not parse slide source"
|
|
3041
|
+
};
|
|
3042
|
+
const loc = findDesignDecl(ast);
|
|
3043
|
+
if (!loc) return {
|
|
3044
|
+
ok: false,
|
|
3045
|
+
exists: false
|
|
3046
|
+
};
|
|
3047
|
+
const objectNode = findDesignObjectNode(ast);
|
|
3048
|
+
if (!objectNode) return {
|
|
3049
|
+
ok: false,
|
|
3050
|
+
exists: true,
|
|
3051
|
+
error: "design has unsupported initializer"
|
|
3052
|
+
};
|
|
3053
|
+
let value;
|
|
2107
3054
|
try {
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
3055
|
+
value = literalToValue(objectNode);
|
|
3056
|
+
} catch (err) {
|
|
3057
|
+
return {
|
|
3058
|
+
ok: false,
|
|
3059
|
+
exists: true,
|
|
3060
|
+
error: err.message
|
|
3061
|
+
};
|
|
2115
3062
|
}
|
|
3063
|
+
const merged = mergeDesign(defaultDesign, value);
|
|
3064
|
+
return {
|
|
3065
|
+
ok: true,
|
|
3066
|
+
design: merged,
|
|
3067
|
+
loc
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
function findDesignObjectNode(ast) {
|
|
2116
3071
|
const body = ast.program?.body ?? [];
|
|
2117
|
-
for (const
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
if (
|
|
2121
|
-
|
|
3072
|
+
for (const node of body) {
|
|
3073
|
+
let varDecl = null;
|
|
3074
|
+
if (node.type === "VariableDeclaration") varDecl = node;
|
|
3075
|
+
else if (node.type === "ExportNamedDeclaration") {
|
|
3076
|
+
const decl = node.declaration;
|
|
3077
|
+
if (decl?.type === "VariableDeclaration") varDecl = decl;
|
|
3078
|
+
}
|
|
3079
|
+
if (!varDecl) continue;
|
|
3080
|
+
const declarations = varDecl.declarations ?? [];
|
|
2122
3081
|
for (const d of declarations) {
|
|
2123
3082
|
const id = d.id;
|
|
2124
|
-
if (!id || id.type !== "Identifier" || id.name !== "
|
|
3083
|
+
if (!id || id.type !== "Identifier" || id.name !== "design") continue;
|
|
2125
3084
|
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));
|
|
3085
|
+
if (!init) return null;
|
|
3086
|
+
let inner = init;
|
|
3087
|
+
if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
|
|
3088
|
+
const expr = inner.expression;
|
|
3089
|
+
if (expr) inner = expr;
|
|
2142
3090
|
}
|
|
2143
|
-
return
|
|
2144
|
-
|
|
2145
|
-
arrayEnd,
|
|
2146
|
-
elementTexts
|
|
2147
|
-
};
|
|
3091
|
+
if (inner.type !== "ObjectExpression") return null;
|
|
3092
|
+
return inner;
|
|
2148
3093
|
}
|
|
2149
3094
|
}
|
|
2150
3095
|
return null;
|
|
2151
3096
|
}
|
|
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}`;
|
|
3097
|
+
function findImports(ast) {
|
|
3098
|
+
const body = ast.program?.body ?? [];
|
|
3099
|
+
const out = [];
|
|
3100
|
+
for (const node of body) {
|
|
3101
|
+
if (node.type !== "ImportDeclaration") continue;
|
|
3102
|
+
const src = node.source?.value;
|
|
3103
|
+
if (typeof src !== "string") continue;
|
|
3104
|
+
const specs = node.specifiers ?? [];
|
|
3105
|
+
out.push({
|
|
3106
|
+
node,
|
|
3107
|
+
source: src,
|
|
3108
|
+
specifiers: specs
|
|
3109
|
+
});
|
|
2220
3110
|
}
|
|
2221
|
-
return
|
|
3111
|
+
return out;
|
|
2222
3112
|
}
|
|
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]);
|
|
3113
|
+
function ensureDesignSystemImport(source, ast) {
|
|
3114
|
+
const imports = findImports(ast);
|
|
3115
|
+
const coreImport = imports.find((imp) => imp.source === "@open-slide/core");
|
|
3116
|
+
if (coreImport) {
|
|
3117
|
+
const hasDesignSystem = coreImport.specifiers.some((spec) => {
|
|
3118
|
+
if (spec.type !== "ImportSpecifier") return false;
|
|
3119
|
+
const imported = spec.imported;
|
|
3120
|
+
return imported?.name === "DesignSystem";
|
|
3121
|
+
});
|
|
3122
|
+
if (hasDesignSystem) return {
|
|
3123
|
+
source,
|
|
3124
|
+
offsetShift: 0
|
|
3125
|
+
};
|
|
3126
|
+
const node = coreImport.node;
|
|
3127
|
+
const importText = source.slice(node.start, node.end);
|
|
3128
|
+
const braceClose = importText.lastIndexOf("}");
|
|
3129
|
+
if (braceClose === -1) return {
|
|
3130
|
+
source,
|
|
3131
|
+
offsetShift: 0
|
|
3132
|
+
};
|
|
3133
|
+
const absoluteBrace = node.start + braceClose;
|
|
3134
|
+
const insertText = coreImport.specifiers.length > 0 ? ", type DesignSystem" : "type DesignSystem";
|
|
3135
|
+
const next$1 = `${source.slice(0, absoluteBrace)}${insertText}${source.slice(absoluteBrace)}`;
|
|
3136
|
+
return {
|
|
3137
|
+
source: next$1,
|
|
3138
|
+
offsetShift: insertText.length
|
|
3139
|
+
};
|
|
2251
3140
|
}
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
3141
|
+
const stmt = `import type { DesignSystem } from '@open-slide/core';\n`;
|
|
3142
|
+
if (imports.length > 0) {
|
|
3143
|
+
const last = imports[imports.length - 1];
|
|
3144
|
+
const insertAt = last.node.end;
|
|
3145
|
+
const trail = source[insertAt] === "\n" ? "" : "\n";
|
|
3146
|
+
const next$1 = `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
|
|
3147
|
+
return {
|
|
3148
|
+
source: next$1,
|
|
3149
|
+
offsetShift: 1 + stmt.length - (trail ? 0 : 1)
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
const next = `${stmt}\n${source}`;
|
|
3153
|
+
return {
|
|
3154
|
+
source: next,
|
|
3155
|
+
offsetShift: stmt.length + 1
|
|
3156
|
+
};
|
|
2256
3157
|
}
|
|
2257
|
-
function
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
3158
|
+
function findInsertionPoint(source, ast) {
|
|
3159
|
+
const imports = findImports(ast);
|
|
3160
|
+
if (imports.length === 0) return 0;
|
|
3161
|
+
const last = imports[imports.length - 1];
|
|
3162
|
+
let off = last.node.end;
|
|
3163
|
+
while (off < source.length && source[off] !== "\n") off++;
|
|
3164
|
+
if (off < source.length) off++;
|
|
3165
|
+
return off;
|
|
3166
|
+
}
|
|
3167
|
+
function applyDesignWrite(source, next) {
|
|
3168
|
+
let body;
|
|
3169
|
+
try {
|
|
3170
|
+
body = serializeDesign(next);
|
|
3171
|
+
} catch (err) {
|
|
2263
3172
|
return {
|
|
2264
|
-
|
|
2265
|
-
|
|
3173
|
+
ok: false,
|
|
3174
|
+
status: 422,
|
|
3175
|
+
error: `serialize failed: ${err.message}`
|
|
2266
3176
|
};
|
|
2267
3177
|
}
|
|
2268
|
-
|
|
2269
|
-
|
|
3178
|
+
const ast = parseSource$1(source);
|
|
3179
|
+
if (!ast) return {
|
|
3180
|
+
ok: false,
|
|
3181
|
+
status: 422,
|
|
3182
|
+
error: "could not parse slide source"
|
|
3183
|
+
};
|
|
3184
|
+
const loc = findDesignDecl(ast);
|
|
3185
|
+
if (loc) {
|
|
3186
|
+
const out$1 = source.slice(0, loc.objectStart) + body + source.slice(loc.objectEnd);
|
|
2270
3187
|
return {
|
|
2271
|
-
|
|
2272
|
-
|
|
3188
|
+
ok: true,
|
|
3189
|
+
source: out$1,
|
|
3190
|
+
created: false
|
|
2273
3191
|
};
|
|
2274
3192
|
}
|
|
2275
|
-
|
|
3193
|
+
const withImport = ensureDesignSystemImport(source, ast);
|
|
3194
|
+
const ast2 = parseSource$1(withImport.source);
|
|
3195
|
+
if (!ast2) return {
|
|
3196
|
+
ok: false,
|
|
3197
|
+
status: 422,
|
|
3198
|
+
error: "failed to re-parse after adding import"
|
|
3199
|
+
};
|
|
3200
|
+
const insertAt = findInsertionPoint(withImport.source, ast2);
|
|
3201
|
+
const block = `\nconst design: DesignSystem = ${body};\n`;
|
|
3202
|
+
const out = withImport.source.slice(0, insertAt) + block + withImport.source.slice(insertAt);
|
|
3203
|
+
return {
|
|
3204
|
+
ok: true,
|
|
3205
|
+
source: out,
|
|
3206
|
+
created: true
|
|
3207
|
+
};
|
|
2276
3208
|
}
|
|
2277
|
-
function
|
|
3209
|
+
function designPlugin(opts) {
|
|
2278
3210
|
const userCwd = opts.userCwd;
|
|
2279
3211
|
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
3212
|
return {
|
|
2285
|
-
name: "open-slide:
|
|
3213
|
+
name: "open-slide:design",
|
|
2286
3214
|
apply: "serve",
|
|
2287
3215
|
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) => {
|
|
3216
|
+
server.middlewares.use("/__design", async (req, res, next) => {
|
|
2321
3217
|
const url = new URL(req.url ?? "/", "http://local");
|
|
2322
3218
|
const method = req.method ?? "GET";
|
|
3219
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
3220
|
+
const file = resolveSlidePath$1(userCwd, slidesDir, slideId);
|
|
3221
|
+
if (!file) return json$1(res, 400, { error: "invalid slideId" });
|
|
2323
3222
|
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" });
|
|
3223
|
+
if (method === "GET" && url.pathname === "/") {
|
|
2337
3224
|
let source;
|
|
2338
3225
|
try {
|
|
2339
|
-
source = await fs.readFile(
|
|
3226
|
+
source = await fs.readFile(file, "utf8");
|
|
2340
3227
|
} catch {
|
|
2341
3228
|
return json$1(res, 404, { error: "slide not found" });
|
|
2342
3229
|
}
|
|
2343
|
-
const
|
|
2344
|
-
if (
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
3230
|
+
const parsed = parseSlideDesign(source);
|
|
3231
|
+
if (parsed.ok) return json$1(res, 200, {
|
|
3232
|
+
design: parsed.design,
|
|
3233
|
+
exists: true,
|
|
3234
|
+
warning: null
|
|
3235
|
+
});
|
|
3236
|
+
if (parsed.exists === false) return json$1(res, 200, {
|
|
3237
|
+
design: defaultDesign,
|
|
3238
|
+
exists: false,
|
|
3239
|
+
warning: null
|
|
3240
|
+
});
|
|
2348
3241
|
return json$1(res, 200, {
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
3242
|
+
design: defaultDesign,
|
|
3243
|
+
exists: true,
|
|
3244
|
+
warning: parsed.error
|
|
2352
3245
|
});
|
|
2353
3246
|
}
|
|
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" });
|
|
3247
|
+
if (method === "PUT" && url.pathname === "/") {
|
|
3248
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
3249
|
+
if (!requestCheck.ok) return json$1(res, requestCheck.status, { error: requestCheck.error });
|
|
3250
|
+
const body = await readBody$1(req);
|
|
3251
|
+
const patch = body.patch;
|
|
3252
|
+
if (!patch || typeof patch !== "object") return json$1(res, 400, { error: "missing patch object" });
|
|
2366
3253
|
let source;
|
|
2367
3254
|
try {
|
|
2368
|
-
source = await fs.readFile(
|
|
3255
|
+
source = await fs.readFile(file, "utf8");
|
|
2369
3256
|
} catch {
|
|
2370
3257
|
return json$1(res, 404, { error: "slide not found" });
|
|
2371
3258
|
}
|
|
2372
|
-
const
|
|
2373
|
-
|
|
2374
|
-
if (
|
|
3259
|
+
const parsed = parseSlideDesign(source);
|
|
3260
|
+
const baseDesign = parsed.ok ? parsed.design : defaultDesign;
|
|
3261
|
+
if (!parsed.ok && parsed.exists) return json$1(res, 422, { error: parsed.error });
|
|
3262
|
+
const merged = mergeDesign(baseDesign, patch);
|
|
3263
|
+
const written = applyDesignWrite(source, merged);
|
|
3264
|
+
if (!written.ok) return json$1(res, written.status, { error: written.error });
|
|
3265
|
+
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
2375
3266
|
return json$1(res, 200, {
|
|
2376
3267
|
ok: true,
|
|
2377
|
-
|
|
2378
|
-
|
|
3268
|
+
design: merged,
|
|
3269
|
+
created: written.created
|
|
2379
3270
|
});
|
|
2380
3271
|
}
|
|
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" });
|
|
3272
|
+
if (method === "POST" && url.pathname === "/reset") {
|
|
3273
|
+
const requestCheck = validateMutationRequest(req);
|
|
3274
|
+
if (!requestCheck.ok) return json$1(res, requestCheck.status, { error: requestCheck.error });
|
|
2391
3275
|
let source;
|
|
2392
3276
|
try {
|
|
2393
|
-
source = await fs.readFile(
|
|
3277
|
+
source = await fs.readFile(file, "utf8");
|
|
2394
3278
|
} catch {
|
|
2395
3279
|
return json$1(res, 404, { error: "slide not found" });
|
|
2396
3280
|
}
|
|
2397
|
-
const
|
|
2398
|
-
if (
|
|
2399
|
-
if (
|
|
2400
|
-
server.ws.send({ type: "full-reload" });
|
|
3281
|
+
const written = applyDesignWrite(source, defaultDesign);
|
|
3282
|
+
if (!written.ok) return json$1(res, written.status, { error: written.error });
|
|
3283
|
+
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
2401
3284
|
return json$1(res, 200, {
|
|
2402
3285
|
ok: true,
|
|
2403
|
-
|
|
2404
|
-
|
|
3286
|
+
design: defaultDesign,
|
|
3287
|
+
created: written.created
|
|
2405
3288
|
});
|
|
2406
3289
|
}
|
|
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
3290
|
return next();
|
|
2416
3291
|
} catch (err) {
|
|
2417
3292
|
json$1(res, 500, { error: String(err.message ?? err) });
|
|
2418
3293
|
}
|
|
2419
3294
|
});
|
|
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
3295
|
}
|
|
2663
3296
|
};
|
|
2664
3297
|
}
|
|
@@ -2710,7 +3343,10 @@ function locTagsPlugin(opts) {
|
|
|
2710
3343
|
transform(code, id) {
|
|
2711
3344
|
const filePath = id.split("?")[0];
|
|
2712
3345
|
if (!filePath.startsWith(slidesRoot + path.sep)) return null;
|
|
2713
|
-
if (!filePath.endsWith(
|
|
3346
|
+
if (!filePath.endsWith(".tsx")) return null;
|
|
3347
|
+
if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.tsx")) return null;
|
|
3348
|
+
const rel = filePath.slice(slidesRoot.length + path.sep.length);
|
|
3349
|
+
if (!rel.includes(path.sep)) return null;
|
|
2714
3350
|
const next = injectLocTags(code);
|
|
2715
3351
|
if (next === null) return null;
|
|
2716
3352
|
return {
|
|
@@ -2887,6 +3523,8 @@ function notesPlugin(opts) {
|
|
|
2887
3523
|
const url = new URL(req.url ?? "/", "http://local");
|
|
2888
3524
|
const method = req.method ?? "GET";
|
|
2889
3525
|
if (method !== "PUT" || url.pathname !== "/") return next();
|
|
3526
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
3527
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2890
3528
|
try {
|
|
2891
3529
|
const body = await readBody(req);
|
|
2892
3530
|
const slideId = body.slideId ?? "";
|
|
@@ -2959,13 +3597,18 @@ function toId(absFile, slidesRoot) {
|
|
|
2959
3597
|
return rel.split(path.sep)[0];
|
|
2960
3598
|
}
|
|
2961
3599
|
const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
|
|
2962
|
-
|
|
3600
|
+
const META_CREATED_AT_RE = /(?:^|[\s,{])createdAt\s*:\s*['"]([^'"]+)['"]/;
|
|
3601
|
+
function extractMeta(src) {
|
|
3602
|
+
const empty = {
|
|
3603
|
+
theme: null,
|
|
3604
|
+
createdAt: null
|
|
3605
|
+
};
|
|
2963
3606
|
const metaStart = src.search(/export\s+const\s+meta\b/);
|
|
2964
|
-
if (metaStart === -1) return
|
|
3607
|
+
if (metaStart === -1) return empty;
|
|
2965
3608
|
const eqIdx = src.indexOf("=", metaStart);
|
|
2966
|
-
if (eqIdx === -1) return
|
|
3609
|
+
if (eqIdx === -1) return empty;
|
|
2967
3610
|
const openBrace = src.indexOf("{", eqIdx);
|
|
2968
|
-
if (openBrace === -1) return
|
|
3611
|
+
if (openBrace === -1) return empty;
|
|
2969
3612
|
let depth = 0;
|
|
2970
3613
|
let closeBrace = -1;
|
|
2971
3614
|
for (let i = openBrace; i < src.length; i++) {
|
|
@@ -2979,34 +3622,52 @@ function extractMetaTheme(src) {
|
|
|
2979
3622
|
}
|
|
2980
3623
|
}
|
|
2981
3624
|
}
|
|
2982
|
-
if (closeBrace === -1) return
|
|
3625
|
+
if (closeBrace === -1) return empty;
|
|
2983
3626
|
const body = src.slice(openBrace + 1, closeBrace);
|
|
2984
|
-
const
|
|
2985
|
-
|
|
3627
|
+
const themeMatch = body.match(META_THEME_RE);
|
|
3628
|
+
const createdAtMatch = body.match(META_CREATED_AT_RE);
|
|
3629
|
+
return {
|
|
3630
|
+
theme: themeMatch ? themeMatch[1] : null,
|
|
3631
|
+
createdAt: createdAtMatch ? createdAtMatch[1] : null
|
|
3632
|
+
};
|
|
2986
3633
|
}
|
|
2987
|
-
async function
|
|
3634
|
+
async function readSlideMeta(abs) {
|
|
2988
3635
|
try {
|
|
2989
3636
|
const src = await fs.readFile(abs, "utf8");
|
|
2990
|
-
return
|
|
3637
|
+
return extractMeta(src);
|
|
2991
3638
|
} catch {
|
|
2992
|
-
return
|
|
3639
|
+
return {
|
|
3640
|
+
theme: null,
|
|
3641
|
+
createdAt: null
|
|
3642
|
+
};
|
|
2993
3643
|
}
|
|
2994
3644
|
}
|
|
3645
|
+
function parseCreatedAtMs(iso) {
|
|
3646
|
+
if (!iso) return null;
|
|
3647
|
+
const ms = Date.parse(iso);
|
|
3648
|
+
return Number.isFinite(ms) ? ms : null;
|
|
3649
|
+
}
|
|
2995
3650
|
async function generateSlidesModule(files, slidesRoot, isDev) {
|
|
2996
3651
|
const entries = await Promise.all(files.map(async (abs) => {
|
|
2997
3652
|
const id = toId(abs, slidesRoot);
|
|
2998
3653
|
const importPath = isDev ? `/@fs/${abs.replace(/^\/+/, "")}` : abs;
|
|
2999
|
-
const
|
|
3654
|
+
const meta = await readSlideMeta(abs);
|
|
3000
3655
|
return {
|
|
3001
3656
|
id,
|
|
3002
3657
|
importPath,
|
|
3003
|
-
theme
|
|
3658
|
+
theme: meta.theme,
|
|
3659
|
+
createdAt: parseCreatedAtMs(meta.createdAt)
|
|
3004
3660
|
};
|
|
3005
3661
|
}));
|
|
3006
3662
|
const ids = JSON.stringify(entries.map((e) => e.id).sort());
|
|
3007
3663
|
const themesMap = {};
|
|
3008
|
-
|
|
3664
|
+
const createdAtMap = {};
|
|
3665
|
+
for (const e of entries) {
|
|
3666
|
+
if (e.theme) themesMap[e.id] = e.theme;
|
|
3667
|
+
if (e.createdAt !== null) createdAtMap[e.id] = e.createdAt;
|
|
3668
|
+
}
|
|
3009
3669
|
const themesJson = JSON.stringify(themesMap);
|
|
3670
|
+
const createdAtJson = JSON.stringify(createdAtMap);
|
|
3010
3671
|
const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
|
|
3011
3672
|
const devRuntime = isDev ? `
|
|
3012
3673
|
const slideImportTokens = ${importTokens};
|
|
@@ -3027,6 +3688,7 @@ if (import.meta.hot) {
|
|
|
3027
3688
|
return `// virtual:open-slide/slides — generated
|
|
3028
3689
|
export const slideIds = ${ids};
|
|
3029
3690
|
export const slideThemes = ${themesJson};
|
|
3691
|
+
export const slideCreatedAt = ${createdAtJson};
|
|
3030
3692
|
${devRuntime}
|
|
3031
3693
|
|
|
3032
3694
|
export async function loadSlide(id) {
|
|
@@ -3229,18 +3891,18 @@ async function readTheme(mdAbs, themesRoot) {
|
|
|
3229
3891
|
};
|
|
3230
3892
|
}
|
|
3231
3893
|
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$
|
|
3894
|
+
const meta = themes.map((t$5) => ({
|
|
3895
|
+
id: t$5.id,
|
|
3896
|
+
name: t$5.frontmatter.name,
|
|
3897
|
+
description: t$5.frontmatter.description,
|
|
3898
|
+
body: t$5.body,
|
|
3899
|
+
hasDemo: t$5.demoAbs !== null
|
|
3238
3900
|
}));
|
|
3239
|
-
const cases = themes.flatMap((t$
|
|
3240
|
-
const abs = t$
|
|
3901
|
+
const cases = themes.flatMap((t$5) => {
|
|
3902
|
+
const abs = t$5.demoAbs;
|
|
3241
3903
|
if (!abs) return [];
|
|
3242
3904
|
const importPath = isDev ? `/@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
|
|
3243
|
-
return [` case ${JSON.stringify(t$
|
|
3905
|
+
return [` case ${JSON.stringify(t$5.id)}: return import(${JSON.stringify(importPath)});`];
|
|
3244
3906
|
}).join("\n");
|
|
3245
3907
|
return `// virtual:open-slide/themes — generated
|
|
3246
3908
|
export const themes = ${JSON.stringify(meta)};
|
|
@@ -3345,19 +4007,15 @@ async function createViteConfig(opts) {
|
|
|
3345
4007
|
config
|
|
3346
4008
|
}),
|
|
3347
4009
|
designPlugin({ userCwd }),
|
|
3348
|
-
|
|
4010
|
+
apiPlugin({
|
|
3349
4011
|
userCwd,
|
|
3350
|
-
slidesDir
|
|
4012
|
+
slidesDir,
|
|
4013
|
+
assetsDir
|
|
3351
4014
|
}),
|
|
3352
4015
|
notesPlugin({
|
|
3353
4016
|
userCwd,
|
|
3354
4017
|
slidesDir
|
|
3355
4018
|
}),
|
|
3356
|
-
filesPlugin({
|
|
3357
|
-
userCwd,
|
|
3358
|
-
slidesDir,
|
|
3359
|
-
assetsDir
|
|
3360
|
-
}),
|
|
3361
4019
|
currentPlugin({
|
|
3362
4020
|
userCwd,
|
|
3363
4021
|
slidesDir
|