@open-slide/core 0.0.2 → 0.0.4
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/bin.js +2 -0
- package/dist/{build-DJGuOT6x.js → build-CuoESF2g.js} +1 -1
- package/dist/cli/bin.js +5 -5
- package/dist/config-DF58h0l4.js +641 -0
- package/dist/{dev-0SG0ArzD.js → dev-rlOZacWo.js} +1 -1
- package/dist/index.d.ts +7 -9
- package/dist/{preview-61Aawrlg.js → preview-DCrD9X36.js} +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +7 -4
- package/src/app/App.tsx +2 -2
- package/src/app/components/ClickNavZones.tsx +34 -0
- package/src/app/components/Player.tsx +26 -7
- package/src/app/components/ThumbnailRail.tsx +5 -5
- package/src/app/components/inspector/CommentPopover.tsx +3 -11
- package/src/app/components/inspector/InspectOverlay.tsx +15 -4
- package/src/app/components/inspector/InspectorProvider.tsx +12 -5
- package/src/app/components/sidebar/FolderItem.tsx +188 -0
- package/src/app/components/sidebar/IconPicker.tsx +59 -0
- package/src/app/components/sidebar/Sidebar.tsx +118 -0
- package/src/app/components/ui/dialog.tsx +141 -0
- package/src/app/components/ui/dropdown-menu.tsx +228 -0
- package/src/app/components/ui/popover.tsx +72 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/lib/export-html.ts +313 -0
- package/src/app/lib/folders.ts +166 -0
- package/src/app/lib/inspector/fiber.ts +2 -2
- package/src/app/lib/inspector/useComments.ts +8 -8
- package/src/app/lib/sdk.ts +18 -5
- package/src/app/lib/slides.ts +8 -0
- package/src/app/routes/Home.tsx +540 -63
- package/src/app/routes/Slide.tsx +298 -0
- package/src/app/virtual.d.ts +4 -4
- package/dist/config-Opp2R1Jf.js +0 -335
- package/src/app/lib/decks.ts +0 -8
- package/src/app/routes/Deck.tsx +0 -185
package/bin.js
ADDED
package/dist/cli/bin.js
CHANGED
|
@@ -4,11 +4,11 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
//#region src/cli/run.ts
|
|
7
|
-
const HELP = `open-slide — author
|
|
7
|
+
const HELP = `open-slide — author slides, we handle the Vite/React stack
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
10
|
open-slide dev Start dev server
|
|
11
|
-
open-slide build Build a static
|
|
11
|
+
open-slide build Build a static site
|
|
12
12
|
open-slide preview Preview the production build
|
|
13
13
|
open-slide --help Show this message
|
|
14
14
|
open-slide --version Print version
|
|
@@ -30,17 +30,17 @@ async function run(argv) {
|
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
32
|
if (cmd === "dev") {
|
|
33
|
-
const { dev } = await import("../dev-
|
|
33
|
+
const { dev } = await import("../dev-rlOZacWo.js");
|
|
34
34
|
await dev();
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
if (cmd === "build") {
|
|
38
|
-
const { build } = await import("../build-
|
|
38
|
+
const { build } = await import("../build-CuoESF2g.js");
|
|
39
39
|
await build();
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
42
|
if (cmd === "preview") {
|
|
43
|
-
const { preview } = await import("../preview-
|
|
43
|
+
const { preview } = await import("../preview-DCrD9X36.js");
|
|
44
44
|
await preview();
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import fs, { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
6
|
+
import react from "@vitejs/plugin-react";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import fg from "fast-glob";
|
|
9
|
+
|
|
10
|
+
//#region src/vite/comments-plugin.ts
|
|
11
|
+
const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
|
|
12
|
+
const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
|
|
13
|
+
function b64urlEncode(s) {
|
|
14
|
+
return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
15
|
+
}
|
|
16
|
+
function b64urlDecode(s) {
|
|
17
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
|
|
18
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
|
|
19
|
+
}
|
|
20
|
+
async function readBody$1(req) {
|
|
21
|
+
return await new Promise((resolve, reject) => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
req.on("data", (c) => chunks.push(c));
|
|
24
|
+
req.on("end", () => {
|
|
25
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
26
|
+
if (!raw) return resolve({});
|
|
27
|
+
try {
|
|
28
|
+
resolve(JSON.parse(raw));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
reject(e);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
req.on("error", reject);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function json$1(res, status, body) {
|
|
37
|
+
res.statusCode = status;
|
|
38
|
+
res.setHeader("content-type", "application/json");
|
|
39
|
+
res.end(JSON.stringify(body));
|
|
40
|
+
}
|
|
41
|
+
function resolveSlidePath(userCwd, slidesDir, slideId) {
|
|
42
|
+
if (!SLIDE_ID_RE$1.test(slideId)) return null;
|
|
43
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
44
|
+
const full = path.resolve(slidesRoot, slideId, "index.tsx");
|
|
45
|
+
if (!full.startsWith(slidesRoot + path.sep)) return null;
|
|
46
|
+
return full;
|
|
47
|
+
}
|
|
48
|
+
function parseMarkers(source) {
|
|
49
|
+
const comments = [];
|
|
50
|
+
const lines = source.split("\n");
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
MARKER_RE.lastIndex = 0;
|
|
54
|
+
const m = MARKER_RE.exec(line);
|
|
55
|
+
if (!m) continue;
|
|
56
|
+
const [, id, ts, textB64] = m;
|
|
57
|
+
try {
|
|
58
|
+
const payload = JSON.parse(b64urlDecode(textB64));
|
|
59
|
+
comments.push({
|
|
60
|
+
id,
|
|
61
|
+
line: i + 1,
|
|
62
|
+
ts,
|
|
63
|
+
note: payload.note,
|
|
64
|
+
hint: payload.hint
|
|
65
|
+
});
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
return comments;
|
|
69
|
+
}
|
|
70
|
+
function newId() {
|
|
71
|
+
return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
72
|
+
}
|
|
73
|
+
function isJsxOpeningLine(line) {
|
|
74
|
+
const t = line.trimStart();
|
|
75
|
+
if (!t.startsWith("<")) return false;
|
|
76
|
+
if (t.startsWith("</")) return false;
|
|
77
|
+
if (t.startsWith("<!")) return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Find the line index to insert a JSX comment above.
|
|
82
|
+
*
|
|
83
|
+
* Babel's `_debugSource.lineNumber/columnNumber` points at the `<` of a JSX
|
|
84
|
+
* opening tag, but the value can go stale (HMR races) or, per reports, point
|
|
85
|
+
* at a line that's not actually a JSX boundary — e.g. inside an inline style
|
|
86
|
+
* object. Verify with the source of truth before committing.
|
|
87
|
+
*/
|
|
88
|
+
function findSafeInsertLine(lines, line, column) {
|
|
89
|
+
const idx = line - 1;
|
|
90
|
+
if (idx < 0 || idx >= lines.length) return null;
|
|
91
|
+
if (column !== void 0 && lines[idx].charAt(column) === "<") return idx;
|
|
92
|
+
if (isJsxOpeningLine(lines[idx])) return idx;
|
|
93
|
+
const WINDOW = 30;
|
|
94
|
+
for (let i = idx - 1; i >= Math.max(0, idx - WINDOW); i--) if (isJsxOpeningLine(lines[i])) return i;
|
|
95
|
+
for (let i = idx + 1; i < Math.min(lines.length, idx + WINDOW); i++) if (isJsxOpeningLine(lines[i])) return i;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function commentsPlugin(opts) {
|
|
99
|
+
const userCwd = opts.userCwd;
|
|
100
|
+
const slidesDir = opts.slidesDir ?? "slides";
|
|
101
|
+
return {
|
|
102
|
+
name: "open-slide:comments",
|
|
103
|
+
apply: "serve",
|
|
104
|
+
configureServer(server) {
|
|
105
|
+
server.middlewares.use("/__comments", async (req, res, next) => {
|
|
106
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
107
|
+
const method = req.method ?? "GET";
|
|
108
|
+
try {
|
|
109
|
+
if (method === "GET" && url.pathname === "/") {
|
|
110
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
111
|
+
const file = resolveSlidePath(userCwd, slidesDir, slideId);
|
|
112
|
+
if (!file) return json$1(res, 400, { error: "invalid slideId" });
|
|
113
|
+
let source;
|
|
114
|
+
try {
|
|
115
|
+
source = await fs.readFile(file, "utf8");
|
|
116
|
+
} catch {
|
|
117
|
+
return json$1(res, 404, { error: "slide not found" });
|
|
118
|
+
}
|
|
119
|
+
return json$1(res, 200, { comments: parseMarkers(source) });
|
|
120
|
+
}
|
|
121
|
+
if (method === "POST" && url.pathname === "/add") {
|
|
122
|
+
const body = await readBody$1(req);
|
|
123
|
+
const slideId = body.slideId ?? "";
|
|
124
|
+
const file = resolveSlidePath(userCwd, slidesDir, slideId);
|
|
125
|
+
if (!file) return json$1(res, 400, { error: "invalid slideId" });
|
|
126
|
+
if (!body.line || body.line < 1) return json$1(res, 400, { error: "invalid line" });
|
|
127
|
+
if (!body.text || typeof body.text !== "string") return json$1(res, 400, { error: "missing text" });
|
|
128
|
+
let source;
|
|
129
|
+
try {
|
|
130
|
+
source = await fs.readFile(file, "utf8");
|
|
131
|
+
} catch {
|
|
132
|
+
return json$1(res, 404, { error: "slide not found" });
|
|
133
|
+
}
|
|
134
|
+
const lines = source.split("\n");
|
|
135
|
+
const idx = findSafeInsertLine(lines, body.line, body.column);
|
|
136
|
+
if (idx === null) return json$1(res, 422, { error: `could not find a safe JSX boundary near line ${body.line}. Try clicking a different element.` });
|
|
137
|
+
const indent = lines[idx].match(/^\s*/)?.[0] ?? "";
|
|
138
|
+
const id = newId();
|
|
139
|
+
const ts = new Date().toISOString();
|
|
140
|
+
const payload = b64urlEncode(JSON.stringify({
|
|
141
|
+
note: body.text,
|
|
142
|
+
hint: body.hint
|
|
143
|
+
}));
|
|
144
|
+
const marker = `${indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
|
|
145
|
+
lines.splice(idx, 0, marker);
|
|
146
|
+
await fs.writeFile(file, lines.join("\n"), "utf8");
|
|
147
|
+
return json$1(res, 200, {
|
|
148
|
+
id,
|
|
149
|
+
line: idx + 1
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (method === "DELETE" && url.pathname.startsWith("/")) {
|
|
153
|
+
const id = url.pathname.slice(1);
|
|
154
|
+
if (!/^c-[a-f0-9]+$/.test(id)) return json$1(res, 400, { error: "invalid id" });
|
|
155
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
156
|
+
const file = resolveSlidePath(userCwd, slidesDir, slideId);
|
|
157
|
+
if (!file) return json$1(res, 400, { error: "invalid slideId" });
|
|
158
|
+
let source;
|
|
159
|
+
try {
|
|
160
|
+
source = await fs.readFile(file, "utf8");
|
|
161
|
+
} catch {
|
|
162
|
+
return json$1(res, 404, { error: "slide not found" });
|
|
163
|
+
}
|
|
164
|
+
const lines = source.split("\n");
|
|
165
|
+
const idRe = new RegExp(`\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`);
|
|
166
|
+
const hit = lines.findIndex((l) => idRe.test(l));
|
|
167
|
+
if (hit === -1) return json$1(res, 404, { error: "marker not found" });
|
|
168
|
+
lines.splice(hit, 1);
|
|
169
|
+
await fs.writeFile(file, lines.join("\n"), "utf8");
|
|
170
|
+
return json$1(res, 200, { ok: true });
|
|
171
|
+
}
|
|
172
|
+
next();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
json$1(res, 500, { error: String(err.message ?? err) });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/vite/files-plugin.ts
|
|
183
|
+
const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
|
|
184
|
+
const SLIDE_ID_RE = /^[a-z0-9_-]+$/i;
|
|
185
|
+
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
186
|
+
async function readBody(req) {
|
|
187
|
+
return await new Promise((resolve, reject) => {
|
|
188
|
+
const chunks = [];
|
|
189
|
+
req.on("data", (c) => chunks.push(c));
|
|
190
|
+
req.on("end", () => {
|
|
191
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
192
|
+
if (!raw) return resolve({});
|
|
193
|
+
try {
|
|
194
|
+
resolve(JSON.parse(raw));
|
|
195
|
+
} catch (e) {
|
|
196
|
+
reject(e);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
req.on("error", reject);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function json(res, status, body) {
|
|
203
|
+
res.statusCode = status;
|
|
204
|
+
res.setHeader("content-type", "application/json");
|
|
205
|
+
res.end(JSON.stringify(body));
|
|
206
|
+
}
|
|
207
|
+
function emptyManifest() {
|
|
208
|
+
return {
|
|
209
|
+
folders: [],
|
|
210
|
+
assignments: {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
async function readManifest(file) {
|
|
214
|
+
try {
|
|
215
|
+
const raw = await fs.readFile(file, "utf8");
|
|
216
|
+
const parsed = JSON.parse(raw);
|
|
217
|
+
return {
|
|
218
|
+
folders: Array.isArray(parsed.folders) ? parsed.folders : [],
|
|
219
|
+
assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
|
|
220
|
+
};
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err.code === "ENOENT") return emptyManifest();
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function writeManifest(file, manifest) {
|
|
227
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
228
|
+
await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
229
|
+
}
|
|
230
|
+
function newFolderId() {
|
|
231
|
+
return `f-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
232
|
+
}
|
|
233
|
+
function validateName(v) {
|
|
234
|
+
if (typeof v !== "string") return null;
|
|
235
|
+
const trimmed = v.trim();
|
|
236
|
+
if (trimmed.length < 1 || trimmed.length > 40) return null;
|
|
237
|
+
return trimmed;
|
|
238
|
+
}
|
|
239
|
+
function validateSlideName(v) {
|
|
240
|
+
if (typeof v !== "string") return null;
|
|
241
|
+
const trimmed = v.trim();
|
|
242
|
+
if (trimmed.length < 1 || trimmed.length > 80) return null;
|
|
243
|
+
return trimmed;
|
|
244
|
+
}
|
|
245
|
+
async function rmSlideDir(slidesRoot, slideId) {
|
|
246
|
+
if (!SLIDE_ID_RE.test(slideId)) return false;
|
|
247
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
248
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return false;
|
|
249
|
+
try {
|
|
250
|
+
await fs.rm(dir, {
|
|
251
|
+
recursive: true,
|
|
252
|
+
force: true
|
|
253
|
+
});
|
|
254
|
+
return true;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function resolveSlideEntry(slidesRoot, slideId) {
|
|
260
|
+
if (!SLIDE_ID_RE.test(slideId)) return null;
|
|
261
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
262
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return null;
|
|
263
|
+
return path.join(dir, "index.tsx");
|
|
264
|
+
}
|
|
265
|
+
function escapeSingleQuoted(s) {
|
|
266
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Rewrite (or insert) the `title` field in the slide module's `export const meta`.
|
|
270
|
+
*
|
|
271
|
+
* Strategy:
|
|
272
|
+
* 1. Find `export const meta` and brace-match its object literal.
|
|
273
|
+
* 2. If the object already has a `title: '...'` entry, replace the literal.
|
|
274
|
+
* 3. If the object exists but has no title, inject a new `title: '...'` line
|
|
275
|
+
* as the first property (preserving the author's surrounding indentation).
|
|
276
|
+
* 4. If there is no `meta` export at all, insert a fresh one right before
|
|
277
|
+
* `export default`.
|
|
278
|
+
*
|
|
279
|
+
* Returns the rewritten source, or `null` if the file shape was too surprising
|
|
280
|
+
* to touch safely (e.g. `export default` missing when we'd need to inject meta).
|
|
281
|
+
*/
|
|
282
|
+
function updateMetaTitleInSource(source, title) {
|
|
283
|
+
const newLiteral = `'${escapeSingleQuoted(title)}'`;
|
|
284
|
+
const metaStart = source.search(/export\s+const\s+meta\b/);
|
|
285
|
+
if (metaStart !== -1) {
|
|
286
|
+
const eqIdx = source.indexOf("=", metaStart);
|
|
287
|
+
if (eqIdx === -1) return null;
|
|
288
|
+
const openBrace = source.indexOf("{", eqIdx);
|
|
289
|
+
if (openBrace === -1) return null;
|
|
290
|
+
let depth = 0;
|
|
291
|
+
let closeBrace = -1;
|
|
292
|
+
for (let i = openBrace; i < source.length; i++) {
|
|
293
|
+
const ch = source[i];
|
|
294
|
+
if (ch === "{") depth++;
|
|
295
|
+
else if (ch === "}") {
|
|
296
|
+
depth--;
|
|
297
|
+
if (depth === 0) {
|
|
298
|
+
closeBrace = i;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (closeBrace === -1) return null;
|
|
304
|
+
const body = source.slice(openBrace + 1, closeBrace);
|
|
305
|
+
const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
|
|
306
|
+
const match = body.match(titleRe);
|
|
307
|
+
if (match) {
|
|
308
|
+
const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
|
|
309
|
+
return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
|
|
310
|
+
}
|
|
311
|
+
const firstIndentMatch = body.match(/\n([ \t]+)\S/);
|
|
312
|
+
const indent = firstIndentMatch ? firstIndentMatch[1] : " ";
|
|
313
|
+
const trimmedBody = body.replace(/^\s*\n?/, "");
|
|
314
|
+
const needsSeparator = trimmedBody.trim().length > 0;
|
|
315
|
+
const insertion$1 = `\n${indent}title: ${newLiteral}${needsSeparator ? "," : ""}`;
|
|
316
|
+
return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
|
|
317
|
+
}
|
|
318
|
+
const exportDefaultIdx = source.search(/export\s+default\b/);
|
|
319
|
+
if (exportDefaultIdx === -1) return null;
|
|
320
|
+
const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
|
|
321
|
+
return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
|
|
322
|
+
}
|
|
323
|
+
function validateIcon(v) {
|
|
324
|
+
if (!v || typeof v !== "object") return null;
|
|
325
|
+
const icon = v;
|
|
326
|
+
if (icon.type === "emoji") {
|
|
327
|
+
if (typeof icon.value !== "string") return null;
|
|
328
|
+
if (icon.value.length < 1 || icon.value.length > 8) return null;
|
|
329
|
+
return {
|
|
330
|
+
type: "emoji",
|
|
331
|
+
value: icon.value
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (icon.type === "color") {
|
|
335
|
+
if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
|
|
336
|
+
return {
|
|
337
|
+
type: "color",
|
|
338
|
+
value: icon.value
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
function filesPlugin(opts) {
|
|
344
|
+
const userCwd = opts.userCwd;
|
|
345
|
+
const slidesDir = opts.slidesDir ?? "slides";
|
|
346
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
347
|
+
const manifestPath = path.join(slidesRoot, ".folders.json");
|
|
348
|
+
return {
|
|
349
|
+
name: "open-slide:files",
|
|
350
|
+
apply: "serve",
|
|
351
|
+
configureServer(server) {
|
|
352
|
+
server.watcher.add(manifestPath);
|
|
353
|
+
server.watcher.on("change", (p) => {
|
|
354
|
+
if (p === manifestPath) server.ws.send({
|
|
355
|
+
type: "custom",
|
|
356
|
+
event: "open-slide:files-changed"
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
server.middlewares.use("/__slides", async (req, res, next) => {
|
|
360
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
361
|
+
const method = req.method ?? "GET";
|
|
362
|
+
try {
|
|
363
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
364
|
+
if (!idMatch) return next();
|
|
365
|
+
const slideId = idMatch[1];
|
|
366
|
+
if (!SLIDE_ID_RE.test(slideId)) return json(res, 400, { error: "invalid slideId" });
|
|
367
|
+
if (method === "PATCH") {
|
|
368
|
+
const body = await readBody(req);
|
|
369
|
+
const name = validateSlideName(body.name);
|
|
370
|
+
if (!name) return json(res, 400, { error: "invalid name" });
|
|
371
|
+
const entry = resolveSlideEntry(slidesRoot, slideId);
|
|
372
|
+
if (!entry) return json(res, 400, { error: "invalid slideId" });
|
|
373
|
+
let source;
|
|
374
|
+
try {
|
|
375
|
+
source = await fs.readFile(entry, "utf8");
|
|
376
|
+
} catch {
|
|
377
|
+
return json(res, 404, { error: "slide not found" });
|
|
378
|
+
}
|
|
379
|
+
const updated = updateMetaTitleInSource(source, name);
|
|
380
|
+
if (updated === null) return json(res, 422, { error: "could not locate a safe place to write meta.title in index.tsx" });
|
|
381
|
+
if (updated !== source) await fs.writeFile(entry, updated, "utf8");
|
|
382
|
+
server.ws.send({ type: "full-reload" });
|
|
383
|
+
return json(res, 200, {
|
|
384
|
+
ok: true,
|
|
385
|
+
slideId,
|
|
386
|
+
name
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (method === "DELETE") {
|
|
390
|
+
const removed = await rmSlideDir(slidesRoot, slideId);
|
|
391
|
+
if (!removed) return json(res, 404, { error: "slide not found" });
|
|
392
|
+
const manifest = await readManifest(manifestPath);
|
|
393
|
+
delete manifest.assignments[slideId];
|
|
394
|
+
await writeManifest(manifestPath, manifest);
|
|
395
|
+
return json(res, 200, { ok: true });
|
|
396
|
+
}
|
|
397
|
+
return next();
|
|
398
|
+
} catch (err) {
|
|
399
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
server.middlewares.use("/__folders", async (req, res, next) => {
|
|
403
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
404
|
+
const method = req.method ?? "GET";
|
|
405
|
+
try {
|
|
406
|
+
if (method === "GET" && url.pathname === "/") {
|
|
407
|
+
const manifest = await readManifest(manifestPath);
|
|
408
|
+
return json(res, 200, manifest);
|
|
409
|
+
}
|
|
410
|
+
if (method === "POST" && url.pathname === "/") {
|
|
411
|
+
const body = await readBody(req);
|
|
412
|
+
const name = validateName(body.name);
|
|
413
|
+
if (!name) return json(res, 400, { error: "invalid name" });
|
|
414
|
+
const icon = validateIcon(body.icon);
|
|
415
|
+
if (!icon) return json(res, 400, { error: "invalid icon" });
|
|
416
|
+
const manifest = await readManifest(manifestPath);
|
|
417
|
+
const folder = {
|
|
418
|
+
id: newFolderId(),
|
|
419
|
+
name,
|
|
420
|
+
icon
|
|
421
|
+
};
|
|
422
|
+
manifest.folders.push(folder);
|
|
423
|
+
await writeManifest(manifestPath, manifest);
|
|
424
|
+
return json(res, 200, folder);
|
|
425
|
+
}
|
|
426
|
+
if (method === "PUT" && url.pathname === "/assign") {
|
|
427
|
+
const body = await readBody(req);
|
|
428
|
+
if (typeof body.slideId !== "string" || !SLIDE_ID_RE.test(body.slideId)) return json(res, 400, { error: "invalid slideId" });
|
|
429
|
+
const slideId = body.slideId;
|
|
430
|
+
let folderId;
|
|
431
|
+
if (body.folderId === null) folderId = null;
|
|
432
|
+
else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
|
|
433
|
+
else return json(res, 400, { error: "invalid folderId" });
|
|
434
|
+
const manifest = await readManifest(manifestPath);
|
|
435
|
+
if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json(res, 404, { error: "folder not found" });
|
|
436
|
+
if (folderId === null) delete manifest.assignments[slideId];
|
|
437
|
+
else manifest.assignments[slideId] = folderId;
|
|
438
|
+
await writeManifest(manifestPath, manifest);
|
|
439
|
+
return json(res, 200, { ok: true });
|
|
440
|
+
}
|
|
441
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
442
|
+
if (idMatch) {
|
|
443
|
+
const id = idMatch[1];
|
|
444
|
+
if (!FOLDER_ID_RE.test(id)) return json(res, 400, { error: "invalid id" });
|
|
445
|
+
if (method === "PATCH") {
|
|
446
|
+
const body = await readBody(req);
|
|
447
|
+
const manifest = await readManifest(manifestPath);
|
|
448
|
+
const folder = manifest.folders.find((f) => f.id === id);
|
|
449
|
+
if (!folder) return json(res, 404, { error: "folder not found" });
|
|
450
|
+
if (body.name !== void 0) {
|
|
451
|
+
const name = validateName(body.name);
|
|
452
|
+
if (!name) return json(res, 400, { error: "invalid name" });
|
|
453
|
+
folder.name = name;
|
|
454
|
+
}
|
|
455
|
+
if (body.icon !== void 0) {
|
|
456
|
+
const icon = validateIcon(body.icon);
|
|
457
|
+
if (!icon) return json(res, 400, { error: "invalid icon" });
|
|
458
|
+
folder.icon = icon;
|
|
459
|
+
}
|
|
460
|
+
await writeManifest(manifestPath, manifest);
|
|
461
|
+
return json(res, 200, folder);
|
|
462
|
+
}
|
|
463
|
+
if (method === "DELETE") {
|
|
464
|
+
const manifest = await readManifest(manifestPath);
|
|
465
|
+
const before = manifest.folders.length;
|
|
466
|
+
manifest.folders = manifest.folders.filter((f) => f.id !== id);
|
|
467
|
+
if (manifest.folders.length === before) return json(res, 404, { error: "folder not found" });
|
|
468
|
+
for (const [slideId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[slideId];
|
|
469
|
+
await writeManifest(manifestPath, manifest);
|
|
470
|
+
return json(res, 200, { ok: true });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
next();
|
|
474
|
+
} catch (err) {
|
|
475
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/vite/open-slide-plugin.ts
|
|
484
|
+
const SLIDES_VMOD = "virtual:open-slide/slides";
|
|
485
|
+
const CONFIG_VMOD = "virtual:open-slide/config";
|
|
486
|
+
function resolved(id) {
|
|
487
|
+
return `\0${id}`;
|
|
488
|
+
}
|
|
489
|
+
async function findSlides(userCwd, slidesDir) {
|
|
490
|
+
const abs = path.resolve(userCwd, slidesDir);
|
|
491
|
+
if (!existsSync(abs)) return [];
|
|
492
|
+
const hits = await fg("*/index.{tsx,jsx,ts,js}", {
|
|
493
|
+
cwd: abs,
|
|
494
|
+
absolute: true,
|
|
495
|
+
onlyFiles: true
|
|
496
|
+
});
|
|
497
|
+
return hits.sort();
|
|
498
|
+
}
|
|
499
|
+
function toId(absFile, slidesRoot) {
|
|
500
|
+
const rel = path.relative(slidesRoot, absFile);
|
|
501
|
+
return rel.split(path.sep)[0];
|
|
502
|
+
}
|
|
503
|
+
function generateSlidesModule(files, slidesRoot, isDev) {
|
|
504
|
+
const entries = files.map((abs) => {
|
|
505
|
+
const id = toId(abs, slidesRoot);
|
|
506
|
+
const importPath = isDev ? `/@fs${abs}` : abs;
|
|
507
|
+
return {
|
|
508
|
+
id,
|
|
509
|
+
importPath
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
const ids = JSON.stringify(entries.map((e) => e.id).sort());
|
|
513
|
+
const cases = entries.map((e) => ` case ${JSON.stringify(e.id)}: return import(${JSON.stringify(e.importPath)});`).join("\n");
|
|
514
|
+
return `// virtual:open-slide/slides — generated
|
|
515
|
+
export const slideIds = ${ids};
|
|
516
|
+
|
|
517
|
+
export async function loadSlide(id) {
|
|
518
|
+
switch (id) {
|
|
519
|
+
${cases}
|
|
520
|
+
default: throw new Error('Slide not found: ' + id);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
`;
|
|
524
|
+
}
|
|
525
|
+
function openSlidePlugin(opts) {
|
|
526
|
+
const { userCwd, config } = opts;
|
|
527
|
+
const slidesDir = config.slidesDir ?? "slides";
|
|
528
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
529
|
+
let isDev = false;
|
|
530
|
+
return {
|
|
531
|
+
name: "open-slide",
|
|
532
|
+
config(_c, env) {
|
|
533
|
+
isDev = env.command === "serve";
|
|
534
|
+
return { server: { fs: { allow: [userCwd] } } };
|
|
535
|
+
},
|
|
536
|
+
resolveId(id) {
|
|
537
|
+
if (id === SLIDES_VMOD) return resolved(SLIDES_VMOD);
|
|
538
|
+
if (id === CONFIG_VMOD) return resolved(CONFIG_VMOD);
|
|
539
|
+
return null;
|
|
540
|
+
},
|
|
541
|
+
async load(id) {
|
|
542
|
+
if (id === resolved(SLIDES_VMOD)) {
|
|
543
|
+
const files = await findSlides(userCwd, slidesDir);
|
|
544
|
+
return generateSlidesModule(files, slidesRoot, isDev);
|
|
545
|
+
}
|
|
546
|
+
if (id === resolved(CONFIG_VMOD)) return `export default ${JSON.stringify(config)};\n`;
|
|
547
|
+
return null;
|
|
548
|
+
},
|
|
549
|
+
configureServer(server) {
|
|
550
|
+
const reload = () => {
|
|
551
|
+
const mod = server.moduleGraph.getModuleById(resolved(SLIDES_VMOD));
|
|
552
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
553
|
+
server.ws.send({ type: "full-reload" });
|
|
554
|
+
};
|
|
555
|
+
server.watcher.add(path.join(slidesRoot, "*"));
|
|
556
|
+
server.watcher.on("add", (p) => {
|
|
557
|
+
if (p.startsWith(slidesRoot)) reload();
|
|
558
|
+
});
|
|
559
|
+
server.watcher.on("unlink", (p) => {
|
|
560
|
+
if (p.startsWith(slidesRoot)) reload();
|
|
561
|
+
});
|
|
562
|
+
server.middlewares.use("/__open-slide/title", (_req, res) => {
|
|
563
|
+
res.setHeader("content-type", "application/json");
|
|
564
|
+
res.end(JSON.stringify({ title: config.title ?? null }));
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
async function loadUserConfig(userCwd) {
|
|
570
|
+
const file = path.join(userCwd, "open-slide.json");
|
|
571
|
+
if (!existsSync(file)) return {};
|
|
572
|
+
const raw = await readFile(file, "utf8");
|
|
573
|
+
return JSON.parse(raw);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
//#endregion
|
|
577
|
+
//#region src/vite/config.ts
|
|
578
|
+
function findPackageRoot(fromFile) {
|
|
579
|
+
let dir = path.dirname(fromFile);
|
|
580
|
+
while (dir !== path.dirname(dir)) {
|
|
581
|
+
if (existsSync(path.join(dir, "package.json"))) return dir;
|
|
582
|
+
dir = path.dirname(dir);
|
|
583
|
+
}
|
|
584
|
+
throw new Error(`Could not find package.json walking up from ${fromFile}`);
|
|
585
|
+
}
|
|
586
|
+
const PKG_ROOT = findPackageRoot(fileURLToPath(import.meta.url));
|
|
587
|
+
const APP_ROOT = path.join(PKG_ROOT, "src", "app");
|
|
588
|
+
async function createViteConfig(opts) {
|
|
589
|
+
const userCwd = path.resolve(opts.userCwd);
|
|
590
|
+
const config = opts.config ?? await loadUserConfig(userCwd);
|
|
591
|
+
const slidesDir = config.slidesDir ?? "slides";
|
|
592
|
+
const slidesAbs = path.resolve(userCwd, slidesDir);
|
|
593
|
+
return {
|
|
594
|
+
root: APP_ROOT,
|
|
595
|
+
configFile: false,
|
|
596
|
+
plugins: [
|
|
597
|
+
react(),
|
|
598
|
+
tailwindcss(),
|
|
599
|
+
openSlidePlugin({
|
|
600
|
+
userCwd,
|
|
601
|
+
config
|
|
602
|
+
}),
|
|
603
|
+
commentsPlugin({
|
|
604
|
+
userCwd,
|
|
605
|
+
slidesDir
|
|
606
|
+
}),
|
|
607
|
+
filesPlugin({
|
|
608
|
+
userCwd,
|
|
609
|
+
slidesDir
|
|
610
|
+
})
|
|
611
|
+
],
|
|
612
|
+
resolve: { alias: { "@": APP_ROOT } },
|
|
613
|
+
optimizeDeps: {
|
|
614
|
+
entries: [path.join(APP_ROOT, "main.tsx")],
|
|
615
|
+
include: [
|
|
616
|
+
"react-router-dom",
|
|
617
|
+
"radix-ui",
|
|
618
|
+
"lucide-react",
|
|
619
|
+
"clsx",
|
|
620
|
+
"tailwind-merge",
|
|
621
|
+
"class-variance-authority",
|
|
622
|
+
"emoji-picker-react"
|
|
623
|
+
]
|
|
624
|
+
},
|
|
625
|
+
server: {
|
|
626
|
+
port: config.port ?? 5173,
|
|
627
|
+
fs: { allow: [
|
|
628
|
+
APP_ROOT,
|
|
629
|
+
userCwd,
|
|
630
|
+
slidesAbs
|
|
631
|
+
] }
|
|
632
|
+
},
|
|
633
|
+
build: {
|
|
634
|
+
outDir: path.resolve(userCwd, "dist"),
|
|
635
|
+
emptyOutDir: true
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
//#endregion
|
|
641
|
+
export { createViteConfig };
|