@open-aippt/core 1.13.2
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/LICENSE +21 -0
- package/README.md +98 -0
- package/bin.js +2 -0
- package/dist/build-DxTqmvsO.js +17 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +86 -0
- package/dist/config-CjzqjrEA.js +4280 -0
- package/dist/config-DIC-yVPp.d.ts +23 -0
- package/dist/design-cpzS8aud.js +35 -0
- package/dist/dev-BYuTeJbA.js +20 -0
- package/dist/format-BCeKbTOM.js +1605 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +467 -0
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +3 -0
- package/dist/preview-DlQvnJPq.js +18 -0
- package/dist/sync-BPZ0m27m.js +139 -0
- package/dist/sync-EsYusbbL.js +3 -0
- package/dist/types-CHmFPIG_.d.ts +430 -0
- package/dist/vite/index.d.ts +14 -0
- package/dist/vite/index.js +4 -0
- package/env.d.ts +59 -0
- package/package.json +103 -0
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +91 -0
- package/skills/create-theme/SKILL.md +250 -0
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +625 -0
- package/src/app/app.tsx +47 -0
- package/src/app/components/asset-view.tsx +966 -0
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +243 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/comment-widget.tsx +93 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
- package/src/app/components/inspector/inspect-overlay.tsx +387 -0
- package/src/app/components/inspector/inspector-panel.tsx +1115 -0
- package/src/app/components/inspector/inspector-provider.tsx +1218 -0
- package/src/app/components/inspector/save-bar.tsx +48 -0
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/notes-drawer.tsx +120 -0
- package/src/app/components/overview-grid.tsx +363 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +80 -0
- package/src/app/components/panel/save-card.tsx +142 -0
- package/src/app/components/pdf-progress-toast.tsx +32 -0
- package/src/app/components/player.tsx +466 -0
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +315 -0
- package/src/app/components/present/help-overlay.tsx +57 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +39 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +46 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +66 -0
- package/src/app/components/present/use-touch-swipe.ts +66 -0
- package/src/app/components/shared-element.tsx +48 -0
- package/src/app/components/sidebar/folder-item.tsx +258 -0
- package/src/app/components/sidebar/icon-picker.tsx +61 -0
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
- package/src/app/components/sidebar/sidebar.tsx +284 -0
- package/src/app/components/slide-canvas.tsx +102 -0
- package/src/app/components/slide-transition-layer.tsx +844 -0
- package/src/app/components/style-panel/design-provider.tsx +148 -0
- package/src/app/components/style-panel/style-panel.tsx +349 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +59 -0
- package/src/app/components/themes/theme-detail.tsx +305 -0
- package/src/app/components/themes/themes-gallery.tsx +149 -0
- package/src/app/components/thumbnail-rail.tsx +805 -0
- package/src/app/components/ui/badge.tsx +45 -0
- package/src/app/components/ui/button.tsx +99 -0
- package/src/app/components/ui/card.tsx +92 -0
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/components/ui/dialog.tsx +157 -0
- package/src/app/components/ui/dropdown-menu.tsx +245 -0
- package/src/app/components/ui/input.tsx +25 -0
- package/src/app/components/ui/label.tsx +24 -0
- package/src/app/components/ui/popover.tsx +75 -0
- package/src/app/components/ui/progress.tsx +31 -0
- package/src/app/components/ui/scroll-area.tsx +53 -0
- package/src/app/components/ui/select.tsx +196 -0
- package/src/app/components/ui/separator.tsx +28 -0
- package/src/app/components/ui/slider.tsx +61 -0
- package/src/app/components/ui/sonner.tsx +48 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/components/ui/textarea.tsx +22 -0
- package/src/app/components/ui/toggle-group.tsx +83 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +58 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/index.html +13 -0
- package/src/app/lib/assets.ts +242 -0
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/lib/design.ts +58 -0
- package/src/app/lib/export-html.ts +326 -0
- package/src/app/lib/export-pdf.ts +298 -0
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/folders.ts +239 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +85 -0
- package/src/app/lib/inspector/use-comments.ts +74 -0
- package/src/app/lib/inspector/use-editor.ts +73 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/print-ready.test.ts +32 -0
- package/src/app/lib/print-ready.ts +51 -0
- package/src/app/lib/sdk.test.ts +13 -0
- package/src/app/lib/sdk.ts +37 -0
- package/src/app/lib/slides.ts +26 -0
- package/src/app/lib/step-context.tsx +261 -0
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/transition.ts +30 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-click-page-navigation.ts +60 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-locale.ts +8 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/lib/use-wheel-page-navigation.ts +99 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +213 -0
- package/src/app/routes/home.tsx +807 -0
- package/src/app/routes/presenter.tsx +418 -0
- package/src/app/routes/slide.tsx +1108 -0
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/styles.css +429 -0
- package/src/app/virtual.d.ts +51 -0
- package/src/locale/en.ts +416 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +422 -0
- package/src/locale/types.ts +443 -0
- package/src/locale/zh-cn.ts +414 -0
- package/src/locale/zh-tw.ts +414 -0
|
@@ -0,0 +1,4280 @@
|
|
|
1
|
+
import { defaultDesign } from "./design-cpzS8aud.js";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
8
|
+
import react from "@vitejs/plugin-react";
|
|
9
|
+
import * as t$4 from "@babel/types";
|
|
10
|
+
import * as t$3 from "@babel/types";
|
|
11
|
+
import * as t$2 from "@babel/types";
|
|
12
|
+
import * as t$1 from "@babel/types";
|
|
13
|
+
import * as t from "@babel/types";
|
|
14
|
+
import { isJSXElement, isJSXFragment } from "@babel/types";
|
|
15
|
+
import { parse } from "@babel/parser";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import fg from "fast-glob";
|
|
18
|
+
import { loadConfigFromFile, normalizePath } from "vite";
|
|
19
|
+
|
|
20
|
+
//#region src/editing/babel-walk.ts
|
|
21
|
+
const SKIP_KEYS = new Set([
|
|
22
|
+
"loc",
|
|
23
|
+
"start",
|
|
24
|
+
"end",
|
|
25
|
+
"type",
|
|
26
|
+
"extra",
|
|
27
|
+
"leadingComments",
|
|
28
|
+
"trailingComments",
|
|
29
|
+
"innerComments"
|
|
30
|
+
]);
|
|
31
|
+
function walk(ast, visit, accept) {
|
|
32
|
+
let stopped = false;
|
|
33
|
+
const recurse = (node) => {
|
|
34
|
+
if (stopped || !node || typeof node !== "object") return;
|
|
35
|
+
if (Array.isArray(node)) {
|
|
36
|
+
for (const c of node) recurse(c);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const n = node;
|
|
40
|
+
if (typeof n.type !== "string") return;
|
|
41
|
+
if (accept(n) && visit(n) === "stop") {
|
|
42
|
+
stopped = true;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const key of Object.keys(n)) {
|
|
46
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
47
|
+
recurse(n[key]);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
recurse(ast);
|
|
51
|
+
}
|
|
52
|
+
const isJsx = (n) => isJSXElement(n) || isJSXFragment(n);
|
|
53
|
+
const acceptAll = () => true;
|
|
54
|
+
function walkJsx(ast, visit) {
|
|
55
|
+
walk(ast, visit, isJsx);
|
|
56
|
+
}
|
|
57
|
+
function walkAll(ast, visit) {
|
|
58
|
+
walk(ast, visit, acceptAll);
|
|
59
|
+
}
|
|
60
|
+
function parseSource$2(source) {
|
|
61
|
+
try {
|
|
62
|
+
const ast = parse(source, {
|
|
63
|
+
sourceType: "module",
|
|
64
|
+
plugins: ["typescript", "jsx"],
|
|
65
|
+
errorRecovery: true
|
|
66
|
+
});
|
|
67
|
+
return ast.errors && ast.errors.length > 0 ? null : ast;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/editing/edit-ops.ts
|
|
75
|
+
function jsString$1(s) {
|
|
76
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
77
|
+
}
|
|
78
|
+
function spliceRange(node, text) {
|
|
79
|
+
return {
|
|
80
|
+
from: node.start ?? 0,
|
|
81
|
+
to: node.end ?? 0,
|
|
82
|
+
text
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function formatJsxAttrValue(value) {
|
|
86
|
+
if (/^[^"\\<>&{}\n\r]*$/.test(value)) return `"${value}"`;
|
|
87
|
+
return `{${jsString$1(value)}}`;
|
|
88
|
+
}
|
|
89
|
+
function jsxAttrName(attr) {
|
|
90
|
+
return t$4.isJSXIdentifier(attr.name) ? attr.name.name : null;
|
|
91
|
+
}
|
|
92
|
+
function findJsxAttr(opening, name) {
|
|
93
|
+
for (const attr of opening.attributes) if (t$4.isJSXAttribute(attr) && jsxAttrName(attr) === name) return attr;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
function readJsxStringAttr(opening, name) {
|
|
97
|
+
const attr = findJsxAttr(opening, name);
|
|
98
|
+
const v = attr?.value;
|
|
99
|
+
if (!v) return null;
|
|
100
|
+
if (t$4.isStringLiteral(v)) return v.value;
|
|
101
|
+
if (t$4.isJSXExpressionContainer(v) && t$4.isStringLiteral(v.expression)) return v.expression.value;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function readJsxNumberAttr(opening, name) {
|
|
105
|
+
const attr = findJsxAttr(opening, name);
|
|
106
|
+
const v = attr?.value;
|
|
107
|
+
if (!v || !t$4.isJSXExpressionContainer(v)) return null;
|
|
108
|
+
if (!t$4.isNumericLiteral(v.expression)) return null;
|
|
109
|
+
const n = v.expression.value;
|
|
110
|
+
return Number.isFinite(n) ? n : null;
|
|
111
|
+
}
|
|
112
|
+
function findImports$1(ast) {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const node of ast.program.body) {
|
|
115
|
+
if (!t$4.isImportDeclaration(node)) continue;
|
|
116
|
+
let def = null;
|
|
117
|
+
for (const spec of node.specifiers) if (t$4.isImportDefaultSpecifier(spec)) {
|
|
118
|
+
def = spec.local.name;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
out.push({
|
|
122
|
+
node,
|
|
123
|
+
source: node.source.value,
|
|
124
|
+
defaultIdent: def
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function collectTopLevelIdentifiers(ast) {
|
|
130
|
+
const names = new Set();
|
|
131
|
+
for (const imp of findImports$1(ast)) {
|
|
132
|
+
if (imp.defaultIdent) names.add(imp.defaultIdent);
|
|
133
|
+
for (const spec of imp.node.specifiers) if (!t$4.isImportDefaultSpecifier(spec)) names.add(spec.local.name);
|
|
134
|
+
}
|
|
135
|
+
return names;
|
|
136
|
+
}
|
|
137
|
+
function safeAssetIdentifier(filename, taken) {
|
|
138
|
+
const stem = filename.replace(/\.[^.]+$/, "");
|
|
139
|
+
let camel = "";
|
|
140
|
+
let upper = false;
|
|
141
|
+
for (const ch of stem) if (/[A-Za-z0-9]/.test(ch)) {
|
|
142
|
+
camel += upper ? ch.toUpperCase() : ch;
|
|
143
|
+
upper = false;
|
|
144
|
+
} else upper = camel.length > 0;
|
|
145
|
+
let base = camel;
|
|
146
|
+
if (!base || !/^[A-Za-z_$]/.test(base)) base = `asset${base.charAt(0).toUpperCase()}${base.slice(1)}` || "asset";
|
|
147
|
+
base = base.charAt(0).toLowerCase() + base.slice(1);
|
|
148
|
+
let candidate = base;
|
|
149
|
+
let i = 2;
|
|
150
|
+
while (taken.has(candidate)) {
|
|
151
|
+
candidate = `${base}${i}`;
|
|
152
|
+
i += 1;
|
|
153
|
+
}
|
|
154
|
+
return candidate;
|
|
155
|
+
}
|
|
156
|
+
function findJsxAncestors$1(ast, line, column) {
|
|
157
|
+
const hits = [];
|
|
158
|
+
walkJsx(ast, (n) => {
|
|
159
|
+
if (!n.loc || !t$4.isJSXElement(n) && !t$4.isJSXFragment(n)) return;
|
|
160
|
+
const s = n.loc.start;
|
|
161
|
+
const e = n.loc.end;
|
|
162
|
+
const afterStart = line > s.line || line === s.line && column >= s.column;
|
|
163
|
+
const beforeEnd = line < e.line || line === e.line && column < e.column;
|
|
164
|
+
if (afterStart && beforeEnd) hits.push({
|
|
165
|
+
node: n,
|
|
166
|
+
size: (n.end ?? 0) - (n.start ?? 0)
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
hits.sort((a, b) => a.size - b.size);
|
|
170
|
+
return hits.map((h) => h.node);
|
|
171
|
+
}
|
|
172
|
+
function findJsxByStart(ast, line, column) {
|
|
173
|
+
let hit = null;
|
|
174
|
+
walkJsx(ast, (n) => {
|
|
175
|
+
if (!t$4.isJSXElement(n) || !n.loc) return;
|
|
176
|
+
const s = n.loc.start;
|
|
177
|
+
if (s.line === line && s.column === column) {
|
|
178
|
+
hit = n;
|
|
179
|
+
return "stop";
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return hit;
|
|
183
|
+
}
|
|
184
|
+
function findInnermostJsxElement(ast, line, column) {
|
|
185
|
+
const exact = findJsxByStart(ast, line, column);
|
|
186
|
+
if (exact) return exact;
|
|
187
|
+
for (const n of findJsxAncestors$1(ast, line, column)) if (t$4.isJSXElement(n)) return n;
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
function findUniqueElementByText(ast, prevText) {
|
|
191
|
+
const hits = [];
|
|
192
|
+
walkJsx(ast, (n) => {
|
|
193
|
+
if (!t$4.isJSXElement(n)) return;
|
|
194
|
+
const parts = [];
|
|
195
|
+
collectTextRangeParts(n, parts);
|
|
196
|
+
if (textRangeContent(parts) !== prevText) return;
|
|
197
|
+
hits.push({
|
|
198
|
+
node: n,
|
|
199
|
+
size: (n.end ?? 0) - (n.start ?? 0)
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
if (hits.length === 0) return null;
|
|
203
|
+
hits.sort((a, b) => a.size - b.size);
|
|
204
|
+
const best = hits[0];
|
|
205
|
+
const bestStart = best.node.start ?? 0;
|
|
206
|
+
const bestEnd = best.node.end ?? 0;
|
|
207
|
+
const hasSiblingMatch = hits.slice(1).some(({ node }) => (node.start ?? 0) > bestStart || (node.end ?? 0) < bestEnd);
|
|
208
|
+
return hasSiblingMatch ? null : best.node;
|
|
209
|
+
}
|
|
210
|
+
function fallbackTextForOps(ops) {
|
|
211
|
+
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;
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
function hasOnlyTextOps(ops) {
|
|
215
|
+
return ops.length > 0 && ops.every((op) => op.kind === "set-text");
|
|
216
|
+
}
|
|
217
|
+
function elementTextMatches(element, prevText) {
|
|
218
|
+
const parts = [];
|
|
219
|
+
collectTextRangeParts(element, parts);
|
|
220
|
+
return textRangeContent(parts) === prevText;
|
|
221
|
+
}
|
|
222
|
+
function elementHasTextCandidate(ast, element, prevText) {
|
|
223
|
+
const norm = prevText.trim();
|
|
224
|
+
return collectElementTextCandidates(ast, element).some((candidate) => candidate.current === norm);
|
|
225
|
+
}
|
|
226
|
+
function findElementForEdit(ast, line, column, ops) {
|
|
227
|
+
const element = findInnermostJsxElement(ast, line, column);
|
|
228
|
+
const prevText = fallbackTextForOps(ops);
|
|
229
|
+
if (prevText === null) return element;
|
|
230
|
+
if (hasOnlyTextOps(ops) && element && (elementTextMatches(element, prevText) || elementHasTextCandidate(ast, element, prevText))) return element;
|
|
231
|
+
const textMatch = findUniqueElementByText(ast, prevText);
|
|
232
|
+
if (element && elementTextMatches(element, prevText)) return textMatch ?? element;
|
|
233
|
+
return textMatch ?? element;
|
|
234
|
+
}
|
|
235
|
+
function buildStyleSplice(source, element, ops) {
|
|
236
|
+
const opening = element.openingElement;
|
|
237
|
+
const existing = findJsxAttr(opening, "style");
|
|
238
|
+
const entries = [];
|
|
239
|
+
let hasRawEntry = false;
|
|
240
|
+
if (existing) {
|
|
241
|
+
const value = existing.value;
|
|
242
|
+
if (!value || !t$4.isJSXExpressionContainer(value)) return { error: "style attribute has unsupported form" };
|
|
243
|
+
const expr = value.expression;
|
|
244
|
+
if (!t$4.isObjectExpression(expr)) {
|
|
245
|
+
if (typeof expr.start !== "number" || typeof expr.end !== "number") return { error: "style value missing source range" };
|
|
246
|
+
entries.push({
|
|
247
|
+
kind: "raw",
|
|
248
|
+
text: `...(${source.slice(expr.start, expr.end)})`
|
|
249
|
+
});
|
|
250
|
+
hasRawEntry = true;
|
|
251
|
+
} else for (const prop of expr.properties) if (t$4.isObjectProperty(prop) && !prop.computed) {
|
|
252
|
+
let keyName = null;
|
|
253
|
+
if (t$4.isIdentifier(prop.key)) keyName = prop.key.name;
|
|
254
|
+
else if (t$4.isStringLiteral(prop.key)) keyName = prop.key.value;
|
|
255
|
+
if (!keyName) return { error: "style has unsupported key" };
|
|
256
|
+
const v = prop.value;
|
|
257
|
+
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" };
|
|
258
|
+
entries.push({
|
|
259
|
+
kind: "prop",
|
|
260
|
+
key: keyName,
|
|
261
|
+
keyText: source.slice(prop.key.start, prop.key.end),
|
|
262
|
+
valueText: source.slice(v.start, v.end)
|
|
263
|
+
});
|
|
264
|
+
} else {
|
|
265
|
+
if (typeof prop.start !== "number" || typeof prop.end !== "number") return { error: "style value missing source range" };
|
|
266
|
+
entries.push({
|
|
267
|
+
kind: "raw",
|
|
268
|
+
text: source.slice(prop.start, prop.end)
|
|
269
|
+
});
|
|
270
|
+
hasRawEntry = true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const op of ops) {
|
|
274
|
+
const matching = entries.filter((entry) => entry.kind === "prop" && entry.key === op.key);
|
|
275
|
+
if (op.value === null) {
|
|
276
|
+
for (const entry of matching) entries.splice(entries.indexOf(entry), 1);
|
|
277
|
+
if (hasRawEntry) entries.push({
|
|
278
|
+
kind: "prop",
|
|
279
|
+
key: op.key,
|
|
280
|
+
keyText: op.key,
|
|
281
|
+
valueText: "undefined"
|
|
282
|
+
});
|
|
283
|
+
} else if (matching.length > 0) matching[matching.length - 1].valueText = jsString$1(op.value);
|
|
284
|
+
else entries.push({
|
|
285
|
+
kind: "prop",
|
|
286
|
+
key: op.key,
|
|
287
|
+
keyText: op.key,
|
|
288
|
+
valueText: jsString$1(op.value)
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (entries.length === 0) {
|
|
292
|
+
if (!existing) return null;
|
|
293
|
+
let from = existing.start ?? 0;
|
|
294
|
+
if (from > 0 && source[from - 1] === " ") from -= 1;
|
|
295
|
+
return {
|
|
296
|
+
from,
|
|
297
|
+
to: existing.end ?? 0,
|
|
298
|
+
text: ""
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const propsText = entries.map((entry) => entry.kind === "prop" ? `${entry.keyText}: ${entry.valueText}` : entry.text).join(", ");
|
|
302
|
+
const newAttr = `style={{ ${propsText} }}`;
|
|
303
|
+
if (existing) {
|
|
304
|
+
const lastAttr$1 = opening.attributes[opening.attributes.length - 1];
|
|
305
|
+
if (lastAttr$1 && lastAttr$1 !== existing && typeof lastAttr$1.end === "number") {
|
|
306
|
+
const attrsAfterStyle = source.slice(existing.end ?? 0, lastAttr$1.end).replace(/^[ \t]+/, "");
|
|
307
|
+
return {
|
|
308
|
+
from: existing.start ?? 0,
|
|
309
|
+
to: lastAttr$1.end,
|
|
310
|
+
text: `${attrsAfterStyle} ${newAttr}`
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
from: existing.start ?? 0,
|
|
315
|
+
to: existing.end ?? 0,
|
|
316
|
+
text: newAttr
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const lastAttr = opening.attributes[opening.attributes.length - 1];
|
|
320
|
+
const at = lastAttr?.end ?? opening.name.end ?? 0;
|
|
321
|
+
return {
|
|
322
|
+
from: at,
|
|
323
|
+
to: at,
|
|
324
|
+
text: ` ${newAttr}`
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function formatJsxText(value) {
|
|
328
|
+
if (/[{}<>]/.test(value) || /^\s|\s$/.test(value) || value === "") return `{${jsString$1(value)}}`;
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
function meaningfulChildren(parent) {
|
|
332
|
+
return parent.children.filter((c) => {
|
|
333
|
+
if (t$4.isJSXText(c)) return c.value.trim() !== "";
|
|
334
|
+
return true;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function isOnlyMeaningfulChild(parent, child) {
|
|
338
|
+
const meaningful = meaningfulChildren(parent);
|
|
339
|
+
return meaningful.length === 1 && meaningful[0] === child;
|
|
340
|
+
}
|
|
341
|
+
function wrapSplice(parent, text) {
|
|
342
|
+
const first = parent.children[0];
|
|
343
|
+
const last = parent.children[parent.children.length - 1];
|
|
344
|
+
return {
|
|
345
|
+
from: first.start ?? 0,
|
|
346
|
+
to: last.end ?? 0,
|
|
347
|
+
text
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function splitLinesWithOffsets(value) {
|
|
351
|
+
const lines = [];
|
|
352
|
+
let start = 0;
|
|
353
|
+
for (let i = 0; i < value.length; i++) {
|
|
354
|
+
const ch = value[i];
|
|
355
|
+
if (ch !== "\n" && ch !== "\r") continue;
|
|
356
|
+
lines.push({
|
|
357
|
+
text: value.slice(start, i),
|
|
358
|
+
start
|
|
359
|
+
});
|
|
360
|
+
if (ch === "\r" && value[i + 1] === "\n") i += 1;
|
|
361
|
+
start = i + 1;
|
|
362
|
+
}
|
|
363
|
+
lines.push({
|
|
364
|
+
text: value.slice(start),
|
|
365
|
+
start
|
|
366
|
+
});
|
|
367
|
+
return lines;
|
|
368
|
+
}
|
|
369
|
+
function cleanJsxTextWithOffsets(value) {
|
|
370
|
+
const lines = splitLinesWithOffsets(value);
|
|
371
|
+
let lastNonEmptyLine = 0;
|
|
372
|
+
for (let i = 0; i < lines.length; i++) if (lines[i].text.trim()) lastNonEmptyLine = i;
|
|
373
|
+
let text = "";
|
|
374
|
+
const offsets = [];
|
|
375
|
+
for (let i = 0; i < lines.length; i++) {
|
|
376
|
+
const chars = Array.from(lines[i].text, (ch, j) => ({
|
|
377
|
+
ch: ch === " " ? " " : ch,
|
|
378
|
+
offset: lines[i].start + j
|
|
379
|
+
}));
|
|
380
|
+
let from = 0;
|
|
381
|
+
let to = chars.length;
|
|
382
|
+
if (i !== 0) while (from < to && chars[from].ch === " ") from += 1;
|
|
383
|
+
if (i !== lines.length - 1) while (to > from && chars[to - 1].ch === " ") to -= 1;
|
|
384
|
+
if (from >= to) continue;
|
|
385
|
+
for (const item of chars.slice(from, to)) {
|
|
386
|
+
text += item.ch;
|
|
387
|
+
offsets.push(item.offset);
|
|
388
|
+
}
|
|
389
|
+
if (i !== lastNonEmptyLine) {
|
|
390
|
+
text += " ";
|
|
391
|
+
offsets.push(null);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
text,
|
|
396
|
+
offsets
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function isJsxBrElement(node) {
|
|
400
|
+
if (!t$4.isJSXElement(node)) return false;
|
|
401
|
+
const name = node.openingElement.name;
|
|
402
|
+
return t$4.isJSXIdentifier(name) && name.name.toLowerCase() === "br";
|
|
403
|
+
}
|
|
404
|
+
function collectTextCandidates(element, out) {
|
|
405
|
+
const meaningful = meaningfulChildren(element);
|
|
406
|
+
const isSole = meaningful.length === 1;
|
|
407
|
+
for (const child of meaningful) if (t$4.isJSXText(child)) {
|
|
408
|
+
const current = child.value.trim();
|
|
409
|
+
if (!current) continue;
|
|
410
|
+
out.push({
|
|
411
|
+
current,
|
|
412
|
+
splice: (v) => isSole ? wrapSplice(element, formatJsxText(v)) : {
|
|
413
|
+
from: child.start ?? 0,
|
|
414
|
+
to: child.end ?? 0,
|
|
415
|
+
text: formatJsxText(v)
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
} else if (t$4.isJSXExpressionContainer(child)) {
|
|
419
|
+
const expr = child.expression;
|
|
420
|
+
if (t$4.isStringLiteral(expr) || t$4.isNumericLiteral(expr)) {
|
|
421
|
+
const current = String(expr.value);
|
|
422
|
+
out.push({
|
|
423
|
+
current,
|
|
424
|
+
splice: (v) => isSole ? wrapSplice(element, `{${jsString$1(v)}}`) : {
|
|
425
|
+
from: child.start ?? 0,
|
|
426
|
+
to: child.end ?? 0,
|
|
427
|
+
text: `{${jsString$1(v)}}`
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
} else if (t$4.isJSXElement(child) || t$4.isJSXFragment(child)) collectTextCandidates(child, out);
|
|
432
|
+
}
|
|
433
|
+
function collectTextRangeParts(element, out) {
|
|
434
|
+
const parts = [];
|
|
435
|
+
collectTextRangePartsRaw(element, parts);
|
|
436
|
+
out.push(...normalizeTextRangeParts(parts));
|
|
437
|
+
}
|
|
438
|
+
function collectTextRangePartsRaw(element, out) {
|
|
439
|
+
for (const child of element.children) if (t$4.isJSXText(child)) {
|
|
440
|
+
const { text: current, offsets } = cleanJsxTextWithOffsets(child.value);
|
|
441
|
+
if (current) out.push({
|
|
442
|
+
node: child,
|
|
443
|
+
parent: element,
|
|
444
|
+
current,
|
|
445
|
+
raw: child.value,
|
|
446
|
+
text: formatJsxText,
|
|
447
|
+
offsets
|
|
448
|
+
});
|
|
449
|
+
} else if (t$4.isJSXExpressionContainer(child)) {
|
|
450
|
+
const expression = child.expression;
|
|
451
|
+
if (t$4.isStringLiteral(expression) || t$4.isNumericLiteral(expression)) {
|
|
452
|
+
const raw = String(expression.value);
|
|
453
|
+
const current = raw;
|
|
454
|
+
if (current) out.push({
|
|
455
|
+
node: child,
|
|
456
|
+
parent: element,
|
|
457
|
+
current,
|
|
458
|
+
raw,
|
|
459
|
+
text: (value) => `{${jsString$1(value)}}`,
|
|
460
|
+
offsets: Array.from({ length: current.length }, (_, i) => i)
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
} else if (isJsxBrElement(child)) out.push({
|
|
464
|
+
node: child,
|
|
465
|
+
current: "\n"
|
|
466
|
+
});
|
|
467
|
+
else if (t$4.isJSXElement(child) || t$4.isJSXFragment(child)) collectTextRangePartsRaw(child, out);
|
|
468
|
+
}
|
|
469
|
+
function normalizeTextRangeParts(parts) {
|
|
470
|
+
return parts.flatMap((part, index) => {
|
|
471
|
+
if (!("raw" in part)) return [part];
|
|
472
|
+
let start = 0;
|
|
473
|
+
let end = part.current.length;
|
|
474
|
+
if (parts[index - 1]?.current === "\n") while (start < end && /\s/.test(part.current[start] ?? "")) start++;
|
|
475
|
+
if (parts[index + 1]?.current === "\n") while (end > start && /\s/.test(part.current[end - 1] ?? "")) end--;
|
|
476
|
+
if (start === 0 && end === part.current.length) return [part];
|
|
477
|
+
if (start >= end) return [];
|
|
478
|
+
return [{
|
|
479
|
+
...part,
|
|
480
|
+
current: part.current.slice(start, end),
|
|
481
|
+
offsets: part.offsets.slice(start, end)
|
|
482
|
+
}];
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
function resetValueForRangeStyle(key) {
|
|
486
|
+
if (key === "fontWeight") return "400";
|
|
487
|
+
if (key === "fontStyle") return "normal";
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
function styleSpanForText(text, key, value) {
|
|
491
|
+
const styleValue = value ?? resetValueForRangeStyle(key);
|
|
492
|
+
if (styleValue === null) return formatJsxText(text);
|
|
493
|
+
return `<span style={{ ${key}: ${jsString$1(styleValue)} }}>${formatJsxText(text)}</span>`;
|
|
494
|
+
}
|
|
495
|
+
function textRangeContent(parts) {
|
|
496
|
+
return parts.map((part) => part.current).join("");
|
|
497
|
+
}
|
|
498
|
+
function compactText(value) {
|
|
499
|
+
return value.replace(/\s+/g, "");
|
|
500
|
+
}
|
|
501
|
+
function textMatchesExpected(current, expected) {
|
|
502
|
+
return current === expected || compactText(current) === compactText(expected);
|
|
503
|
+
}
|
|
504
|
+
function formatRichText(value, formatText = formatJsxText) {
|
|
505
|
+
return value.split("\n").map((part) => formatText(part)).join("<br />");
|
|
506
|
+
}
|
|
507
|
+
function formatOptionalText(value, formatText = formatJsxText) {
|
|
508
|
+
return value ? formatText(value) : "";
|
|
509
|
+
}
|
|
510
|
+
function textDiff(prevText, nextText) {
|
|
511
|
+
let start = 0;
|
|
512
|
+
while (start < prevText.length && start < nextText.length && prevText[start] === nextText[start]) start += 1;
|
|
513
|
+
let prevEnd = prevText.length;
|
|
514
|
+
let nextEnd = nextText.length;
|
|
515
|
+
while (prevEnd > start && nextEnd > start && prevText[prevEnd - 1] === nextText[nextEnd - 1]) {
|
|
516
|
+
prevEnd -= 1;
|
|
517
|
+
nextEnd -= 1;
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
start,
|
|
521
|
+
end: prevEnd,
|
|
522
|
+
value: nextText.slice(start, nextEnd)
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function textLeafSplice(part, value) {
|
|
526
|
+
const rawRange = textLeafRawRange(part, 0, part.current.length);
|
|
527
|
+
if (!rawRange) return spliceRange(part.node, part.text(value));
|
|
528
|
+
const { rawStart, rawEnd } = rawRange;
|
|
529
|
+
return {
|
|
530
|
+
from: part.node.start ?? 0,
|
|
531
|
+
to: part.node.end ?? 0,
|
|
532
|
+
text: `${part.raw.slice(0, rawStart)}${formatRichText(value, part.text)}${part.raw.slice(rawEnd)}`
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function textLeafRawRange(part, start, end) {
|
|
536
|
+
if (start >= end) return null;
|
|
537
|
+
let first = null;
|
|
538
|
+
let last = null;
|
|
539
|
+
for (let i = start; i < end; i++) {
|
|
540
|
+
const offset = part.offsets[i];
|
|
541
|
+
if (offset === void 0) return null;
|
|
542
|
+
if (offset === null) continue;
|
|
543
|
+
first ??= offset;
|
|
544
|
+
last = offset;
|
|
545
|
+
}
|
|
546
|
+
if (first === null || last === null) return null;
|
|
547
|
+
return {
|
|
548
|
+
rawStart: first,
|
|
549
|
+
rawEnd: last + 1
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function buildTextRangeReplaceSplices(parts, start, end, value) {
|
|
553
|
+
const splices = [];
|
|
554
|
+
let offset = 0;
|
|
555
|
+
let inserted = false;
|
|
556
|
+
for (const part of parts) {
|
|
557
|
+
const partStart = offset;
|
|
558
|
+
const partEnd = partStart + part.current.length;
|
|
559
|
+
offset = partEnd;
|
|
560
|
+
const overlaps = start < partEnd && end > partStart;
|
|
561
|
+
const insertsHere = start === end && !inserted && start >= partStart && start <= partEnd;
|
|
562
|
+
if (!overlaps && !insertsHere) continue;
|
|
563
|
+
if ("raw" in part) {
|
|
564
|
+
const localStart = Math.max(start, partStart) - partStart;
|
|
565
|
+
const localEnd = overlaps ? Math.min(end, partEnd) - partStart : localStart;
|
|
566
|
+
const nextText = `${part.current.slice(0, localStart)}${inserted ? "" : value}${part.current.slice(localEnd)}`;
|
|
567
|
+
splices.push(textLeafSplice(part, nextText));
|
|
568
|
+
} else if (overlaps) splices.push(spliceRange(part.node, inserted ? "" : formatRichText(value)));
|
|
569
|
+
else if (insertsHere) {
|
|
570
|
+
const at = start === partStart ? part.node.start ?? 0 : part.node.end ?? 0;
|
|
571
|
+
splices.push({
|
|
572
|
+
from: at,
|
|
573
|
+
to: at,
|
|
574
|
+
text: formatRichText(value)
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
inserted = true;
|
|
578
|
+
}
|
|
579
|
+
if (!inserted && start === end && start === offset) {
|
|
580
|
+
const last = parts[parts.length - 1];
|
|
581
|
+
if (!last) return { error: "element has no editable text" };
|
|
582
|
+
if ("raw" in last) splices.push(textLeafSplice(last, `${last.current}${value}`));
|
|
583
|
+
else splices.push({
|
|
584
|
+
from: last.node.end ?? 0,
|
|
585
|
+
to: last.node.end ?? 0,
|
|
586
|
+
text: formatRichText(value)
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return splices;
|
|
590
|
+
}
|
|
591
|
+
function buildTextContentSplices(element, value, prevText) {
|
|
592
|
+
const parts = [];
|
|
593
|
+
collectTextRangeParts(element, parts);
|
|
594
|
+
const current = textRangeContent(parts);
|
|
595
|
+
if (!textMatchesExpected(current, prevText)) return { error: "no text candidate matches the current value" };
|
|
596
|
+
const diff = textDiff(current, value);
|
|
597
|
+
if (diff.start === diff.end && diff.value === "") return [];
|
|
598
|
+
return buildTextRangeReplaceSplices(parts, diff.start, diff.end, diff.value);
|
|
599
|
+
}
|
|
600
|
+
function buildTextRangeStyleSplices(ast, source, element, start, end, op, prevText) {
|
|
601
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start) return { error: "invalid text range" };
|
|
602
|
+
const parts = [];
|
|
603
|
+
collectTextRangeParts(element, parts);
|
|
604
|
+
const current = prevText ?? textRangeContent(parts);
|
|
605
|
+
if (!current) return { error: "element has no editable text" };
|
|
606
|
+
if (end > current.length) return { error: "text range is out of bounds" };
|
|
607
|
+
const renderedText = textRangeContent(parts);
|
|
608
|
+
if (prevText !== void 0 && renderedText !== prevText) {
|
|
609
|
+
if (elementTextCandidateMatches(ast, element, prevText)) {
|
|
610
|
+
const result = buildStyleSplice(source, element, [op]);
|
|
611
|
+
if (result && "error" in result) return result;
|
|
612
|
+
return result ? [result] : [];
|
|
613
|
+
}
|
|
614
|
+
return { error: "no text candidate matches the current value" };
|
|
615
|
+
}
|
|
616
|
+
const splices = [];
|
|
617
|
+
let leafStart = 0;
|
|
618
|
+
for (const leaf of parts) {
|
|
619
|
+
const leafEnd = leafStart + leaf.current.length;
|
|
620
|
+
if (!("raw" in leaf)) {
|
|
621
|
+
leafStart = leafEnd;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const selectedStart = Math.max(start, leafStart);
|
|
625
|
+
const selectedEnd = Math.min(end, leafEnd);
|
|
626
|
+
if (selectedStart >= selectedEnd) {
|
|
627
|
+
leafStart = leafEnd;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (selectedStart === leafStart && selectedEnd === leafEnd && t$4.isJSXElement(leaf.parent) && leaf.parent !== element && isOnlyMeaningfulChild(leaf.parent, leaf.node)) {
|
|
631
|
+
const result = buildStyleSplice(source, leaf.parent, [op]);
|
|
632
|
+
if (result && "error" in result) return result;
|
|
633
|
+
if (result) splices.push(result);
|
|
634
|
+
leafStart = leafEnd;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const localStart = selectedStart - leafStart;
|
|
638
|
+
const localEnd = selectedEnd - leafStart;
|
|
639
|
+
const rawRange = textLeafRawRange(leaf, localStart, localEnd);
|
|
640
|
+
if (!rawRange) return { error: "text range source mismatch" };
|
|
641
|
+
const raw = leaf.raw;
|
|
642
|
+
const { rawStart, rawEnd } = rawRange;
|
|
643
|
+
const before = raw.slice(0, rawStart);
|
|
644
|
+
const selected = leaf.current.slice(localStart, localEnd);
|
|
645
|
+
const after = raw.slice(rawEnd);
|
|
646
|
+
const beforeText = t$4.isJSXText(leaf.node) ? before : formatOptionalText(before, leaf.text);
|
|
647
|
+
const afterText = t$4.isJSXText(leaf.node) ? after : formatOptionalText(after, leaf.text);
|
|
648
|
+
splices.push(spliceRange(leaf.node, `${beforeText}${styleSpanForText(selected, op.key, op.value)}${afterText}`));
|
|
649
|
+
leafStart = leafEnd;
|
|
650
|
+
}
|
|
651
|
+
return splices.length > 0 ? splices : null;
|
|
652
|
+
}
|
|
653
|
+
function propPassthroughName(element) {
|
|
654
|
+
const meaningful = meaningfulChildren(element);
|
|
655
|
+
if (meaningful.length !== 1) return null;
|
|
656
|
+
const child = meaningful[0];
|
|
657
|
+
if (!t$4.isJSXExpressionContainer(child)) return null;
|
|
658
|
+
return t$4.isIdentifier(child.expression) ? child.expression.name : null;
|
|
659
|
+
}
|
|
660
|
+
function findEnclosingComponent(ast, target) {
|
|
661
|
+
let best = null;
|
|
662
|
+
let bestSize = Number.POSITIVE_INFINITY;
|
|
663
|
+
const targetStart = target.start ?? 0;
|
|
664
|
+
const targetEnd = target.end ?? 0;
|
|
665
|
+
const consider = (name, fn) => {
|
|
666
|
+
if (!/^[A-Z]/.test(name)) return;
|
|
667
|
+
const fnStart = fn.start ?? 0;
|
|
668
|
+
const fnEnd = fn.end ?? 0;
|
|
669
|
+
if (fnStart > targetStart || fnEnd < targetEnd) return;
|
|
670
|
+
const size = fnEnd - fnStart;
|
|
671
|
+
if (size < bestSize) {
|
|
672
|
+
best = {
|
|
673
|
+
name,
|
|
674
|
+
fn
|
|
675
|
+
};
|
|
676
|
+
bestSize = size;
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
const visitDecl = (decl) => {
|
|
680
|
+
if (t$4.isFunctionDeclaration(decl) && decl.id) consider(decl.id.name, decl);
|
|
681
|
+
else if (t$4.isVariableDeclaration(decl)) for (const d of decl.declarations) {
|
|
682
|
+
if (!t$4.isVariableDeclarator(d) || !t$4.isIdentifier(d.id) || !d.init) continue;
|
|
683
|
+
if (t$4.isArrowFunctionExpression(d.init) || t$4.isFunctionExpression(d.init)) consider(d.id.name, d.init);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
for (const decl of ast.program.body) {
|
|
687
|
+
visitDecl(decl);
|
|
688
|
+
if (t$4.isExportNamedDeclaration(decl) || t$4.isExportDefaultDeclaration(decl)) {
|
|
689
|
+
const inner = decl.declaration;
|
|
690
|
+
if (inner && (t$4.isStatement(inner) || t$4.isFunctionDeclaration(inner))) visitDecl(inner);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return best;
|
|
694
|
+
}
|
|
695
|
+
function componentDestructuresProp(fn, propName) {
|
|
696
|
+
if (fn.params.length === 0) return false;
|
|
697
|
+
let first = fn.params[0];
|
|
698
|
+
if (t$4.isAssignmentPattern(first)) first = first.left;
|
|
699
|
+
if (!t$4.isObjectPattern(first)) return false;
|
|
700
|
+
for (const prop of first.properties) {
|
|
701
|
+
if (!t$4.isObjectProperty(prop)) continue;
|
|
702
|
+
if (t$4.isIdentifier(prop.key) && prop.key.name === propName) return true;
|
|
703
|
+
if (t$4.isStringLiteral(prop.key) && prop.key.value === propName) return true;
|
|
704
|
+
}
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
function collectCallSiteCandidates(ast, componentName) {
|
|
708
|
+
const out = [];
|
|
709
|
+
walkJsx(ast, (n) => {
|
|
710
|
+
if (!t$4.isJSXElement(n)) return;
|
|
711
|
+
const elName = n.openingElement.name;
|
|
712
|
+
if (t$4.isJSXIdentifier(elName) && elName.name === componentName) collectTextCandidates(n, out);
|
|
713
|
+
});
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
function collectPropCallSiteCandidates(ast, componentName, propName) {
|
|
717
|
+
const out = [];
|
|
718
|
+
walkJsx(ast, (n) => {
|
|
719
|
+
if (!t$4.isJSXElement(n)) return;
|
|
720
|
+
const elName = n.openingElement.name;
|
|
721
|
+
if (!t$4.isJSXIdentifier(elName) || elName.name !== componentName) return;
|
|
722
|
+
const attr = findJsxAttr(n.openingElement, propName);
|
|
723
|
+
if (!attr?.value) return;
|
|
724
|
+
const v = attr.value;
|
|
725
|
+
if (t$4.isStringLiteral(v)) out.push({
|
|
726
|
+
current: v.value,
|
|
727
|
+
splice: (s) => spliceRange(v, formatJsxAttrValue(s))
|
|
728
|
+
});
|
|
729
|
+
else if (t$4.isJSXExpressionContainer(v)) {
|
|
730
|
+
const expr = v.expression;
|
|
731
|
+
if (t$4.isStringLiteral(expr) || t$4.isNumericLiteral(expr)) out.push({
|
|
732
|
+
current: String(expr.value),
|
|
733
|
+
splice: (s) => spliceRange(v, formatJsxAttrValue(s))
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
function findEnclosingMapCallback(ast, target) {
|
|
740
|
+
let best = null;
|
|
741
|
+
const targetStart = target.start ?? 0;
|
|
742
|
+
const targetEnd = target.end ?? 0;
|
|
743
|
+
walkAll(ast, (node) => {
|
|
744
|
+
if (!t$4.isCallExpression(node)) return;
|
|
745
|
+
const callee = node.callee;
|
|
746
|
+
if (!t$4.isMemberExpression(callee) || callee.computed) return;
|
|
747
|
+
if (!t$4.isIdentifier(callee.property)) return;
|
|
748
|
+
if (callee.property.name !== "map" && callee.property.name !== "flatMap") return;
|
|
749
|
+
const fn = node.arguments[0];
|
|
750
|
+
if (!fn || !t$4.isArrowFunctionExpression(fn) && !t$4.isFunctionExpression(fn)) return;
|
|
751
|
+
const fnStart = fn.start ?? 0;
|
|
752
|
+
const fnEnd = fn.end ?? 0;
|
|
753
|
+
if (fnStart > targetStart || fnEnd < targetEnd) return;
|
|
754
|
+
if (!t$4.isExpression(callee.object)) return;
|
|
755
|
+
const size = fnEnd - fnStart;
|
|
756
|
+
if (!best || size < best.size) best = {
|
|
757
|
+
fn,
|
|
758
|
+
arrayArg: callee.object,
|
|
759
|
+
size
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
if (!best) return null;
|
|
763
|
+
const found = best;
|
|
764
|
+
return {
|
|
765
|
+
fn: found.fn,
|
|
766
|
+
arrayArg: found.arrayArg
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function resolveArrayLiteralElements(ast, expr) {
|
|
770
|
+
const dropHoles = (arr) => arr.elements.filter((e) => e != null);
|
|
771
|
+
if (t$4.isArrayExpression(expr)) return dropHoles(expr);
|
|
772
|
+
if (!t$4.isIdentifier(expr)) return null;
|
|
773
|
+
const name = expr.name;
|
|
774
|
+
const useStart = expr.start ?? 0;
|
|
775
|
+
let init = null;
|
|
776
|
+
walkAll(ast, (node) => {
|
|
777
|
+
if (!t$4.isVariableDeclarator(node)) return;
|
|
778
|
+
if (!t$4.isIdentifier(node.id) || node.id.name !== name) return;
|
|
779
|
+
if (!node.init || !t$4.isArrayExpression(node.init)) return;
|
|
780
|
+
if ((node.init.start ?? 0) > useStart) return;
|
|
781
|
+
init = node.init;
|
|
782
|
+
});
|
|
783
|
+
return init ? dropHoles(init) : null;
|
|
784
|
+
}
|
|
785
|
+
function findObjectProperty(obj, name) {
|
|
786
|
+
if (!t$4.isObjectExpression(obj)) return null;
|
|
787
|
+
for (const prop of obj.properties) {
|
|
788
|
+
if (!t$4.isObjectProperty(prop) || prop.computed) continue;
|
|
789
|
+
if (t$4.isIdentifier(prop.key) && prop.key.name === name) return prop;
|
|
790
|
+
if (t$4.isStringLiteral(prop.key) && prop.key.value === name) return prop;
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
function decodeMapPassthrough(element, callbackParam) {
|
|
795
|
+
const meaningful = meaningfulChildren(element);
|
|
796
|
+
if (meaningful.length !== 1) return null;
|
|
797
|
+
const child = meaningful[0];
|
|
798
|
+
if (!t$4.isJSXExpressionContainer(child)) return null;
|
|
799
|
+
const expr = child.expression;
|
|
800
|
+
if (t$4.isMemberExpression(expr)) {
|
|
801
|
+
if (expr.computed) return null;
|
|
802
|
+
if (!t$4.isIdentifier(expr.object) || !t$4.isIdentifier(expr.property)) return null;
|
|
803
|
+
if (!callbackParam || !t$4.isIdentifier(callbackParam)) return null;
|
|
804
|
+
if (callbackParam.name !== expr.object.name) return null;
|
|
805
|
+
return expr.property.name;
|
|
806
|
+
}
|
|
807
|
+
if (t$4.isIdentifier(expr)) {
|
|
808
|
+
const fieldName = expr.name;
|
|
809
|
+
if (!callbackParam || !t$4.isObjectPattern(callbackParam)) return null;
|
|
810
|
+
for (const prop of callbackParam.properties) {
|
|
811
|
+
if (!t$4.isObjectProperty(prop) || prop.computed) continue;
|
|
812
|
+
if (!t$4.isIdentifier(prop.key) || prop.key.name !== fieldName) continue;
|
|
813
|
+
return t$4.isIdentifier(prop.value) && prop.value.name === fieldName ? fieldName : null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
function collectArrayMapCandidates(ast, element) {
|
|
819
|
+
const ctx = findEnclosingMapCallback(ast, element);
|
|
820
|
+
if (!ctx) return [];
|
|
821
|
+
const fieldName = decodeMapPassthrough(element, ctx.fn.params[0]);
|
|
822
|
+
if (!fieldName) return [];
|
|
823
|
+
const elements = resolveArrayLiteralElements(ast, ctx.arrayArg);
|
|
824
|
+
if (!elements) return [];
|
|
825
|
+
const out = [];
|
|
826
|
+
for (const obj of elements) {
|
|
827
|
+
const prop = findObjectProperty(obj, fieldName);
|
|
828
|
+
if (!prop) continue;
|
|
829
|
+
const v = prop.value;
|
|
830
|
+
if (t$4.isStringLiteral(v)) out.push({
|
|
831
|
+
current: v.value,
|
|
832
|
+
splice: (s) => spliceRange(v, jsString$1(s))
|
|
833
|
+
});
|
|
834
|
+
else if (t$4.isNumericLiteral(v)) out.push({
|
|
835
|
+
current: String(v.value),
|
|
836
|
+
splice: (s) => spliceRange(v, jsString$1(s))
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
}
|
|
841
|
+
function collectElementTextCandidates(ast, element) {
|
|
842
|
+
const candidates = [];
|
|
843
|
+
collectTextCandidates(element, candidates);
|
|
844
|
+
if (candidates.length === 0) {
|
|
845
|
+
const passthrough = propPassthroughName(element);
|
|
846
|
+
const enclosing = passthrough ? findEnclosingComponent(ast, element) : null;
|
|
847
|
+
if (passthrough === "children" && enclosing) candidates.push(...collectCallSiteCandidates(ast, enclosing.name));
|
|
848
|
+
else if (passthrough && enclosing && componentDestructuresProp(enclosing.fn, passthrough)) candidates.push(...collectPropCallSiteCandidates(ast, enclosing.name, passthrough));
|
|
849
|
+
}
|
|
850
|
+
if (candidates.length === 0) candidates.push(...collectArrayMapCandidates(ast, element));
|
|
851
|
+
return candidates;
|
|
852
|
+
}
|
|
853
|
+
function elementTextCandidateMatches(ast, element, prevText) {
|
|
854
|
+
const norm = prevText.trim();
|
|
855
|
+
return collectElementTextCandidates(ast, element).some((candidate) => candidate.current === norm);
|
|
856
|
+
}
|
|
857
|
+
function buildTextSplice(ast, element, value, prevText) {
|
|
858
|
+
const candidates = collectElementTextCandidates(ast, element);
|
|
859
|
+
if (candidates.length === 0) return { error: "element has no editable text" };
|
|
860
|
+
if (candidates.length === 1) return candidates[0].splice(value);
|
|
861
|
+
if (prevText === void 0) return { error: "element has multiple text candidates; missing prevText" };
|
|
862
|
+
const norm = prevText.trim();
|
|
863
|
+
const matches = candidates.filter((c) => c.current === norm);
|
|
864
|
+
if (matches.length === 0) return { error: "no text candidate matches the current value" };
|
|
865
|
+
if (matches.length > 1) return { error: "multiple text candidates share the same value; cannot disambiguate" };
|
|
866
|
+
return matches[0].splice(value);
|
|
867
|
+
}
|
|
868
|
+
function planAssetImport(ast, assetPath) {
|
|
869
|
+
const imports = findImports$1(ast);
|
|
870
|
+
for (const imp of imports) if (imp.source === assetPath && imp.defaultIdent) return {
|
|
871
|
+
identifier: imp.defaultIdent,
|
|
872
|
+
importSplice: null
|
|
873
|
+
};
|
|
874
|
+
const filename = assetPath.slice(assetPath.lastIndexOf("/") + 1);
|
|
875
|
+
const identifier = safeAssetIdentifier(filename, collectTopLevelIdentifiers(ast));
|
|
876
|
+
const importStmt = `import ${identifier} from '${assetPath.replace(/'/g, "\\'")}';\n`;
|
|
877
|
+
const last = imports[imports.length - 1];
|
|
878
|
+
const insertAt = last ? last.node.end ?? 0 : 0;
|
|
879
|
+
const prefix = last ? "\n" : "";
|
|
880
|
+
return {
|
|
881
|
+
identifier,
|
|
882
|
+
importSplice: {
|
|
883
|
+
from: insertAt,
|
|
884
|
+
to: insertAt,
|
|
885
|
+
text: prefix + importStmt
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
function planAssetAttr(ast, element, attr, assetPath) {
|
|
890
|
+
if (!attr || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(attr)) return { error: "invalid attribute name" };
|
|
891
|
+
if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
|
|
892
|
+
const { identifier, importSplice } = planAssetImport(ast, assetPath);
|
|
893
|
+
const opening = element.openingElement;
|
|
894
|
+
const newAttr = `${attr}={${identifier}}`;
|
|
895
|
+
const existing = findJsxAttr(opening, attr);
|
|
896
|
+
const attrSplice = existing ? {
|
|
897
|
+
from: existing.start ?? 0,
|
|
898
|
+
to: existing.end ?? 0,
|
|
899
|
+
text: newAttr
|
|
900
|
+
} : {
|
|
901
|
+
from: opening.name.end ?? 0,
|
|
902
|
+
to: opening.name.end ?? 0,
|
|
903
|
+
text: ` ${newAttr}`
|
|
904
|
+
};
|
|
905
|
+
return {
|
|
906
|
+
importSplice,
|
|
907
|
+
attrSplice
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
function planReplacePlaceholder(ast, element, assetPath) {
|
|
911
|
+
const opening = element.openingElement;
|
|
912
|
+
if (!t$4.isJSXIdentifier(opening.name) || opening.name.name !== "ImagePlaceholder") return { error: "not a placeholder" };
|
|
913
|
+
if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return { error: "asset path must start with ./assets/ or @assets/" };
|
|
914
|
+
const hint = readJsxStringAttr(opening, "hint") ?? "";
|
|
915
|
+
const width = readJsxNumberAttr(opening, "width");
|
|
916
|
+
const height = readJsxNumberAttr(opening, "height");
|
|
917
|
+
const { identifier, importSplice } = planAssetImport(ast, assetPath);
|
|
918
|
+
const styleParts = [];
|
|
919
|
+
if (width != null) styleParts.push(`width: ${width}`);
|
|
920
|
+
else if (height != null) styleParts.push(`width: '100%'`);
|
|
921
|
+
if (height != null) styleParts.push(`height: ${height}`);
|
|
922
|
+
else if (width != null) styleParts.push(`height: '100%'`);
|
|
923
|
+
styleParts.push(`objectFit: 'cover'`);
|
|
924
|
+
styleParts.push(`objectPosition: '50% 50%'`);
|
|
925
|
+
const replacement = `<img src={${identifier}} alt=${jsString$1(hint)} style={{ ${styleParts.join(", ")} }} />`;
|
|
926
|
+
return {
|
|
927
|
+
importSplice,
|
|
928
|
+
elementSplice: spliceRange(element, replacement)
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
function applyEdit(source, line, column, ops) {
|
|
932
|
+
if (ops.length === 0) return {
|
|
933
|
+
ok: true,
|
|
934
|
+
source
|
|
935
|
+
};
|
|
936
|
+
const ast = parseSource$2(source);
|
|
937
|
+
if (!ast) return {
|
|
938
|
+
ok: false,
|
|
939
|
+
status: 422,
|
|
940
|
+
error: "could not parse source"
|
|
941
|
+
};
|
|
942
|
+
const element = findElementForEdit(ast, line, column, ops);
|
|
943
|
+
if (!element) return {
|
|
944
|
+
ok: false,
|
|
945
|
+
status: 422,
|
|
946
|
+
error: "no JSX element at location"
|
|
947
|
+
};
|
|
948
|
+
const splices = [];
|
|
949
|
+
const styleOps = ops.flatMap((op) => op.kind === "set-style" ? [{
|
|
950
|
+
key: op.key,
|
|
951
|
+
value: op.value
|
|
952
|
+
}] : []);
|
|
953
|
+
if (styleOps.length > 0) {
|
|
954
|
+
const result = buildStyleSplice(source, element, styleOps);
|
|
955
|
+
if (result && "error" in result) return {
|
|
956
|
+
ok: false,
|
|
957
|
+
status: 422,
|
|
958
|
+
error: result.error
|
|
959
|
+
};
|
|
960
|
+
if (result) splices.push(result);
|
|
961
|
+
}
|
|
962
|
+
for (const op of ops) {
|
|
963
|
+
if (op.kind !== "set-text-range-style") continue;
|
|
964
|
+
const result = buildTextRangeStyleSplices(ast, source, element, op.start, op.end, {
|
|
965
|
+
key: op.key,
|
|
966
|
+
value: op.value
|
|
967
|
+
}, op.prevText);
|
|
968
|
+
if (result && "error" in result) return {
|
|
969
|
+
ok: false,
|
|
970
|
+
status: 422,
|
|
971
|
+
error: result.error
|
|
972
|
+
};
|
|
973
|
+
if (result) splices.push(...result);
|
|
974
|
+
}
|
|
975
|
+
for (const op of ops) {
|
|
976
|
+
if (op.kind !== "set-text") continue;
|
|
977
|
+
if (op.prevText !== void 0 && (op.value.includes("\n") || op.prevText.includes("\n"))) {
|
|
978
|
+
const richResult = buildTextContentSplices(element, op.value, op.prevText);
|
|
979
|
+
if (!("error" in richResult)) {
|
|
980
|
+
splices.push(...richResult);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const result = buildTextSplice(ast, element, op.value, op.prevText);
|
|
985
|
+
if ("error" in result) {
|
|
986
|
+
if (op.prevText === void 0) return {
|
|
987
|
+
ok: false,
|
|
988
|
+
status: 422,
|
|
989
|
+
error: result.error
|
|
990
|
+
};
|
|
991
|
+
const richResult = buildTextContentSplices(element, op.value, op.prevText);
|
|
992
|
+
if ("error" in richResult) return {
|
|
993
|
+
ok: false,
|
|
994
|
+
status: 422,
|
|
995
|
+
error: result.error
|
|
996
|
+
};
|
|
997
|
+
splices.push(...richResult);
|
|
998
|
+
} else splices.push(result);
|
|
999
|
+
}
|
|
1000
|
+
const assetOps = ops.flatMap((op) => op.kind === "set-attr-asset" ? [op] : []);
|
|
1001
|
+
const placeholderOps = ops.flatMap((op) => op.kind === "replace-placeholder-with-image" ? [op] : []);
|
|
1002
|
+
if (assetOps.length > 0 || placeholderOps.length > 0) {
|
|
1003
|
+
const importSplices = [];
|
|
1004
|
+
for (const op of assetOps) {
|
|
1005
|
+
const plan = planAssetAttr(ast, element, op.attr, op.assetPath);
|
|
1006
|
+
if ("error" in plan) return {
|
|
1007
|
+
ok: false,
|
|
1008
|
+
status: 422,
|
|
1009
|
+
error: plan.error
|
|
1010
|
+
};
|
|
1011
|
+
splices.push(plan.attrSplice);
|
|
1012
|
+
if (plan.importSplice) importSplices.push(plan.importSplice);
|
|
1013
|
+
}
|
|
1014
|
+
for (const op of placeholderOps) {
|
|
1015
|
+
const plan = planReplacePlaceholder(ast, element, op.assetPath);
|
|
1016
|
+
if ("error" in plan) return {
|
|
1017
|
+
ok: false,
|
|
1018
|
+
status: 422,
|
|
1019
|
+
error: plan.error
|
|
1020
|
+
};
|
|
1021
|
+
splices.push(plan.elementSplice);
|
|
1022
|
+
if (plan.importSplice) importSplices.push(plan.importSplice);
|
|
1023
|
+
}
|
|
1024
|
+
if (importSplices.length > 0) {
|
|
1025
|
+
const from = importSplices[0].from;
|
|
1026
|
+
const to = importSplices[0].to;
|
|
1027
|
+
const text = importSplices.map((s) => s.text).join("");
|
|
1028
|
+
splices.push({
|
|
1029
|
+
from,
|
|
1030
|
+
to,
|
|
1031
|
+
text
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (splices.length === 0) return {
|
|
1036
|
+
ok: true,
|
|
1037
|
+
source
|
|
1038
|
+
};
|
|
1039
|
+
splices.sort((a, b) => b.from - a.from);
|
|
1040
|
+
let next = source;
|
|
1041
|
+
for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
|
|
1042
|
+
if (!parseSource$2(next)) return {
|
|
1043
|
+
ok: false,
|
|
1044
|
+
status: 422,
|
|
1045
|
+
error: "edit would produce invalid source"
|
|
1046
|
+
};
|
|
1047
|
+
return {
|
|
1048
|
+
ok: true,
|
|
1049
|
+
source: next
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
//#endregion
|
|
1054
|
+
//#region src/editing/revert-asset.ts
|
|
1055
|
+
function collectImgSrcUses(ast, identifier) {
|
|
1056
|
+
const uses = [];
|
|
1057
|
+
walkJsx(ast, (n) => {
|
|
1058
|
+
if (!t$3.isJSXElement(n)) return;
|
|
1059
|
+
const opening = n.openingElement;
|
|
1060
|
+
if (!t$3.isJSXIdentifier(opening.name) || opening.name.name !== "img") return;
|
|
1061
|
+
const src = findJsxAttr(opening, "src");
|
|
1062
|
+
if (!src?.value) return;
|
|
1063
|
+
if (!t$3.isJSXExpressionContainer(src.value)) return;
|
|
1064
|
+
const expr = src.value.expression;
|
|
1065
|
+
if (!t$3.isIdentifier(expr) || expr.name !== identifier) return;
|
|
1066
|
+
uses.push({
|
|
1067
|
+
element: n,
|
|
1068
|
+
identNode: expr
|
|
1069
|
+
});
|
|
1070
|
+
});
|
|
1071
|
+
return uses;
|
|
1072
|
+
}
|
|
1073
|
+
function readStyleNumericDim(opening, key) {
|
|
1074
|
+
const style = findJsxAttr(opening, "style");
|
|
1075
|
+
if (!style?.value) return null;
|
|
1076
|
+
if (!t$3.isJSXExpressionContainer(style.value)) return null;
|
|
1077
|
+
const obj = style.value.expression;
|
|
1078
|
+
if (!t$3.isObjectExpression(obj)) return null;
|
|
1079
|
+
for (const prop of obj.properties) {
|
|
1080
|
+
if (!t$3.isObjectProperty(prop)) continue;
|
|
1081
|
+
if (prop.computed) continue;
|
|
1082
|
+
const k = prop.key;
|
|
1083
|
+
const keyName = t$3.isIdentifier(k) ? k.name : t$3.isStringLiteral(k) ? k.value : null;
|
|
1084
|
+
if (keyName !== key) continue;
|
|
1085
|
+
const v = prop.value;
|
|
1086
|
+
if (t$3.isNumericLiteral(v) && Number.isFinite(v.value)) return v.value;
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
function buildPlaceholderReplacement(hint, width, height) {
|
|
1092
|
+
const parts = [`hint=${formatJsxAttrValue(hint)}`];
|
|
1093
|
+
if (width != null) parts.push(`width={${width}}`);
|
|
1094
|
+
if (height != null) parts.push(`height={${height}}`);
|
|
1095
|
+
return `<ImagePlaceholder ${parts.join(" ")} />`;
|
|
1096
|
+
}
|
|
1097
|
+
function planEnsureImagePlaceholderImport(ast) {
|
|
1098
|
+
const readKind = (n) => n.importKind === "type";
|
|
1099
|
+
const imports = findImports$1(ast);
|
|
1100
|
+
let valueImport = null;
|
|
1101
|
+
for (const imp of imports) {
|
|
1102
|
+
if (imp.source !== "@open-aippt/core") continue;
|
|
1103
|
+
const declIsTypeOnly = readKind(imp.node);
|
|
1104
|
+
for (const spec of imp.node.specifiers) {
|
|
1105
|
+
if (!t$3.isImportSpecifier(spec)) continue;
|
|
1106
|
+
const imported = spec.imported;
|
|
1107
|
+
const name = t$3.isIdentifier(imported) ? imported.name : imported.value;
|
|
1108
|
+
if (name !== "ImagePlaceholder") continue;
|
|
1109
|
+
const specIsTypeOnly = readKind(spec) || declIsTypeOnly;
|
|
1110
|
+
if (!specIsTypeOnly) return null;
|
|
1111
|
+
}
|
|
1112
|
+
if (!declIsTypeOnly && !valueImport) valueImport = imp;
|
|
1113
|
+
}
|
|
1114
|
+
if (valueImport) {
|
|
1115
|
+
const node = valueImport.node;
|
|
1116
|
+
const lastSpec = node.specifiers[node.specifiers.length - 1];
|
|
1117
|
+
if (lastSpec && t$3.isImportSpecifier(lastSpec)) {
|
|
1118
|
+
const insertAt$1 = lastSpec.end ?? 0;
|
|
1119
|
+
return {
|
|
1120
|
+
from: insertAt$1,
|
|
1121
|
+
to: insertAt$1,
|
|
1122
|
+
text: ", ImagePlaceholder"
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
if (lastSpec && t$3.isImportDefaultSpecifier(lastSpec)) {
|
|
1126
|
+
const insertAt$1 = lastSpec.end ?? 0;
|
|
1127
|
+
return {
|
|
1128
|
+
from: insertAt$1,
|
|
1129
|
+
to: insertAt$1,
|
|
1130
|
+
text: ", { ImagePlaceholder }"
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
const insertAt = (node.source.start ?? 0) - 5;
|
|
1134
|
+
return {
|
|
1135
|
+
from: insertAt,
|
|
1136
|
+
to: insertAt,
|
|
1137
|
+
text: "{ ImagePlaceholder } "
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
return {
|
|
1141
|
+
from: 0,
|
|
1142
|
+
to: 0,
|
|
1143
|
+
text: "import { ImagePlaceholder } from '@open-aippt/core';\n"
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
function findAssetUsages(source, assetPath) {
|
|
1147
|
+
const ast = parseSource$2(source);
|
|
1148
|
+
if (!ast) return 0;
|
|
1149
|
+
const imports = findImports$1(ast);
|
|
1150
|
+
const target = imports.find((imp) => imp.source === assetPath && imp.defaultIdent);
|
|
1151
|
+
if (!target?.defaultIdent) return 0;
|
|
1152
|
+
return collectImgSrcUses(ast, target.defaultIdent).length;
|
|
1153
|
+
}
|
|
1154
|
+
function findReferencedAssets(source, assetPaths) {
|
|
1155
|
+
const referenced = new Set();
|
|
1156
|
+
if (assetPaths.length === 0) return referenced;
|
|
1157
|
+
const ast = parseSource$2(source);
|
|
1158
|
+
if (!ast) return referenced;
|
|
1159
|
+
const wanted = new Set(assetPaths);
|
|
1160
|
+
const identToPath = new Map();
|
|
1161
|
+
const importLocals = new Set();
|
|
1162
|
+
for (const imp of findImports$1(ast)) {
|
|
1163
|
+
if (!imp.defaultIdent) continue;
|
|
1164
|
+
if (!wanted.has(imp.source)) continue;
|
|
1165
|
+
identToPath.set(imp.defaultIdent, imp.source);
|
|
1166
|
+
for (const spec of imp.node.specifiers) if (t$3.isImportDefaultSpecifier(spec) && spec.local.name === imp.defaultIdent) importLocals.add(spec.local);
|
|
1167
|
+
}
|
|
1168
|
+
if (identToPath.size === 0) return referenced;
|
|
1169
|
+
walkAll(ast, (n) => {
|
|
1170
|
+
if (!t$3.isIdentifier(n)) return;
|
|
1171
|
+
const p = identToPath.get(n.name);
|
|
1172
|
+
if (!p) return;
|
|
1173
|
+
if (importLocals.has(n)) return;
|
|
1174
|
+
referenced.add(p);
|
|
1175
|
+
});
|
|
1176
|
+
return referenced;
|
|
1177
|
+
}
|
|
1178
|
+
function applyRevertAsset(source, assetPath) {
|
|
1179
|
+
const ast = parseSource$2(source);
|
|
1180
|
+
if (!ast) return {
|
|
1181
|
+
ok: false,
|
|
1182
|
+
status: 422,
|
|
1183
|
+
error: "could not parse source"
|
|
1184
|
+
};
|
|
1185
|
+
const imports = findImports$1(ast);
|
|
1186
|
+
const target = imports.find((imp) => imp.source === assetPath && imp.defaultIdent);
|
|
1187
|
+
if (!target?.defaultIdent) return {
|
|
1188
|
+
ok: true,
|
|
1189
|
+
source
|
|
1190
|
+
};
|
|
1191
|
+
const identifier = target.defaultIdent;
|
|
1192
|
+
const importLocal = (() => {
|
|
1193
|
+
for (const spec of target.node.specifiers) if (t$3.isImportDefaultSpecifier(spec) && spec.local.name === identifier) return spec.local;
|
|
1194
|
+
return null;
|
|
1195
|
+
})();
|
|
1196
|
+
const imgUses = collectImgSrcUses(ast, identifier);
|
|
1197
|
+
const allowed = new Set(imgUses.map((u) => u.identNode));
|
|
1198
|
+
if (importLocal) allowed.add(importLocal);
|
|
1199
|
+
let foreign = false;
|
|
1200
|
+
walkAll(ast, (n) => {
|
|
1201
|
+
if (!t$3.isIdentifier(n) || n.name !== identifier) return;
|
|
1202
|
+
if (!allowed.has(n)) foreign = true;
|
|
1203
|
+
});
|
|
1204
|
+
if (foreign) return {
|
|
1205
|
+
ok: false,
|
|
1206
|
+
status: 422,
|
|
1207
|
+
error: `cannot revert: '${identifier}' is referenced outside <img src={${identifier}}>`
|
|
1208
|
+
};
|
|
1209
|
+
const splices = [];
|
|
1210
|
+
for (const use of imgUses) {
|
|
1211
|
+
const opening = use.element.openingElement;
|
|
1212
|
+
const hint = readJsxStringAttr(opening, "alt") ?? "";
|
|
1213
|
+
const width = readStyleNumericDim(opening, "width");
|
|
1214
|
+
const height = readStyleNumericDim(opening, "height");
|
|
1215
|
+
splices.push(spliceRange(use.element, buildPlaceholderReplacement(hint, width, height)));
|
|
1216
|
+
}
|
|
1217
|
+
const importNode = target.node;
|
|
1218
|
+
const importFrom = importNode.start ?? 0;
|
|
1219
|
+
let importTo = importNode.end ?? 0;
|
|
1220
|
+
if (source[importTo] === "\n") importTo += 1;
|
|
1221
|
+
splices.push({
|
|
1222
|
+
from: importFrom,
|
|
1223
|
+
to: importTo,
|
|
1224
|
+
text: ""
|
|
1225
|
+
});
|
|
1226
|
+
const ensureSplice = planEnsureImagePlaceholderImport(ast);
|
|
1227
|
+
if (ensureSplice) splices.push(ensureSplice);
|
|
1228
|
+
if (splices.length === 0) return {
|
|
1229
|
+
ok: true,
|
|
1230
|
+
source
|
|
1231
|
+
};
|
|
1232
|
+
splices.sort((a, b) => b.from - a.from);
|
|
1233
|
+
let next = source;
|
|
1234
|
+
for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
|
|
1235
|
+
if (!parseSource$2(next)) return {
|
|
1236
|
+
ok: false,
|
|
1237
|
+
status: 422,
|
|
1238
|
+
error: "edit would produce invalid source"
|
|
1239
|
+
};
|
|
1240
|
+
return {
|
|
1241
|
+
ok: true,
|
|
1242
|
+
source: next
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
//#endregion
|
|
1247
|
+
//#region src/editing/slide-ops.ts
|
|
1248
|
+
const SLIDE_ID_RE = /^[a-z0-9_-]+$/i;
|
|
1249
|
+
function validateSlideName(v) {
|
|
1250
|
+
if (typeof v !== "string") return null;
|
|
1251
|
+
const trimmed = v.trim();
|
|
1252
|
+
if (trimmed.length < 1 || trimmed.length > 80) return null;
|
|
1253
|
+
return trimmed;
|
|
1254
|
+
}
|
|
1255
|
+
function unwrapExpression(node) {
|
|
1256
|
+
let current = node;
|
|
1257
|
+
while (current && (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression")) current = current.expression;
|
|
1258
|
+
return current;
|
|
1259
|
+
}
|
|
1260
|
+
function readMetaTitleInSource(source) {
|
|
1261
|
+
let ast;
|
|
1262
|
+
try {
|
|
1263
|
+
ast = parse(source, {
|
|
1264
|
+
sourceType: "module",
|
|
1265
|
+
plugins: ["typescript", "jsx"],
|
|
1266
|
+
errorRecovery: true
|
|
1267
|
+
});
|
|
1268
|
+
} catch {
|
|
1269
|
+
return { kind: "unsupported" };
|
|
1270
|
+
}
|
|
1271
|
+
const body = ast.program?.body ?? [];
|
|
1272
|
+
for (const stmt of body) {
|
|
1273
|
+
if (stmt.type !== "ExportNamedDeclaration") continue;
|
|
1274
|
+
const decl = stmt.declaration;
|
|
1275
|
+
if (!decl || decl.type !== "VariableDeclaration") continue;
|
|
1276
|
+
const declarations = decl.declarations ?? [];
|
|
1277
|
+
for (const d of declarations) {
|
|
1278
|
+
const id = d.id;
|
|
1279
|
+
if (!id || id.type !== "Identifier" || id.name !== "meta") continue;
|
|
1280
|
+
const init = unwrapExpression(d.init);
|
|
1281
|
+
if (!init || init.type !== "ObjectExpression") return { kind: "unsupported" };
|
|
1282
|
+
const properties = init.properties ?? [];
|
|
1283
|
+
for (const property of properties) {
|
|
1284
|
+
if (property.type !== "ObjectProperty" || property.computed) continue;
|
|
1285
|
+
const key = property.key;
|
|
1286
|
+
const keyName = key?.type === "Identifier" ? key.name : key?.type === "StringLiteral" ? key.value : void 0;
|
|
1287
|
+
if (keyName !== "title") continue;
|
|
1288
|
+
const value = property.value;
|
|
1289
|
+
if (value?.type === "StringLiteral" && typeof value.value === "string") return {
|
|
1290
|
+
kind: "found",
|
|
1291
|
+
title: value.value
|
|
1292
|
+
};
|
|
1293
|
+
if (value?.type === "TemplateLiteral") {
|
|
1294
|
+
const expressions = value.expressions ?? [];
|
|
1295
|
+
const quasis = value.quasis ?? [];
|
|
1296
|
+
const firstValue = quasis[0]?.value;
|
|
1297
|
+
const cooked = firstValue?.cooked;
|
|
1298
|
+
const raw = firstValue?.raw;
|
|
1299
|
+
if (expressions.length === 0 && typeof (cooked ?? raw) === "string") return {
|
|
1300
|
+
kind: "found",
|
|
1301
|
+
title: cooked ?? raw
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
return { kind: "unsupported" };
|
|
1305
|
+
}
|
|
1306
|
+
return { kind: "missing" };
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return { kind: "missing" };
|
|
1310
|
+
}
|
|
1311
|
+
async function rmSlideDir(slidesRoot, slideId) {
|
|
1312
|
+
if (!SLIDE_ID_RE.test(slideId)) return false;
|
|
1313
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
1314
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return false;
|
|
1315
|
+
try {
|
|
1316
|
+
await fs.rm(dir, {
|
|
1317
|
+
recursive: true,
|
|
1318
|
+
force: true
|
|
1319
|
+
});
|
|
1320
|
+
return true;
|
|
1321
|
+
} catch {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
async function duplicateSlideDir(slidesRoot, slideId, desiredId) {
|
|
1326
|
+
if (!SLIDE_ID_RE.test(slideId)) return {
|
|
1327
|
+
ok: false,
|
|
1328
|
+
status: 400,
|
|
1329
|
+
error: "invalid slideId"
|
|
1330
|
+
};
|
|
1331
|
+
const root = path.resolve(slidesRoot);
|
|
1332
|
+
const srcDir = path.resolve(root, slideId);
|
|
1333
|
+
if (!srcDir.startsWith(root + path.sep)) return {
|
|
1334
|
+
ok: false,
|
|
1335
|
+
status: 400,
|
|
1336
|
+
error: "invalid slideId"
|
|
1337
|
+
};
|
|
1338
|
+
try {
|
|
1339
|
+
await fs.access(path.join(srcDir, "index.tsx"));
|
|
1340
|
+
} catch {
|
|
1341
|
+
return {
|
|
1342
|
+
ok: false,
|
|
1343
|
+
status: 404,
|
|
1344
|
+
error: "slide not found"
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
let newId;
|
|
1348
|
+
if (desiredId !== void 0) {
|
|
1349
|
+
if (!SLIDE_ID_RE.test(desiredId)) return {
|
|
1350
|
+
ok: false,
|
|
1351
|
+
status: 400,
|
|
1352
|
+
error: "invalid newId"
|
|
1353
|
+
};
|
|
1354
|
+
newId = desiredId;
|
|
1355
|
+
const dstDir$1 = path.resolve(root, newId);
|
|
1356
|
+
if (!dstDir$1.startsWith(root + path.sep)) return {
|
|
1357
|
+
ok: false,
|
|
1358
|
+
status: 400,
|
|
1359
|
+
error: "invalid newId"
|
|
1360
|
+
};
|
|
1361
|
+
try {
|
|
1362
|
+
await fs.access(dstDir$1);
|
|
1363
|
+
return {
|
|
1364
|
+
ok: false,
|
|
1365
|
+
status: 409,
|
|
1366
|
+
error: "slide already exists"
|
|
1367
|
+
};
|
|
1368
|
+
} catch {}
|
|
1369
|
+
} else {
|
|
1370
|
+
let suffix = 1;
|
|
1371
|
+
while (true) {
|
|
1372
|
+
newId = suffix === 1 ? `${slideId}-copy` : `${slideId}-copy-${suffix}`;
|
|
1373
|
+
try {
|
|
1374
|
+
await fs.access(path.resolve(root, newId));
|
|
1375
|
+
suffix++;
|
|
1376
|
+
} catch {
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
const dstDir = path.resolve(root, newId);
|
|
1382
|
+
if (!dstDir.startsWith(root + path.sep)) return {
|
|
1383
|
+
ok: false,
|
|
1384
|
+
status: 400,
|
|
1385
|
+
error: "invalid newId"
|
|
1386
|
+
};
|
|
1387
|
+
const srcEntry = path.join(srcDir, "index.tsx");
|
|
1388
|
+
let copiedEntrySource;
|
|
1389
|
+
try {
|
|
1390
|
+
const source = await fs.readFile(srcEntry, "utf8");
|
|
1391
|
+
const metaTitle = readMetaTitleInSource(source);
|
|
1392
|
+
if (metaTitle.kind === "unsupported") return {
|
|
1393
|
+
ok: false,
|
|
1394
|
+
status: 422,
|
|
1395
|
+
error: "could not update copied slide title"
|
|
1396
|
+
};
|
|
1397
|
+
const title = metaTitle.kind === "found" ? metaTitle.title : slideId;
|
|
1398
|
+
const updated = updateMetaTitleInSource(source, `${title} (copy)`);
|
|
1399
|
+
if (updated === null) return {
|
|
1400
|
+
ok: false,
|
|
1401
|
+
status: 422,
|
|
1402
|
+
error: "could not update copied slide title"
|
|
1403
|
+
};
|
|
1404
|
+
copiedEntrySource = updated;
|
|
1405
|
+
} catch {
|
|
1406
|
+
return {
|
|
1407
|
+
ok: false,
|
|
1408
|
+
status: 404,
|
|
1409
|
+
error: "slide not found"
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
try {
|
|
1413
|
+
await fs.cp(srcDir, dstDir, {
|
|
1414
|
+
recursive: true,
|
|
1415
|
+
errorOnExist: true,
|
|
1416
|
+
force: false
|
|
1417
|
+
});
|
|
1418
|
+
await fs.writeFile(path.join(dstDir, "index.tsx"), copiedEntrySource, "utf8");
|
|
1419
|
+
return {
|
|
1420
|
+
ok: true,
|
|
1421
|
+
slideId: newId
|
|
1422
|
+
};
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
if (err.code === "EEXIST") return {
|
|
1425
|
+
ok: false,
|
|
1426
|
+
status: 409,
|
|
1427
|
+
error: "slide already exists"
|
|
1428
|
+
};
|
|
1429
|
+
return {
|
|
1430
|
+
ok: false,
|
|
1431
|
+
status: 500,
|
|
1432
|
+
error: String(err.message ?? err)
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
function resolveSlideEntry(slidesRoot, slideId) {
|
|
1437
|
+
if (!SLIDE_ID_RE.test(slideId)) return null;
|
|
1438
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
1439
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return null;
|
|
1440
|
+
return path.join(dir, "index.tsx");
|
|
1441
|
+
}
|
|
1442
|
+
function escapeSingleQuoted(s) {
|
|
1443
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Rewrite (or insert) the `title` field in the slide module's `export const meta`.
|
|
1447
|
+
*
|
|
1448
|
+
* Strategy:
|
|
1449
|
+
* 1. Find `export const meta` and brace-match its object literal.
|
|
1450
|
+
* 2. If the object already has a `title: '...'` entry, replace the literal.
|
|
1451
|
+
* 3. If the object exists but has no title, inject a new `title: '...'` line
|
|
1452
|
+
* as the first property (preserving the author's surrounding indentation).
|
|
1453
|
+
* 4. If there is no `meta` export at all, insert a fresh one right before
|
|
1454
|
+
* `export default`.
|
|
1455
|
+
*
|
|
1456
|
+
* Returns the rewritten source, or `null` if the file shape was too surprising
|
|
1457
|
+
* to touch safely (e.g. `export default` missing when we'd need to inject meta).
|
|
1458
|
+
*/
|
|
1459
|
+
function updateMetaTitleInSource(source, title) {
|
|
1460
|
+
const newLiteral = `'${escapeSingleQuoted(title)}'`;
|
|
1461
|
+
const metaStart = source.search(/export\s+const\s+meta\b/);
|
|
1462
|
+
if (metaStart !== -1) {
|
|
1463
|
+
const eqIdx = source.indexOf("=", metaStart);
|
|
1464
|
+
if (eqIdx === -1) return null;
|
|
1465
|
+
const openBrace = source.indexOf("{", eqIdx);
|
|
1466
|
+
if (openBrace === -1) return null;
|
|
1467
|
+
let depth = 0;
|
|
1468
|
+
let closeBrace = -1;
|
|
1469
|
+
for (let i = openBrace; i < source.length; i++) {
|
|
1470
|
+
const ch = source[i];
|
|
1471
|
+
if (ch === "{") depth++;
|
|
1472
|
+
else if (ch === "}") {
|
|
1473
|
+
depth--;
|
|
1474
|
+
if (depth === 0) {
|
|
1475
|
+
closeBrace = i;
|
|
1476
|
+
break;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (closeBrace === -1) return null;
|
|
1481
|
+
const body = source.slice(openBrace + 1, closeBrace);
|
|
1482
|
+
const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
|
|
1483
|
+
const match = body.match(titleRe);
|
|
1484
|
+
if (match) {
|
|
1485
|
+
const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
|
|
1486
|
+
return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
|
|
1487
|
+
}
|
|
1488
|
+
const firstIndentMatch = body.match(/\n([ \t]+)\S/);
|
|
1489
|
+
const indent$1 = firstIndentMatch ? firstIndentMatch[1] : " ";
|
|
1490
|
+
const trimmedBody = body.replace(/^\s*\n?/, "");
|
|
1491
|
+
const needsSeparator = trimmedBody.trim().length > 0;
|
|
1492
|
+
const insertion$1 = `\n${indent$1}title: ${newLiteral}${needsSeparator ? "," : ""}`;
|
|
1493
|
+
return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
|
|
1494
|
+
}
|
|
1495
|
+
const exportDefaultIdx = source.search(/export\s+default\b/);
|
|
1496
|
+
if (exportDefaultIdx === -1) return null;
|
|
1497
|
+
const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
|
|
1498
|
+
return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
|
|
1499
|
+
}
|
|
1500
|
+
function findDefaultExportArray(source) {
|
|
1501
|
+
let ast;
|
|
1502
|
+
try {
|
|
1503
|
+
ast = parse(source, {
|
|
1504
|
+
sourceType: "module",
|
|
1505
|
+
plugins: ["typescript", "jsx"],
|
|
1506
|
+
errorRecovery: true
|
|
1507
|
+
});
|
|
1508
|
+
} catch {
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
const body = ast.program?.body ?? [];
|
|
1512
|
+
for (const node of body) {
|
|
1513
|
+
if (node.type !== "ExportDefaultDeclaration") continue;
|
|
1514
|
+
let inner = node.declaration;
|
|
1515
|
+
while (inner && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression")) inner = inner.expression;
|
|
1516
|
+
if (!inner || inner.type !== "ArrayExpression") return null;
|
|
1517
|
+
const arrayStart = inner.start;
|
|
1518
|
+
const arrayEnd = inner.end;
|
|
1519
|
+
const rawElements = inner.elements ?? [];
|
|
1520
|
+
const elements = [];
|
|
1521
|
+
for (const el of rawElements) {
|
|
1522
|
+
if (!el || typeof el.start !== "number" || typeof el.end !== "number") return null;
|
|
1523
|
+
elements.push({
|
|
1524
|
+
start: el.start,
|
|
1525
|
+
end: el.end
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
return {
|
|
1529
|
+
elements,
|
|
1530
|
+
arrayStart,
|
|
1531
|
+
arrayEnd
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Rewrite `export default [...]` so its elements appear in the requested order.
|
|
1538
|
+
*
|
|
1539
|
+
* `order[i]` is the original index that should land at new position `i`. The
|
|
1540
|
+
* function preserves each element's exact source slice (including any inline
|
|
1541
|
+
* comments that hug an identifier) and keeps the inter-element separator slots
|
|
1542
|
+
* in their original positions, so a 3-page array `[A, B, C]` reordered to
|
|
1543
|
+
* `[2, 0, 1]` becomes `[C, A, B]` with the same indentation and trailing
|
|
1544
|
+
* commas the author wrote.
|
|
1545
|
+
*
|
|
1546
|
+
* Returns `null` when the file's default export isn't an array literal, or the
|
|
1547
|
+
* order is not a valid permutation of `[0, n-1]`.
|
|
1548
|
+
*/
|
|
1549
|
+
function reorderDefaultExportPagesInSource(source, order) {
|
|
1550
|
+
const found = findDefaultExportArray(source);
|
|
1551
|
+
if (!found) return null;
|
|
1552
|
+
const { elements, arrayStart, arrayEnd } = found;
|
|
1553
|
+
const n = elements.length;
|
|
1554
|
+
if (order.length !== n) return null;
|
|
1555
|
+
const seen = new Set();
|
|
1556
|
+
for (const idx of order) {
|
|
1557
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= n) return null;
|
|
1558
|
+
if (seen.has(idx)) return null;
|
|
1559
|
+
seen.add(idx);
|
|
1560
|
+
}
|
|
1561
|
+
if (n === 0) return source;
|
|
1562
|
+
let identity = true;
|
|
1563
|
+
for (let i = 0; i < n; i++) if (order[i] !== i) {
|
|
1564
|
+
identity = false;
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
if (identity) return source;
|
|
1568
|
+
const prefix = source.slice(arrayStart, elements[0].start);
|
|
1569
|
+
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
1570
|
+
const separators = [];
|
|
1571
|
+
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
1572
|
+
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
1573
|
+
let rebuilt = prefix + elementText[order[0]];
|
|
1574
|
+
for (let i = 1; i < n; i++) rebuilt += separators[i - 1] + elementText[order[i]];
|
|
1575
|
+
rebuilt += suffix;
|
|
1576
|
+
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
1577
|
+
}
|
|
1578
|
+
function findNotesArray(source) {
|
|
1579
|
+
let ast;
|
|
1580
|
+
try {
|
|
1581
|
+
ast = parse(source, {
|
|
1582
|
+
sourceType: "module",
|
|
1583
|
+
plugins: ["typescript", "jsx"],
|
|
1584
|
+
errorRecovery: true
|
|
1585
|
+
});
|
|
1586
|
+
} catch {
|
|
1587
|
+
return "invalid";
|
|
1588
|
+
}
|
|
1589
|
+
const body = ast.program?.body ?? [];
|
|
1590
|
+
for (const stmt of body) {
|
|
1591
|
+
if (stmt.type !== "ExportNamedDeclaration") continue;
|
|
1592
|
+
const decl = stmt.declaration;
|
|
1593
|
+
if (!decl || decl.type !== "VariableDeclaration") continue;
|
|
1594
|
+
const declarations = decl.declarations ?? [];
|
|
1595
|
+
for (const d of declarations) {
|
|
1596
|
+
const id = d.id;
|
|
1597
|
+
if (!id || id.type !== "Identifier" || id.name !== "notes") continue;
|
|
1598
|
+
const init = d.init;
|
|
1599
|
+
if (!init || init.type !== "ArrayExpression") return "invalid";
|
|
1600
|
+
const arrayStart = init.start;
|
|
1601
|
+
const arrayEnd = init.end;
|
|
1602
|
+
if (typeof arrayStart !== "number" || typeof arrayEnd !== "number") return "invalid";
|
|
1603
|
+
const rawElements = init.elements ?? [];
|
|
1604
|
+
const elementTexts = [];
|
|
1605
|
+
for (const el of rawElements) {
|
|
1606
|
+
if (el === null) {
|
|
1607
|
+
elementTexts.push("undefined");
|
|
1608
|
+
continue;
|
|
1609
|
+
}
|
|
1610
|
+
if (el.type === "SpreadElement") return "invalid";
|
|
1611
|
+
const start = el.start;
|
|
1612
|
+
const end = el.end;
|
|
1613
|
+
if (typeof start !== "number" || typeof end !== "number") return "invalid";
|
|
1614
|
+
elementTexts.push(source.slice(start, end));
|
|
1615
|
+
}
|
|
1616
|
+
return {
|
|
1617
|
+
arrayStart,
|
|
1618
|
+
arrayEnd,
|
|
1619
|
+
elementTexts
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return null;
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Reorder `export const notes = [...]` to follow the page-array reorder.
|
|
1627
|
+
*
|
|
1628
|
+
* `order[i]` is the original page index that should land at new position `i`.
|
|
1629
|
+
* The notes array is index-aligned with the pages array but may be shorter
|
|
1630
|
+
* (trailing `undefined` slots are routinely trimmed). Missing elements are
|
|
1631
|
+
* treated as `undefined`, and trailing `undefined` is trimmed again after
|
|
1632
|
+
* reordering to keep the file tidy.
|
|
1633
|
+
*
|
|
1634
|
+
* Returns the rewritten source, the original source if no `notes` export
|
|
1635
|
+
* exists or the reorder is a no-op, or `null` if the `notes` export's shape
|
|
1636
|
+
* is too surprising to touch safely.
|
|
1637
|
+
*/
|
|
1638
|
+
function reorderNotesArrayInSource(source, order) {
|
|
1639
|
+
for (const idx of order) if (!Number.isInteger(idx) || idx < 0) return null;
|
|
1640
|
+
const found = findNotesArray(source);
|
|
1641
|
+
if (found === "invalid") return null;
|
|
1642
|
+
if (found === null) return source;
|
|
1643
|
+
const { arrayStart, arrayEnd, elementTexts } = found;
|
|
1644
|
+
const pick = (i) => i >= 0 && i < elementTexts.length ? elementTexts[i] : "undefined";
|
|
1645
|
+
return rebuildNotesArray(source, arrayStart, arrayEnd, order.map(pick));
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Remove the note aligned with the page at `index` so the `notes` export stays
|
|
1649
|
+
* index-aligned with `export default [...]` after a page deletion. Mirrors
|
|
1650
|
+
* {@link removePageFromDefaultExportInSource}.
|
|
1651
|
+
*
|
|
1652
|
+
* Returns the rewritten source, the original source if no `notes` export exists
|
|
1653
|
+
* or the index falls past the recorded notes, or `null` if the `notes` export's
|
|
1654
|
+
* shape is too surprising to touch safely.
|
|
1655
|
+
*/
|
|
1656
|
+
function removeNotesElementInSource(source, index) {
|
|
1657
|
+
if (!Number.isInteger(index) || index < 0) return null;
|
|
1658
|
+
const found = findNotesArray(source);
|
|
1659
|
+
if (found === "invalid") return null;
|
|
1660
|
+
if (found === null) return source;
|
|
1661
|
+
const { arrayStart, arrayEnd, elementTexts } = found;
|
|
1662
|
+
if (index >= elementTexts.length) return source;
|
|
1663
|
+
const next = elementTexts.slice();
|
|
1664
|
+
next.splice(index, 1);
|
|
1665
|
+
return rebuildNotesArray(source, arrayStart, arrayEnd, next);
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Duplicate the note aligned with the page at `index`, inserting the copy right
|
|
1669
|
+
* after it so the `notes` export stays index-aligned with `export default [...]`
|
|
1670
|
+
* after a page duplication. Mirrors {@link duplicatePageInDefaultExportInSource}.
|
|
1671
|
+
*
|
|
1672
|
+
* Returns the rewritten source, the original source if no `notes` export exists
|
|
1673
|
+
* or the index falls past the recorded notes (the new slot and everything after
|
|
1674
|
+
* it are absent, so nothing shifts), or `null` if the shape is too surprising.
|
|
1675
|
+
*/
|
|
1676
|
+
function duplicateNotesElementInSource(source, index) {
|
|
1677
|
+
if (!Number.isInteger(index) || index < 0) return null;
|
|
1678
|
+
const found = findNotesArray(source);
|
|
1679
|
+
if (found === "invalid") return null;
|
|
1680
|
+
if (found === null) return source;
|
|
1681
|
+
const { arrayStart, arrayEnd, elementTexts } = found;
|
|
1682
|
+
if (index >= elementTexts.length) return source;
|
|
1683
|
+
const next = elementTexts.slice();
|
|
1684
|
+
next.splice(index + 1, 0, next[index]);
|
|
1685
|
+
return rebuildNotesArray(source, arrayStart, arrayEnd, next);
|
|
1686
|
+
}
|
|
1687
|
+
function rebuildNotesArray(source, arrayStart, arrayEnd, elements) {
|
|
1688
|
+
const trimmed = elements.slice();
|
|
1689
|
+
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === "undefined") trimmed.pop();
|
|
1690
|
+
const replacement = trimmed.length === 0 ? "[]" : `[\n${trimmed.map((s) => ` ${s},`).join("\n")}\n]`;
|
|
1691
|
+
if (replacement === source.slice(arrayStart, arrayEnd)) return source;
|
|
1692
|
+
return source.slice(0, arrayStart) + replacement + source.slice(arrayEnd);
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Remove the element at `index` from `export default [...]`.
|
|
1696
|
+
*
|
|
1697
|
+
* Preserves the source slice of every other element, dropping the separator
|
|
1698
|
+
* immediately following the removed element (or the preceding one when the
|
|
1699
|
+
* removed element is the last). Returns `null` when the default export isn't
|
|
1700
|
+
* an array literal or `index` is out of range.
|
|
1701
|
+
*/
|
|
1702
|
+
function removePageFromDefaultExportInSource(source, index) {
|
|
1703
|
+
const found = findDefaultExportArray(source);
|
|
1704
|
+
if (!found) return null;
|
|
1705
|
+
const { elements, arrayStart, arrayEnd } = found;
|
|
1706
|
+
const n = elements.length;
|
|
1707
|
+
if (!Number.isInteger(index) || index < 0 || index >= n) return null;
|
|
1708
|
+
if (n === 1) return `${source.slice(0, arrayStart)}[]${source.slice(arrayEnd)}`;
|
|
1709
|
+
const prefix = source.slice(arrayStart, elements[0].start);
|
|
1710
|
+
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
1711
|
+
const separators = [];
|
|
1712
|
+
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
1713
|
+
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
1714
|
+
const keptElements = [];
|
|
1715
|
+
const keptSeparators = [];
|
|
1716
|
+
for (let i = 0; i < n; i++) {
|
|
1717
|
+
if (i === index) continue;
|
|
1718
|
+
keptElements.push(elementText[i]);
|
|
1719
|
+
}
|
|
1720
|
+
for (let i = 0; i < n - 1; i++) {
|
|
1721
|
+
if (index === n - 1 ? i === n - 2 : i === index) continue;
|
|
1722
|
+
keptSeparators.push(separators[i]);
|
|
1723
|
+
}
|
|
1724
|
+
let rebuilt = prefix + keptElements[0];
|
|
1725
|
+
for (let i = 1; i < keptElements.length; i++) rebuilt += keptSeparators[i - 1] + keptElements[i];
|
|
1726
|
+
rebuilt += suffix;
|
|
1727
|
+
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
1728
|
+
}
|
|
1729
|
+
function chooseInsertSeparator(prefix, existingSeparators) {
|
|
1730
|
+
const sample = existingSeparators.find((s) => s.includes(","));
|
|
1731
|
+
if (sample) return sample;
|
|
1732
|
+
if (prefix.includes("\n")) {
|
|
1733
|
+
const m = prefix.match(/\n([ \t]*)$/);
|
|
1734
|
+
const indent$1 = m ? m[1] : " ";
|
|
1735
|
+
return `,\n${indent$1}`;
|
|
1736
|
+
}
|
|
1737
|
+
return ", ";
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Duplicate the element at `index` in `export default [...]`, inserting the
|
|
1741
|
+
* copy immediately after the original. Reuses an existing inter-element
|
|
1742
|
+
* separator when one is available so the cloned entry matches the surrounding
|
|
1743
|
+
* indentation. Returns `null` when the default export isn't an array literal
|
|
1744
|
+
* or `index` is out of range.
|
|
1745
|
+
*/
|
|
1746
|
+
function duplicatePageInDefaultExportInSource(source, index) {
|
|
1747
|
+
const found = findDefaultExportArray(source);
|
|
1748
|
+
if (!found) return null;
|
|
1749
|
+
const { elements, arrayStart, arrayEnd } = found;
|
|
1750
|
+
const n = elements.length;
|
|
1751
|
+
if (!Number.isInteger(index) || index < 0 || index >= n) return null;
|
|
1752
|
+
const prefix = source.slice(arrayStart, elements[0].start);
|
|
1753
|
+
const suffix = source.slice(elements[n - 1].end, arrayEnd);
|
|
1754
|
+
const separators = [];
|
|
1755
|
+
for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
|
|
1756
|
+
const elementText = elements.map((el) => source.slice(el.start, el.end));
|
|
1757
|
+
const insertSep = chooseInsertSeparator(prefix, separators);
|
|
1758
|
+
const newElements = [];
|
|
1759
|
+
const newSeparators = [];
|
|
1760
|
+
for (let i = 0; i < n; i++) {
|
|
1761
|
+
newElements.push(elementText[i]);
|
|
1762
|
+
if (i === index) {
|
|
1763
|
+
newElements.push(elementText[i]);
|
|
1764
|
+
newSeparators.push(insertSep);
|
|
1765
|
+
}
|
|
1766
|
+
if (i < n - 1) newSeparators.push(separators[i]);
|
|
1767
|
+
}
|
|
1768
|
+
let rebuilt = prefix + newElements[0];
|
|
1769
|
+
for (let i = 1; i < newElements.length; i++) rebuilt += newSeparators[i - 1] + newElements[i];
|
|
1770
|
+
rebuilt += suffix;
|
|
1771
|
+
return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
//#endregion
|
|
1775
|
+
//#region src/files/assets.ts
|
|
1776
|
+
const GLOBAL_SCOPE = "@global";
|
|
1777
|
+
const ASSET_MAX_BYTES = 25 * 1024 * 1024;
|
|
1778
|
+
const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
|
|
1779
|
+
const MIME_BY_EXT = {
|
|
1780
|
+
png: "image/png",
|
|
1781
|
+
jpg: "image/jpeg",
|
|
1782
|
+
jpeg: "image/jpeg",
|
|
1783
|
+
gif: "image/gif",
|
|
1784
|
+
svg: "image/svg+xml",
|
|
1785
|
+
webp: "image/webp",
|
|
1786
|
+
avif: "image/avif",
|
|
1787
|
+
ico: "image/x-icon",
|
|
1788
|
+
mp4: "video/mp4",
|
|
1789
|
+
webm: "video/webm",
|
|
1790
|
+
mov: "video/quicktime",
|
|
1791
|
+
woff: "font/woff",
|
|
1792
|
+
woff2: "font/woff2",
|
|
1793
|
+
ttf: "font/ttf",
|
|
1794
|
+
otf: "font/otf",
|
|
1795
|
+
json: "application/json",
|
|
1796
|
+
txt: "text/plain; charset=utf-8",
|
|
1797
|
+
md: "text/markdown; charset=utf-8"
|
|
1798
|
+
};
|
|
1799
|
+
function mimeForFilename(name) {
|
|
1800
|
+
const dot = name.lastIndexOf(".");
|
|
1801
|
+
if (dot < 0) return "application/octet-stream";
|
|
1802
|
+
const ext = name.slice(dot + 1).toLowerCase();
|
|
1803
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
1804
|
+
}
|
|
1805
|
+
function validateAssetName(v) {
|
|
1806
|
+
if (typeof v !== "string") return null;
|
|
1807
|
+
const trimmed = v.trim();
|
|
1808
|
+
if (trimmed.length < 1 || trimmed.length > 120) return null;
|
|
1809
|
+
if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
|
|
1810
|
+
if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
|
|
1811
|
+
if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
|
|
1812
|
+
const dot = trimmed.lastIndexOf(".");
|
|
1813
|
+
if (dot <= 0 || dot === trimmed.length - 1) return null;
|
|
1814
|
+
return trimmed;
|
|
1815
|
+
}
|
|
1816
|
+
function resolveAssetsDir(slidesRoot, slideId) {
|
|
1817
|
+
if (!SLIDE_ID_RE.test(slideId)) return null;
|
|
1818
|
+
const slideDir = path.resolve(slidesRoot, slideId);
|
|
1819
|
+
if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
|
|
1820
|
+
const assetsDir = path.resolve(slideDir, "assets");
|
|
1821
|
+
if (assetsDir !== path.join(slideDir, "assets")) return null;
|
|
1822
|
+
return assetsDir;
|
|
1823
|
+
}
|
|
1824
|
+
function resolveAssetFile(slidesRoot, slideId, filename) {
|
|
1825
|
+
const assetsDir = resolveAssetsDir(slidesRoot, slideId);
|
|
1826
|
+
if (!assetsDir) return null;
|
|
1827
|
+
if (!validateAssetName(filename)) return null;
|
|
1828
|
+
const file = path.resolve(assetsDir, filename);
|
|
1829
|
+
if (!file.startsWith(assetsDir + path.sep)) return null;
|
|
1830
|
+
return file;
|
|
1831
|
+
}
|
|
1832
|
+
function resolveScopedAssetsDir(slidesRoot, globalAssetsRoot, scope) {
|
|
1833
|
+
if (scope === GLOBAL_SCOPE) return globalAssetsRoot;
|
|
1834
|
+
return resolveAssetsDir(slidesRoot, scope);
|
|
1835
|
+
}
|
|
1836
|
+
function resolveScopedAssetFile(slidesRoot, globalAssetsRoot, scope, filename) {
|
|
1837
|
+
if (scope === GLOBAL_SCOPE) {
|
|
1838
|
+
if (!validateAssetName(filename)) return null;
|
|
1839
|
+
const file = path.resolve(globalAssetsRoot, filename);
|
|
1840
|
+
if (!file.startsWith(globalAssetsRoot + path.sep)) return null;
|
|
1841
|
+
return file;
|
|
1842
|
+
}
|
|
1843
|
+
return resolveAssetFile(slidesRoot, scope, filename);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
//#endregion
|
|
1847
|
+
//#region src/http/request-guard.ts
|
|
1848
|
+
function firstHeaderValue(value) {
|
|
1849
|
+
if (Array.isArray(value)) return value[0] ?? null;
|
|
1850
|
+
return value ?? null;
|
|
1851
|
+
}
|
|
1852
|
+
function headerValue(req, name) {
|
|
1853
|
+
return firstHeaderValue(req.headers[name.toLowerCase()])?.trim() ?? null;
|
|
1854
|
+
}
|
|
1855
|
+
function firstCommaToken(value) {
|
|
1856
|
+
if (!value) return null;
|
|
1857
|
+
const [first] = value.split(",", 1);
|
|
1858
|
+
return first?.trim() || null;
|
|
1859
|
+
}
|
|
1860
|
+
function requestProto(req) {
|
|
1861
|
+
const forwarded = firstCommaToken(headerValue(req, "x-forwarded-proto"))?.toLowerCase();
|
|
1862
|
+
if (forwarded === "http" || forwarded === "https") return forwarded;
|
|
1863
|
+
return "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
|
|
1864
|
+
}
|
|
1865
|
+
function normalizedOrigin(origin) {
|
|
1866
|
+
try {
|
|
1867
|
+
const url = new URL(origin);
|
|
1868
|
+
return `${url.protocol}//${url.host}`.toLowerCase();
|
|
1869
|
+
} catch {
|
|
1870
|
+
return null;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
function validateMutationRequest(req, opts = {}) {
|
|
1874
|
+
if (opts.requireJsonBody) {
|
|
1875
|
+
const contentType = headerValue(req, "content-type")?.toLowerCase();
|
|
1876
|
+
if (!contentType || !contentType.startsWith("application/json")) return {
|
|
1877
|
+
ok: false,
|
|
1878
|
+
status: 415,
|
|
1879
|
+
error: "content-type must be application/json"
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
const fetchSite = firstCommaToken(headerValue(req, "sec-fetch-site"))?.toLowerCase();
|
|
1883
|
+
if (fetchSite === "cross-site") return {
|
|
1884
|
+
ok: false,
|
|
1885
|
+
status: 403,
|
|
1886
|
+
error: "cross-site request blocked"
|
|
1887
|
+
};
|
|
1888
|
+
const originRaw = headerValue(req, "origin");
|
|
1889
|
+
if (!originRaw) return { ok: true };
|
|
1890
|
+
if (originRaw.toLowerCase() === "null") return {
|
|
1891
|
+
ok: false,
|
|
1892
|
+
status: 403,
|
|
1893
|
+
error: "opaque origin is not allowed"
|
|
1894
|
+
};
|
|
1895
|
+
const actualOrigin = normalizedOrigin(originRaw);
|
|
1896
|
+
if (!actualOrigin) return {
|
|
1897
|
+
ok: false,
|
|
1898
|
+
status: 403,
|
|
1899
|
+
error: "invalid origin header"
|
|
1900
|
+
};
|
|
1901
|
+
const host = firstCommaToken(headerValue(req, "x-forwarded-host")) ?? headerValue(req, "host");
|
|
1902
|
+
if (!host) return {
|
|
1903
|
+
ok: false,
|
|
1904
|
+
status: 400,
|
|
1905
|
+
error: "missing host header"
|
|
1906
|
+
};
|
|
1907
|
+
const expectedOrigin = `${requestProto(req)}://${host}`.toLowerCase();
|
|
1908
|
+
if (actualOrigin !== expectedOrigin) return {
|
|
1909
|
+
ok: false,
|
|
1910
|
+
status: 403,
|
|
1911
|
+
error: "origin mismatch"
|
|
1912
|
+
};
|
|
1913
|
+
return { ok: true };
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
//#endregion
|
|
1917
|
+
//#region src/vite/routes/context.ts
|
|
1918
|
+
function makeContext(opts) {
|
|
1919
|
+
const userCwd = opts.userCwd;
|
|
1920
|
+
const slidesDir = opts.slidesDir ?? "slides";
|
|
1921
|
+
const assetsDir = opts.assetsDir ?? "assets";
|
|
1922
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
1923
|
+
const globalAssetsRoot = path.resolve(userCwd, assetsDir);
|
|
1924
|
+
const manifestPath = path.join(slidesRoot, ".folders.json");
|
|
1925
|
+
return {
|
|
1926
|
+
userCwd,
|
|
1927
|
+
slidesDir,
|
|
1928
|
+
slidesRoot,
|
|
1929
|
+
globalAssetsRoot,
|
|
1930
|
+
manifestPath,
|
|
1931
|
+
coreVersion: opts.coreVersion
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
async function readBody(req) {
|
|
1935
|
+
return await new Promise((resolve, reject) => {
|
|
1936
|
+
const chunks = [];
|
|
1937
|
+
req.on("data", (c) => chunks.push(c));
|
|
1938
|
+
req.on("end", () => {
|
|
1939
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1940
|
+
if (!raw) return resolve({});
|
|
1941
|
+
try {
|
|
1942
|
+
resolve(JSON.parse(raw));
|
|
1943
|
+
} catch (e) {
|
|
1944
|
+
reject(e);
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
req.on("error", reject);
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
function json(res, status, body) {
|
|
1951
|
+
res.statusCode = status;
|
|
1952
|
+
res.setHeader("content-type", "application/json");
|
|
1953
|
+
res.end(JSON.stringify(body));
|
|
1954
|
+
}
|
|
1955
|
+
function resolveSlidePath(userCwd, slidesDir, slideId) {
|
|
1956
|
+
if (!SLIDE_ID_RE.test(slideId)) return null;
|
|
1957
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
1958
|
+
const full = path.resolve(slidesRoot, slideId, "index.tsx");
|
|
1959
|
+
if (!full.startsWith(slidesRoot + path.sep)) return null;
|
|
1960
|
+
return full;
|
|
1961
|
+
}
|
|
1962
|
+
function resolveSlideEntryPath(ctx, slideId) {
|
|
1963
|
+
return resolveSlidePath(ctx.userCwd, ctx.slidesDir, slideId);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
//#endregion
|
|
1967
|
+
//#region src/vite/routes/assets.ts
|
|
1968
|
+
function registerAssetRoutes(server, ctx) {
|
|
1969
|
+
server.middlewares.use("/__assets", async (req, res, next) => {
|
|
1970
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
1971
|
+
const method = req.method ?? "GET";
|
|
1972
|
+
try {
|
|
1973
|
+
const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
|
|
1974
|
+
const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
|
|
1975
|
+
const usagesMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)\/usages$/);
|
|
1976
|
+
if (usagesMatch && method === "GET") {
|
|
1977
|
+
const scope = usagesMatch[1];
|
|
1978
|
+
const filename = decodeURIComponent(usagesMatch[2]);
|
|
1979
|
+
if (!validateAssetName(filename)) return json(res, 400, { error: "invalid path" });
|
|
1980
|
+
const isGlobal = scope === GLOBAL_SCOPE;
|
|
1981
|
+
const assetPath = isGlobal ? `@assets/${filename}` : `./assets/${filename}`;
|
|
1982
|
+
let slideIds;
|
|
1983
|
+
if (isGlobal) try {
|
|
1984
|
+
const entries = await fs.readdir(ctx.slidesRoot, { withFileTypes: true });
|
|
1985
|
+
slideIds = entries.filter((e) => e.isDirectory() && SLIDE_ID_RE.test(e.name)).map((e) => e.name);
|
|
1986
|
+
} catch {
|
|
1987
|
+
slideIds = [];
|
|
1988
|
+
}
|
|
1989
|
+
else {
|
|
1990
|
+
if (!SLIDE_ID_RE.test(scope)) return json(res, 400, { error: "invalid slideId" });
|
|
1991
|
+
slideIds = [scope];
|
|
1992
|
+
}
|
|
1993
|
+
const usages = [];
|
|
1994
|
+
let totalCount = 0;
|
|
1995
|
+
for (const sid of slideIds) {
|
|
1996
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, sid);
|
|
1997
|
+
if (!entry) continue;
|
|
1998
|
+
let source;
|
|
1999
|
+
try {
|
|
2000
|
+
source = await fs.readFile(entry, "utf8");
|
|
2001
|
+
} catch {
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
const count = findAssetUsages(source, assetPath);
|
|
2005
|
+
if (count > 0) {
|
|
2006
|
+
usages.push({
|
|
2007
|
+
slideId: sid,
|
|
2008
|
+
count
|
|
2009
|
+
});
|
|
2010
|
+
totalCount += count;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
return json(res, 200, {
|
|
2014
|
+
usages,
|
|
2015
|
+
totalCount
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
if (listMatch && method === "GET") {
|
|
2019
|
+
const slideId = listMatch[1];
|
|
2020
|
+
const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
|
|
2021
|
+
if (!scopedDir) return json(res, 400, { error: "invalid slideId" });
|
|
2022
|
+
let entries;
|
|
2023
|
+
try {
|
|
2024
|
+
entries = await fs.readdir(scopedDir);
|
|
2025
|
+
} catch (err) {
|
|
2026
|
+
if (err.code === "ENOENT") return json(res, 200, { assets: [] });
|
|
2027
|
+
throw err;
|
|
2028
|
+
}
|
|
2029
|
+
const assets = [];
|
|
2030
|
+
for (const name of entries) {
|
|
2031
|
+
if (!validateAssetName(name)) continue;
|
|
2032
|
+
const stat = await fs.stat(path.join(scopedDir, name));
|
|
2033
|
+
if (!stat.isFile()) continue;
|
|
2034
|
+
assets.push({
|
|
2035
|
+
name,
|
|
2036
|
+
size: stat.size,
|
|
2037
|
+
mtime: stat.mtimeMs,
|
|
2038
|
+
mime: mimeForFilename(name),
|
|
2039
|
+
url: `/__assets/${slideId}/${encodeURIComponent(name)}`,
|
|
2040
|
+
unused: true
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
assets.sort((a, b) => a.name.localeCompare(b.name));
|
|
2044
|
+
if (assets.length > 0) {
|
|
2045
|
+
const isGlobal = slideId === GLOBAL_SCOPE;
|
|
2046
|
+
let scanIds;
|
|
2047
|
+
if (isGlobal) try {
|
|
2048
|
+
const dirs = await fs.readdir(ctx.slidesRoot, { withFileTypes: true });
|
|
2049
|
+
scanIds = dirs.filter((e) => e.isDirectory() && SLIDE_ID_RE.test(e.name)).map((e) => e.name);
|
|
2050
|
+
} catch {
|
|
2051
|
+
scanIds = [];
|
|
2052
|
+
}
|
|
2053
|
+
else scanIds = SLIDE_ID_RE.test(slideId) ? [slideId] : [];
|
|
2054
|
+
const paths = assets.map((a) => isGlobal ? `@assets/${a.name}` : `./assets/${a.name}`);
|
|
2055
|
+
const pathToAsset = new Map(paths.map((p, i) => [p, assets[i]]));
|
|
2056
|
+
for (const sid of scanIds) {
|
|
2057
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, sid);
|
|
2058
|
+
if (!entry) continue;
|
|
2059
|
+
let source;
|
|
2060
|
+
try {
|
|
2061
|
+
source = await fs.readFile(entry, "utf8");
|
|
2062
|
+
} catch {
|
|
2063
|
+
continue;
|
|
2064
|
+
}
|
|
2065
|
+
for (const p of findReferencedAssets(source, paths)) {
|
|
2066
|
+
const a = pathToAsset.get(p);
|
|
2067
|
+
if (a) a.unused = false;
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return json(res, 200, { assets });
|
|
2072
|
+
}
|
|
2073
|
+
if (fileMatch) {
|
|
2074
|
+
const slideId = fileMatch[1];
|
|
2075
|
+
const filename = decodeURIComponent(fileMatch[2]);
|
|
2076
|
+
const file = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, filename);
|
|
2077
|
+
if (!file) return json(res, 400, { error: "invalid path" });
|
|
2078
|
+
if (method === "GET") try {
|
|
2079
|
+
const buf = await fs.readFile(file);
|
|
2080
|
+
res.statusCode = 200;
|
|
2081
|
+
res.setHeader("content-type", mimeForFilename(filename));
|
|
2082
|
+
res.setHeader("cache-control", "no-store");
|
|
2083
|
+
res.end(buf);
|
|
2084
|
+
return;
|
|
2085
|
+
} catch (err) {
|
|
2086
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
|
|
2087
|
+
throw err;
|
|
2088
|
+
}
|
|
2089
|
+
if (method === "POST") {
|
|
2090
|
+
const requestCheck = validateMutationRequest(req);
|
|
2091
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2092
|
+
const overwrite = url.searchParams.get("overwrite") === "1";
|
|
2093
|
+
const lenHeader = req.headers["content-length"];
|
|
2094
|
+
const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
|
|
2095
|
+
if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json(res, 413, { error: "file too large" });
|
|
2096
|
+
if (!overwrite) try {
|
|
2097
|
+
await fs.access(file);
|
|
2098
|
+
return json(res, 409, { error: "asset exists" });
|
|
2099
|
+
} catch {}
|
|
2100
|
+
const scopedDir = resolveScopedAssetsDir(ctx.slidesRoot, ctx.globalAssetsRoot, slideId);
|
|
2101
|
+
if (!scopedDir) return json(res, 400, { error: "invalid slideId" });
|
|
2102
|
+
await fs.mkdir(scopedDir, { recursive: true });
|
|
2103
|
+
const chunks = [];
|
|
2104
|
+
let total = 0;
|
|
2105
|
+
let oversized = false;
|
|
2106
|
+
await new Promise((resolve, reject) => {
|
|
2107
|
+
req.on("data", (c) => {
|
|
2108
|
+
total += c.length;
|
|
2109
|
+
if (total > ASSET_MAX_BYTES) {
|
|
2110
|
+
oversized = true;
|
|
2111
|
+
req.destroy();
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
chunks.push(c);
|
|
2115
|
+
});
|
|
2116
|
+
req.on("end", () => resolve());
|
|
2117
|
+
req.on("error", reject);
|
|
2118
|
+
});
|
|
2119
|
+
if (oversized) return json(res, 413, { error: "file too large" });
|
|
2120
|
+
await fs.writeFile(file, Buffer.concat(chunks));
|
|
2121
|
+
return json(res, 200, {
|
|
2122
|
+
ok: true,
|
|
2123
|
+
name: filename,
|
|
2124
|
+
size: total,
|
|
2125
|
+
mime: mimeForFilename(filename),
|
|
2126
|
+
url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
if (method === "PATCH") {
|
|
2130
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2131
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2132
|
+
const body = await readBody(req);
|
|
2133
|
+
const target = validateAssetName(body.name);
|
|
2134
|
+
if (!target) return json(res, 400, { error: "invalid name" });
|
|
2135
|
+
if (target === filename) return json(res, 200, {
|
|
2136
|
+
ok: true,
|
|
2137
|
+
name: filename
|
|
2138
|
+
});
|
|
2139
|
+
const dest = resolveScopedAssetFile(ctx.slidesRoot, ctx.globalAssetsRoot, slideId, target);
|
|
2140
|
+
if (!dest) return json(res, 400, { error: "invalid name" });
|
|
2141
|
+
try {
|
|
2142
|
+
await fs.access(dest);
|
|
2143
|
+
return json(res, 409, { error: "target exists" });
|
|
2144
|
+
} catch {}
|
|
2145
|
+
try {
|
|
2146
|
+
await fs.rename(file, dest);
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
|
|
2149
|
+
throw err;
|
|
2150
|
+
}
|
|
2151
|
+
return json(res, 200, {
|
|
2152
|
+
ok: true,
|
|
2153
|
+
name: target
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
if (method === "DELETE") {
|
|
2157
|
+
const requestCheck = validateMutationRequest(req);
|
|
2158
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2159
|
+
try {
|
|
2160
|
+
await fs.unlink(file);
|
|
2161
|
+
} catch (err) {
|
|
2162
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
|
|
2163
|
+
throw err;
|
|
2164
|
+
}
|
|
2165
|
+
return json(res, 200, { ok: true });
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
return next();
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
2171
|
+
}
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
//#endregion
|
|
2176
|
+
//#region src/editing/comments.ts
|
|
2177
|
+
const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
|
|
2178
|
+
function b64urlEncode(s) {
|
|
2179
|
+
return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2180
|
+
}
|
|
2181
|
+
function b64urlDecode(s) {
|
|
2182
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
|
|
2183
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
|
|
2184
|
+
}
|
|
2185
|
+
function parseMarkers(source) {
|
|
2186
|
+
const comments = [];
|
|
2187
|
+
const lines = source.split("\n");
|
|
2188
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2189
|
+
const line = lines[i];
|
|
2190
|
+
MARKER_RE.lastIndex = 0;
|
|
2191
|
+
const m = MARKER_RE.exec(line);
|
|
2192
|
+
if (!m) continue;
|
|
2193
|
+
const [, id, ts, textB64] = m;
|
|
2194
|
+
try {
|
|
2195
|
+
const payload = JSON.parse(b64urlDecode(textB64));
|
|
2196
|
+
comments.push({
|
|
2197
|
+
id,
|
|
2198
|
+
line: i + 1,
|
|
2199
|
+
ts,
|
|
2200
|
+
note: payload.note,
|
|
2201
|
+
hint: payload.hint
|
|
2202
|
+
});
|
|
2203
|
+
} catch {}
|
|
2204
|
+
}
|
|
2205
|
+
return comments;
|
|
2206
|
+
}
|
|
2207
|
+
function newCommentId() {
|
|
2208
|
+
return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
2209
|
+
}
|
|
2210
|
+
function markerDeleteRegex(id) {
|
|
2211
|
+
return new RegExp(`\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`);
|
|
2212
|
+
}
|
|
2213
|
+
function lineToOffset(source, line) {
|
|
2214
|
+
let off = 0;
|
|
2215
|
+
for (let l = 1; l < line; l++) {
|
|
2216
|
+
const nl = source.indexOf("\n", off);
|
|
2217
|
+
if (nl === -1) return source.length;
|
|
2218
|
+
off = nl + 1;
|
|
2219
|
+
}
|
|
2220
|
+
return off;
|
|
2221
|
+
}
|
|
2222
|
+
function lineIndent(source, lineNumber) {
|
|
2223
|
+
const start = lineToOffset(source, lineNumber);
|
|
2224
|
+
const m = source.slice(start, start + 200).match(/^[ \t]*/);
|
|
2225
|
+
return m?.[0] ?? "";
|
|
2226
|
+
}
|
|
2227
|
+
function findJsxAncestors(ast, line, column) {
|
|
2228
|
+
const hits = [];
|
|
2229
|
+
walkJsx(ast, (n) => {
|
|
2230
|
+
if (!n.loc || !t$2.isJSXElement(n) && !t$2.isJSXFragment(n)) return;
|
|
2231
|
+
const s = n.loc.start;
|
|
2232
|
+
const e = n.loc.end;
|
|
2233
|
+
const afterStart = line > s.line || line === s.line && column >= s.column;
|
|
2234
|
+
const beforeEnd = line < e.line || line === e.line && column < e.column;
|
|
2235
|
+
if (afterStart && beforeEnd) hits.push({
|
|
2236
|
+
node: n,
|
|
2237
|
+
size: (n.end ?? 0) - (n.start ?? 0)
|
|
2238
|
+
});
|
|
2239
|
+
});
|
|
2240
|
+
hits.sort((a, b) => a.size - b.size);
|
|
2241
|
+
return hits.map((h) => h.node);
|
|
2242
|
+
}
|
|
2243
|
+
function planInsertion(source, target) {
|
|
2244
|
+
if (t$2.isJSXFragment(target)) {
|
|
2245
|
+
const opening = target.openingFragment;
|
|
2246
|
+
const startLine = target.loc?.start.line ?? 1;
|
|
2247
|
+
return {
|
|
2248
|
+
offset: opening.end ?? 0,
|
|
2249
|
+
indent: `${lineIndent(source, startLine)} `
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
if (t$2.isJSXElement(target)) {
|
|
2253
|
+
const opening = target.openingElement;
|
|
2254
|
+
if (opening.selfClosing) return null;
|
|
2255
|
+
const startLine = target.loc?.start.line ?? 1;
|
|
2256
|
+
return {
|
|
2257
|
+
offset: opening.end ?? 0,
|
|
2258
|
+
indent: `${lineIndent(source, startLine)} `
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
return null;
|
|
2262
|
+
}
|
|
2263
|
+
function findInsertion(source, line, column) {
|
|
2264
|
+
const ast = parseSource$2(source);
|
|
2265
|
+
if (!ast) return null;
|
|
2266
|
+
const col = column ?? 0;
|
|
2267
|
+
const ancestors = findJsxAncestors(ast, line, col);
|
|
2268
|
+
for (const node of ancestors) {
|
|
2269
|
+
const plan = planInsertion(source, node);
|
|
2270
|
+
if (plan) return plan;
|
|
2271
|
+
}
|
|
2272
|
+
return null;
|
|
2273
|
+
}
|
|
2274
|
+
function offsetToLine(source, offset) {
|
|
2275
|
+
let line = 1;
|
|
2276
|
+
for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
|
|
2277
|
+
return line;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
//#endregion
|
|
2281
|
+
//#region src/vite/routes/comments.ts
|
|
2282
|
+
function registerCommentRoutes(server, ctx) {
|
|
2283
|
+
server.middlewares.use("/__comments", async (req, res, next) => {
|
|
2284
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2285
|
+
const method = req.method ?? "GET";
|
|
2286
|
+
try {
|
|
2287
|
+
if (method === "GET" && url.pathname === "/") {
|
|
2288
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
2289
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2290
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
2291
|
+
let source;
|
|
2292
|
+
try {
|
|
2293
|
+
source = await fs.readFile(file, "utf8");
|
|
2294
|
+
} catch {
|
|
2295
|
+
return json(res, 404, { error: "slide not found" });
|
|
2296
|
+
}
|
|
2297
|
+
return json(res, 200, { comments: parseMarkers(source) });
|
|
2298
|
+
}
|
|
2299
|
+
if (method === "POST" && url.pathname === "/add") {
|
|
2300
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2301
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2302
|
+
const body = await readBody(req);
|
|
2303
|
+
const slideId = body.slideId ?? "";
|
|
2304
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2305
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
2306
|
+
if (!body.line || body.line < 1) return json(res, 400, { error: "invalid line" });
|
|
2307
|
+
if (!body.text || typeof body.text !== "string") return json(res, 400, { error: "missing text" });
|
|
2308
|
+
let source;
|
|
2309
|
+
try {
|
|
2310
|
+
source = await fs.readFile(file, "utf8");
|
|
2311
|
+
} catch {
|
|
2312
|
+
return json(res, 404, { error: "slide not found" });
|
|
2313
|
+
}
|
|
2314
|
+
const plan = findInsertion(source, body.line, body.column);
|
|
2315
|
+
if (!plan) return json(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
|
|
2316
|
+
const id = newCommentId();
|
|
2317
|
+
const ts = new Date().toISOString();
|
|
2318
|
+
const payload = b64urlEncode(JSON.stringify({
|
|
2319
|
+
note: body.text,
|
|
2320
|
+
hint: body.hint
|
|
2321
|
+
}));
|
|
2322
|
+
const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
|
|
2323
|
+
const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
|
|
2324
|
+
await fs.writeFile(file, next$1, "utf8");
|
|
2325
|
+
const markerLine = offsetToLine(next$1, plan.offset + 1);
|
|
2326
|
+
return json(res, 200, {
|
|
2327
|
+
id,
|
|
2328
|
+
line: markerLine
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
if (method === "DELETE" && url.pathname.startsWith("/")) {
|
|
2332
|
+
const requestCheck = validateMutationRequest(req);
|
|
2333
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2334
|
+
const id = url.pathname.slice(1);
|
|
2335
|
+
if (!/^c-[a-f0-9]+$/.test(id)) return json(res, 400, { error: "invalid id" });
|
|
2336
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
2337
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2338
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
2339
|
+
let source;
|
|
2340
|
+
try {
|
|
2341
|
+
source = await fs.readFile(file, "utf8");
|
|
2342
|
+
} catch {
|
|
2343
|
+
return json(res, 404, { error: "slide not found" });
|
|
2344
|
+
}
|
|
2345
|
+
const lines = source.split("\n");
|
|
2346
|
+
const idRe = markerDeleteRegex(id);
|
|
2347
|
+
const hit = lines.findIndex((l) => idRe.test(l));
|
|
2348
|
+
if (hit === -1) return json(res, 404, { error: "marker not found" });
|
|
2349
|
+
lines.splice(hit, 1);
|
|
2350
|
+
await fs.writeFile(file, lines.join("\n"), "utf8");
|
|
2351
|
+
return json(res, 200, { ok: true });
|
|
2352
|
+
}
|
|
2353
|
+
next();
|
|
2354
|
+
} catch (err) {
|
|
2355
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
2356
|
+
}
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
//#endregion
|
|
2361
|
+
//#region src/vite/routes/edit.ts
|
|
2362
|
+
function registerEditRoutes(server, ctx) {
|
|
2363
|
+
server.middlewares.use("/__edit", async (req, res, next) => {
|
|
2364
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2365
|
+
const method = req.method ?? "GET";
|
|
2366
|
+
if (method !== "POST") return next();
|
|
2367
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2368
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2369
|
+
try {
|
|
2370
|
+
if (url.pathname === "/") {
|
|
2371
|
+
const body = await readBody(req);
|
|
2372
|
+
const slideId = body.slideId ?? "";
|
|
2373
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2374
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
2375
|
+
if (!body.line || body.line < 1) return json(res, 400, { error: "invalid line" });
|
|
2376
|
+
if (!Array.isArray(body.ops)) return json(res, 400, { error: "missing ops" });
|
|
2377
|
+
let source;
|
|
2378
|
+
try {
|
|
2379
|
+
source = await fs.readFile(file, "utf8");
|
|
2380
|
+
} catch {
|
|
2381
|
+
return json(res, 404, { error: "slide not found" });
|
|
2382
|
+
}
|
|
2383
|
+
const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
|
|
2384
|
+
if (!result.ok) return json(res, result.status, { error: result.error });
|
|
2385
|
+
const changed = result.source !== source;
|
|
2386
|
+
if (changed) await fs.writeFile(file, result.source, "utf8");
|
|
2387
|
+
return json(res, 200, {
|
|
2388
|
+
ok: true,
|
|
2389
|
+
changed
|
|
2390
|
+
});
|
|
2391
|
+
}
|
|
2392
|
+
if (url.pathname === "/revert-asset") {
|
|
2393
|
+
const body = await readBody(req);
|
|
2394
|
+
const slideId = body.slideId ?? "";
|
|
2395
|
+
const assetPath = body.assetPath;
|
|
2396
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2397
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
2398
|
+
if (typeof assetPath !== "string" || !assetPath) return json(res, 400, { error: "missing assetPath" });
|
|
2399
|
+
if (!assetPath.startsWith("./assets/") && !assetPath.startsWith("@assets/")) return json(res, 400, { error: "asset path must start with ./assets/ or @assets/" });
|
|
2400
|
+
let source;
|
|
2401
|
+
try {
|
|
2402
|
+
source = await fs.readFile(file, "utf8");
|
|
2403
|
+
} catch {
|
|
2404
|
+
return json(res, 404, { error: "slide not found" });
|
|
2405
|
+
}
|
|
2406
|
+
const result = applyRevertAsset(source, assetPath);
|
|
2407
|
+
if (!result.ok) return json(res, result.status, { error: result.error });
|
|
2408
|
+
const changed = result.source !== source;
|
|
2409
|
+
if (changed) await fs.writeFile(file, result.source, "utf8");
|
|
2410
|
+
return json(res, 200, {
|
|
2411
|
+
ok: true,
|
|
2412
|
+
changed
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
if (url.pathname === "/batch") {
|
|
2416
|
+
const body = await readBody(req);
|
|
2417
|
+
const slideId = body.slideId ?? "";
|
|
2418
|
+
const file = resolveSlideEntryPath(ctx, slideId);
|
|
2419
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
2420
|
+
if (!Array.isArray(body.edits)) return json(res, 400, { error: "missing edits" });
|
|
2421
|
+
let source;
|
|
2422
|
+
try {
|
|
2423
|
+
source = await fs.readFile(file, "utf8");
|
|
2424
|
+
} catch {
|
|
2425
|
+
return json(res, 404, { error: "slide not found" });
|
|
2426
|
+
}
|
|
2427
|
+
const original = source;
|
|
2428
|
+
const results = [];
|
|
2429
|
+
for (const edit of body.edits) {
|
|
2430
|
+
if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
|
|
2431
|
+
results.push({
|
|
2432
|
+
ok: false,
|
|
2433
|
+
error: "invalid edit"
|
|
2434
|
+
});
|
|
2435
|
+
continue;
|
|
2436
|
+
}
|
|
2437
|
+
const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
|
|
2438
|
+
if (r.ok) {
|
|
2439
|
+
source = r.source;
|
|
2440
|
+
results.push({ ok: true });
|
|
2441
|
+
} else results.push({
|
|
2442
|
+
ok: false,
|
|
2443
|
+
error: r.error
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
const changed = source !== original;
|
|
2447
|
+
if (changed) await fs.writeFile(file, source, "utf8");
|
|
2448
|
+
return json(res, 200, {
|
|
2449
|
+
ok: true,
|
|
2450
|
+
changed,
|
|
2451
|
+
results
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
return next();
|
|
2455
|
+
} catch (err) {
|
|
2456
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
2457
|
+
}
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
//#endregion
|
|
2462
|
+
//#region src/files/folders.ts
|
|
2463
|
+
const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
|
|
2464
|
+
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
2465
|
+
function emptyManifest() {
|
|
2466
|
+
return {
|
|
2467
|
+
folders: [],
|
|
2468
|
+
assignments: {}
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
async function readManifest(file) {
|
|
2472
|
+
try {
|
|
2473
|
+
const raw = await fs.readFile(file, "utf8");
|
|
2474
|
+
const parsed = JSON.parse(raw);
|
|
2475
|
+
return {
|
|
2476
|
+
folders: Array.isArray(parsed.folders) ? parsed.folders : [],
|
|
2477
|
+
assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
|
|
2478
|
+
};
|
|
2479
|
+
} catch (err) {
|
|
2480
|
+
if (err.code === "ENOENT") return emptyManifest();
|
|
2481
|
+
throw err;
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
async function writeManifest(file, manifest) {
|
|
2485
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
2486
|
+
await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
2487
|
+
}
|
|
2488
|
+
function newFolderId() {
|
|
2489
|
+
return `f-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
2490
|
+
}
|
|
2491
|
+
function validateName(v) {
|
|
2492
|
+
if (typeof v !== "string") return null;
|
|
2493
|
+
const trimmed = v.trim();
|
|
2494
|
+
if (trimmed.length < 1 || trimmed.length > 40) return null;
|
|
2495
|
+
return trimmed;
|
|
2496
|
+
}
|
|
2497
|
+
function validateReorder(v, current) {
|
|
2498
|
+
if (!Array.isArray(v) || v.length !== current.length) return null;
|
|
2499
|
+
const known = new Set(current.map((f) => f.id));
|
|
2500
|
+
const seen = new Set();
|
|
2501
|
+
const out = [];
|
|
2502
|
+
for (const id of v) {
|
|
2503
|
+
if (typeof id !== "string" || !FOLDER_ID_RE.test(id)) return null;
|
|
2504
|
+
if (!known.has(id) || seen.has(id)) return null;
|
|
2505
|
+
seen.add(id);
|
|
2506
|
+
out.push(id);
|
|
2507
|
+
}
|
|
2508
|
+
return out;
|
|
2509
|
+
}
|
|
2510
|
+
function validateIcon(v) {
|
|
2511
|
+
if (!v || typeof v !== "object") return null;
|
|
2512
|
+
const icon = v;
|
|
2513
|
+
if (icon.type === "emoji") {
|
|
2514
|
+
if (typeof icon.value !== "string") return null;
|
|
2515
|
+
if (icon.value.length < 1 || icon.value.length > 8) return null;
|
|
2516
|
+
return {
|
|
2517
|
+
type: "emoji",
|
|
2518
|
+
value: icon.value
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
if (icon.type === "color") {
|
|
2522
|
+
if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
|
|
2523
|
+
return {
|
|
2524
|
+
type: "color",
|
|
2525
|
+
value: icon.value
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
return null;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
//#endregion
|
|
2532
|
+
//#region src/vite/routes/folders.ts
|
|
2533
|
+
function registerFolderRoutes(server, ctx) {
|
|
2534
|
+
server.middlewares.use("/__folders", async (req, res, next) => {
|
|
2535
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2536
|
+
const method = req.method ?? "GET";
|
|
2537
|
+
try {
|
|
2538
|
+
if (method === "GET" && url.pathname === "/") {
|
|
2539
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2540
|
+
return json(res, 200, manifest);
|
|
2541
|
+
}
|
|
2542
|
+
if (method === "POST" && url.pathname === "/") {
|
|
2543
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2544
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2545
|
+
const body = await readBody(req);
|
|
2546
|
+
const name = validateName(body.name);
|
|
2547
|
+
if (!name) return json(res, 400, { error: "invalid name" });
|
|
2548
|
+
const icon = validateIcon(body.icon);
|
|
2549
|
+
if (!icon) return json(res, 400, { error: "invalid icon" });
|
|
2550
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2551
|
+
const folder = {
|
|
2552
|
+
id: newFolderId(),
|
|
2553
|
+
name,
|
|
2554
|
+
icon
|
|
2555
|
+
};
|
|
2556
|
+
manifest.folders.push(folder);
|
|
2557
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2558
|
+
return json(res, 200, folder);
|
|
2559
|
+
}
|
|
2560
|
+
if (method === "PUT" && url.pathname === "/assign") {
|
|
2561
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2562
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2563
|
+
const body = await readBody(req);
|
|
2564
|
+
if (typeof body.slideId !== "string" || !SLIDE_ID_RE.test(body.slideId)) return json(res, 400, { error: "invalid slideId" });
|
|
2565
|
+
const slideId = body.slideId;
|
|
2566
|
+
let folderId;
|
|
2567
|
+
if (body.folderId === null) folderId = null;
|
|
2568
|
+
else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
|
|
2569
|
+
else return json(res, 400, { error: "invalid folderId" });
|
|
2570
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2571
|
+
if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json(res, 404, { error: "folder not found" });
|
|
2572
|
+
if (folderId === null) delete manifest.assignments[slideId];
|
|
2573
|
+
else manifest.assignments[slideId] = folderId;
|
|
2574
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2575
|
+
return json(res, 200, { ok: true });
|
|
2576
|
+
}
|
|
2577
|
+
if (method === "PUT" && url.pathname === "/reorder") {
|
|
2578
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2579
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2580
|
+
const body = await readBody(req);
|
|
2581
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2582
|
+
const ids = validateReorder(body.ids, manifest.folders);
|
|
2583
|
+
if (!ids) return json(res, 400, { error: "invalid ids" });
|
|
2584
|
+
const byId = new Map(manifest.folders.map((f) => [f.id, f]));
|
|
2585
|
+
manifest.folders = ids.map((id) => byId.get(id));
|
|
2586
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2587
|
+
return json(res, 200, { ok: true });
|
|
2588
|
+
}
|
|
2589
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
2590
|
+
if (idMatch) {
|
|
2591
|
+
const id = idMatch[1];
|
|
2592
|
+
if (!FOLDER_ID_RE.test(id)) return json(res, 400, { error: "invalid id" });
|
|
2593
|
+
if (method === "PATCH") {
|
|
2594
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2595
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2596
|
+
const body = await readBody(req);
|
|
2597
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2598
|
+
const folder = manifest.folders.find((f) => f.id === id);
|
|
2599
|
+
if (!folder) return json(res, 404, { error: "folder not found" });
|
|
2600
|
+
if (body.name !== void 0) {
|
|
2601
|
+
const name = validateName(body.name);
|
|
2602
|
+
if (!name) return json(res, 400, { error: "invalid name" });
|
|
2603
|
+
folder.name = name;
|
|
2604
|
+
}
|
|
2605
|
+
if (body.icon !== void 0) {
|
|
2606
|
+
const icon = validateIcon(body.icon);
|
|
2607
|
+
if (!icon) return json(res, 400, { error: "invalid icon" });
|
|
2608
|
+
folder.icon = icon;
|
|
2609
|
+
}
|
|
2610
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2611
|
+
return json(res, 200, folder);
|
|
2612
|
+
}
|
|
2613
|
+
if (method === "DELETE") {
|
|
2614
|
+
const requestCheck = validateMutationRequest(req);
|
|
2615
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2616
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2617
|
+
const before = manifest.folders.length;
|
|
2618
|
+
manifest.folders = manifest.folders.filter((f) => f.id !== id);
|
|
2619
|
+
if (manifest.folders.length === before) return json(res, 404, { error: "folder not found" });
|
|
2620
|
+
for (const [slideId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[slideId];
|
|
2621
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2622
|
+
return json(res, 200, { ok: true });
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
next();
|
|
2626
|
+
} catch (err) {
|
|
2627
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
2628
|
+
}
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
//#endregion
|
|
2633
|
+
//#region src/vite/routes/slides.ts
|
|
2634
|
+
function registerSlideRoutes(server, ctx) {
|
|
2635
|
+
server.middlewares.use("/__slides", async (req, res, next) => {
|
|
2636
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2637
|
+
const method = req.method ?? "GET";
|
|
2638
|
+
try {
|
|
2639
|
+
const reorderMatch = url.pathname.match(/^\/([^/]+)\/reorder$/);
|
|
2640
|
+
if (reorderMatch && method === "PUT") {
|
|
2641
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2642
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2643
|
+
const slideId$1 = reorderMatch[1];
|
|
2644
|
+
if (!SLIDE_ID_RE.test(slideId$1)) return json(res, 400, { error: "invalid slideId" });
|
|
2645
|
+
const body = await readBody(req);
|
|
2646
|
+
if (!Array.isArray(body.order)) return json(res, 400, { error: "invalid order" });
|
|
2647
|
+
const order = [];
|
|
2648
|
+
for (const v of body.order) {
|
|
2649
|
+
if (!Number.isInteger(v)) return json(res, 400, { error: "invalid order" });
|
|
2650
|
+
order.push(v);
|
|
2651
|
+
}
|
|
2652
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
|
|
2653
|
+
if (!entry) return json(res, 400, { error: "invalid slideId" });
|
|
2654
|
+
let source;
|
|
2655
|
+
try {
|
|
2656
|
+
source = await fs.readFile(entry, "utf8");
|
|
2657
|
+
} catch {
|
|
2658
|
+
return json(res, 404, { error: "slide not found" });
|
|
2659
|
+
}
|
|
2660
|
+
const reordered = reorderDefaultExportPagesInSource(source, order);
|
|
2661
|
+
if (reordered === null) return json(res, 422, { error: "could not reorder pages — order must be a permutation of the existing array" });
|
|
2662
|
+
const withNotes = reorderNotesArrayInSource(reordered, order);
|
|
2663
|
+
if (withNotes === null) return json(res, 422, { error: "could not reorder pages — `notes` export has an unexpected shape" });
|
|
2664
|
+
if (withNotes !== source) await fs.writeFile(entry, withNotes, "utf8");
|
|
2665
|
+
return json(res, 200, {
|
|
2666
|
+
ok: true,
|
|
2667
|
+
slideId: slideId$1,
|
|
2668
|
+
order
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
const pageOpMatch = url.pathname.match(/^\/([^/]+)\/pages\/(\d+)(?:\/([a-z]+))?$/);
|
|
2672
|
+
if (pageOpMatch) {
|
|
2673
|
+
const slideId$1 = pageOpMatch[1];
|
|
2674
|
+
const pageIndex = Number.parseInt(pageOpMatch[2], 10);
|
|
2675
|
+
const op = pageOpMatch[3];
|
|
2676
|
+
if (!SLIDE_ID_RE.test(slideId$1)) return json(res, 400, { error: "invalid slideId" });
|
|
2677
|
+
if (!Number.isInteger(pageIndex) || pageIndex < 0) return json(res, 400, { error: "invalid page index" });
|
|
2678
|
+
const isDelete = method === "DELETE" && !op;
|
|
2679
|
+
const isDuplicate = method === "POST" && op === "duplicate";
|
|
2680
|
+
if (!isDelete && !isDuplicate) return next();
|
|
2681
|
+
const requestCheck = validateMutationRequest(req);
|
|
2682
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2683
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId$1);
|
|
2684
|
+
if (!entry) return json(res, 400, { error: "invalid slideId" });
|
|
2685
|
+
let source;
|
|
2686
|
+
try {
|
|
2687
|
+
source = await fs.readFile(entry, "utf8");
|
|
2688
|
+
} catch {
|
|
2689
|
+
return json(res, 404, { error: "slide not found" });
|
|
2690
|
+
}
|
|
2691
|
+
const updated = isDelete ? removePageFromDefaultExportInSource(source, pageIndex) : duplicatePageInDefaultExportInSource(source, pageIndex);
|
|
2692
|
+
if (updated === null) return json(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" });
|
|
2693
|
+
const withNotes = isDelete ? removeNotesElementInSource(updated, pageIndex) : duplicateNotesElementInSource(updated, pageIndex);
|
|
2694
|
+
if (withNotes === null) return json(res, 422, { error: isDelete ? "could not delete page — `notes` export has an unexpected shape" : "could not duplicate page — `notes` export has an unexpected shape" });
|
|
2695
|
+
if (withNotes !== source) await fs.writeFile(entry, withNotes, "utf8");
|
|
2696
|
+
return json(res, 200, {
|
|
2697
|
+
ok: true,
|
|
2698
|
+
slideId: slideId$1,
|
|
2699
|
+
index: pageIndex
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
const duplicateMatch = url.pathname.match(/^\/([^/]+)\/duplicate$/);
|
|
2703
|
+
if (duplicateMatch && method === "POST") {
|
|
2704
|
+
const requestCheck = validateMutationRequest(req);
|
|
2705
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2706
|
+
const slideId$1 = duplicateMatch[1];
|
|
2707
|
+
if (!SLIDE_ID_RE.test(slideId$1)) return json(res, 400, { error: "invalid slideId" });
|
|
2708
|
+
const body = await readBody(req);
|
|
2709
|
+
if (body.newId !== void 0 && typeof body.newId !== "string") return json(res, 400, { error: "invalid newId" });
|
|
2710
|
+
const duplicated = await duplicateSlideDir(ctx.slidesRoot, slideId$1, body.newId);
|
|
2711
|
+
if (!duplicated.ok) return json(res, duplicated.status, { error: duplicated.error });
|
|
2712
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2713
|
+
const folderId = manifest.assignments[slideId$1];
|
|
2714
|
+
if (folderId) {
|
|
2715
|
+
manifest.assignments[duplicated.slideId] = folderId;
|
|
2716
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2717
|
+
}
|
|
2718
|
+
return json(res, 200, {
|
|
2719
|
+
ok: true,
|
|
2720
|
+
slideId: duplicated.slideId
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
2724
|
+
if (!idMatch) return next();
|
|
2725
|
+
const slideId = idMatch[1];
|
|
2726
|
+
if (!SLIDE_ID_RE.test(slideId)) return json(res, 400, { error: "invalid slideId" });
|
|
2727
|
+
if (method === "PATCH") {
|
|
2728
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2729
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2730
|
+
const body = await readBody(req);
|
|
2731
|
+
const name = validateSlideName(body.name);
|
|
2732
|
+
if (!name) return json(res, 400, { error: "invalid name" });
|
|
2733
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId);
|
|
2734
|
+
if (!entry) return json(res, 400, { error: "invalid slideId" });
|
|
2735
|
+
let source;
|
|
2736
|
+
try {
|
|
2737
|
+
source = await fs.readFile(entry, "utf8");
|
|
2738
|
+
} catch {
|
|
2739
|
+
return json(res, 404, { error: "slide not found" });
|
|
2740
|
+
}
|
|
2741
|
+
const updated = updateMetaTitleInSource(source, name);
|
|
2742
|
+
if (updated === null) return json(res, 422, { error: "could not locate a safe place to write meta.title in index.tsx" });
|
|
2743
|
+
if (updated !== source) await fs.writeFile(entry, updated, "utf8");
|
|
2744
|
+
server.ws.send({ type: "full-reload" });
|
|
2745
|
+
return json(res, 200, {
|
|
2746
|
+
ok: true,
|
|
2747
|
+
slideId,
|
|
2748
|
+
name
|
|
2749
|
+
});
|
|
2750
|
+
}
|
|
2751
|
+
if (method === "DELETE") {
|
|
2752
|
+
const requestCheck = validateMutationRequest(req);
|
|
2753
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2754
|
+
const removed = await rmSlideDir(ctx.slidesRoot, slideId);
|
|
2755
|
+
if (!removed) return json(res, 404, { error: "slide not found" });
|
|
2756
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
2757
|
+
delete manifest.assignments[slideId];
|
|
2758
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
2759
|
+
return json(res, 200, { ok: true });
|
|
2760
|
+
}
|
|
2761
|
+
return next();
|
|
2762
|
+
} catch (err) {
|
|
2763
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
2764
|
+
}
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
//#endregion
|
|
2769
|
+
//#region src/vite/routes/svgl.ts
|
|
2770
|
+
function registerSvglRoutes(server) {
|
|
2771
|
+
server.middlewares.use("/__svgl", async (req, res, next) => {
|
|
2772
|
+
const reqUrl = new URL(req.url ?? "/", "http://local");
|
|
2773
|
+
const method = req.method ?? "GET";
|
|
2774
|
+
if (method !== "GET") return next();
|
|
2775
|
+
try {
|
|
2776
|
+
let target = null;
|
|
2777
|
+
if (reqUrl.pathname === "/search") {
|
|
2778
|
+
const params = new URLSearchParams();
|
|
2779
|
+
const q = reqUrl.searchParams.get("q");
|
|
2780
|
+
const limit = reqUrl.searchParams.get("limit");
|
|
2781
|
+
if (q) params.set("search", q);
|
|
2782
|
+
if (limit) params.set("limit", limit);
|
|
2783
|
+
const qs = params.toString();
|
|
2784
|
+
target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
|
|
2785
|
+
} else if (reqUrl.pathname === "/svg") {
|
|
2786
|
+
const u = reqUrl.searchParams.get("u");
|
|
2787
|
+
if (!u) return json(res, 400, { error: "missing u" });
|
|
2788
|
+
let parsed;
|
|
2789
|
+
try {
|
|
2790
|
+
parsed = new URL(u);
|
|
2791
|
+
} catch {
|
|
2792
|
+
return json(res, 400, { error: "invalid u" });
|
|
2793
|
+
}
|
|
2794
|
+
if (parsed.protocol !== "https:") return json(res, 400, { error: "https only" });
|
|
2795
|
+
const host = parsed.hostname.toLowerCase();
|
|
2796
|
+
if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json(res, 400, { error: "host not allowed" });
|
|
2797
|
+
target = parsed.toString();
|
|
2798
|
+
} else return next();
|
|
2799
|
+
const upstream = await fetch(target);
|
|
2800
|
+
const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
|
|
2801
|
+
res.statusCode = upstream.status;
|
|
2802
|
+
res.setHeader("content-type", ct);
|
|
2803
|
+
res.setHeader("cache-control", "no-store");
|
|
2804
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
2805
|
+
res.end(buf);
|
|
2806
|
+
} catch (err) {
|
|
2807
|
+
json(res, 502, { error: String(err.message ?? err) });
|
|
2808
|
+
}
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
//#endregion
|
|
2813
|
+
//#region src/vite/routes/update.ts
|
|
2814
|
+
const PKG = "@open-aippt/core";
|
|
2815
|
+
const CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
2816
|
+
const COMMAND_TIMEOUT_MS = 3e5;
|
|
2817
|
+
let cache = null;
|
|
2818
|
+
let updateInFlight = null;
|
|
2819
|
+
function parseSemver(v) {
|
|
2820
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
2821
|
+
if (!m) return null;
|
|
2822
|
+
return [
|
|
2823
|
+
Number(m[1]),
|
|
2824
|
+
Number(m[2]),
|
|
2825
|
+
Number(m[3])
|
|
2826
|
+
];
|
|
2827
|
+
}
|
|
2828
|
+
function isOutdated(current, latest) {
|
|
2829
|
+
const a = parseSemver(current);
|
|
2830
|
+
const b = parseSemver(latest);
|
|
2831
|
+
if (!a || !b) return false;
|
|
2832
|
+
for (let i = 0; i < 3; i++) {
|
|
2833
|
+
if (b[i] > a[i]) return true;
|
|
2834
|
+
if (b[i] < a[i]) return false;
|
|
2835
|
+
}
|
|
2836
|
+
return false;
|
|
2837
|
+
}
|
|
2838
|
+
async function fetchLatest(now) {
|
|
2839
|
+
if (cache && now - cache.at < CACHE_TTL_MS) return cache.latest;
|
|
2840
|
+
try {
|
|
2841
|
+
const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, {
|
|
2842
|
+
signal: AbortSignal.timeout(3e3),
|
|
2843
|
+
headers: { accept: "application/json" }
|
|
2844
|
+
});
|
|
2845
|
+
if (!res.ok) throw new Error(`registry ${res.status}`);
|
|
2846
|
+
const body = await res.json();
|
|
2847
|
+
const latest = typeof body.version === "string" ? body.version : null;
|
|
2848
|
+
cache = {
|
|
2849
|
+
at: now,
|
|
2850
|
+
latest
|
|
2851
|
+
};
|
|
2852
|
+
return latest;
|
|
2853
|
+
} catch {
|
|
2854
|
+
return cache?.latest ?? null;
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
async function fileExists(file) {
|
|
2858
|
+
try {
|
|
2859
|
+
await fs.access(file);
|
|
2860
|
+
return true;
|
|
2861
|
+
} catch {
|
|
2862
|
+
return false;
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
async function detectPackageManager(cwd) {
|
|
2866
|
+
const ua = process.env.npm_config_user_agent ?? "";
|
|
2867
|
+
if (ua.startsWith("pnpm")) return "pnpm";
|
|
2868
|
+
if (ua.startsWith("yarn")) return "yarn";
|
|
2869
|
+
if (ua.startsWith("bun")) return "bun";
|
|
2870
|
+
if (ua.startsWith("npm")) return "npm";
|
|
2871
|
+
if (await fileExists(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
2872
|
+
if (await fileExists(path.join(cwd, "yarn.lock"))) return "yarn";
|
|
2873
|
+
if (await fileExists(path.join(cwd, "bun.lockb"))) return "bun";
|
|
2874
|
+
if (await fileExists(path.join(cwd, "bun.lock"))) return "bun";
|
|
2875
|
+
if (await fileExists(path.join(cwd, "package-lock.json"))) return "npm";
|
|
2876
|
+
return "npm";
|
|
2877
|
+
}
|
|
2878
|
+
function updateCommandFor(packageManager) {
|
|
2879
|
+
switch (packageManager) {
|
|
2880
|
+
case "pnpm": return {
|
|
2881
|
+
cmd: "pnpm",
|
|
2882
|
+
args: ["add", `${PKG}@latest`]
|
|
2883
|
+
};
|
|
2884
|
+
case "yarn": return {
|
|
2885
|
+
cmd: "yarn",
|
|
2886
|
+
args: ["add", `${PKG}@latest`]
|
|
2887
|
+
};
|
|
2888
|
+
case "bun": return {
|
|
2889
|
+
cmd: "bun",
|
|
2890
|
+
args: ["add", `${PKG}@latest`]
|
|
2891
|
+
};
|
|
2892
|
+
case "npm": return {
|
|
2893
|
+
cmd: "npm",
|
|
2894
|
+
args: ["install", `${PKG}@latest`]
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
function localOpenAipptCommand(cwd) {
|
|
2899
|
+
const bin = process.platform === "win32" ? "open-aippt.cmd" : "open-aippt";
|
|
2900
|
+
return {
|
|
2901
|
+
cmd: path.join(cwd, "node_modules", ".bin", bin),
|
|
2902
|
+
args: ["sync:skills"]
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2905
|
+
function formatCommand(spec) {
|
|
2906
|
+
return [spec.cmd, ...spec.args].join(" ");
|
|
2907
|
+
}
|
|
2908
|
+
async function runCommand(spec, cwd) {
|
|
2909
|
+
await new Promise((resolve, reject) => {
|
|
2910
|
+
const child = spawn(spec.cmd, spec.args, {
|
|
2911
|
+
cwd,
|
|
2912
|
+
env: process.env,
|
|
2913
|
+
shell: process.platform === "win32",
|
|
2914
|
+
stdio: [
|
|
2915
|
+
"ignore",
|
|
2916
|
+
"ignore",
|
|
2917
|
+
"pipe"
|
|
2918
|
+
]
|
|
2919
|
+
});
|
|
2920
|
+
let stderr = "";
|
|
2921
|
+
const timer = setTimeout(() => {
|
|
2922
|
+
child.kill();
|
|
2923
|
+
reject(new Error(`${formatCommand(spec)} timed out`));
|
|
2924
|
+
}, COMMAND_TIMEOUT_MS);
|
|
2925
|
+
child.stderr.on("data", (chunk) => {
|
|
2926
|
+
stderr += chunk.toString("utf8");
|
|
2927
|
+
if (stderr.length > 2e3) stderr = stderr.slice(-2e3);
|
|
2928
|
+
});
|
|
2929
|
+
child.on("error", (err) => {
|
|
2930
|
+
clearTimeout(timer);
|
|
2931
|
+
reject(err);
|
|
2932
|
+
});
|
|
2933
|
+
child.on("close", (code) => {
|
|
2934
|
+
clearTimeout(timer);
|
|
2935
|
+
if (code === 0) {
|
|
2936
|
+
resolve();
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
const detail = stderr.trim();
|
|
2940
|
+
reject(new Error(detail || `${formatCommand(spec)} exited with code ${code ?? "unknown"}`));
|
|
2941
|
+
});
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
async function updatePackage(ctx) {
|
|
2945
|
+
const packageManager = await detectPackageManager(ctx.userCwd);
|
|
2946
|
+
const updateCommand = updateCommandFor(packageManager);
|
|
2947
|
+
const syncCommand = localOpenAipptCommand(ctx.userCwd);
|
|
2948
|
+
await runCommand(updateCommand, ctx.userCwd);
|
|
2949
|
+
await runCommand(syncCommand, ctx.userCwd);
|
|
2950
|
+
cache = null;
|
|
2951
|
+
const latest = await fetchLatest(Date.now());
|
|
2952
|
+
return {
|
|
2953
|
+
packageManager,
|
|
2954
|
+
command: `${formatCommand(updateCommand)} && open-aippt sync:skills`,
|
|
2955
|
+
latest,
|
|
2956
|
+
message: "Updated @open-aippt/core and synced skills."
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
function registerUpdateRoutes(server, ctx) {
|
|
2960
|
+
server.middlewares.use("/__update-check", async (req, res, next) => {
|
|
2961
|
+
if ((req.method ?? "GET") !== "GET") return next();
|
|
2962
|
+
const latest = await fetchLatest(Date.now());
|
|
2963
|
+
const result = {
|
|
2964
|
+
current: ctx.coreVersion,
|
|
2965
|
+
latest,
|
|
2966
|
+
outdated: latest ? isOutdated(ctx.coreVersion, latest) : false
|
|
2967
|
+
};
|
|
2968
|
+
res.setHeader("cache-control", "no-store");
|
|
2969
|
+
json(res, 200, result);
|
|
2970
|
+
});
|
|
2971
|
+
server.middlewares.use("/__update-package", async (req, res, next) => {
|
|
2972
|
+
if ((req.method ?? "GET") !== "POST") return next();
|
|
2973
|
+
const guard = validateMutationRequest(req);
|
|
2974
|
+
if (!guard.ok) return json(res, guard.status, { error: guard.error });
|
|
2975
|
+
try {
|
|
2976
|
+
updateInFlight ??= updatePackage(ctx).finally(() => {
|
|
2977
|
+
updateInFlight = null;
|
|
2978
|
+
});
|
|
2979
|
+
const result = await updateInFlight;
|
|
2980
|
+
json(res, 200, result);
|
|
2981
|
+
} catch (err) {
|
|
2982
|
+
json(res, 500, { error: err instanceof Error ? err.message : "update failed" });
|
|
2983
|
+
}
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
//#endregion
|
|
2988
|
+
//#region src/vite/routes/watchers.ts
|
|
2989
|
+
function registerWatchers(server, ctx) {
|
|
2990
|
+
server.watcher.add(ctx.manifestPath);
|
|
2991
|
+
server.watcher.on("change", (p) => {
|
|
2992
|
+
if (p === ctx.manifestPath) server.ws.send({
|
|
2993
|
+
type: "custom",
|
|
2994
|
+
event: "open-aippt:files-changed"
|
|
2995
|
+
});
|
|
2996
|
+
});
|
|
2997
|
+
server.watcher.add(ctx.globalAssetsRoot);
|
|
2998
|
+
const onAssetChange = (p) => {
|
|
2999
|
+
if (p.startsWith(ctx.globalAssetsRoot + path.sep) || p === ctx.globalAssetsRoot) {
|
|
3000
|
+
server.ws.send({
|
|
3001
|
+
type: "custom",
|
|
3002
|
+
event: "open-aippt:assets-changed",
|
|
3003
|
+
data: { slideId: GLOBAL_SCOPE }
|
|
3004
|
+
});
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
if (!p.startsWith(ctx.slidesRoot + path.sep)) return;
|
|
3008
|
+
const rel = p.slice(ctx.slidesRoot.length + 1);
|
|
3009
|
+
const parts = rel.split(path.sep);
|
|
3010
|
+
if (parts.length < 3 || parts[1] !== "assets") return;
|
|
3011
|
+
const slideId = parts[0];
|
|
3012
|
+
if (!SLIDE_ID_RE.test(slideId)) return;
|
|
3013
|
+
server.ws.send({
|
|
3014
|
+
type: "custom",
|
|
3015
|
+
event: "open-aippt:assets-changed",
|
|
3016
|
+
data: { slideId }
|
|
3017
|
+
});
|
|
3018
|
+
};
|
|
3019
|
+
server.watcher.on("add", onAssetChange);
|
|
3020
|
+
server.watcher.on("change", onAssetChange);
|
|
3021
|
+
server.watcher.on("unlink", onAssetChange);
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
//#endregion
|
|
3025
|
+
//#region src/vite/api-plugin.ts
|
|
3026
|
+
function apiPlugin(opts) {
|
|
3027
|
+
return {
|
|
3028
|
+
name: "open-aippt:api",
|
|
3029
|
+
apply: "serve",
|
|
3030
|
+
configureServer(server) {
|
|
3031
|
+
const ctx = makeContext(opts);
|
|
3032
|
+
registerWatchers(server, ctx);
|
|
3033
|
+
registerEditRoutes(server, ctx);
|
|
3034
|
+
registerCommentRoutes(server, ctx);
|
|
3035
|
+
registerSlideRoutes(server, ctx);
|
|
3036
|
+
registerAssetRoutes(server, ctx);
|
|
3037
|
+
registerSvglRoutes(server);
|
|
3038
|
+
registerFolderRoutes(server, ctx);
|
|
3039
|
+
registerUpdateRoutes(server, ctx);
|
|
3040
|
+
}
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
//#endregion
|
|
3045
|
+
//#region src/vite/current-plugin.ts
|
|
3046
|
+
const TEXT_SNIPPET_MAX = 120;
|
|
3047
|
+
function parseSelection(raw) {
|
|
3048
|
+
if (raw == null || typeof raw !== "object") return null;
|
|
3049
|
+
const sel = raw;
|
|
3050
|
+
if (typeof sel.line !== "number" || !Number.isFinite(sel.line)) return null;
|
|
3051
|
+
if (typeof sel.column !== "number" || !Number.isFinite(sel.column)) return null;
|
|
3052
|
+
const tagName = typeof sel.tagName === "string" ? sel.tagName.toLowerCase().slice(0, 32) : "unknown";
|
|
3053
|
+
const text = typeof sel.text === "string" ? sel.text.replace(/\s+/g, " ").trim().slice(0, TEXT_SNIPPET_MAX) : "";
|
|
3054
|
+
return {
|
|
3055
|
+
line: Math.max(1, Math.floor(sel.line)),
|
|
3056
|
+
column: Math.max(0, Math.floor(sel.column)),
|
|
3057
|
+
tagName,
|
|
3058
|
+
text
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
function currentPlugin(opts) {
|
|
3062
|
+
const userCwd = opts.userCwd;
|
|
3063
|
+
const slidesDir = opts.slidesDir ?? "slides";
|
|
3064
|
+
const outDir = path.join(userCwd, "node_modules", ".open-aippt");
|
|
3065
|
+
const outFile = path.join(outDir, "current.json");
|
|
3066
|
+
const tmpFile = `${outFile}.tmp`;
|
|
3067
|
+
let cached = null;
|
|
3068
|
+
return {
|
|
3069
|
+
name: "open-aippt:current",
|
|
3070
|
+
apply: "serve",
|
|
3071
|
+
configureServer(server) {
|
|
3072
|
+
server.ws.on("open-aippt:current", async (raw) => {
|
|
3073
|
+
const next = cached ? { ...cached } : {
|
|
3074
|
+
slideId: "",
|
|
3075
|
+
pageIndex: 0,
|
|
3076
|
+
pageNumber: 1,
|
|
3077
|
+
totalPages: 1,
|
|
3078
|
+
slideTitle: "",
|
|
3079
|
+
view: "slides",
|
|
3080
|
+
pagePath: "",
|
|
3081
|
+
selection: null
|
|
3082
|
+
};
|
|
3083
|
+
if (typeof raw?.slideId === "string") {
|
|
3084
|
+
if (!SLIDE_ID_RE.test(raw.slideId)) return;
|
|
3085
|
+
const totalPages = typeof raw.totalPages === "number" && Number.isFinite(raw.totalPages) && raw.totalPages > 0 ? Math.floor(raw.totalPages) : 1;
|
|
3086
|
+
const rawIndex = typeof raw.pageIndex === "number" && Number.isFinite(raw.pageIndex) ? Math.floor(raw.pageIndex) : 0;
|
|
3087
|
+
const pageIndex = Math.max(0, Math.min(totalPages - 1, rawIndex));
|
|
3088
|
+
const slideTitle = typeof raw.slideTitle === "string" ? raw.slideTitle : raw.slideId;
|
|
3089
|
+
const view = raw.view === "assets" ? "assets" : "slides";
|
|
3090
|
+
const pagePath = path.join(slidesDir, raw.slideId, "index.tsx").split(path.sep).join("/");
|
|
3091
|
+
if (cached?.slideId !== raw.slideId || cached?.pageIndex !== pageIndex) next.selection = null;
|
|
3092
|
+
next.slideId = raw.slideId;
|
|
3093
|
+
next.pageIndex = pageIndex;
|
|
3094
|
+
next.pageNumber = pageIndex + 1;
|
|
3095
|
+
next.totalPages = totalPages;
|
|
3096
|
+
next.slideTitle = slideTitle;
|
|
3097
|
+
next.view = view;
|
|
3098
|
+
next.pagePath = pagePath;
|
|
3099
|
+
}
|
|
3100
|
+
if ("selection" in raw) next.selection = parseSelection(raw.selection);
|
|
3101
|
+
if (!next.slideId) return;
|
|
3102
|
+
cached = next;
|
|
3103
|
+
const body = {
|
|
3104
|
+
...next,
|
|
3105
|
+
updatedAt: new Date().toISOString()
|
|
3106
|
+
};
|
|
3107
|
+
try {
|
|
3108
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
3109
|
+
await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
3110
|
+
await fs.rename(tmpFile, outFile);
|
|
3111
|
+
} catch {}
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
//#endregion
|
|
3118
|
+
//#region src/vite/design-plugin.ts
|
|
3119
|
+
function parseSource$1(source) {
|
|
3120
|
+
try {
|
|
3121
|
+
return parse(source, {
|
|
3122
|
+
sourceType: "module",
|
|
3123
|
+
plugins: ["typescript", "jsx"],
|
|
3124
|
+
errorRecovery: true
|
|
3125
|
+
});
|
|
3126
|
+
} catch {
|
|
3127
|
+
return null;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
function findDesignDecl(ast) {
|
|
3131
|
+
const body = ast.program?.body ?? [];
|
|
3132
|
+
for (const node of body) {
|
|
3133
|
+
let varDecl = null;
|
|
3134
|
+
if (node.type === "VariableDeclaration") varDecl = node;
|
|
3135
|
+
else if (node.type === "ExportNamedDeclaration") {
|
|
3136
|
+
const decl = node.declaration;
|
|
3137
|
+
if (decl?.type === "VariableDeclaration") varDecl = decl;
|
|
3138
|
+
}
|
|
3139
|
+
if (!varDecl) continue;
|
|
3140
|
+
const declarations = varDecl.declarations ?? [];
|
|
3141
|
+
for (const d of declarations) {
|
|
3142
|
+
const id = d.id;
|
|
3143
|
+
if (!id || id.type !== "Identifier" || id.name !== "design") continue;
|
|
3144
|
+
const init = d.init;
|
|
3145
|
+
if (!init) return null;
|
|
3146
|
+
let inner = init;
|
|
3147
|
+
if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
|
|
3148
|
+
const expr = inner.expression;
|
|
3149
|
+
if (expr) inner = expr;
|
|
3150
|
+
}
|
|
3151
|
+
if (inner.type !== "ObjectExpression") return null;
|
|
3152
|
+
return {
|
|
3153
|
+
declStart: node.start,
|
|
3154
|
+
declEnd: node.end,
|
|
3155
|
+
objectStart: inner.start,
|
|
3156
|
+
objectEnd: inner.end
|
|
3157
|
+
};
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
return null;
|
|
3161
|
+
}
|
|
3162
|
+
function literalToValue(node) {
|
|
3163
|
+
switch (node.type) {
|
|
3164
|
+
case "StringLiteral": return node.value;
|
|
3165
|
+
case "NumericLiteral": return node.value;
|
|
3166
|
+
case "BooleanLiteral": return node.value;
|
|
3167
|
+
case "NullLiteral": return null;
|
|
3168
|
+
case "UnaryExpression": {
|
|
3169
|
+
const op = node.operator;
|
|
3170
|
+
const arg = node.argument;
|
|
3171
|
+
const v = literalToValue(arg);
|
|
3172
|
+
if (op === "-" && typeof v === "number") return -v;
|
|
3173
|
+
if (op === "+" && typeof v === "number") return v;
|
|
3174
|
+
throw new Error(`unsupported unary operator ${op}`);
|
|
3175
|
+
}
|
|
3176
|
+
case "TemplateLiteral": {
|
|
3177
|
+
const quasis = node.quasis;
|
|
3178
|
+
const expressions = node.expressions;
|
|
3179
|
+
if (expressions.length > 0) throw new Error("template literal has expressions");
|
|
3180
|
+
return quasis[0].value.cooked ?? quasis[0].value.raw;
|
|
3181
|
+
}
|
|
3182
|
+
case "ArrayExpression": {
|
|
3183
|
+
const elements = node.elements;
|
|
3184
|
+
return elements.map((el) => {
|
|
3185
|
+
if (!el) throw new Error("array has hole");
|
|
3186
|
+
return literalToValue(el);
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
3189
|
+
case "ObjectExpression": {
|
|
3190
|
+
const properties = node.properties;
|
|
3191
|
+
const out = {};
|
|
3192
|
+
for (const prop of properties) {
|
|
3193
|
+
if (prop.type !== "ObjectProperty") throw new Error("object has spread or method");
|
|
3194
|
+
const p = prop;
|
|
3195
|
+
if (p.computed) throw new Error("object has computed key");
|
|
3196
|
+
let key;
|
|
3197
|
+
if (p.key.type === "Identifier" && typeof p.key.name === "string") key = p.key.name;
|
|
3198
|
+
else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") key = p.key.value;
|
|
3199
|
+
else throw new Error("unsupported object key");
|
|
3200
|
+
out[key] = literalToValue(p.value);
|
|
3201
|
+
}
|
|
3202
|
+
return out;
|
|
3203
|
+
}
|
|
3204
|
+
default: throw new Error(`unsupported node type ${node.type}`);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
function isPlainObject(v) {
|
|
3208
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3209
|
+
}
|
|
3210
|
+
function mergeDesign(base, patch) {
|
|
3211
|
+
const out = JSON.parse(JSON.stringify(base));
|
|
3212
|
+
const apply = (target, src) => {
|
|
3213
|
+
for (const [k, v] of Object.entries(src)) if (isPlainObject(v) && isPlainObject(target[k])) apply(target[k], v);
|
|
3214
|
+
else target[k] = v;
|
|
3215
|
+
};
|
|
3216
|
+
if (isPlainObject(patch)) apply(out, patch);
|
|
3217
|
+
return out;
|
|
3218
|
+
}
|
|
3219
|
+
function indent(level) {
|
|
3220
|
+
return " ".repeat(level);
|
|
3221
|
+
}
|
|
3222
|
+
function jsString(s) {
|
|
3223
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
3224
|
+
}
|
|
3225
|
+
function isValidIdentifier(name) {
|
|
3226
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
3227
|
+
}
|
|
3228
|
+
function serializeValue(value, level) {
|
|
3229
|
+
if (value === null) return "null";
|
|
3230
|
+
if (typeof value === "string") return jsString(value);
|
|
3231
|
+
if (typeof value === "number") {
|
|
3232
|
+
if (!Number.isFinite(value)) throw new Error("non-finite number");
|
|
3233
|
+
return String(value);
|
|
3234
|
+
}
|
|
3235
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
3236
|
+
if (Array.isArray(value)) {
|
|
3237
|
+
if (value.length === 0) return "[]";
|
|
3238
|
+
const inner = value.map((el) => serializeValue(el, level + 1)).join(", ");
|
|
3239
|
+
return `[${inner}]`;
|
|
3240
|
+
}
|
|
3241
|
+
if (isPlainObject(value)) {
|
|
3242
|
+
const entries = Object.entries(value);
|
|
3243
|
+
if (entries.length === 0) return "{}";
|
|
3244
|
+
const childIndent = indent(level + 1);
|
|
3245
|
+
const lines = entries.map(([k, v]) => {
|
|
3246
|
+
const key = isValidIdentifier(k) ? k : jsString(k);
|
|
3247
|
+
return `${childIndent}${key}: ${serializeValue(v, level + 1)},`;
|
|
3248
|
+
});
|
|
3249
|
+
return `{\n${lines.join("\n")}\n${indent(level)}}`;
|
|
3250
|
+
}
|
|
3251
|
+
throw new Error(`unsupported value type ${typeof value}`);
|
|
3252
|
+
}
|
|
3253
|
+
function serializeDesign(design) {
|
|
3254
|
+
return serializeValue(design, 0);
|
|
3255
|
+
}
|
|
3256
|
+
function parseSlideDesign(source) {
|
|
3257
|
+
const ast = parseSource$1(source);
|
|
3258
|
+
if (!ast) return {
|
|
3259
|
+
ok: false,
|
|
3260
|
+
exists: true,
|
|
3261
|
+
error: "could not parse slide source"
|
|
3262
|
+
};
|
|
3263
|
+
const loc = findDesignDecl(ast);
|
|
3264
|
+
if (!loc) return {
|
|
3265
|
+
ok: false,
|
|
3266
|
+
exists: false
|
|
3267
|
+
};
|
|
3268
|
+
const objectNode = findDesignObjectNode(ast);
|
|
3269
|
+
if (!objectNode) return {
|
|
3270
|
+
ok: false,
|
|
3271
|
+
exists: true,
|
|
3272
|
+
error: "design has unsupported initializer"
|
|
3273
|
+
};
|
|
3274
|
+
let value;
|
|
3275
|
+
try {
|
|
3276
|
+
value = literalToValue(objectNode);
|
|
3277
|
+
} catch (err) {
|
|
3278
|
+
return {
|
|
3279
|
+
ok: false,
|
|
3280
|
+
exists: true,
|
|
3281
|
+
error: err.message
|
|
3282
|
+
};
|
|
3283
|
+
}
|
|
3284
|
+
const merged = mergeDesign(defaultDesign, value);
|
|
3285
|
+
return {
|
|
3286
|
+
ok: true,
|
|
3287
|
+
design: merged,
|
|
3288
|
+
loc
|
|
3289
|
+
};
|
|
3290
|
+
}
|
|
3291
|
+
function findDesignObjectNode(ast) {
|
|
3292
|
+
const body = ast.program?.body ?? [];
|
|
3293
|
+
for (const node of body) {
|
|
3294
|
+
let varDecl = null;
|
|
3295
|
+
if (node.type === "VariableDeclaration") varDecl = node;
|
|
3296
|
+
else if (node.type === "ExportNamedDeclaration") {
|
|
3297
|
+
const decl = node.declaration;
|
|
3298
|
+
if (decl?.type === "VariableDeclaration") varDecl = decl;
|
|
3299
|
+
}
|
|
3300
|
+
if (!varDecl) continue;
|
|
3301
|
+
const declarations = varDecl.declarations ?? [];
|
|
3302
|
+
for (const d of declarations) {
|
|
3303
|
+
const id = d.id;
|
|
3304
|
+
if (!id || id.type !== "Identifier" || id.name !== "design") continue;
|
|
3305
|
+
const init = d.init;
|
|
3306
|
+
if (!init) return null;
|
|
3307
|
+
let inner = init;
|
|
3308
|
+
if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
|
|
3309
|
+
const expr = inner.expression;
|
|
3310
|
+
if (expr) inner = expr;
|
|
3311
|
+
}
|
|
3312
|
+
if (inner.type !== "ObjectExpression") return null;
|
|
3313
|
+
return inner;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
return null;
|
|
3317
|
+
}
|
|
3318
|
+
function findImports(ast) {
|
|
3319
|
+
const body = ast.program?.body ?? [];
|
|
3320
|
+
const out = [];
|
|
3321
|
+
for (const node of body) {
|
|
3322
|
+
if (node.type !== "ImportDeclaration") continue;
|
|
3323
|
+
const src = node.source?.value;
|
|
3324
|
+
if (typeof src !== "string") continue;
|
|
3325
|
+
const specs = node.specifiers ?? [];
|
|
3326
|
+
out.push({
|
|
3327
|
+
node,
|
|
3328
|
+
source: src,
|
|
3329
|
+
specifiers: specs
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
return out;
|
|
3333
|
+
}
|
|
3334
|
+
function ensureDesignSystemImport(source, ast) {
|
|
3335
|
+
const imports = findImports(ast);
|
|
3336
|
+
const coreImport = imports.find((imp) => imp.source === "@open-aippt/core");
|
|
3337
|
+
if (coreImport) {
|
|
3338
|
+
const hasDesignSystem = coreImport.specifiers.some((spec) => {
|
|
3339
|
+
if (spec.type !== "ImportSpecifier") return false;
|
|
3340
|
+
const imported = spec.imported;
|
|
3341
|
+
return imported?.name === "DesignSystem";
|
|
3342
|
+
});
|
|
3343
|
+
if (hasDesignSystem) return {
|
|
3344
|
+
source,
|
|
3345
|
+
offsetShift: 0
|
|
3346
|
+
};
|
|
3347
|
+
const node = coreImport.node;
|
|
3348
|
+
const importText = source.slice(node.start, node.end);
|
|
3349
|
+
const braceClose = importText.lastIndexOf("}");
|
|
3350
|
+
if (braceClose === -1) return {
|
|
3351
|
+
source,
|
|
3352
|
+
offsetShift: 0
|
|
3353
|
+
};
|
|
3354
|
+
const absoluteBrace = node.start + braceClose;
|
|
3355
|
+
const insertText = coreImport.specifiers.length > 0 ? ", type DesignSystem" : "type DesignSystem";
|
|
3356
|
+
const next$1 = `${source.slice(0, absoluteBrace)}${insertText}${source.slice(absoluteBrace)}`;
|
|
3357
|
+
return {
|
|
3358
|
+
source: next$1,
|
|
3359
|
+
offsetShift: insertText.length
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
const stmt = `import type { DesignSystem } from '@open-aippt/core';\n`;
|
|
3363
|
+
if (imports.length > 0) {
|
|
3364
|
+
const last = imports[imports.length - 1];
|
|
3365
|
+
const insertAt = last.node.end;
|
|
3366
|
+
const trail = source[insertAt] === "\n" ? "" : "\n";
|
|
3367
|
+
const next$1 = `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
|
|
3368
|
+
return {
|
|
3369
|
+
source: next$1,
|
|
3370
|
+
offsetShift: 1 + stmt.length - (trail ? 0 : 1)
|
|
3371
|
+
};
|
|
3372
|
+
}
|
|
3373
|
+
const next = `${stmt}\n${source}`;
|
|
3374
|
+
return {
|
|
3375
|
+
source: next,
|
|
3376
|
+
offsetShift: stmt.length + 1
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
function findInsertionPoint(source, ast) {
|
|
3380
|
+
const imports = findImports(ast);
|
|
3381
|
+
if (imports.length === 0) return 0;
|
|
3382
|
+
const last = imports[imports.length - 1];
|
|
3383
|
+
let off = last.node.end;
|
|
3384
|
+
while (off < source.length && source[off] !== "\n") off++;
|
|
3385
|
+
if (off < source.length) off++;
|
|
3386
|
+
return off;
|
|
3387
|
+
}
|
|
3388
|
+
function applyDesignWrite(source, next) {
|
|
3389
|
+
let body;
|
|
3390
|
+
try {
|
|
3391
|
+
body = serializeDesign(next);
|
|
3392
|
+
} catch (err) {
|
|
3393
|
+
return {
|
|
3394
|
+
ok: false,
|
|
3395
|
+
status: 422,
|
|
3396
|
+
error: `serialize failed: ${err.message}`
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
3399
|
+
const ast = parseSource$1(source);
|
|
3400
|
+
if (!ast) return {
|
|
3401
|
+
ok: false,
|
|
3402
|
+
status: 422,
|
|
3403
|
+
error: "could not parse slide source"
|
|
3404
|
+
};
|
|
3405
|
+
const loc = findDesignDecl(ast);
|
|
3406
|
+
if (loc) {
|
|
3407
|
+
const out$1 = source.slice(0, loc.objectStart) + body + source.slice(loc.objectEnd);
|
|
3408
|
+
return {
|
|
3409
|
+
ok: true,
|
|
3410
|
+
source: out$1,
|
|
3411
|
+
created: false
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
const withImport = ensureDesignSystemImport(source, ast);
|
|
3415
|
+
const ast2 = parseSource$1(withImport.source);
|
|
3416
|
+
if (!ast2) return {
|
|
3417
|
+
ok: false,
|
|
3418
|
+
status: 422,
|
|
3419
|
+
error: "failed to re-parse after adding import"
|
|
3420
|
+
};
|
|
3421
|
+
const insertAt = findInsertionPoint(withImport.source, ast2);
|
|
3422
|
+
const block = `\nconst design: DesignSystem = ${body};\n`;
|
|
3423
|
+
const out = withImport.source.slice(0, insertAt) + block + withImport.source.slice(insertAt);
|
|
3424
|
+
return {
|
|
3425
|
+
ok: true,
|
|
3426
|
+
source: out,
|
|
3427
|
+
created: true
|
|
3428
|
+
};
|
|
3429
|
+
}
|
|
3430
|
+
function designPlugin(opts) {
|
|
3431
|
+
const userCwd = opts.userCwd;
|
|
3432
|
+
const slidesDir = opts.slidesDir ?? "slides";
|
|
3433
|
+
return {
|
|
3434
|
+
name: "open-aippt:design",
|
|
3435
|
+
apply: "serve",
|
|
3436
|
+
configureServer(server) {
|
|
3437
|
+
server.middlewares.use("/__design", async (req, res, next) => {
|
|
3438
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
3439
|
+
const method = req.method ?? "GET";
|
|
3440
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
3441
|
+
const file = resolveSlidePath(userCwd, slidesDir, slideId);
|
|
3442
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
3443
|
+
try {
|
|
3444
|
+
if (method === "GET" && url.pathname === "/") {
|
|
3445
|
+
let source;
|
|
3446
|
+
try {
|
|
3447
|
+
source = await fs.readFile(file, "utf8");
|
|
3448
|
+
} catch {
|
|
3449
|
+
return json(res, 404, { error: "slide not found" });
|
|
3450
|
+
}
|
|
3451
|
+
const parsed = parseSlideDesign(source);
|
|
3452
|
+
if (parsed.ok) return json(res, 200, {
|
|
3453
|
+
design: parsed.design,
|
|
3454
|
+
exists: true,
|
|
3455
|
+
warning: null
|
|
3456
|
+
});
|
|
3457
|
+
if (parsed.exists === false) return json(res, 200, {
|
|
3458
|
+
design: defaultDesign,
|
|
3459
|
+
exists: false,
|
|
3460
|
+
warning: null
|
|
3461
|
+
});
|
|
3462
|
+
return json(res, 200, {
|
|
3463
|
+
design: defaultDesign,
|
|
3464
|
+
exists: true,
|
|
3465
|
+
warning: parsed.error
|
|
3466
|
+
});
|
|
3467
|
+
}
|
|
3468
|
+
if (method === "PUT" && url.pathname === "/") {
|
|
3469
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
3470
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
3471
|
+
const body = await readBody(req);
|
|
3472
|
+
const patch = body.patch;
|
|
3473
|
+
if (!patch || typeof patch !== "object") return json(res, 400, { error: "missing patch object" });
|
|
3474
|
+
let source;
|
|
3475
|
+
try {
|
|
3476
|
+
source = await fs.readFile(file, "utf8");
|
|
3477
|
+
} catch {
|
|
3478
|
+
return json(res, 404, { error: "slide not found" });
|
|
3479
|
+
}
|
|
3480
|
+
const parsed = parseSlideDesign(source);
|
|
3481
|
+
const baseDesign = parsed.ok ? parsed.design : defaultDesign;
|
|
3482
|
+
if (!parsed.ok && parsed.exists) return json(res, 422, { error: parsed.error });
|
|
3483
|
+
const merged = mergeDesign(baseDesign, patch);
|
|
3484
|
+
const written = applyDesignWrite(source, merged);
|
|
3485
|
+
if (!written.ok) return json(res, written.status, { error: written.error });
|
|
3486
|
+
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
3487
|
+
return json(res, 200, {
|
|
3488
|
+
ok: true,
|
|
3489
|
+
design: merged,
|
|
3490
|
+
created: written.created
|
|
3491
|
+
});
|
|
3492
|
+
}
|
|
3493
|
+
if (method === "POST" && url.pathname === "/reset") {
|
|
3494
|
+
const requestCheck = validateMutationRequest(req);
|
|
3495
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
3496
|
+
let source;
|
|
3497
|
+
try {
|
|
3498
|
+
source = await fs.readFile(file, "utf8");
|
|
3499
|
+
} catch {
|
|
3500
|
+
return json(res, 404, { error: "slide not found" });
|
|
3501
|
+
}
|
|
3502
|
+
const written = applyDesignWrite(source, defaultDesign);
|
|
3503
|
+
if (!written.ok) return json(res, written.status, { error: written.error });
|
|
3504
|
+
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
3505
|
+
return json(res, 200, {
|
|
3506
|
+
ok: true,
|
|
3507
|
+
design: defaultDesign,
|
|
3508
|
+
created: written.created
|
|
3509
|
+
});
|
|
3510
|
+
}
|
|
3511
|
+
return next();
|
|
3512
|
+
} catch (err) {
|
|
3513
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
3514
|
+
}
|
|
3515
|
+
});
|
|
3516
|
+
}
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
//#endregion
|
|
3521
|
+
//#region src/vite/loc-tags-plugin.ts
|
|
3522
|
+
const FORWARDING_COMPONENTS = new Set(["ImagePlaceholder"]);
|
|
3523
|
+
function isTaggableJsxName(name) {
|
|
3524
|
+
if (!t$1.isJSXIdentifier(name)) return false;
|
|
3525
|
+
return /^[a-z]/.test(name.name) || FORWARDING_COMPONENTS.has(name.name);
|
|
3526
|
+
}
|
|
3527
|
+
function alreadyTagged(opening) {
|
|
3528
|
+
return opening.attributes.some((attr) => t$1.isJSXAttribute(attr) && t$1.isJSXIdentifier(attr.name) && attr.name.name === "data-slide-loc");
|
|
3529
|
+
}
|
|
3530
|
+
function injectLocTags(code) {
|
|
3531
|
+
let ast;
|
|
3532
|
+
try {
|
|
3533
|
+
ast = parse(code, {
|
|
3534
|
+
sourceType: "module",
|
|
3535
|
+
plugins: ["typescript", "jsx"],
|
|
3536
|
+
errorRecovery: true
|
|
3537
|
+
});
|
|
3538
|
+
} catch {
|
|
3539
|
+
return null;
|
|
3540
|
+
}
|
|
3541
|
+
const insertions = [];
|
|
3542
|
+
walkJsx(ast, (node) => {
|
|
3543
|
+
if (!t$1.isJSXElement(node) || !node.loc) return;
|
|
3544
|
+
const opening = node.openingElement;
|
|
3545
|
+
const name = opening.name;
|
|
3546
|
+
if (!isTaggableJsxName(name) || alreadyTagged(opening)) return;
|
|
3547
|
+
insertions.push({
|
|
3548
|
+
offset: name.end ?? 0,
|
|
3549
|
+
text: ` data-slide-loc="${node.loc.start.line}:${node.loc.start.column}"`
|
|
3550
|
+
});
|
|
3551
|
+
});
|
|
3552
|
+
if (insertions.length === 0) return null;
|
|
3553
|
+
insertions.sort((a, b) => b.offset - a.offset);
|
|
3554
|
+
let next = code;
|
|
3555
|
+
for (const ins of insertions) next = next.slice(0, ins.offset) + ins.text + next.slice(ins.offset);
|
|
3556
|
+
return next;
|
|
3557
|
+
}
|
|
3558
|
+
function isSlideSourceFile(id, slidesRootPosix) {
|
|
3559
|
+
const filePath = id.split(/[?#]/)[0].replace(/\\/g, "/");
|
|
3560
|
+
if (!filePath.startsWith(`${slidesRootPosix}/`)) return false;
|
|
3561
|
+
if (!filePath.endsWith(".tsx")) return false;
|
|
3562
|
+
if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.tsx")) return false;
|
|
3563
|
+
const rel = filePath.slice(slidesRootPosix.length + 1);
|
|
3564
|
+
return rel.includes("/");
|
|
3565
|
+
}
|
|
3566
|
+
function locTagsPlugin(opts) {
|
|
3567
|
+
const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides").replace(/\\/g, "/");
|
|
3568
|
+
return {
|
|
3569
|
+
name: "open-aippt:loc-tags",
|
|
3570
|
+
apply: "serve",
|
|
3571
|
+
enforce: "pre",
|
|
3572
|
+
transform(code, id) {
|
|
3573
|
+
if (!isSlideSourceFile(id, slidesRoot)) return null;
|
|
3574
|
+
const next = injectLocTags(code);
|
|
3575
|
+
if (next === null) return null;
|
|
3576
|
+
return {
|
|
3577
|
+
code: next,
|
|
3578
|
+
map: null
|
|
3579
|
+
};
|
|
3580
|
+
}
|
|
3581
|
+
};
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
//#endregion
|
|
3585
|
+
//#region src/vite/notes-plugin.ts
|
|
3586
|
+
function parseSource(source) {
|
|
3587
|
+
try {
|
|
3588
|
+
return parse(source, {
|
|
3589
|
+
sourceType: "module",
|
|
3590
|
+
plugins: ["typescript", "jsx"],
|
|
3591
|
+
errorRecovery: true
|
|
3592
|
+
});
|
|
3593
|
+
} catch {
|
|
3594
|
+
return null;
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
function findNotesExport(ast) {
|
|
3598
|
+
for (const stmt of ast.program.body) {
|
|
3599
|
+
if (!t.isExportNamedDeclaration(stmt)) continue;
|
|
3600
|
+
const decl = stmt.declaration;
|
|
3601
|
+
if (!decl || !t.isVariableDeclaration(decl)) continue;
|
|
3602
|
+
for (const d of decl.declarations) {
|
|
3603
|
+
if (!t.isVariableDeclarator(d)) continue;
|
|
3604
|
+
if (!t.isIdentifier(d.id) || d.id.name !== "notes") continue;
|
|
3605
|
+
if (!d.init) return { error: "`notes` export has no initializer" };
|
|
3606
|
+
if (!t.isArrayExpression(d.init)) return { error: "`notes` export is not an array literal" };
|
|
3607
|
+
const arr = d.init;
|
|
3608
|
+
if (typeof stmt.start !== "number" || typeof stmt.end !== "number") return { error: "`notes` export missing source range" };
|
|
3609
|
+
if (typeof arr.start !== "number" || typeof arr.end !== "number") return { error: "`notes` array missing source range" };
|
|
3610
|
+
return {
|
|
3611
|
+
declStart: stmt.start,
|
|
3612
|
+
declEnd: stmt.end,
|
|
3613
|
+
arrayStart: arr.start,
|
|
3614
|
+
arrayEnd: arr.end,
|
|
3615
|
+
elements: arr.elements
|
|
3616
|
+
};
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
return null;
|
|
3620
|
+
}
|
|
3621
|
+
function renderNoteLiteral(text) {
|
|
3622
|
+
if (text === "") return "undefined";
|
|
3623
|
+
const hasNewline = /\n/.test(text);
|
|
3624
|
+
if (hasNewline) {
|
|
3625
|
+
const escaped = text.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
3626
|
+
return `\`${escaped}\``;
|
|
3627
|
+
}
|
|
3628
|
+
return JSON.stringify(text);
|
|
3629
|
+
}
|
|
3630
|
+
function findInsertionOffset(ast, source) {
|
|
3631
|
+
let lastImportEnd = -1;
|
|
3632
|
+
for (const stmt of ast.program.body) if (t.isImportDeclaration(stmt) && typeof stmt.end === "number") lastImportEnd = Math.max(lastImportEnd, stmt.end);
|
|
3633
|
+
if (lastImportEnd >= 0) return lastImportEnd;
|
|
3634
|
+
return source.length;
|
|
3635
|
+
}
|
|
3636
|
+
function applyNotesEdit(source, index, text) {
|
|
3637
|
+
if (!Number.isInteger(index) || index < 0) return {
|
|
3638
|
+
ok: false,
|
|
3639
|
+
status: 400,
|
|
3640
|
+
error: "invalid index"
|
|
3641
|
+
};
|
|
3642
|
+
const ast = parseSource(source);
|
|
3643
|
+
if (!ast) return {
|
|
3644
|
+
ok: false,
|
|
3645
|
+
status: 422,
|
|
3646
|
+
error: "could not parse source"
|
|
3647
|
+
};
|
|
3648
|
+
const found = findNotesExport(ast);
|
|
3649
|
+
if (found && "error" in found) return {
|
|
3650
|
+
ok: false,
|
|
3651
|
+
status: 422,
|
|
3652
|
+
error: found.error
|
|
3653
|
+
};
|
|
3654
|
+
const literal = renderNoteLiteral(text);
|
|
3655
|
+
if (!found) {
|
|
3656
|
+
if (text === "") return {
|
|
3657
|
+
ok: true,
|
|
3658
|
+
source
|
|
3659
|
+
};
|
|
3660
|
+
const padding = Array.from({ length: index }, () => "undefined");
|
|
3661
|
+
const items = [...padding, literal];
|
|
3662
|
+
const block = [
|
|
3663
|
+
"",
|
|
3664
|
+
"",
|
|
3665
|
+
"export const notes: (string | undefined)[] = [",
|
|
3666
|
+
...items.map((s) => ` ${s},`),
|
|
3667
|
+
"];",
|
|
3668
|
+
""
|
|
3669
|
+
].join("\n");
|
|
3670
|
+
const offset = findInsertionOffset(ast, source);
|
|
3671
|
+
const next$1 = source.slice(0, offset) + block + source.slice(offset);
|
|
3672
|
+
return {
|
|
3673
|
+
ok: true,
|
|
3674
|
+
source: next$1
|
|
3675
|
+
};
|
|
3676
|
+
}
|
|
3677
|
+
const elementTexts = [];
|
|
3678
|
+
for (const el of found.elements) {
|
|
3679
|
+
if (el === null) {
|
|
3680
|
+
elementTexts.push("undefined");
|
|
3681
|
+
continue;
|
|
3682
|
+
}
|
|
3683
|
+
if (typeof el.start !== "number" || typeof el.end !== "number") return {
|
|
3684
|
+
ok: false,
|
|
3685
|
+
status: 422,
|
|
3686
|
+
error: "`notes` element missing source range"
|
|
3687
|
+
};
|
|
3688
|
+
elementTexts.push(source.slice(el.start, el.end));
|
|
3689
|
+
}
|
|
3690
|
+
while (elementTexts.length <= index) elementTexts.push("undefined");
|
|
3691
|
+
elementTexts[index] = literal;
|
|
3692
|
+
while (elementTexts.length > 0 && elementTexts[elementTexts.length - 1] === "undefined") elementTexts.pop();
|
|
3693
|
+
const replacement = elementTexts.length === 0 ? "[]" : `[\n${elementTexts.map((s) => ` ${s},`).join("\n")}\n]`;
|
|
3694
|
+
const next = source.slice(0, found.arrayStart) + replacement + source.slice(found.arrayEnd);
|
|
3695
|
+
return {
|
|
3696
|
+
ok: true,
|
|
3697
|
+
source: next
|
|
3698
|
+
};
|
|
3699
|
+
}
|
|
3700
|
+
function notesPlugin(opts) {
|
|
3701
|
+
const userCwd = opts.userCwd;
|
|
3702
|
+
const slidesDir = opts.slidesDir ?? "slides";
|
|
3703
|
+
const recentWrites = new Map();
|
|
3704
|
+
const RECENT_WRITE_WINDOW_MS = 1500;
|
|
3705
|
+
return {
|
|
3706
|
+
name: "open-aippt:notes",
|
|
3707
|
+
apply: "serve",
|
|
3708
|
+
handleHotUpdate(ctx) {
|
|
3709
|
+
const ts = recentWrites.get(ctx.file);
|
|
3710
|
+
if (ts != null && Date.now() - ts < RECENT_WRITE_WINDOW_MS) {
|
|
3711
|
+
recentWrites.delete(ctx.file);
|
|
3712
|
+
return [];
|
|
3713
|
+
}
|
|
3714
|
+
return void 0;
|
|
3715
|
+
},
|
|
3716
|
+
configureServer(server) {
|
|
3717
|
+
server.middlewares.use("/__notes", async (req, res, next) => {
|
|
3718
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
3719
|
+
const method = req.method ?? "GET";
|
|
3720
|
+
if (method !== "PUT" || url.pathname !== "/") return next();
|
|
3721
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
3722
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
3723
|
+
try {
|
|
3724
|
+
const body = await readBody(req);
|
|
3725
|
+
const slideId = body.slideId ?? "";
|
|
3726
|
+
const file = resolveSlidePath(userCwd, slidesDir, slideId);
|
|
3727
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
3728
|
+
if (typeof body.index !== "number") return json(res, 400, { error: "missing index" });
|
|
3729
|
+
if (typeof body.text !== "string") return json(res, 400, { error: "missing text" });
|
|
3730
|
+
let source;
|
|
3731
|
+
try {
|
|
3732
|
+
source = await fs.readFile(file, "utf8");
|
|
3733
|
+
} catch {
|
|
3734
|
+
return json(res, 404, { error: "slide not found" });
|
|
3735
|
+
}
|
|
3736
|
+
const result = applyNotesEdit(source, body.index, body.text);
|
|
3737
|
+
if (!result.ok) return json(res, result.status, { error: result.error });
|
|
3738
|
+
const changed = result.source !== source;
|
|
3739
|
+
if (changed) {
|
|
3740
|
+
recentWrites.set(file, Date.now());
|
|
3741
|
+
await fs.writeFile(file, result.source, "utf8");
|
|
3742
|
+
}
|
|
3743
|
+
return json(res, 200, {
|
|
3744
|
+
ok: true,
|
|
3745
|
+
changed
|
|
3746
|
+
});
|
|
3747
|
+
} catch (err) {
|
|
3748
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
3749
|
+
}
|
|
3750
|
+
});
|
|
3751
|
+
}
|
|
3752
|
+
};
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
//#endregion
|
|
3756
|
+
//#region src/vite/open-aippt-plugin.ts
|
|
3757
|
+
const CONFIG_FILE = "open-aippt.config.ts";
|
|
3758
|
+
const SLIDES_VMOD = "virtual:open-aippt/slides";
|
|
3759
|
+
const CONFIG_VMOD = "virtual:open-aippt/config";
|
|
3760
|
+
const FOLDERS_VMOD = "virtual:open-aippt/folders";
|
|
3761
|
+
async function readFoldersManifest(file) {
|
|
3762
|
+
try {
|
|
3763
|
+
const raw = await fs.readFile(file, "utf8");
|
|
3764
|
+
const parsed = JSON.parse(raw);
|
|
3765
|
+
return {
|
|
3766
|
+
folders: Array.isArray(parsed.folders) ? parsed.folders : [],
|
|
3767
|
+
assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
|
|
3768
|
+
};
|
|
3769
|
+
} catch (err) {
|
|
3770
|
+
if (err.code === "ENOENT") return {
|
|
3771
|
+
folders: [],
|
|
3772
|
+
assignments: {}
|
|
3773
|
+
};
|
|
3774
|
+
throw err;
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
function resolved$1(id) {
|
|
3778
|
+
return `\0${id}`;
|
|
3779
|
+
}
|
|
3780
|
+
async function findSlides(userCwd, slidesDir) {
|
|
3781
|
+
const abs = path.resolve(userCwd, slidesDir);
|
|
3782
|
+
if (!existsSync(abs)) return [];
|
|
3783
|
+
const hits = await fg("*/index.{tsx,jsx,ts,js}", {
|
|
3784
|
+
cwd: abs,
|
|
3785
|
+
absolute: true,
|
|
3786
|
+
onlyFiles: true
|
|
3787
|
+
});
|
|
3788
|
+
return hits.sort();
|
|
3789
|
+
}
|
|
3790
|
+
function toId(absFile, slidesRoot) {
|
|
3791
|
+
const rel = path.relative(slidesRoot, absFile);
|
|
3792
|
+
return rel.split(path.sep)[0];
|
|
3793
|
+
}
|
|
3794
|
+
const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
|
|
3795
|
+
const META_CREATED_AT_RE = /(?:^|[\s,{])createdAt\s*:\s*['"]([^'"]+)['"]/;
|
|
3796
|
+
function extractMeta(src) {
|
|
3797
|
+
const empty = {
|
|
3798
|
+
theme: null,
|
|
3799
|
+
createdAt: null
|
|
3800
|
+
};
|
|
3801
|
+
const metaStart = src.search(/export\s+const\s+meta\b/);
|
|
3802
|
+
if (metaStart === -1) return empty;
|
|
3803
|
+
const eqIdx = src.indexOf("=", metaStart);
|
|
3804
|
+
if (eqIdx === -1) return empty;
|
|
3805
|
+
const openBrace = src.indexOf("{", eqIdx);
|
|
3806
|
+
if (openBrace === -1) return empty;
|
|
3807
|
+
let depth = 0;
|
|
3808
|
+
let closeBrace = -1;
|
|
3809
|
+
for (let i = openBrace; i < src.length; i++) {
|
|
3810
|
+
const ch = src[i];
|
|
3811
|
+
if (ch === "{") depth++;
|
|
3812
|
+
else if (ch === "}") {
|
|
3813
|
+
depth--;
|
|
3814
|
+
if (depth === 0) {
|
|
3815
|
+
closeBrace = i;
|
|
3816
|
+
break;
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
if (closeBrace === -1) return empty;
|
|
3821
|
+
const body = src.slice(openBrace + 1, closeBrace);
|
|
3822
|
+
const themeMatch = body.match(META_THEME_RE);
|
|
3823
|
+
const createdAtMatch = body.match(META_CREATED_AT_RE);
|
|
3824
|
+
return {
|
|
3825
|
+
theme: themeMatch ? themeMatch[1] : null,
|
|
3826
|
+
createdAt: createdAtMatch ? createdAtMatch[1] : null
|
|
3827
|
+
};
|
|
3828
|
+
}
|
|
3829
|
+
async function readSlideMeta(abs) {
|
|
3830
|
+
try {
|
|
3831
|
+
const src = await fs.readFile(abs, "utf8");
|
|
3832
|
+
return extractMeta(src);
|
|
3833
|
+
} catch {
|
|
3834
|
+
return {
|
|
3835
|
+
theme: null,
|
|
3836
|
+
createdAt: null
|
|
3837
|
+
};
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
function parseCreatedAtMs(iso) {
|
|
3841
|
+
if (!iso) return null;
|
|
3842
|
+
const ms = Date.parse(iso);
|
|
3843
|
+
return Number.isFinite(ms) ? ms : null;
|
|
3844
|
+
}
|
|
3845
|
+
async function generateSlidesModule(files, slidesRoot, isDev) {
|
|
3846
|
+
const entries = await Promise.all(files.map(async (abs) => {
|
|
3847
|
+
const id = toId(abs, slidesRoot);
|
|
3848
|
+
const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
|
|
3849
|
+
const meta = await readSlideMeta(abs);
|
|
3850
|
+
return {
|
|
3851
|
+
id,
|
|
3852
|
+
importPath,
|
|
3853
|
+
theme: meta.theme,
|
|
3854
|
+
createdAt: parseCreatedAtMs(meta.createdAt)
|
|
3855
|
+
};
|
|
3856
|
+
}));
|
|
3857
|
+
const ids = JSON.stringify(entries.map((e) => e.id).sort());
|
|
3858
|
+
const themesMap = {};
|
|
3859
|
+
const createdAtMap = {};
|
|
3860
|
+
for (const e of entries) {
|
|
3861
|
+
if (e.theme) themesMap[e.id] = e.theme;
|
|
3862
|
+
if (e.createdAt !== null) createdAtMap[e.id] = e.createdAt;
|
|
3863
|
+
}
|
|
3864
|
+
const themesJson = JSON.stringify(themesMap);
|
|
3865
|
+
const createdAtJson = JSON.stringify(createdAtMap);
|
|
3866
|
+
const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
|
|
3867
|
+
const devRuntime = isDev ? `
|
|
3868
|
+
const slideImportTokens = ${importTokens};
|
|
3869
|
+
if (import.meta.hot) {
|
|
3870
|
+
import.meta.hot.on('open-aippt:slide-changed', (data) => {
|
|
3871
|
+
const ids = Array.isArray(data?.slideIds) ? data.slideIds : data?.slideId ? [data.slideId] : [];
|
|
3872
|
+
const token = Date.now();
|
|
3873
|
+
for (const id of ids) {
|
|
3874
|
+
if (Object.prototype.hasOwnProperty.call(slideImportTokens, id)) slideImportTokens[id] = token;
|
|
3875
|
+
}
|
|
3876
|
+
});
|
|
3877
|
+
}
|
|
3878
|
+
` : "";
|
|
3879
|
+
const cases = entries.map((e) => {
|
|
3880
|
+
const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
|
|
3881
|
+
return ` case ${JSON.stringify(e.id)}: return ${importExpr};`;
|
|
3882
|
+
}).join("\n");
|
|
3883
|
+
return `// virtual:open-aippt/slides — generated
|
|
3884
|
+
export const slideIds = ${ids};
|
|
3885
|
+
export const slideThemes = ${themesJson};
|
|
3886
|
+
export const slideCreatedAt = ${createdAtJson};
|
|
3887
|
+
${devRuntime}
|
|
3888
|
+
|
|
3889
|
+
export async function loadSlide(id) {
|
|
3890
|
+
switch (id) {
|
|
3891
|
+
${cases}
|
|
3892
|
+
default: throw new Error('Slide not found: ' + id);
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
`;
|
|
3896
|
+
}
|
|
3897
|
+
function openAipptPlugin(opts) {
|
|
3898
|
+
const { userCwd, config, coreVersion } = opts;
|
|
3899
|
+
const slidesDir = config.slidesDir ?? "slides";
|
|
3900
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
3901
|
+
const foldersManifestPath = path.join(slidesRoot, ".folders.json");
|
|
3902
|
+
let isDev = false;
|
|
3903
|
+
const slideIdForEntry = (p) => {
|
|
3904
|
+
const rel = path.relative(slidesRoot, p);
|
|
3905
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
3906
|
+
const parts = rel.split(path.sep);
|
|
3907
|
+
if (parts.length !== 2) return null;
|
|
3908
|
+
if (!/^index\.(tsx|jsx|ts|js)$/.test(parts[1])) return null;
|
|
3909
|
+
return parts[0];
|
|
3910
|
+
};
|
|
3911
|
+
let slideChangeTimer = null;
|
|
3912
|
+
const pendingSlideChanges = new Set();
|
|
3913
|
+
const queueSlideChanged = (server, id) => {
|
|
3914
|
+
pendingSlideChanges.add(id);
|
|
3915
|
+
if (slideChangeTimer) clearTimeout(slideChangeTimer);
|
|
3916
|
+
slideChangeTimer = setTimeout(() => {
|
|
3917
|
+
slideChangeTimer = null;
|
|
3918
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
|
|
3919
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
3920
|
+
const slideIds = Array.from(pendingSlideChanges);
|
|
3921
|
+
pendingSlideChanges.clear();
|
|
3922
|
+
server.ws.send({
|
|
3923
|
+
type: "custom",
|
|
3924
|
+
event: "open-aippt:slide-changed",
|
|
3925
|
+
data: { slideIds }
|
|
3926
|
+
});
|
|
3927
|
+
}, 100);
|
|
3928
|
+
};
|
|
3929
|
+
return {
|
|
3930
|
+
name: "open-aippt",
|
|
3931
|
+
config(_c, env) {
|
|
3932
|
+
isDev = env.command === "serve";
|
|
3933
|
+
return { server: { fs: { allow: [userCwd] } } };
|
|
3934
|
+
},
|
|
3935
|
+
resolveId(id) {
|
|
3936
|
+
if (id === SLIDES_VMOD) return resolved$1(SLIDES_VMOD);
|
|
3937
|
+
if (id === CONFIG_VMOD) return resolved$1(CONFIG_VMOD);
|
|
3938
|
+
if (id === FOLDERS_VMOD) return resolved$1(FOLDERS_VMOD);
|
|
3939
|
+
return null;
|
|
3940
|
+
},
|
|
3941
|
+
async load(id) {
|
|
3942
|
+
if (id === resolved$1(SLIDES_VMOD)) {
|
|
3943
|
+
const files = await findSlides(userCwd, slidesDir);
|
|
3944
|
+
return await generateSlidesModule(files, slidesRoot, isDev);
|
|
3945
|
+
}
|
|
3946
|
+
if (id === resolved$1(CONFIG_VMOD)) {
|
|
3947
|
+
const userBuild = config.build ?? {};
|
|
3948
|
+
const buildResolved = isDev ? {
|
|
3949
|
+
showSlideBrowser: true,
|
|
3950
|
+
showSlideUi: true,
|
|
3951
|
+
allowHtmlDownload: true
|
|
3952
|
+
} : {
|
|
3953
|
+
showSlideBrowser: userBuild.showSlideBrowser ?? true,
|
|
3954
|
+
showSlideUi: userBuild.showSlideUi ?? true,
|
|
3955
|
+
allowHtmlDownload: userBuild.allowHtmlDownload ?? true
|
|
3956
|
+
};
|
|
3957
|
+
const resolvedConfig = {
|
|
3958
|
+
...config,
|
|
3959
|
+
build: buildResolved,
|
|
3960
|
+
version: coreVersion
|
|
3961
|
+
};
|
|
3962
|
+
return `export default ${JSON.stringify(resolvedConfig)};\n`;
|
|
3963
|
+
}
|
|
3964
|
+
if (id === resolved$1(FOLDERS_VMOD)) {
|
|
3965
|
+
const manifest = await readFoldersManifest(foldersManifestPath);
|
|
3966
|
+
return `export default ${JSON.stringify(manifest)};\n`;
|
|
3967
|
+
}
|
|
3968
|
+
return null;
|
|
3969
|
+
},
|
|
3970
|
+
handleHotUpdate(ctx) {
|
|
3971
|
+
const slideId = slideIdForEntry(ctx.file);
|
|
3972
|
+
if (!slideId) return;
|
|
3973
|
+
queueSlideChanged(ctx.server, slideId);
|
|
3974
|
+
return [];
|
|
3975
|
+
},
|
|
3976
|
+
configureServer(server) {
|
|
3977
|
+
const isSlideEntry = (p) => slideIdForEntry(p) !== null;
|
|
3978
|
+
let reloadTimer = null;
|
|
3979
|
+
const reload = () => {
|
|
3980
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
3981
|
+
reloadTimer = setTimeout(() => {
|
|
3982
|
+
reloadTimer = null;
|
|
3983
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
|
|
3984
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
3985
|
+
server.ws.send({ type: "full-reload" });
|
|
3986
|
+
}, 150);
|
|
3987
|
+
};
|
|
3988
|
+
if (existsSync(slidesRoot)) server.watcher.add(slidesRoot);
|
|
3989
|
+
server.watcher.on("add", (p) => {
|
|
3990
|
+
if (isSlideEntry(p)) reload();
|
|
3991
|
+
});
|
|
3992
|
+
server.watcher.on("unlink", (p) => {
|
|
3993
|
+
if (isSlideEntry(p)) reload();
|
|
3994
|
+
});
|
|
3995
|
+
let foldersTimer = null;
|
|
3996
|
+
const invalidateFolders = () => {
|
|
3997
|
+
if (foldersTimer) clearTimeout(foldersTimer);
|
|
3998
|
+
foldersTimer = setTimeout(() => {
|
|
3999
|
+
foldersTimer = null;
|
|
4000
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(FOLDERS_VMOD));
|
|
4001
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
4002
|
+
}, 100);
|
|
4003
|
+
};
|
|
4004
|
+
server.watcher.add(foldersManifestPath);
|
|
4005
|
+
server.watcher.on("change", (p) => {
|
|
4006
|
+
if (p === foldersManifestPath) invalidateFolders();
|
|
4007
|
+
});
|
|
4008
|
+
server.watcher.on("add", (p) => {
|
|
4009
|
+
if (p === foldersManifestPath) invalidateFolders();
|
|
4010
|
+
});
|
|
4011
|
+
server.watcher.on("unlink", (p) => {
|
|
4012
|
+
if (p === foldersManifestPath) invalidateFolders();
|
|
4013
|
+
});
|
|
4014
|
+
}
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
4017
|
+
async function loadUserConfig(userCwd) {
|
|
4018
|
+
const file = path.join(userCwd, CONFIG_FILE);
|
|
4019
|
+
if (!existsSync(file)) return {};
|
|
4020
|
+
const loaded = await loadConfigFromFile({
|
|
4021
|
+
command: "serve",
|
|
4022
|
+
mode: "development"
|
|
4023
|
+
}, file, userCwd, "silent");
|
|
4024
|
+
return loaded?.config ?? {};
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
//#endregion
|
|
4028
|
+
//#region src/vite/themes-plugin.ts
|
|
4029
|
+
const THEMES_VMOD = "virtual:open-aippt/themes";
|
|
4030
|
+
function resolved(id) {
|
|
4031
|
+
return `\0${id}`;
|
|
4032
|
+
}
|
|
4033
|
+
const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
4034
|
+
function parseFrontmatter(raw, themeId) {
|
|
4035
|
+
const match = raw.match(FM_RE);
|
|
4036
|
+
const fmText = match ? match[1] : "";
|
|
4037
|
+
const body = match ? match[2] : raw;
|
|
4038
|
+
const data = {};
|
|
4039
|
+
for (const line of fmText.split(/\r?\n/)) {
|
|
4040
|
+
const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
|
|
4041
|
+
if (!m) continue;
|
|
4042
|
+
let value = m[2].trim();
|
|
4043
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
4044
|
+
data[m[1]] = value;
|
|
4045
|
+
}
|
|
4046
|
+
return {
|
|
4047
|
+
fm: {
|
|
4048
|
+
name: data.name || themeId,
|
|
4049
|
+
description: data.description || ""
|
|
4050
|
+
},
|
|
4051
|
+
body: body.trim()
|
|
4052
|
+
};
|
|
4053
|
+
}
|
|
4054
|
+
async function findThemes(userCwd, themesDir) {
|
|
4055
|
+
const abs = path.resolve(userCwd, themesDir);
|
|
4056
|
+
if (!existsSync(abs)) return [];
|
|
4057
|
+
const hits = await fg("*.md", {
|
|
4058
|
+
cwd: abs,
|
|
4059
|
+
absolute: true,
|
|
4060
|
+
onlyFiles: true
|
|
4061
|
+
});
|
|
4062
|
+
return hits.sort();
|
|
4063
|
+
}
|
|
4064
|
+
async function readTheme(mdAbs, themesRoot) {
|
|
4065
|
+
const id = path.basename(mdAbs, ".md");
|
|
4066
|
+
const raw = await fs.readFile(mdAbs, "utf8");
|
|
4067
|
+
const { fm, body } = parseFrontmatter(raw, id);
|
|
4068
|
+
const demoCandidates = [
|
|
4069
|
+
`${id}.demo.tsx`,
|
|
4070
|
+
`${id}.demo.jsx`,
|
|
4071
|
+
`${id}.demo.ts`,
|
|
4072
|
+
`${id}.demo.js`
|
|
4073
|
+
];
|
|
4074
|
+
let demoAbs = null;
|
|
4075
|
+
for (const cand of demoCandidates) {
|
|
4076
|
+
const p = path.join(themesRoot, cand);
|
|
4077
|
+
if (existsSync(p)) {
|
|
4078
|
+
demoAbs = p;
|
|
4079
|
+
break;
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
return {
|
|
4083
|
+
id,
|
|
4084
|
+
frontmatter: fm,
|
|
4085
|
+
body,
|
|
4086
|
+
demoAbs
|
|
4087
|
+
};
|
|
4088
|
+
}
|
|
4089
|
+
function generateThemesModule(themes, isDev) {
|
|
4090
|
+
const meta = themes.map((t$5) => ({
|
|
4091
|
+
id: t$5.id,
|
|
4092
|
+
name: t$5.frontmatter.name,
|
|
4093
|
+
description: t$5.frontmatter.description,
|
|
4094
|
+
body: t$5.body,
|
|
4095
|
+
hasDemo: t$5.demoAbs !== null
|
|
4096
|
+
}));
|
|
4097
|
+
const cases = themes.flatMap((t$5) => {
|
|
4098
|
+
const abs = t$5.demoAbs;
|
|
4099
|
+
if (!abs) return [];
|
|
4100
|
+
const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
|
|
4101
|
+
const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(importPath)})` : `import(${JSON.stringify(importPath)})`;
|
|
4102
|
+
return [` case ${JSON.stringify(t$5.id)}: return ${importExpr};`];
|
|
4103
|
+
}).join("\n");
|
|
4104
|
+
return `// virtual:open-aippt/themes — generated
|
|
4105
|
+
export const themes = ${JSON.stringify(meta)};
|
|
4106
|
+
|
|
4107
|
+
export async function loadThemeDemo(id) {
|
|
4108
|
+
switch (id) {
|
|
4109
|
+
${cases}
|
|
4110
|
+
default: throw new Error('Theme demo not found: ' + id);
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
`;
|
|
4114
|
+
}
|
|
4115
|
+
function themesPlugin(opts) {
|
|
4116
|
+
const { userCwd, config } = opts;
|
|
4117
|
+
const themesDir = config.themesDir ?? "themes";
|
|
4118
|
+
const themesRoot = path.resolve(userCwd, themesDir);
|
|
4119
|
+
let isDev = false;
|
|
4120
|
+
return {
|
|
4121
|
+
name: "open-aippt:themes",
|
|
4122
|
+
config(_c, env) {
|
|
4123
|
+
isDev = env.command === "serve";
|
|
4124
|
+
},
|
|
4125
|
+
resolveId(id) {
|
|
4126
|
+
if (id === THEMES_VMOD) return resolved(THEMES_VMOD);
|
|
4127
|
+
return null;
|
|
4128
|
+
},
|
|
4129
|
+
async load(id) {
|
|
4130
|
+
if (id !== resolved(THEMES_VMOD)) return null;
|
|
4131
|
+
const files = await findThemes(userCwd, themesDir);
|
|
4132
|
+
const themes = await Promise.all(files.map((f) => readTheme(f, themesRoot)));
|
|
4133
|
+
return generateThemesModule(themes, isDev);
|
|
4134
|
+
},
|
|
4135
|
+
configureServer(server) {
|
|
4136
|
+
const isThemeFile = (p) => {
|
|
4137
|
+
const rel = path.relative(themesRoot, p);
|
|
4138
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
|
4139
|
+
if (rel.includes(path.sep)) return false;
|
|
4140
|
+
return /\.(md|demo\.(tsx|jsx|ts|js))$/.test(rel);
|
|
4141
|
+
};
|
|
4142
|
+
let reloadTimer = null;
|
|
4143
|
+
const reload = () => {
|
|
4144
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
4145
|
+
reloadTimer = setTimeout(() => {
|
|
4146
|
+
reloadTimer = null;
|
|
4147
|
+
const mod = server.moduleGraph.getModuleById(resolved(THEMES_VMOD));
|
|
4148
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
4149
|
+
server.ws.send({ type: "full-reload" });
|
|
4150
|
+
}, 150);
|
|
4151
|
+
};
|
|
4152
|
+
if (existsSync(themesRoot)) server.watcher.add(themesRoot);
|
|
4153
|
+
server.watcher.on("add", (p) => {
|
|
4154
|
+
if (isThemeFile(p)) reload();
|
|
4155
|
+
});
|
|
4156
|
+
server.watcher.on("unlink", (p) => {
|
|
4157
|
+
if (isThemeFile(p)) reload();
|
|
4158
|
+
});
|
|
4159
|
+
server.watcher.on("change", (p) => {
|
|
4160
|
+
if (isThemeFile(p)) reload();
|
|
4161
|
+
});
|
|
4162
|
+
}
|
|
4163
|
+
};
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
//#endregion
|
|
4167
|
+
//#region src/vite/config.ts
|
|
4168
|
+
function findPackageRoot(fromFile) {
|
|
4169
|
+
let dir = path.dirname(fromFile);
|
|
4170
|
+
while (dir !== path.dirname(dir)) {
|
|
4171
|
+
if (existsSync(path.join(dir, "package.json"))) return dir;
|
|
4172
|
+
dir = path.dirname(dir);
|
|
4173
|
+
}
|
|
4174
|
+
throw new Error(`Could not find package.json walking up from ${fromFile}`);
|
|
4175
|
+
}
|
|
4176
|
+
const PKG_ROOT = findPackageRoot(fileURLToPath(import.meta.url));
|
|
4177
|
+
const APP_ROOT = path.join(PKG_ROOT, "src", "app");
|
|
4178
|
+
function readCoreVersion() {
|
|
4179
|
+
try {
|
|
4180
|
+
const raw = readFileSync(path.join(PKG_ROOT, "package.json"), "utf8");
|
|
4181
|
+
return JSON.parse(raw).version ?? "0.0.0";
|
|
4182
|
+
} catch {
|
|
4183
|
+
return "0.0.0";
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
const CORE_VERSION = readCoreVersion();
|
|
4187
|
+
async function createViteConfig(opts) {
|
|
4188
|
+
const userCwd = path.resolve(opts.userCwd);
|
|
4189
|
+
const config = opts.config ?? await loadUserConfig(userCwd);
|
|
4190
|
+
const slidesDir = config.slidesDir ?? "slides";
|
|
4191
|
+
const themesDir = config.themesDir ?? "themes";
|
|
4192
|
+
const assetsDir = config.assetsDir ?? "assets";
|
|
4193
|
+
const slidesAbs = path.resolve(userCwd, slidesDir);
|
|
4194
|
+
const themesAbs = path.resolve(userCwd, themesDir);
|
|
4195
|
+
const assetsAbs = path.resolve(userCwd, assetsDir);
|
|
4196
|
+
return {
|
|
4197
|
+
base: config.base ?? "/",
|
|
4198
|
+
root: APP_ROOT,
|
|
4199
|
+
configFile: false,
|
|
4200
|
+
envDir: userCwd,
|
|
4201
|
+
plugins: [
|
|
4202
|
+
locTagsPlugin({
|
|
4203
|
+
userCwd,
|
|
4204
|
+
slidesDir
|
|
4205
|
+
}),
|
|
4206
|
+
react(),
|
|
4207
|
+
tailwindcss(),
|
|
4208
|
+
openAipptPlugin({
|
|
4209
|
+
userCwd,
|
|
4210
|
+
config,
|
|
4211
|
+
coreVersion: CORE_VERSION
|
|
4212
|
+
}),
|
|
4213
|
+
themesPlugin({
|
|
4214
|
+
userCwd,
|
|
4215
|
+
config
|
|
4216
|
+
}),
|
|
4217
|
+
designPlugin({ userCwd }),
|
|
4218
|
+
apiPlugin({
|
|
4219
|
+
userCwd,
|
|
4220
|
+
slidesDir,
|
|
4221
|
+
assetsDir,
|
|
4222
|
+
coreVersion: CORE_VERSION
|
|
4223
|
+
}),
|
|
4224
|
+
notesPlugin({
|
|
4225
|
+
userCwd,
|
|
4226
|
+
slidesDir
|
|
4227
|
+
}),
|
|
4228
|
+
currentPlugin({
|
|
4229
|
+
userCwd,
|
|
4230
|
+
slidesDir
|
|
4231
|
+
})
|
|
4232
|
+
],
|
|
4233
|
+
resolve: { alias: {
|
|
4234
|
+
"@": APP_ROOT,
|
|
4235
|
+
"@assets": assetsAbs
|
|
4236
|
+
} },
|
|
4237
|
+
optimizeDeps: {
|
|
4238
|
+
entries: [path.join(APP_ROOT, "main.tsx")],
|
|
4239
|
+
include: [
|
|
4240
|
+
"react",
|
|
4241
|
+
"react-dom",
|
|
4242
|
+
"react-dom/client",
|
|
4243
|
+
"next-themes",
|
|
4244
|
+
"react-router-dom",
|
|
4245
|
+
"radix-ui",
|
|
4246
|
+
"lucide-react",
|
|
4247
|
+
"clsx",
|
|
4248
|
+
"tailwind-merge",
|
|
4249
|
+
"class-variance-authority",
|
|
4250
|
+
"emoji-picker-react"
|
|
4251
|
+
],
|
|
4252
|
+
esbuildOptions: { plugins: [{
|
|
4253
|
+
name: "open-aippt:virtual-externals",
|
|
4254
|
+
setup(build$1) {
|
|
4255
|
+
build$1.onResolve({ filter: /^virtual:open-aippt\// }, (args) => ({
|
|
4256
|
+
path: args.path,
|
|
4257
|
+
external: true
|
|
4258
|
+
}));
|
|
4259
|
+
}
|
|
4260
|
+
}] }
|
|
4261
|
+
},
|
|
4262
|
+
server: {
|
|
4263
|
+
port: config.port ?? 5173,
|
|
4264
|
+
fs: { allow: [
|
|
4265
|
+
APP_ROOT,
|
|
4266
|
+
userCwd,
|
|
4267
|
+
slidesAbs,
|
|
4268
|
+
themesAbs,
|
|
4269
|
+
assetsAbs
|
|
4270
|
+
] }
|
|
4271
|
+
},
|
|
4272
|
+
build: {
|
|
4273
|
+
outDir: path.resolve(userCwd, "dist"),
|
|
4274
|
+
emptyOutDir: true
|
|
4275
|
+
}
|
|
4276
|
+
};
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
//#endregion
|
|
4280
|
+
export { createViteConfig };
|