@open-slide/core 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-CXY2DSzy.js → build-DHiRlpjn.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-BYTf0qVz.js → config-LZM903FE.js} +742 -44
- package/dist/{dev-BxCKugi3.js → dev-B3JzCYn7.js} +1 -1
- package/dist/{preview-C1F-rHfx.js → preview-UikovHEt.js} +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +3 -1
- package/src/app/App.tsx +2 -0
- package/src/app/components/AssetView.tsx +846 -0
- package/src/app/components/ClickNavZones.tsx +2 -2
- package/src/app/components/PdfProgressToast.tsx +23 -0
- package/src/app/components/ThumbnailRail.tsx +2 -2
- package/src/app/components/inspector/CommentWidget.tsx +1 -1
- package/src/app/components/inspector/InspectOverlay.tsx +81 -41
- package/src/app/components/inspector/InspectorPanel.tsx +948 -0
- package/src/app/components/inspector/InspectorProvider.tsx +229 -13
- package/src/app/components/inspector/SaveBar.tsx +77 -0
- package/src/app/components/ui/input.tsx +21 -0
- package/src/app/components/ui/label.tsx +24 -0
- package/src/app/components/ui/progress.tsx +31 -0
- package/src/app/components/ui/select.tsx +190 -0
- package/src/app/components/ui/slider.tsx +61 -0
- package/src/app/components/ui/sonner.tsx +38 -0
- package/src/app/components/ui/textarea.tsx +18 -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 +55 -0
- package/src/app/lib/assets.ts +166 -0
- package/src/app/lib/export-pdf.ts +194 -0
- package/src/app/lib/inspector/fiber.ts +40 -5
- package/src/app/lib/inspector/useEditor.ts +62 -0
- package/src/app/lib/print-ready.ts +58 -0
- package/src/app/routes/Slide.tsx +140 -51
- package/src/app/components/inspector/CommentPopover.tsx +0 -94
|
@@ -9,6 +9,42 @@ import { parse } from "@babel/parser";
|
|
|
9
9
|
import fg from "fast-glob";
|
|
10
10
|
import { loadConfigFromFile } from "vite";
|
|
11
11
|
|
|
12
|
+
//#region src/vite/babel-walk.ts
|
|
13
|
+
const SKIP_KEYS = new Set([
|
|
14
|
+
"loc",
|
|
15
|
+
"start",
|
|
16
|
+
"end",
|
|
17
|
+
"type",
|
|
18
|
+
"extra",
|
|
19
|
+
"leadingComments",
|
|
20
|
+
"trailingComments",
|
|
21
|
+
"innerComments"
|
|
22
|
+
]);
|
|
23
|
+
function walkJsx(ast, visit) {
|
|
24
|
+
let stopped = false;
|
|
25
|
+
const walk = (node) => {
|
|
26
|
+
if (stopped || !node || typeof node !== "object") return;
|
|
27
|
+
if (Array.isArray(node)) {
|
|
28
|
+
for (const c of node) walk(c);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const n = node;
|
|
32
|
+
if (typeof n.type !== "string") return;
|
|
33
|
+
if (n.type === "JSXElement" || n.type === "JSXFragment") {
|
|
34
|
+
if (visit(n) === "stop") {
|
|
35
|
+
stopped = true;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const key of Object.keys(n)) {
|
|
40
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
41
|
+
walk(n[key]);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
walk(ast);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
12
48
|
//#region src/vite/comments-plugin.ts
|
|
13
49
|
const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
|
|
14
50
|
const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
|
|
@@ -86,42 +122,19 @@ function lineIndent(source, lineNumber) {
|
|
|
86
122
|
const m = source.slice(start, start + 200).match(/^[ \t]*/);
|
|
87
123
|
return m?.[0] ?? "";
|
|
88
124
|
}
|
|
89
|
-
/**
|
|
90
|
-
* Walk the AST, collect every JSXElement/JSXFragment whose location encloses
|
|
91
|
-
* the click point, ordered innermost-first.
|
|
92
|
-
*
|
|
93
|
-
* "Encloses" here is inclusive at the start (so a click on the opening `<`
|
|
94
|
-
* counts as inside) and exclusive at the end. We deliberately don't trust
|
|
95
|
-
* Babel's `_debugSource` line/column to be exact — HMR or upstream transforms
|
|
96
|
-
* can shift it slightly — so we treat the click as a probe and pick the
|
|
97
|
-
* tightest JSX container around it.
|
|
98
|
-
*/
|
|
99
125
|
function findJsxAncestors(ast, line, column) {
|
|
100
126
|
const hits = [];
|
|
101
|
-
|
|
102
|
-
if (!
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const afterStart = line > s.line || line === s.line && column >= s.column;
|
|
113
|
-
const beforeEnd = line < e.line || line === e.line && column < e.column;
|
|
114
|
-
if (afterStart && beforeEnd) hits.push({
|
|
115
|
-
node: n,
|
|
116
|
-
size: n.end - n.start
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
for (const key of Object.keys(n)) {
|
|
120
|
-
if (key === "loc" || key === "start" || key === "end" || key === "type" || key === "extra" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
|
|
121
|
-
walk(n[key]);
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
walk(ast);
|
|
127
|
+
walkJsx(ast, (n) => {
|
|
128
|
+
if (!n.loc) return;
|
|
129
|
+
const s = n.loc.start;
|
|
130
|
+
const e = n.loc.end;
|
|
131
|
+
const afterStart = line > s.line || line === s.line && column >= s.column;
|
|
132
|
+
const beforeEnd = line < e.line || line === e.line && column < e.column;
|
|
133
|
+
if (afterStart && beforeEnd) hits.push({
|
|
134
|
+
node: n,
|
|
135
|
+
size: n.end - n.start
|
|
136
|
+
});
|
|
137
|
+
});
|
|
125
138
|
hits.sort((a, b) => a.size - b.size);
|
|
126
139
|
return hits.map((h) => h.node);
|
|
127
140
|
}
|
|
@@ -146,16 +159,6 @@ function planInsertion(source, target) {
|
|
|
146
159
|
}
|
|
147
160
|
return null;
|
|
148
161
|
}
|
|
149
|
-
/**
|
|
150
|
-
* Resolve a click on the slide page (line/col from React fiber's
|
|
151
|
-
* `_debugSource`) to an in-source offset where we can safely splice a
|
|
152
|
-
* `@slide-comment` marker.
|
|
153
|
-
*
|
|
154
|
-
* Strategy: parse the file, find every JSX container around the click, and
|
|
155
|
-
* walk innermost → outermost looking for the first one we can insert *inside*
|
|
156
|
-
* (i.e. not self-closing). Self-closing elements like `<img/>` get hoisted to
|
|
157
|
-
* their nearest non-self-closing ancestor.
|
|
158
|
-
*/
|
|
159
162
|
function findInsertion(source, line, column) {
|
|
160
163
|
let ast;
|
|
161
164
|
try {
|
|
@@ -181,6 +184,321 @@ function offsetToLine(source, offset) {
|
|
|
181
184
|
for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
|
|
182
185
|
return line;
|
|
183
186
|
}
|
|
187
|
+
function parseSource(source) {
|
|
188
|
+
try {
|
|
189
|
+
return parse(source, {
|
|
190
|
+
sourceType: "module",
|
|
191
|
+
plugins: ["typescript", "jsx"],
|
|
192
|
+
errorRecovery: true
|
|
193
|
+
});
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function findInnermostJsxElement(source, line, column) {
|
|
199
|
+
const ast = parseSource(source);
|
|
200
|
+
if (!ast) return null;
|
|
201
|
+
const exact = findJsxByStart(ast, line, column);
|
|
202
|
+
if (exact) return exact;
|
|
203
|
+
const ancestors = findJsxAncestors(ast, line, column);
|
|
204
|
+
for (const n of ancestors) if (n.type === "JSXElement") return n;
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
function findJsxByStart(ast, line, column) {
|
|
208
|
+
let hit = null;
|
|
209
|
+
walkJsx(ast, (n) => {
|
|
210
|
+
if (n.type !== "JSXElement" || !n.loc) return;
|
|
211
|
+
const s = n.loc.start;
|
|
212
|
+
if (s.line === line && s.column === column) {
|
|
213
|
+
hit = n;
|
|
214
|
+
return "stop";
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return hit;
|
|
218
|
+
}
|
|
219
|
+
function jsString(s) {
|
|
220
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
221
|
+
}
|
|
222
|
+
function findStyleAttr(opening) {
|
|
223
|
+
const attrs = opening.attributes ?? [];
|
|
224
|
+
for (const attr of attrs) {
|
|
225
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
226
|
+
const name = attr.name;
|
|
227
|
+
if (name?.type === "JSXIdentifier" && name.name === "style") return attr;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function buildStyleSplice(source, element, ops) {
|
|
232
|
+
const opening = element.openingElement;
|
|
233
|
+
if (!opening) return { error: "no opening element" };
|
|
234
|
+
const existing = findStyleAttr(opening);
|
|
235
|
+
const style = new Map();
|
|
236
|
+
if (existing) {
|
|
237
|
+
const value = existing.value;
|
|
238
|
+
if (!value || value.type !== "JSXExpressionContainer") return { error: "style attribute has unsupported form" };
|
|
239
|
+
const expr = value.expression;
|
|
240
|
+
if (expr.type !== "ObjectExpression") return { error: "style is not a literal object" };
|
|
241
|
+
const properties = expr.properties;
|
|
242
|
+
for (const prop of properties) {
|
|
243
|
+
if (prop.type !== "ObjectProperty") return { error: "style contains spread or method" };
|
|
244
|
+
const p = prop;
|
|
245
|
+
if (p.computed) return { error: "style has computed key" };
|
|
246
|
+
let keyName = null;
|
|
247
|
+
if (p.key.type === "Identifier" && p.key.name) keyName = p.key.name;
|
|
248
|
+
else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") keyName = p.key.value;
|
|
249
|
+
if (!keyName) return { error: "style has unsupported key" };
|
|
250
|
+
style.set(keyName, source.slice(p.value.start, p.value.end));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const op of ops) if (op.value === null) style.delete(op.key);
|
|
254
|
+
else style.set(op.key, jsString(op.value));
|
|
255
|
+
if (style.size === 0) {
|
|
256
|
+
if (!existing) return null;
|
|
257
|
+
let from = existing.start;
|
|
258
|
+
if (from > 0 && source[from - 1] === " ") from -= 1;
|
|
259
|
+
return {
|
|
260
|
+
from,
|
|
261
|
+
to: existing.end,
|
|
262
|
+
text: ""
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const propsText = Array.from(style.entries()).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
266
|
+
const newAttr = `style={{ ${propsText} }}`;
|
|
267
|
+
if (existing) return {
|
|
268
|
+
from: existing.start,
|
|
269
|
+
to: existing.end,
|
|
270
|
+
text: newAttr
|
|
271
|
+
};
|
|
272
|
+
const name = opening.name;
|
|
273
|
+
return {
|
|
274
|
+
from: name.end,
|
|
275
|
+
to: name.end,
|
|
276
|
+
text: ` ${newAttr}`
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function formatJsxText(value) {
|
|
280
|
+
if (/[{}<>]/.test(value) || /^\s|\s$/.test(value) || value === "") return `{${jsString(value)}}`;
|
|
281
|
+
return value;
|
|
282
|
+
}
|
|
283
|
+
function buildTextSplice(element, value) {
|
|
284
|
+
const children = element.children ?? [];
|
|
285
|
+
if (children.length === 0) return { error: "element has no children to edit" };
|
|
286
|
+
const meaningful = children.filter((c) => {
|
|
287
|
+
if (c.type === "JSXText") {
|
|
288
|
+
const v = c.value;
|
|
289
|
+
return v.trim() !== "";
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
});
|
|
293
|
+
if (meaningful.length !== 1) return { error: "element has complex children" };
|
|
294
|
+
const child = meaningful[0];
|
|
295
|
+
if (child.type === "JSXText") {
|
|
296
|
+
const first = children[0];
|
|
297
|
+
const last = children[children.length - 1];
|
|
298
|
+
return {
|
|
299
|
+
from: first.start,
|
|
300
|
+
to: last.end,
|
|
301
|
+
text: formatJsxText(value)
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
if (child.type === "JSXExpressionContainer") {
|
|
305
|
+
const expr = child.expression;
|
|
306
|
+
if (expr.type === "StringLiteral" || expr.type === "NumericLiteral") return {
|
|
307
|
+
from: child.start,
|
|
308
|
+
to: child.end,
|
|
309
|
+
text: `{${jsString(value)}}`
|
|
310
|
+
};
|
|
311
|
+
return { error: "element has dynamic expression child" };
|
|
312
|
+
}
|
|
313
|
+
return { error: "element has complex children" };
|
|
314
|
+
}
|
|
315
|
+
function findImports(ast) {
|
|
316
|
+
const body = ast.program?.body ?? [];
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const node of body) {
|
|
319
|
+
if (node.type !== "ImportDeclaration") continue;
|
|
320
|
+
const src = node.source?.value;
|
|
321
|
+
if (typeof src !== "string") continue;
|
|
322
|
+
const specs = node.specifiers ?? [];
|
|
323
|
+
let def = null;
|
|
324
|
+
for (const spec of specs) if (spec.type === "ImportDefaultSpecifier") {
|
|
325
|
+
const local = spec.local?.name;
|
|
326
|
+
if (typeof local === "string") {
|
|
327
|
+
def = local;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
out.push({
|
|
332
|
+
node,
|
|
333
|
+
source: src,
|
|
334
|
+
defaultIdent: def
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
function collectTopLevelIdentifiers(ast) {
|
|
340
|
+
const names = new Set();
|
|
341
|
+
for (const imp of findImports(ast)) {
|
|
342
|
+
if (imp.defaultIdent) names.add(imp.defaultIdent);
|
|
343
|
+
const specs = imp.node.specifiers ?? [];
|
|
344
|
+
for (const spec of specs) if (spec.type !== "ImportDefaultSpecifier") {
|
|
345
|
+
const local = spec.local?.name;
|
|
346
|
+
if (typeof local === "string") names.add(local);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return names;
|
|
350
|
+
}
|
|
351
|
+
function safeAssetIdentifier(filename, taken) {
|
|
352
|
+
const stem = filename.replace(/\.[^.]+$/, "");
|
|
353
|
+
let camel = "";
|
|
354
|
+
let upper = false;
|
|
355
|
+
for (const ch of stem) if (/[A-Za-z0-9]/.test(ch)) {
|
|
356
|
+
camel += upper ? ch.toUpperCase() : ch;
|
|
357
|
+
upper = false;
|
|
358
|
+
} else upper = camel.length > 0;
|
|
359
|
+
let base = camel;
|
|
360
|
+
if (!base || !/^[A-Za-z_$]/.test(base)) base = `asset${base.charAt(0).toUpperCase()}${base.slice(1)}` || "asset";
|
|
361
|
+
base = base.charAt(0).toLowerCase() + base.slice(1);
|
|
362
|
+
let candidate = base;
|
|
363
|
+
let i = 2;
|
|
364
|
+
while (taken.has(candidate)) {
|
|
365
|
+
candidate = `${base}${i}`;
|
|
366
|
+
i += 1;
|
|
367
|
+
}
|
|
368
|
+
return candidate;
|
|
369
|
+
}
|
|
370
|
+
function findJsxAttr(opening, name) {
|
|
371
|
+
const attrs = opening.attributes ?? [];
|
|
372
|
+
for (const attr of attrs) {
|
|
373
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
374
|
+
const n = attr.name;
|
|
375
|
+
if (n?.type === "JSXIdentifier" && n.name === name) return attr;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
function planAssetAttr(ast, element, attr, assetPath) {
|
|
380
|
+
const opening = element.openingElement;
|
|
381
|
+
if (!opening) return { error: "no opening element" };
|
|
382
|
+
if (!attr || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(attr)) return { error: "invalid attribute name" };
|
|
383
|
+
if (!assetPath.startsWith("./assets/")) return { error: "asset path must start with ./assets/" };
|
|
384
|
+
const imports = findImports(ast);
|
|
385
|
+
let identifier = null;
|
|
386
|
+
for (const imp of imports) if (imp.source === assetPath && imp.defaultIdent) {
|
|
387
|
+
identifier = imp.defaultIdent;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
let importSplice = null;
|
|
391
|
+
if (!identifier) {
|
|
392
|
+
const filename = assetPath.slice(assetPath.lastIndexOf("/") + 1);
|
|
393
|
+
const taken = collectTopLevelIdentifiers(ast);
|
|
394
|
+
identifier = safeAssetIdentifier(filename, taken);
|
|
395
|
+
const importStmt = `import ${identifier} from '${assetPath.replace(/'/g, "\\'")}';\n`;
|
|
396
|
+
const insertAt = imports.length > 0 ? imports[imports.length - 1].node.end : 0;
|
|
397
|
+
const prefix = imports.length > 0 ? "\n" : "";
|
|
398
|
+
importSplice = {
|
|
399
|
+
from: insertAt,
|
|
400
|
+
to: insertAt,
|
|
401
|
+
text: prefix + importStmt
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const newAttr = `${attr}={${identifier}}`;
|
|
405
|
+
const existing = findJsxAttr(opening, attr);
|
|
406
|
+
let attrSplice;
|
|
407
|
+
if (existing) attrSplice = {
|
|
408
|
+
from: existing.start,
|
|
409
|
+
to: existing.end,
|
|
410
|
+
text: newAttr
|
|
411
|
+
};
|
|
412
|
+
else {
|
|
413
|
+
const name = opening.name;
|
|
414
|
+
attrSplice = {
|
|
415
|
+
from: name.end,
|
|
416
|
+
to: name.end,
|
|
417
|
+
text: ` ${newAttr}`
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
importSplice,
|
|
422
|
+
attrSplice
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function applyEdit(source, line, column, ops) {
|
|
426
|
+
if (ops.length === 0) return {
|
|
427
|
+
ok: true,
|
|
428
|
+
source
|
|
429
|
+
};
|
|
430
|
+
const element = findInnermostJsxElement(source, line, column);
|
|
431
|
+
if (!element) return {
|
|
432
|
+
ok: false,
|
|
433
|
+
status: 422,
|
|
434
|
+
error: "no JSX element at location"
|
|
435
|
+
};
|
|
436
|
+
const splices = [];
|
|
437
|
+
const styleOps = ops.flatMap((op) => op.kind === "set-style" ? [{
|
|
438
|
+
key: op.key,
|
|
439
|
+
value: op.value
|
|
440
|
+
}] : []);
|
|
441
|
+
if (styleOps.length > 0) {
|
|
442
|
+
const result = buildStyleSplice(source, element, styleOps);
|
|
443
|
+
if (result && "error" in result) return {
|
|
444
|
+
ok: false,
|
|
445
|
+
status: 422,
|
|
446
|
+
error: result.error
|
|
447
|
+
};
|
|
448
|
+
if (result) splices.push(result);
|
|
449
|
+
}
|
|
450
|
+
for (const op of ops) {
|
|
451
|
+
if (op.kind !== "set-text") continue;
|
|
452
|
+
const result = buildTextSplice(element, op.value);
|
|
453
|
+
if ("error" in result) return {
|
|
454
|
+
ok: false,
|
|
455
|
+
status: 422,
|
|
456
|
+
error: result.error
|
|
457
|
+
};
|
|
458
|
+
splices.push(result);
|
|
459
|
+
}
|
|
460
|
+
const assetOps = ops.flatMap((op) => op.kind === "set-attr-asset" ? [op] : []);
|
|
461
|
+
if (assetOps.length > 0) {
|
|
462
|
+
const ast = parseSource(source);
|
|
463
|
+
if (!ast) return {
|
|
464
|
+
ok: false,
|
|
465
|
+
status: 422,
|
|
466
|
+
error: "could not parse source"
|
|
467
|
+
};
|
|
468
|
+
const importSplices = [];
|
|
469
|
+
for (const op of assetOps) {
|
|
470
|
+
const plan = planAssetAttr(ast, element, op.attr, op.assetPath);
|
|
471
|
+
if ("error" in plan) return {
|
|
472
|
+
ok: false,
|
|
473
|
+
status: 422,
|
|
474
|
+
error: plan.error
|
|
475
|
+
};
|
|
476
|
+
splices.push(plan.attrSplice);
|
|
477
|
+
if (plan.importSplice) importSplices.push(plan.importSplice);
|
|
478
|
+
}
|
|
479
|
+
if (importSplices.length > 0) {
|
|
480
|
+
const from = importSplices[0].from;
|
|
481
|
+
const to = importSplices[0].to;
|
|
482
|
+
const text = importSplices.map((s) => s.text).join("");
|
|
483
|
+
splices.push({
|
|
484
|
+
from,
|
|
485
|
+
to,
|
|
486
|
+
text
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (splices.length === 0) return {
|
|
491
|
+
ok: true,
|
|
492
|
+
source
|
|
493
|
+
};
|
|
494
|
+
splices.sort((a, b) => b.from - a.from);
|
|
495
|
+
let next = source;
|
|
496
|
+
for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
|
|
497
|
+
return {
|
|
498
|
+
ok: true,
|
|
499
|
+
source: next
|
|
500
|
+
};
|
|
501
|
+
}
|
|
184
502
|
function commentsPlugin(opts) {
|
|
185
503
|
const userCwd = opts.userCwd;
|
|
186
504
|
const slidesDir = opts.slidesDir ?? "slides";
|
|
@@ -188,6 +506,77 @@ function commentsPlugin(opts) {
|
|
|
188
506
|
name: "open-slide:comments",
|
|
189
507
|
apply: "serve",
|
|
190
508
|
configureServer(server) {
|
|
509
|
+
server.middlewares.use("/__edit", async (req, res, next) => {
|
|
510
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
511
|
+
const method = req.method ?? "GET";
|
|
512
|
+
if (method !== "POST") return next();
|
|
513
|
+
try {
|
|
514
|
+
if (url.pathname === "/") {
|
|
515
|
+
const body = await readBody$1(req);
|
|
516
|
+
const slideId = body.slideId ?? "";
|
|
517
|
+
const file = resolveSlidePath(userCwd, slidesDir, slideId);
|
|
518
|
+
if (!file) return json$1(res, 400, { error: "invalid slideId" });
|
|
519
|
+
if (!body.line || body.line < 1) return json$1(res, 400, { error: "invalid line" });
|
|
520
|
+
if (!Array.isArray(body.ops)) return json$1(res, 400, { error: "missing ops" });
|
|
521
|
+
let source;
|
|
522
|
+
try {
|
|
523
|
+
source = await fs.readFile(file, "utf8");
|
|
524
|
+
} catch {
|
|
525
|
+
return json$1(res, 404, { error: "slide not found" });
|
|
526
|
+
}
|
|
527
|
+
const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
|
|
528
|
+
if (!result.ok) return json$1(res, result.status, { error: result.error });
|
|
529
|
+
const changed = result.source !== source;
|
|
530
|
+
if (changed) await fs.writeFile(file, result.source, "utf8");
|
|
531
|
+
return json$1(res, 200, {
|
|
532
|
+
ok: true,
|
|
533
|
+
changed
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (url.pathname === "/batch") {
|
|
537
|
+
const body = await readBody$1(req);
|
|
538
|
+
const slideId = body.slideId ?? "";
|
|
539
|
+
const file = resolveSlidePath(userCwd, slidesDir, slideId);
|
|
540
|
+
if (!file) return json$1(res, 400, { error: "invalid slideId" });
|
|
541
|
+
if (!Array.isArray(body.edits)) return json$1(res, 400, { error: "missing edits" });
|
|
542
|
+
let source;
|
|
543
|
+
try {
|
|
544
|
+
source = await fs.readFile(file, "utf8");
|
|
545
|
+
} catch {
|
|
546
|
+
return json$1(res, 404, { error: "slide not found" });
|
|
547
|
+
}
|
|
548
|
+
const original = source;
|
|
549
|
+
const results = [];
|
|
550
|
+
for (const edit of body.edits) {
|
|
551
|
+
if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
|
|
552
|
+
results.push({
|
|
553
|
+
ok: false,
|
|
554
|
+
error: "invalid edit"
|
|
555
|
+
});
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
|
|
559
|
+
if (r.ok) {
|
|
560
|
+
source = r.source;
|
|
561
|
+
results.push({ ok: true });
|
|
562
|
+
} else results.push({
|
|
563
|
+
ok: false,
|
|
564
|
+
error: r.error
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
const changed = source !== original;
|
|
568
|
+
if (changed) await fs.writeFile(file, source, "utf8");
|
|
569
|
+
return json$1(res, 200, {
|
|
570
|
+
ok: true,
|
|
571
|
+
changed,
|
|
572
|
+
results
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return next();
|
|
576
|
+
} catch (err) {
|
|
577
|
+
json$1(res, 500, { error: String(err.message ?? err) });
|
|
578
|
+
}
|
|
579
|
+
});
|
|
191
580
|
server.middlewares.use("/__comments", async (req, res, next) => {
|
|
192
581
|
const url = new URL(req.url ?? "/", "http://local");
|
|
193
582
|
const method = req.method ?? "GET";
|
|
@@ -268,6 +657,45 @@ function commentsPlugin(opts) {
|
|
|
268
657
|
const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
|
|
269
658
|
const SLIDE_ID_RE = /^[a-z0-9_-]+$/i;
|
|
270
659
|
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
660
|
+
const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
|
|
661
|
+
const ASSET_MAX_BYTES = 25 * 1024 * 1024;
|
|
662
|
+
const MIME_BY_EXT = {
|
|
663
|
+
png: "image/png",
|
|
664
|
+
jpg: "image/jpeg",
|
|
665
|
+
jpeg: "image/jpeg",
|
|
666
|
+
gif: "image/gif",
|
|
667
|
+
svg: "image/svg+xml",
|
|
668
|
+
webp: "image/webp",
|
|
669
|
+
avif: "image/avif",
|
|
670
|
+
ico: "image/x-icon",
|
|
671
|
+
mp4: "video/mp4",
|
|
672
|
+
webm: "video/webm",
|
|
673
|
+
mov: "video/quicktime",
|
|
674
|
+
woff: "font/woff",
|
|
675
|
+
woff2: "font/woff2",
|
|
676
|
+
ttf: "font/ttf",
|
|
677
|
+
otf: "font/otf",
|
|
678
|
+
json: "application/json",
|
|
679
|
+
txt: "text/plain; charset=utf-8",
|
|
680
|
+
md: "text/markdown; charset=utf-8"
|
|
681
|
+
};
|
|
682
|
+
function mimeForFilename(name) {
|
|
683
|
+
const dot = name.lastIndexOf(".");
|
|
684
|
+
if (dot < 0) return "application/octet-stream";
|
|
685
|
+
const ext = name.slice(dot + 1).toLowerCase();
|
|
686
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
687
|
+
}
|
|
688
|
+
function validateAssetName(v) {
|
|
689
|
+
if (typeof v !== "string") return null;
|
|
690
|
+
const trimmed = v.trim();
|
|
691
|
+
if (trimmed.length < 1 || trimmed.length > 120) return null;
|
|
692
|
+
if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
|
|
693
|
+
if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
|
|
694
|
+
if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
|
|
695
|
+
const dot = trimmed.lastIndexOf(".");
|
|
696
|
+
if (dot <= 0 || dot === trimmed.length - 1) return null;
|
|
697
|
+
return trimmed;
|
|
698
|
+
}
|
|
271
699
|
async function readBody(req) {
|
|
272
700
|
return await new Promise((resolve, reject) => {
|
|
273
701
|
const chunks = [];
|
|
@@ -341,6 +769,22 @@ async function rmSlideDir(slidesRoot, slideId) {
|
|
|
341
769
|
return false;
|
|
342
770
|
}
|
|
343
771
|
}
|
|
772
|
+
function resolveAssetsDir(slidesRoot, slideId) {
|
|
773
|
+
if (!SLIDE_ID_RE.test(slideId)) return null;
|
|
774
|
+
const slideDir = path.resolve(slidesRoot, slideId);
|
|
775
|
+
if (!slideDir.startsWith(slidesRoot + path.sep)) return null;
|
|
776
|
+
const assetsDir = path.resolve(slideDir, "assets");
|
|
777
|
+
if (assetsDir !== path.join(slideDir, "assets")) return null;
|
|
778
|
+
return assetsDir;
|
|
779
|
+
}
|
|
780
|
+
function resolveAssetFile(slidesRoot, slideId, filename) {
|
|
781
|
+
const assetsDir = resolveAssetsDir(slidesRoot, slideId);
|
|
782
|
+
if (!assetsDir) return null;
|
|
783
|
+
if (!validateAssetName(filename)) return null;
|
|
784
|
+
const file = path.resolve(assetsDir, filename);
|
|
785
|
+
if (!file.startsWith(assetsDir + path.sep)) return null;
|
|
786
|
+
return file;
|
|
787
|
+
}
|
|
344
788
|
function resolveSlideEntry(slidesRoot, slideId) {
|
|
345
789
|
if (!SLIDE_ID_RE.test(slideId)) return null;
|
|
346
790
|
const dir = path.resolve(slidesRoot, slideId);
|
|
@@ -441,6 +885,22 @@ function filesPlugin(opts) {
|
|
|
441
885
|
event: "open-slide:files-changed"
|
|
442
886
|
});
|
|
443
887
|
});
|
|
888
|
+
const onAssetChange = (p) => {
|
|
889
|
+
if (!p.startsWith(slidesRoot + path.sep)) return;
|
|
890
|
+
const rel = p.slice(slidesRoot.length + 1);
|
|
891
|
+
const parts = rel.split(path.sep);
|
|
892
|
+
if (parts.length < 3 || parts[1] !== "assets") return;
|
|
893
|
+
const slideId = parts[0];
|
|
894
|
+
if (!SLIDE_ID_RE.test(slideId)) return;
|
|
895
|
+
server.ws.send({
|
|
896
|
+
type: "custom",
|
|
897
|
+
event: "open-slide:assets-changed",
|
|
898
|
+
data: { slideId }
|
|
899
|
+
});
|
|
900
|
+
};
|
|
901
|
+
server.watcher.on("add", onAssetChange);
|
|
902
|
+
server.watcher.on("change", onAssetChange);
|
|
903
|
+
server.watcher.on("unlink", onAssetChange);
|
|
444
904
|
server.middlewares.use("/__slides", async (req, res, next) => {
|
|
445
905
|
const url = new URL(req.url ?? "/", "http://local");
|
|
446
906
|
const method = req.method ?? "GET";
|
|
@@ -484,6 +944,172 @@ function filesPlugin(opts) {
|
|
|
484
944
|
json(res, 500, { error: String(err.message ?? err) });
|
|
485
945
|
}
|
|
486
946
|
});
|
|
947
|
+
server.middlewares.use("/__assets", async (req, res, next) => {
|
|
948
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
949
|
+
const method = req.method ?? "GET";
|
|
950
|
+
try {
|
|
951
|
+
const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
|
|
952
|
+
const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
|
|
953
|
+
if (listMatch && method === "GET") {
|
|
954
|
+
const slideId = listMatch[1];
|
|
955
|
+
const assetsDir = resolveAssetsDir(slidesRoot, slideId);
|
|
956
|
+
if (!assetsDir) return json(res, 400, { error: "invalid slideId" });
|
|
957
|
+
let entries;
|
|
958
|
+
try {
|
|
959
|
+
entries = await fs.readdir(assetsDir);
|
|
960
|
+
} catch (err) {
|
|
961
|
+
if (err.code === "ENOENT") return json(res, 200, { assets: [] });
|
|
962
|
+
throw err;
|
|
963
|
+
}
|
|
964
|
+
const assets = [];
|
|
965
|
+
for (const name of entries) {
|
|
966
|
+
if (!validateAssetName(name)) continue;
|
|
967
|
+
const stat = await fs.stat(path.join(assetsDir, name));
|
|
968
|
+
if (!stat.isFile()) continue;
|
|
969
|
+
assets.push({
|
|
970
|
+
name,
|
|
971
|
+
size: stat.size,
|
|
972
|
+
mtime: stat.mtimeMs,
|
|
973
|
+
mime: mimeForFilename(name),
|
|
974
|
+
url: `/__assets/${slideId}/${encodeURIComponent(name)}`
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
assets.sort((a, b) => a.name.localeCompare(b.name));
|
|
978
|
+
return json(res, 200, { assets });
|
|
979
|
+
}
|
|
980
|
+
if (fileMatch) {
|
|
981
|
+
const slideId = fileMatch[1];
|
|
982
|
+
const filename = decodeURIComponent(fileMatch[2]);
|
|
983
|
+
const file = resolveAssetFile(slidesRoot, slideId, filename);
|
|
984
|
+
if (!file) return json(res, 400, { error: "invalid path" });
|
|
985
|
+
if (method === "GET") try {
|
|
986
|
+
const buf = await fs.readFile(file);
|
|
987
|
+
res.statusCode = 200;
|
|
988
|
+
res.setHeader("content-type", mimeForFilename(filename));
|
|
989
|
+
res.setHeader("cache-control", "no-store");
|
|
990
|
+
res.end(buf);
|
|
991
|
+
return;
|
|
992
|
+
} catch (err) {
|
|
993
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
|
|
994
|
+
throw err;
|
|
995
|
+
}
|
|
996
|
+
if (method === "POST") {
|
|
997
|
+
const overwrite = url.searchParams.get("overwrite") === "1";
|
|
998
|
+
const lenHeader = req.headers["content-length"];
|
|
999
|
+
const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
|
|
1000
|
+
if (Number.isFinite(len) && len > ASSET_MAX_BYTES) return json(res, 413, { error: "file too large" });
|
|
1001
|
+
if (!overwrite) try {
|
|
1002
|
+
await fs.access(file);
|
|
1003
|
+
return json(res, 409, { error: "asset exists" });
|
|
1004
|
+
} catch {}
|
|
1005
|
+
const assetsDir = resolveAssetsDir(slidesRoot, slideId);
|
|
1006
|
+
if (!assetsDir) return json(res, 400, { error: "invalid slideId" });
|
|
1007
|
+
await fs.mkdir(assetsDir, { recursive: true });
|
|
1008
|
+
const chunks = [];
|
|
1009
|
+
let total = 0;
|
|
1010
|
+
let oversized = false;
|
|
1011
|
+
await new Promise((resolve, reject) => {
|
|
1012
|
+
req.on("data", (c) => {
|
|
1013
|
+
total += c.length;
|
|
1014
|
+
if (total > ASSET_MAX_BYTES) {
|
|
1015
|
+
oversized = true;
|
|
1016
|
+
req.destroy();
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
chunks.push(c);
|
|
1020
|
+
});
|
|
1021
|
+
req.on("end", () => resolve());
|
|
1022
|
+
req.on("error", reject);
|
|
1023
|
+
});
|
|
1024
|
+
if (oversized) return json(res, 413, { error: "file too large" });
|
|
1025
|
+
await fs.writeFile(file, Buffer.concat(chunks));
|
|
1026
|
+
return json(res, 200, {
|
|
1027
|
+
ok: true,
|
|
1028
|
+
name: filename,
|
|
1029
|
+
size: total,
|
|
1030
|
+
mime: mimeForFilename(filename),
|
|
1031
|
+
url: `/__assets/${slideId}/${encodeURIComponent(filename)}`
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
if (method === "PATCH") {
|
|
1035
|
+
const body = await readBody(req);
|
|
1036
|
+
const target = validateAssetName(body.name);
|
|
1037
|
+
if (!target) return json(res, 400, { error: "invalid name" });
|
|
1038
|
+
if (target === filename) return json(res, 200, {
|
|
1039
|
+
ok: true,
|
|
1040
|
+
name: filename
|
|
1041
|
+
});
|
|
1042
|
+
const dest = resolveAssetFile(slidesRoot, slideId, target);
|
|
1043
|
+
if (!dest) return json(res, 400, { error: "invalid name" });
|
|
1044
|
+
try {
|
|
1045
|
+
await fs.access(dest);
|
|
1046
|
+
return json(res, 409, { error: "target exists" });
|
|
1047
|
+
} catch {}
|
|
1048
|
+
try {
|
|
1049
|
+
await fs.rename(file, dest);
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
|
|
1052
|
+
throw err;
|
|
1053
|
+
}
|
|
1054
|
+
return json(res, 200, {
|
|
1055
|
+
ok: true,
|
|
1056
|
+
name: target
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
if (method === "DELETE") {
|
|
1060
|
+
try {
|
|
1061
|
+
await fs.unlink(file);
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
|
|
1064
|
+
throw err;
|
|
1065
|
+
}
|
|
1066
|
+
return json(res, 200, { ok: true });
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return next();
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
server.middlewares.use("/__svgl", async (req, res, next) => {
|
|
1075
|
+
const reqUrl = new URL(req.url ?? "/", "http://local");
|
|
1076
|
+
const method = req.method ?? "GET";
|
|
1077
|
+
if (method !== "GET") return next();
|
|
1078
|
+
try {
|
|
1079
|
+
let target = null;
|
|
1080
|
+
if (reqUrl.pathname === "/search") {
|
|
1081
|
+
const params = new URLSearchParams();
|
|
1082
|
+
const q = reqUrl.searchParams.get("q");
|
|
1083
|
+
const limit = reqUrl.searchParams.get("limit");
|
|
1084
|
+
if (q) params.set("search", q);
|
|
1085
|
+
if (limit) params.set("limit", limit);
|
|
1086
|
+
const qs = params.toString();
|
|
1087
|
+
target = `https://api.svgl.app/${qs ? `?${qs}` : ""}`;
|
|
1088
|
+
} else if (reqUrl.pathname === "/svg") {
|
|
1089
|
+
const u = reqUrl.searchParams.get("u");
|
|
1090
|
+
if (!u) return json(res, 400, { error: "missing u" });
|
|
1091
|
+
let parsed;
|
|
1092
|
+
try {
|
|
1093
|
+
parsed = new URL(u);
|
|
1094
|
+
} catch {
|
|
1095
|
+
return json(res, 400, { error: "invalid u" });
|
|
1096
|
+
}
|
|
1097
|
+
if (parsed.protocol !== "https:") return json(res, 400, { error: "https only" });
|
|
1098
|
+
const host = parsed.hostname.toLowerCase();
|
|
1099
|
+
if (host !== "svgl.app" && !host.endsWith(".svgl.app")) return json(res, 400, { error: "host not allowed" });
|
|
1100
|
+
target = parsed.toString();
|
|
1101
|
+
} else return next();
|
|
1102
|
+
const upstream = await fetch(target);
|
|
1103
|
+
const ct = upstream.headers.get("content-type") ?? "application/octet-stream";
|
|
1104
|
+
res.statusCode = upstream.status;
|
|
1105
|
+
res.setHeader("content-type", ct);
|
|
1106
|
+
res.setHeader("cache-control", "no-store");
|
|
1107
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
1108
|
+
res.end(buf);
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
json(res, 502, { error: String(err.message ?? err) });
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
487
1113
|
server.middlewares.use("/__folders", async (req, res, next) => {
|
|
488
1114
|
const url = new URL(req.url ?? "/", "http://local");
|
|
489
1115
|
const method = req.method ?? "GET";
|
|
@@ -564,6 +1190,74 @@ function filesPlugin(opts) {
|
|
|
564
1190
|
};
|
|
565
1191
|
}
|
|
566
1192
|
|
|
1193
|
+
//#endregion
|
|
1194
|
+
//#region src/vite/loc-tags-plugin.ts
|
|
1195
|
+
function isHostJsxName(name) {
|
|
1196
|
+
if (!name || typeof name !== "object") return false;
|
|
1197
|
+
const n = name;
|
|
1198
|
+
return n.type === "JSXIdentifier" && typeof n.name === "string" && /^[a-z]/.test(n.name);
|
|
1199
|
+
}
|
|
1200
|
+
function alreadyTagged(opening) {
|
|
1201
|
+
const attrs = opening.attributes ?? [];
|
|
1202
|
+
for (const attr of attrs) {
|
|
1203
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
1204
|
+
const name = attr.name;
|
|
1205
|
+
if (name?.type === "JSXIdentifier" && name.name === "data-slide-loc") return true;
|
|
1206
|
+
}
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
function injectLocTags(code) {
|
|
1210
|
+
let ast;
|
|
1211
|
+
try {
|
|
1212
|
+
ast = parse(code, {
|
|
1213
|
+
sourceType: "module",
|
|
1214
|
+
plugins: ["typescript", "jsx"],
|
|
1215
|
+
errorRecovery: true
|
|
1216
|
+
});
|
|
1217
|
+
} catch {
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
const insertions = [];
|
|
1221
|
+
walkJsx(ast, (node) => {
|
|
1222
|
+
if (node.type !== "JSXElement") return;
|
|
1223
|
+
const opening = node.openingElement;
|
|
1224
|
+
if (!opening) return;
|
|
1225
|
+
const name = opening.name;
|
|
1226
|
+
if (!isHostJsxName(name)) return;
|
|
1227
|
+
if (alreadyTagged(opening)) return;
|
|
1228
|
+
const loc = node.loc;
|
|
1229
|
+
if (!loc) return;
|
|
1230
|
+
insertions.push({
|
|
1231
|
+
offset: name.end,
|
|
1232
|
+
text: ` data-slide-loc="${loc.start.line}:${loc.start.column}"`
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
if (insertions.length === 0) return null;
|
|
1236
|
+
insertions.sort((a, b) => b.offset - a.offset);
|
|
1237
|
+
let next = code;
|
|
1238
|
+
for (const ins of insertions) next = next.slice(0, ins.offset) + ins.text + next.slice(ins.offset);
|
|
1239
|
+
return next;
|
|
1240
|
+
}
|
|
1241
|
+
function locTagsPlugin(opts) {
|
|
1242
|
+
const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides");
|
|
1243
|
+
return {
|
|
1244
|
+
name: "open-slide:loc-tags",
|
|
1245
|
+
apply: "serve",
|
|
1246
|
+
enforce: "pre",
|
|
1247
|
+
transform(code, id) {
|
|
1248
|
+
const filePath = id.split("?")[0];
|
|
1249
|
+
if (!filePath.startsWith(slidesRoot + path.sep)) return null;
|
|
1250
|
+
if (!filePath.endsWith(`${path.sep}index.tsx`)) return null;
|
|
1251
|
+
const next = injectLocTags(code);
|
|
1252
|
+
if (next === null) return null;
|
|
1253
|
+
return {
|
|
1254
|
+
code: next,
|
|
1255
|
+
map: null
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
567
1261
|
//#endregion
|
|
568
1262
|
//#region src/vite/open-slide-plugin.ts
|
|
569
1263
|
const CONFIG_FILE = "open-slide.config.ts";
|
|
@@ -732,6 +1426,10 @@ async function createViteConfig(opts) {
|
|
|
732
1426
|
root: APP_ROOT,
|
|
733
1427
|
configFile: false,
|
|
734
1428
|
plugins: [
|
|
1429
|
+
locTagsPlugin({
|
|
1430
|
+
userCwd,
|
|
1431
|
+
slidesDir
|
|
1432
|
+
}),
|
|
735
1433
|
react(),
|
|
736
1434
|
tailwindcss(),
|
|
737
1435
|
openSlidePlugin({
|