@hajimism/dek 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ja.md +75 -0
- package/README.md +75 -0
- package/package.json +65 -0
- package/src/cli/address.ts +53 -0
- package/src/cli/build.ts +85 -0
- package/src/cli/check.ts +97 -0
- package/src/cli/commands.ts +851 -0
- package/src/cli/contract.ts +444 -0
- package/src/cli/cues.ts +18 -0
- package/src/cli/files.ts +309 -0
- package/src/cli/flags.ts +295 -0
- package/src/cli/format.ts +156 -0
- package/src/cli/goto.ts +96 -0
- package/src/cli/init.ts +136 -0
- package/src/cli/keep-alive.ts +72 -0
- package/src/cli/lint.ts +84 -0
- package/src/cli/ls.ts +119 -0
- package/src/cli/main.ts +231 -0
- package/src/cli/marks.ts +16 -0
- package/src/cli/mv.ts +37 -0
- package/src/cli/new.ts +64 -0
- package/src/cli/pdf.ts +16 -0
- package/src/cli/qr.ts +466 -0
- package/src/cli/ref.ts +165 -0
- package/src/cli/rehearse.ts +42 -0
- package/src/cli/result.ts +161 -0
- package/src/cli/scope.ts +308 -0
- package/src/cli/serve.ts +125 -0
- package/src/cli/shot.ts +134 -0
- package/src/cli/show.ts +103 -0
- package/src/cli/sync.ts +37 -0
- package/src/cli/text.ts +479 -0
- package/src/cli/theme.ts +56 -0
- package/src/cli/tty.ts +81 -0
- package/src/cli/usage.ts +198 -0
- package/src/cli/video.ts +17 -0
- package/src/cli/voice.ts +113 -0
- package/src/cli.ts +4 -0
- package/src/core/assets.ts +239 -0
- package/src/core/build.ts +77 -0
- package/src/core/bundled-theme.ts +15 -0
- package/src/core/capture-go.ts +72 -0
- package/src/core/chrome.ts +128 -0
- package/src/core/config-keys.ts +82 -0
- package/src/core/config.ts +95 -0
- package/src/core/css-scan.ts +158 -0
- package/src/core/css-transform.ts +140 -0
- package/src/core/css-value.ts +127 -0
- package/src/core/css.ts +545 -0
- package/src/core/cue.ts +187 -0
- package/src/core/deck-paths.ts +34 -0
- package/src/core/diagnostic.ts +114 -0
- package/src/core/document.ts +218 -0
- package/src/core/error.ts +31 -0
- package/src/core/escape.ts +16 -0
- package/src/core/fill.ts +100 -0
- package/src/core/finish-beat.ts +30 -0
- package/src/core/github.ts +234 -0
- package/src/core/html-scan.ts +253 -0
- package/src/core/html.ts +234 -0
- package/src/core/in-page-go.ts +128 -0
- package/src/core/lines.ts +43 -0
- package/src/core/lint/asset-refs.ts +109 -0
- package/src/core/lint/context.ts +116 -0
- package/src/core/lint/files.ts +117 -0
- package/src/core/lint/keys.ts +48 -0
- package/src/core/lint/morph.ts +91 -0
- package/src/core/lint/script.ts +102 -0
- package/src/core/lint/shared-styles.ts +165 -0
- package/src/core/lint/slide-html.ts +466 -0
- package/src/core/lint/slide-script.ts +179 -0
- package/src/core/lint/theme-css.ts +295 -0
- package/src/core/lint/timing.ts +66 -0
- package/src/core/lint/tokens.ts +520 -0
- package/src/core/lint/voice.ts +76 -0
- package/src/core/lint.ts +137 -0
- package/src/core/live-protocol.ts +130 -0
- package/src/core/marks.ts +198 -0
- package/src/core/mv.ts +293 -0
- package/src/core/ogp.ts +99 -0
- package/src/core/optional.ts +24 -0
- package/src/core/overflow.ts +220 -0
- package/src/core/page-pool.ts +53 -0
- package/src/core/page.ts +66 -0
- package/src/core/parse.ts +307 -0
- package/src/core/path.ts +58 -0
- package/src/core/pdf.ts +71 -0
- package/src/core/playwright-module.d.ts +43 -0
- package/src/core/playwright-motion.ts +42 -0
- package/src/core/playwright-sheet.ts +32 -0
- package/src/core/playwright-visual.ts +391 -0
- package/src/core/playwright-worker.ts +5 -0
- package/src/core/playwright.ts +431 -0
- package/src/core/position.ts +146 -0
- package/src/core/presenter-state.ts +43 -0
- package/src/core/presenter.ts +131 -0
- package/src/core/prose.ts +16 -0
- package/src/core/pseudo-text.ts +67 -0
- package/src/core/rail-width.ts +4 -0
- package/src/core/ref-name.ts +110 -0
- package/src/core/ref.ts +238 -0
- package/src/core/resolve.ts +228 -0
- package/src/core/rumdl.ts +99 -0
- package/src/core/safe-fs.ts +196 -0
- package/src/core/sarif.ts +135 -0
- package/src/core/schema.ts +89 -0
- package/src/core/script-lines.ts +53 -0
- package/src/core/sheet.ts +223 -0
- package/src/core/shot/morph.ts +80 -0
- package/src/core/shot/motion.ts +187 -0
- package/src/core/shot/still.ts +216 -0
- package/src/core/shot-cache.ts +140 -0
- package/src/core/size.ts +11 -0
- package/src/core/skeleton-record.ts +51 -0
- package/src/core/skeleton.ts +89 -0
- package/src/core/slide-draw.ts +165 -0
- package/src/core/slide-eval-worker.ts +58 -0
- package/src/core/slide-measure.ts +272 -0
- package/src/core/slide-script-eval.ts +258 -0
- package/src/core/slide-script.ts +252 -0
- package/src/core/spawn.ts +128 -0
- package/src/core/step.ts +94 -0
- package/src/core/still-page.ts +70 -0
- package/src/core/suggest.ts +33 -0
- package/src/core/sync.ts +316 -0
- package/src/core/text-contrast.ts +466 -0
- package/src/core/theme-excerpt.ts +187 -0
- package/src/core/theme-facts.ts +51 -0
- package/src/core/timeline.ts +216 -0
- package/src/core/timing.ts +88 -0
- package/src/core/toml-keys.ts +349 -0
- package/src/core/url-attributes.ts +103 -0
- package/src/core/video-document.ts +15 -0
- package/src/core/visual-cache.ts +100 -0
- package/src/core/visual.ts +558 -0
- package/src/core/voice.ts +321 -0
- package/src/core/zod.ts +79 -0
- package/src/runtime/browser.ts +203 -0
- package/src/runtime/clone.ts +52 -0
- package/src/runtime/deck-control.ts +43 -0
- package/src/runtime/fit.ts +32 -0
- package/src/runtime/go.ts +56 -0
- package/src/runtime/input.ts +128 -0
- package/src/runtime/laser-view.ts +110 -0
- package/src/runtime/laser.ts +145 -0
- package/src/runtime/live-host.ts +33 -0
- package/src/runtime/live-reload.ts +29 -0
- package/src/runtime/live.ts +88 -0
- package/src/runtime/marks-view.ts +74 -0
- package/src/runtime/motion.ts +113 -0
- package/src/runtime/navigator.ts +189 -0
- package/src/runtime/player.ts +28 -0
- package/src/runtime/presenter-view.ts +289 -0
- package/src/runtime/presenter.ts +52 -0
- package/src/runtime/rail-view.ts +229 -0
- package/src/runtime/rail.ts +52 -0
- package/src/runtime/rehearse.ts +264 -0
- package/src/runtime/routes.ts +142 -0
- package/src/runtime/settle.ts +45 -0
- package/src/runtime/slide.d.ts +28 -0
- package/src/runtime/socket.ts +143 -0
- package/src/runtime/stage.ts +259 -0
- package/src/runtime/step.ts +139 -0
- package/src/runtime/window.ts +38 -0
- package/src/server/auth.ts +210 -0
- package/src/server/deck-snapshot.ts +146 -0
- package/src/server/dev.ts +251 -0
- package/src/server/hub.ts +104 -0
- package/src/server/lan.ts +51 -0
- package/src/server/lock.ts +97 -0
- package/src/server/pages.ts +71 -0
- package/src/server/pairing.ts +40 -0
- package/src/server/project-watch.ts +142 -0
- package/src/server/rooms.ts +133 -0
- package/src/server/routes.ts +286 -0
- package/src/server/serial.ts +25 -0
- package/src/server/watch.ts +308 -0
- package/src/theme/default.css +175 -0
- package/src/video/bake.ts +152 -0
- package/src/video/mux.ts +103 -0
- package/src/video/recorder.ts +115 -0
- package/src/video/sidecar.ts +60 -0
- package/src/video/worker.ts +51 -0
- package/src/voice/engine.ts +177 -0
- package/src/voice/synth.ts +127 -0
- package/src/voice/wav.ts +115 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileInside, readTheme } from "../core/assets.ts";
|
|
4
|
+
import { DekError, errorFields } from "../core/error.ts";
|
|
5
|
+
import { escapeAttr, escapeHtml } from "../core/escape.ts";
|
|
6
|
+
import { deckSlides, htmlShell, slidePlace, stampSlide } from "../core/html.ts";
|
|
7
|
+
import { decodePosition } from "../core/live-protocol.ts";
|
|
8
|
+
import { markedPositions, toggleMark } from "../core/marks.ts";
|
|
9
|
+
import type { Project, ProjectDeck } from "../core/resolve.ts";
|
|
10
|
+
import { voiceCacheFile } from "../core/voice.ts";
|
|
11
|
+
import { deckRoutePath, parseDeckRoute, splitDeckPath, type VoiceFile } from "../runtime/routes.ts";
|
|
12
|
+
import { EVENT_EXPOSURE, type Exposure, mayRead, type Route } from "./auth.ts";
|
|
13
|
+
import type { EventHub } from "./hub.ts";
|
|
14
|
+
import type { RoomClient, Rooms } from "./rooms.ts";
|
|
15
|
+
|
|
16
|
+
/** What a socket carries: its deck's room, and whether it may move the room. */
|
|
17
|
+
export type WsData = { room: string; control: boolean };
|
|
18
|
+
|
|
19
|
+
/** What the routes read: the server's scope, its project as it is now, and its state. */
|
|
20
|
+
export type RouteContext = {
|
|
21
|
+
/** The deck a server scoped to one deck serves. */
|
|
22
|
+
scopedDeckName?: string;
|
|
23
|
+
deckDir?: string;
|
|
24
|
+
project(): Project;
|
|
25
|
+
hub: EventHub;
|
|
26
|
+
rooms: Rooms<RoomClient>;
|
|
27
|
+
/** A deck's live page. */
|
|
28
|
+
deckPage(dir: string, mode: "player" | "presenter"): Response;
|
|
29
|
+
/** The project's list of decks. */
|
|
30
|
+
indexPage(): Response;
|
|
31
|
+
/** Turn the request into a socket; false when it asked for none. */
|
|
32
|
+
upgrade(req: Request, data: WsData): boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type Routed = {
|
|
36
|
+
route: Route;
|
|
37
|
+
respond: (seen: Exposure) => Response | undefined | Promise<Response | undefined>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Which route a request names, and how it answers. The server checks the route's exposure
|
|
42
|
+
* before it answers, so no route guards itself; `seen` is what the request is cleared for.
|
|
43
|
+
*/
|
|
44
|
+
export function routeRequest(req: Request, ctx: RouteContext): Routed {
|
|
45
|
+
const url = new URL(req.url);
|
|
46
|
+
const split = splitDeckPath(url.pathname, ctx.scopedDeckName);
|
|
47
|
+
if (!split) {
|
|
48
|
+
return url.pathname === "/" || url.pathname === ""
|
|
49
|
+
? { route: "index", respond: () => ctx.indexPage() }
|
|
50
|
+
: MISSING;
|
|
51
|
+
}
|
|
52
|
+
const route = parseDeckRoute(split.rest);
|
|
53
|
+
if (!route) {
|
|
54
|
+
return MISSING;
|
|
55
|
+
}
|
|
56
|
+
const { deckName } = split;
|
|
57
|
+
const deck = ctx.project().decks.find((entry) => entry.name === deckName);
|
|
58
|
+
// A server scoped to a deck serves its page even while the script fails to parse.
|
|
59
|
+
const dir = ctx.scopedDeckName !== undefined ? ctx.deckDir : deck?.dir;
|
|
60
|
+
switch (route.kind) {
|
|
61
|
+
case "socket":
|
|
62
|
+
return {
|
|
63
|
+
route: "socket",
|
|
64
|
+
respond: (seen) =>
|
|
65
|
+
ctx.upgrade(req, { room: deckName, control: seen === "presenter" })
|
|
66
|
+
? undefined
|
|
67
|
+
: new Response("Expected WebSocket", { status: 400 }),
|
|
68
|
+
};
|
|
69
|
+
case "events":
|
|
70
|
+
return { route: "events", respond: (seen) => sseResponse(ctx.hub, deckName, seen) };
|
|
71
|
+
case "slide":
|
|
72
|
+
return {
|
|
73
|
+
route: "slide",
|
|
74
|
+
respond: () => {
|
|
75
|
+
// A slide the script does not list has no place in the deck, so no page shows it.
|
|
76
|
+
const sections = deck?.deck.sections ?? [];
|
|
77
|
+
const index = sections.findIndex((section) => section.slug === route.slug);
|
|
78
|
+
const html = deck && index >= 0 && deckSlides(deck).written(route.slug);
|
|
79
|
+
return html
|
|
80
|
+
? htmlResponse(
|
|
81
|
+
stampSlide(html, { slug: route.slug, place: slidePlace(sections, index) }),
|
|
82
|
+
)
|
|
83
|
+
: notFound();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
case "theme":
|
|
87
|
+
return {
|
|
88
|
+
route: "theme",
|
|
89
|
+
respond: () =>
|
|
90
|
+
deck
|
|
91
|
+
? new Response(readTheme(deck.dir, false), {
|
|
92
|
+
headers: {
|
|
93
|
+
"content-type": "text/css; charset=utf-8",
|
|
94
|
+
"cache-control": "no-store",
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
: notFound(),
|
|
98
|
+
};
|
|
99
|
+
case "voice":
|
|
100
|
+
return { route: "voice", respond: () => voiceResponse(deck, route.file) };
|
|
101
|
+
case "current":
|
|
102
|
+
case "goto":
|
|
103
|
+
return {
|
|
104
|
+
route: "control",
|
|
105
|
+
respond: () => navResponse(req, { room: deckName, action: route.kind }, ctx.rooms),
|
|
106
|
+
};
|
|
107
|
+
case "marks":
|
|
108
|
+
return {
|
|
109
|
+
route: "marks",
|
|
110
|
+
respond: () =>
|
|
111
|
+
deck ? marksResponse(req, ctx.project().root, deck) : jsonNotFound(deckName),
|
|
112
|
+
};
|
|
113
|
+
case "asset": {
|
|
114
|
+
const asset = dir && safeDeckAsset(dir, route.path);
|
|
115
|
+
return asset
|
|
116
|
+
? {
|
|
117
|
+
route: "asset",
|
|
118
|
+
respond: () =>
|
|
119
|
+
new Response(Bun.file(asset), { headers: { "cache-control": "no-store" } }),
|
|
120
|
+
}
|
|
121
|
+
: MISSING;
|
|
122
|
+
}
|
|
123
|
+
case "player":
|
|
124
|
+
case "presenter": {
|
|
125
|
+
const mode = route.kind;
|
|
126
|
+
return dir ? { route: mode, respond: () => ctx.deckPage(dir, mode) } : MISSING;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const MISSING: Routed = { route: "missing", respond: notFound };
|
|
132
|
+
|
|
133
|
+
function voiceResponse(deck: ProjectDeck | undefined, file: VoiceFile): Response {
|
|
134
|
+
const path = deck && voiceCacheFile(deck.dir, file);
|
|
135
|
+
if (!path || !existsSync(path)) {
|
|
136
|
+
return notFound();
|
|
137
|
+
}
|
|
138
|
+
return new Response(Bun.file(path), {
|
|
139
|
+
headers: {
|
|
140
|
+
"content-type": file === "audio.wav" ? "audio/wav" : "application/json; charset=utf-8",
|
|
141
|
+
"cache-control": "no-store",
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `dekc current` and `dekc goto`: where a deck stands, or send it to a slide. */
|
|
147
|
+
async function navResponse(
|
|
148
|
+
req: Request,
|
|
149
|
+
nav: { room: string; action: "current" | "goto" },
|
|
150
|
+
rooms: Rooms<RoomClient>,
|
|
151
|
+
): Promise<Response> {
|
|
152
|
+
if (nav.action === "current" && req.method === "GET") {
|
|
153
|
+
return jsonResponse({ ok: true, ...rooms.current(nav.room) });
|
|
154
|
+
}
|
|
155
|
+
if (nav.action === "goto" && req.method === "POST") {
|
|
156
|
+
let body: { slug?: string } = {};
|
|
157
|
+
try {
|
|
158
|
+
body = (await req.json()) as { slug?: string };
|
|
159
|
+
} catch {
|
|
160
|
+
body = {};
|
|
161
|
+
}
|
|
162
|
+
const slug = body.slug?.trim();
|
|
163
|
+
if (!slug) {
|
|
164
|
+
return jsonResponse(
|
|
165
|
+
{ ok: false, error: { message: 'the request names no slide: send {"slug": "<slug>"}' } },
|
|
166
|
+
400,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const moved = rooms.goto(nav.room, slug);
|
|
170
|
+
return moved.ok
|
|
171
|
+
? jsonResponse({ ok: true, ...moved.at })
|
|
172
|
+
: jsonResponse({ ok: false, error: { message: moved.message } }, 404);
|
|
173
|
+
}
|
|
174
|
+
return new Response("Method not allowed", { status: 405 });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The presenter's marks on a deck: GET lists where they are, and a POST of a position marks the
|
|
179
|
+
* beat there, or unmarks it when it is marked. Both answer with where the deck's marks are now.
|
|
180
|
+
*/
|
|
181
|
+
async function marksResponse(req: Request, root: string, deck: ProjectDeck): Promise<Response> {
|
|
182
|
+
if (req.method === "GET") {
|
|
183
|
+
return jsonResponse({ ok: true, positions: markedPositions(root, deck) });
|
|
184
|
+
}
|
|
185
|
+
if (req.method !== "POST") {
|
|
186
|
+
return new Response("Method not allowed", { status: 405 });
|
|
187
|
+
}
|
|
188
|
+
let body: unknown;
|
|
189
|
+
try {
|
|
190
|
+
body = await req.json();
|
|
191
|
+
} catch {
|
|
192
|
+
body = undefined;
|
|
193
|
+
}
|
|
194
|
+
const position = decodePosition(body);
|
|
195
|
+
if (!position) {
|
|
196
|
+
return jsonResponse(
|
|
197
|
+
{
|
|
198
|
+
ok: false,
|
|
199
|
+
error: { message: 'the request names no beat: send {"slideIndex": n, "beatIndex": n}' },
|
|
200
|
+
},
|
|
201
|
+
400,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
return jsonResponse({ ok: true, ...toggleMark(root, deck, position) });
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (!(error instanceof DekError)) {
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
return jsonResponse({ ok: false, error: errorFields(error) }, 404);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function jsonNotFound(deckName: string): Response {
|
|
215
|
+
return jsonResponse({ ok: false, error: { message: `deck "${deckName}" not found` } }, 404);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function safeDeckAsset(deckDir: string, relative: string): string | undefined {
|
|
219
|
+
const root = join(deckDir, "assets");
|
|
220
|
+
const found = fileInside(join(root, relative), root);
|
|
221
|
+
return found.kind === "file" ? found.path : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
225
|
+
return new Response(`${JSON.stringify(body)}\n`, {
|
|
226
|
+
status,
|
|
227
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function htmlResponse(html: string, status = 200): Response {
|
|
232
|
+
return new Response(html, {
|
|
233
|
+
status,
|
|
234
|
+
headers: {
|
|
235
|
+
"content-type": "text/html; charset=utf-8",
|
|
236
|
+
"cache-control": "no-store",
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function errorPage(error: unknown): string {
|
|
242
|
+
const { message, hint } = errorFields(error);
|
|
243
|
+
return htmlShell({
|
|
244
|
+
title: "dek",
|
|
245
|
+
body: `<pre>${escapeHtml(hint ? `${message}\n${hint}` : message)}</pre>`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The project's list of decks, each with its presenter view, and the decks that failed to load. */
|
|
250
|
+
export function renderIndexHtml(
|
|
251
|
+
decks: Array<{ name: string; title: string }>,
|
|
252
|
+
failed: Array<{ name: string }> = [],
|
|
253
|
+
): string {
|
|
254
|
+
const items = decks
|
|
255
|
+
.map(
|
|
256
|
+
(deck) =>
|
|
257
|
+
`<li><a href="${escapeAttr(deckRoutePath(deck.name, { kind: "player" }))}">${escapeHtml(deck.name)} — ${escapeHtml(deck.title)}</a> · <a href="${escapeAttr(deckRoutePath(deck.name, { kind: "presenter" }))}">presenter</a></li>`,
|
|
258
|
+
)
|
|
259
|
+
.join("");
|
|
260
|
+
const failedItems = failed.map((entry) => `<li>${escapeHtml(entry.name)}</li>`).join("");
|
|
261
|
+
const failedBlock = failed.length > 0 ? `<h2>failed</h2><ul>${failedItems}</ul>` : "";
|
|
262
|
+
return htmlShell({
|
|
263
|
+
title: "dek",
|
|
264
|
+
body: `<h1>decks</h1>
|
|
265
|
+
<ul>${items}</ul>
|
|
266
|
+
${failedBlock}`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A deck's event stream, carrying only the events the request is cleared to read. */
|
|
271
|
+
function sseResponse(hub: EventHub, deckName: string, seen: Exposure): Response {
|
|
272
|
+
return new Response(
|
|
273
|
+
hub.sse((event) => event.deck === deckName && mayRead(seen, EVENT_EXPOSURE[event.type])),
|
|
274
|
+
{
|
|
275
|
+
headers: {
|
|
276
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
277
|
+
"cache-control": "no-store",
|
|
278
|
+
connection: "keep-alive",
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function notFound(): Response {
|
|
285
|
+
return new Response("Not found", { status: 404 });
|
|
286
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function createSerialTask(run: () => Promise<void>): () => void {
|
|
2
|
+
let running = false;
|
|
3
|
+
let queued = false;
|
|
4
|
+
return () => {
|
|
5
|
+
if (running) {
|
|
6
|
+
queued = true;
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
running = true;
|
|
10
|
+
void (async () => {
|
|
11
|
+
try {
|
|
12
|
+
do {
|
|
13
|
+
queued = false;
|
|
14
|
+
try {
|
|
15
|
+
await run();
|
|
16
|
+
} catch {
|
|
17
|
+
/* keep draining the trailing run */
|
|
18
|
+
}
|
|
19
|
+
} while (queued);
|
|
20
|
+
} finally {
|
|
21
|
+
running = false;
|
|
22
|
+
}
|
|
23
|
+
})();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { existsSync, watch } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { deckPaths } from "../core/deck-paths.ts";
|
|
4
|
+
import { type Diagnostic, errorDiagnostic } from "../core/diagnostic.ts";
|
|
5
|
+
import { DekError, errorFields } from "../core/error.ts";
|
|
6
|
+
import { lintDeckAsync, lintProject, unreadableScriptDiagnostics } from "../core/lint.ts";
|
|
7
|
+
import type { LiveEvent } from "../core/live-protocol.ts";
|
|
8
|
+
import { ScriptError } from "../core/parse.ts";
|
|
9
|
+
import type { PlaywrightRunner } from "../core/playwright.ts";
|
|
10
|
+
import { resolveDeck } from "../core/resolve.ts";
|
|
11
|
+
import { type SyncResult, syncDeck } from "../core/sync.ts";
|
|
12
|
+
import { lintVisualDeck } from "../core/visual.ts";
|
|
13
|
+
import {
|
|
14
|
+
type DiagnoseScope,
|
|
15
|
+
diffSnapshot,
|
|
16
|
+
mergeScope,
|
|
17
|
+
slideMtimes,
|
|
18
|
+
takeSnapshot,
|
|
19
|
+
} from "./deck-snapshot.ts";
|
|
20
|
+
import { createSerialTask } from "./serial.ts";
|
|
21
|
+
|
|
22
|
+
export type Stoppable = { close: () => void };
|
|
23
|
+
|
|
24
|
+
/** How often a watcher re-scans for edits fs.watch missed; shared by the deck and project polls. */
|
|
25
|
+
export const POLL_INTERVAL_MS = 2000;
|
|
26
|
+
|
|
27
|
+
type WatchDeckOptions = {
|
|
28
|
+
visual?: boolean;
|
|
29
|
+
visualRunner?: PlaywrightRunner;
|
|
30
|
+
pollIntervalMs?: number;
|
|
31
|
+
setInterval?: typeof setInterval;
|
|
32
|
+
clearInterval?: typeof clearInterval;
|
|
33
|
+
synthVoice?: () => Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Watch one deck and `emit` what its open pages must do: each scan diffs the deck's files
|
|
38
|
+
* against the last scan (see `diffSnapshot`), then syncs, lints, and synthesizes as it says.
|
|
39
|
+
*/
|
|
40
|
+
export function watchDeck(
|
|
41
|
+
deckDir: string,
|
|
42
|
+
emit: (event: LiveEvent) => void,
|
|
43
|
+
options: WatchDeckOptions = {},
|
|
44
|
+
): Stoppable {
|
|
45
|
+
let snapshot = takeSnapshot(deckDir);
|
|
46
|
+
let closed = false;
|
|
47
|
+
const isClosed = () => closed;
|
|
48
|
+
const diagnose = createDiagnose(deckDir, options);
|
|
49
|
+
const requestDiagnostics = createDiagnosticsQueue(diagnose, emit, isClosed);
|
|
50
|
+
const synthVoice = createVoiceSynth(deckDir, emit, isClosed, options.synthVoice);
|
|
51
|
+
|
|
52
|
+
const syncScript = (announceEmpty: boolean): void => {
|
|
53
|
+
const event = syncEvent(syncQuietly(deckDir), announceEmpty);
|
|
54
|
+
// A sync event reloads the whole page, so the slides it wrote need no reload-slide.
|
|
55
|
+
snapshot = { ...snapshot, slides: slideMtimes(deckDir) };
|
|
56
|
+
if (event) {
|
|
57
|
+
emit(event);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const scanOnce = (): void => {
|
|
62
|
+
const next = takeSnapshot(deckDir);
|
|
63
|
+
const change = diffSnapshot(snapshot, next);
|
|
64
|
+
snapshot = next;
|
|
65
|
+
if (change.sync) {
|
|
66
|
+
syncScript(true);
|
|
67
|
+
}
|
|
68
|
+
for (const event of change.events) {
|
|
69
|
+
emit(event);
|
|
70
|
+
}
|
|
71
|
+
if (change.diagnose) {
|
|
72
|
+
requestDiagnostics(change.diagnose);
|
|
73
|
+
}
|
|
74
|
+
if (change.synth) {
|
|
75
|
+
synthVoice();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const scan = (): void => {
|
|
80
|
+
if (closed) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// A scan runs from a timer, where a throw would end the server: a folder that turned into a
|
|
84
|
+
// link out of the project, or one deleted between the listing and the read, is reported.
|
|
85
|
+
try {
|
|
86
|
+
scanOnce();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!closed && existsSync(deckDir)) {
|
|
89
|
+
emit({ type: "diagnostics", diagnostics: [watchErrorDiagnostic(error)] });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const triggers = startScanTriggers(deckDir, scan, options, isClosed);
|
|
95
|
+
// A script written before the server started still gets its skeletons.
|
|
96
|
+
syncScript(false);
|
|
97
|
+
requestDiagnostics({});
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
close() {
|
|
101
|
+
closed = true;
|
|
102
|
+
triggers.close();
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run `scan` shortly after fs.watch reports an edit, and on a poll for the edits it misses; a
|
|
109
|
+
* watcher that fails turns the poll on even when it was off.
|
|
110
|
+
*/
|
|
111
|
+
function startScanTriggers(
|
|
112
|
+
deckDir: string,
|
|
113
|
+
scan: () => void,
|
|
114
|
+
options: WatchDeckOptions,
|
|
115
|
+
isClosed: () => boolean,
|
|
116
|
+
): Stoppable {
|
|
117
|
+
const startInterval = options.setInterval ?? setInterval;
|
|
118
|
+
const stopInterval = options.clearInterval ?? clearInterval;
|
|
119
|
+
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
120
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
121
|
+
|
|
122
|
+
const schedule = (): void => {
|
|
123
|
+
if (debounce) {
|
|
124
|
+
clearTimeout(debounce);
|
|
125
|
+
}
|
|
126
|
+
debounce = setTimeout(scan, 20);
|
|
127
|
+
};
|
|
128
|
+
const startPoll = (ms: number): void => {
|
|
129
|
+
if (timer || isClosed() || ms <= 0) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
timer = startInterval(scan, ms);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const watcher = watch(deckDir, { recursive: true }, schedule);
|
|
136
|
+
startPoll(options.pollIntervalMs ?? POLL_INTERVAL_MS);
|
|
137
|
+
if (typeof watcher.on === "function") {
|
|
138
|
+
watcher.on("error", () => {
|
|
139
|
+
startPoll(options.pollIntervalMs || POLL_INTERVAL_MS);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
close() {
|
|
145
|
+
if (debounce) {
|
|
146
|
+
clearTimeout(debounce);
|
|
147
|
+
}
|
|
148
|
+
if (timer) {
|
|
149
|
+
stopInterval(timer);
|
|
150
|
+
}
|
|
151
|
+
watcher.close();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One diagnostics pass over the deck: the lint rules, then the visual pass when it is on. */
|
|
157
|
+
function createDiagnose(
|
|
158
|
+
deckDir: string,
|
|
159
|
+
options: WatchDeckOptions,
|
|
160
|
+
): (scope: DiagnoseScope) => Promise<Diagnostic[]> {
|
|
161
|
+
// A browser that is not installed turns the visual pass off until one is found again.
|
|
162
|
+
let visualEnabled = options.visual === true;
|
|
163
|
+
return async (scope) => {
|
|
164
|
+
const { diagnostics, resolved } = await lintPass(deckDir);
|
|
165
|
+
if (visualEnabled && resolved) {
|
|
166
|
+
try {
|
|
167
|
+
const visual = await lintVisualDeck(resolved, {
|
|
168
|
+
...(scope.slug !== undefined ? { slug: scope.slug } : {}),
|
|
169
|
+
...(options.visualRunner ? { runner: options.visualRunner } : {}),
|
|
170
|
+
});
|
|
171
|
+
visualEnabled = visual !== null;
|
|
172
|
+
diagnostics.push(...(visual ?? []));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
diagnostics.push(watchErrorDiagnostic(error));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return diagnostics;
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The lint rules over the deck, and the deck as resolved when it resolves. */
|
|
182
|
+
async function lintPass(
|
|
183
|
+
deckDir: string,
|
|
184
|
+
): Promise<{ diagnostics: Diagnostic[]; resolved?: ReturnType<typeof resolveDeck> }> {
|
|
185
|
+
let resolved: ReturnType<typeof resolveDeck> | undefined;
|
|
186
|
+
try {
|
|
187
|
+
resolved = resolveDeck(deckDir);
|
|
188
|
+
return {
|
|
189
|
+
diagnostics: [...lintProject(resolved.project), ...(await lintDeckAsync(resolved))],
|
|
190
|
+
resolved,
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// A script that does not read says every reason at once, as lint does.
|
|
194
|
+
const diagnostics =
|
|
195
|
+
error instanceof ScriptError
|
|
196
|
+
? unreadableScriptDiagnostics({ scriptPath: deckPaths(deckDir).script, error })
|
|
197
|
+
: [watchErrorDiagnostic(error)];
|
|
198
|
+
// A deck that resolved but failed to lint still gets its visual pass.
|
|
199
|
+
return { diagnostics, ...(resolved ? { resolved } : {}) };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Run one diagnostics pass at a time and `emit` what each finds, unless the watch closed. */
|
|
204
|
+
function createDiagnosticsQueue(
|
|
205
|
+
diagnose: (scope: DiagnoseScope) => Promise<Diagnostic[]>,
|
|
206
|
+
emit: (event: LiveEvent) => void,
|
|
207
|
+
isClosed: () => boolean,
|
|
208
|
+
): (scope: DiagnoseScope) => void {
|
|
209
|
+
// Edits that land while a pass runs are merged into one pass after it.
|
|
210
|
+
let pending: DiagnoseScope | undefined;
|
|
211
|
+
const run = createSerialTask(async () => {
|
|
212
|
+
const scope = pending;
|
|
213
|
+
pending = undefined;
|
|
214
|
+
if (!scope || isClosed()) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const diagnostics = await diagnose(scope);
|
|
218
|
+
if (!isClosed()) {
|
|
219
|
+
emit({ type: "diagnostics", diagnostics });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return (scope) => {
|
|
223
|
+
pending = mergeScope(pending, scope);
|
|
224
|
+
run();
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Synthesize the deck's voice, one run at a time, and tell its pages the timeline changed. */
|
|
229
|
+
function createVoiceSynth(
|
|
230
|
+
deckDir: string,
|
|
231
|
+
emit: (event: LiveEvent) => void,
|
|
232
|
+
isClosed: () => boolean,
|
|
233
|
+
synthVoice: (() => Promise<void>) | undefined,
|
|
234
|
+
): () => void {
|
|
235
|
+
const synth = async (): Promise<boolean> => {
|
|
236
|
+
if (synthVoice) {
|
|
237
|
+
await synthVoice();
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
const { hasVoice } = await import("../core/voice.ts");
|
|
241
|
+
if (!hasVoice(deckDir)) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
const { synthDeck } = await import("../voice/synth.ts");
|
|
245
|
+
await synthDeck(deckDir);
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
return createSerialTask(async () => {
|
|
249
|
+
try {
|
|
250
|
+
// A synthesis that outlives the watch has no pages left to tell.
|
|
251
|
+
if ((await synth()) && !isClosed()) {
|
|
252
|
+
emit({ type: "timeline" });
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// Voice is optional: an engine that is not running is worth a line, not a stack trace.
|
|
256
|
+
console.error(voiceFailureLine(error));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Create skeletons for sections without slide HTML. A script the author cannot parse yet is
|
|
263
|
+
* reported by the diagnostics pass that follows, so the failure itself stays quiet here.
|
|
264
|
+
*/
|
|
265
|
+
function syncQuietly(deckDir: string): SyncResult {
|
|
266
|
+
try {
|
|
267
|
+
return syncDeck(deckDir);
|
|
268
|
+
} catch {
|
|
269
|
+
// resolveDeck in diagnose reports the same error with its path and line.
|
|
270
|
+
return {
|
|
271
|
+
created: [],
|
|
272
|
+
updated: [],
|
|
273
|
+
removed: [],
|
|
274
|
+
kept: [],
|
|
275
|
+
dekFiles: { created: [], updated: [] },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The sync event a page hears, or none when nothing changed and none is owed. */
|
|
281
|
+
function syncEvent(result: SyncResult, announceEmpty: boolean): LiveEvent | undefined {
|
|
282
|
+
// Slides go by slug: the stream reaches the audience, and a path on disk names the
|
|
283
|
+
// presenter's home and user.
|
|
284
|
+
const slugs = (paths: string[]) => paths.map((path) => basename(path, ".html"));
|
|
285
|
+
const created = slugs(result.created);
|
|
286
|
+
const updated = slugs(result.updated);
|
|
287
|
+
const removed = slugs(result.removed);
|
|
288
|
+
if (created.length === 0 && updated.length === 0 && removed.length === 0 && !announceEmpty) {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
type: "sync",
|
|
293
|
+
created,
|
|
294
|
+
...(updated.length > 0 ? { updated } : {}),
|
|
295
|
+
...(removed.length > 0 ? { removed } : {}),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A failed synthesis as one line on the server's stderr: what failed, then the fix. */
|
|
300
|
+
export function voiceFailureLine(error: unknown): string {
|
|
301
|
+
const { message, hint } = errorFields(error);
|
|
302
|
+
return `voice: ${message}${hint ? ` (${hint})` : ""}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A failure while watching, as the diagnostic a page shows: a DekError keeps its hint. */
|
|
306
|
+
export function watchErrorDiagnostic(error: unknown): Diagnostic {
|
|
307
|
+
return errorDiagnostic(error instanceof DekError ? "parse" : "error", errorFields(error));
|
|
308
|
+
}
|