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