@hajimism/dek 0.5.0 → 0.6.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/CHANGELOG.md +37 -0
- package/package.json +12 -5
- package/src/cli/address.ts +48 -11
- package/src/cli/build.ts +3 -1
- package/src/cli/commands.ts +24 -10
- package/src/cli/contract.ts +28 -2
- package/src/cli/flags.ts +5 -0
- package/src/cli/keep-alive.ts +12 -6
- package/src/cli/lint.ts +53 -1
- package/src/cli/main.ts +2 -2
- package/src/cli/sync.ts +21 -6
- package/src/cli/text.ts +20 -2
- package/src/cli/theme.ts +1 -1
- package/src/core/build.ts +4 -0
- package/src/core/config-keys.ts +7 -2
- package/src/core/config.ts +63 -0
- package/src/core/diagnostic.ts +9 -1
- package/src/core/document.ts +28 -3
- package/src/core/lint/context.ts +2 -0
- package/src/core/lint/shared-styles.ts +26 -1
- package/src/core/lint/theme-css.ts +64 -3
- package/src/core/lint.ts +15 -2
- package/src/core/page.ts +1 -0
- package/src/core/plugin-host.ts +669 -0
- package/src/core/plugin.ts +436 -0
- package/src/core/sandbox.ts +1 -1
- package/src/core/sarif.ts +47 -5
- package/src/core/slide-measure.ts +1 -0
- package/src/core/spawn.ts +9 -7
- package/src/core/sync.ts +3 -1
- package/src/plugin-api.ts +126 -0
- package/src/plugins/study/check.ts +128 -0
- package/src/plugins/study/main.ts +47 -0
- package/src/plugins/study/manifest.ts +32 -0
- package/src/plugins/study/page.ts +295 -0
- package/src/plugins/study/place.ts +88 -0
- package/src/plugins/study/sarif.ts +30 -0
- package/src/plugins/study/study.ts +137 -0
- package/src/plugins/study/text.ts +55 -0
- package/src/plugins/study/yaml.ts +226 -0
- package/src/plugins/words/check.ts +109 -0
- package/src/plugins/words/lines.ts +26 -0
- package/src/plugins/words/list.ts +288 -0
- package/src/plugins/words/main.ts +50 -0
- package/src/plugins/words/manifest.ts +36 -0
- package/src/plugins/words/match.ts +60 -0
- package/src/plugins/words/sarif.ts +37 -0
- package/src/plugins/words/text.ts +352 -0
- package/src/plugins/words/yaml.ts +225 -0
- package/src/runtime/player.ts +18 -2
- package/src/runtime/plugins.ts +236 -0
- package/src/runtime/start.ts +16 -1
- package/src/runtime/window.ts +3 -0
- package/src/server/deck-snapshot.ts +32 -1
- package/src/server/dev.ts +9 -3
- package/src/server/lock.ts +39 -9
- package/src/server/pages.ts +9 -1
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import { PAGE_ID } from "../core/page.ts";
|
|
4
|
+
import type { Position } from "../core/step.ts";
|
|
5
|
+
import type { DekPage, PageApi, PageMount, PagePosition } from "../plugin-api.ts";
|
|
6
|
+
import { isLetterKey, isTextEntry } from "./step.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Page API 1 (ADR 33): `window.dek.plugin`, which a plugin's page calls once it runs, after the
|
|
10
|
+
* player. Each plugin gets a layer over every slide, in the slide's own pixels, kept in `#deck`
|
|
11
|
+
* beside the slides so that it scales and moves with them, and is captured with them in a view
|
|
12
|
+
* transition, while a slide's own markup, which lint checked, is never touched.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What the page carries for each plugin: its declared keys and its data files. */
|
|
16
|
+
type PluginConfig = Record<
|
|
17
|
+
string,
|
|
18
|
+
{ keys: string[]; data: Record<string, { text: string; version: string }> }
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
export type PluginHost = {
|
|
22
|
+
/** Show the layers of the slide at `pos`; runs inside each move, as the stage draws it. */
|
|
23
|
+
render(pos: Position): void;
|
|
24
|
+
/** The deck settled at `pos`: every plugin hears where it stands. */
|
|
25
|
+
settled(pos: Position): void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Registered = {
|
|
29
|
+
name: string;
|
|
30
|
+
change: Array<(position: PagePosition) => void>;
|
|
31
|
+
mount: Array<(mount: PageMount) => void>;
|
|
32
|
+
layers: Map<string, HTMLElement>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const LAYER_CLASS = "dek-plugin-layer";
|
|
36
|
+
const PAGE_API = 1;
|
|
37
|
+
|
|
38
|
+
export function createPluginHost(options: {
|
|
39
|
+
deck: string;
|
|
40
|
+
slides: Array<{ slug: string; title: string; beats: unknown[] }>;
|
|
41
|
+
position: () => Position;
|
|
42
|
+
}): PluginHost {
|
|
43
|
+
// The plugins' block comes after the player in the page, so it is read when a plugin asks.
|
|
44
|
+
let config: PluginConfig | undefined;
|
|
45
|
+
const registered: Registered[] = [];
|
|
46
|
+
const keys = new Map<string, { plugin: string; listener: () => void }>();
|
|
47
|
+
|
|
48
|
+
const at = (pos: Position): PagePosition => ({
|
|
49
|
+
slug: options.slides[pos.slideIndex]?.slug ?? "",
|
|
50
|
+
index: pos.slideIndex,
|
|
51
|
+
beat: pos.beatIndex,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Runs a plugin's code; what it throws is the plugin's, and the player goes on. */
|
|
55
|
+
const guard = (name: string, run: () => void): void => {
|
|
56
|
+
try {
|
|
57
|
+
run();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(`dek: plugin ${name} threw`, error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const slideEl = (slug: string): HTMLElement | null =>
|
|
64
|
+
document.querySelector<HTMLElement>(
|
|
65
|
+
`#${PAGE_ID.deck} > .slide[data-slug="${CSS.escape(slug)}"]`,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/** The plugin's layer over `slug`, made on first use, after the slides in `#deck`. */
|
|
69
|
+
const layerOf = (plugin: Registered, slug: string): HTMLElement => {
|
|
70
|
+
let host = plugin.layers.get(slug);
|
|
71
|
+
if (!host) {
|
|
72
|
+
host = document.createElement("div");
|
|
73
|
+
host.className = LAYER_CLASS;
|
|
74
|
+
host.dataset.plugin = plugin.name;
|
|
75
|
+
host.dataset.slug = slug;
|
|
76
|
+
host.setAttribute("aria-hidden", "true");
|
|
77
|
+
// The layer is the slide's box; only what a plugin draws in it takes the pointer.
|
|
78
|
+
host.style.cssText = "position:absolute;inset:0;pointer-events:none;display:none";
|
|
79
|
+
host.attachShadow({ mode: "open" });
|
|
80
|
+
document.getElementById(PAGE_ID.deck)?.append(host);
|
|
81
|
+
plugin.layers.set(slug, host);
|
|
82
|
+
}
|
|
83
|
+
return host;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const mount = (plugin: Registered, slug: string): void => {
|
|
87
|
+
const root = slideEl(slug);
|
|
88
|
+
const layer = layerOf(plugin, slug).shadowRoot;
|
|
89
|
+
if (!root || !layer) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
layer.replaceChildren();
|
|
93
|
+
for (const listener of plugin.mount) {
|
|
94
|
+
guard(plugin.name, () => listener({ slug, root, layer, view: "stage" }));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const render = (pos: Position): void => {
|
|
99
|
+
const slug = options.slides[pos.slideIndex]?.slug;
|
|
100
|
+
for (const plugin of registered) {
|
|
101
|
+
for (const [layerSlug, host] of plugin.layers) {
|
|
102
|
+
host.style.display = layerSlug === slug ? "block" : "none";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const settled = (pos: Position): void => {
|
|
108
|
+
for (const plugin of registered) {
|
|
109
|
+
for (const listener of plugin.change) {
|
|
110
|
+
guard(plugin.name, () => listener(at(pos)));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const dek: DekPage = {
|
|
116
|
+
plugin(name, pluginOptions, setup) {
|
|
117
|
+
if (pluginOptions?.api !== PAGE_API) {
|
|
118
|
+
console.error(
|
|
119
|
+
`dek: plugin ${name} asks for page API ${String(pluginOptions?.api)}; this dek gives ${PAGE_API}`,
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
config ??= readConfig();
|
|
124
|
+
const declared = config[name];
|
|
125
|
+
if (!declared || registered.some((plugin) => plugin.name === name)) {
|
|
126
|
+
console.error(`dek: plugin ${name} is not one this page carries`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const plugin: Registered = { name, change: [], mount: [], layers: new Map() };
|
|
130
|
+
const api: PageApi = {
|
|
131
|
+
deck: { name: options.deck },
|
|
132
|
+
slides: options.slides.map(({ slug, title }) => ({ slug, title })),
|
|
133
|
+
current: () => at(options.position()),
|
|
134
|
+
on(event: "change" | "mount", listener: (value: never) => void) {
|
|
135
|
+
if (event === "change" || event === "mount") {
|
|
136
|
+
plugin[event].push(listener as never);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
shown: (element) => element.closest("[data-step]:not(.is-shown)") === null,
|
|
140
|
+
data: (path) => declared.data[path] ?? null,
|
|
141
|
+
key(letter, listener) {
|
|
142
|
+
if (!declared.keys.includes(letter) || keys.has(letter)) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
keys.set(letter, { plugin: name, listener });
|
|
146
|
+
return true;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
let ok = true;
|
|
150
|
+
try {
|
|
151
|
+
setup(api);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
ok = false;
|
|
154
|
+
console.error(`dek: plugin ${name} threw`, error);
|
|
155
|
+
}
|
|
156
|
+
if (!ok) {
|
|
157
|
+
for (const [letter, bound] of keys) {
|
|
158
|
+
if (bound.plugin === name) {
|
|
159
|
+
keys.delete(letter);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
registered.push(plugin);
|
|
165
|
+
for (const { slug } of options.slides) {
|
|
166
|
+
mount(plugin, slug);
|
|
167
|
+
}
|
|
168
|
+
render(options.position());
|
|
169
|
+
for (const listener of plugin.change) {
|
|
170
|
+
guard(name, () => listener(at(options.position())));
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
window.dek = dek;
|
|
175
|
+
|
|
176
|
+
document.addEventListener("keydown", (event) => {
|
|
177
|
+
if (isTextEntry(event.target)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
for (const [letter, bound] of keys) {
|
|
181
|
+
if (isLetterKey(event, letter)) {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
guard(bound.plugin, bound.listener);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// A live reload swaps a slide for a new element: its layers start over on the new one.
|
|
190
|
+
const deckEl = document.getElementById(PAGE_ID.deck);
|
|
191
|
+
if (deckEl && typeof MutationObserver !== "undefined") {
|
|
192
|
+
new MutationObserver((records) => {
|
|
193
|
+
const replaced = new Set<string>();
|
|
194
|
+
for (const record of records) {
|
|
195
|
+
for (const node of record.addedNodes) {
|
|
196
|
+
if (
|
|
197
|
+
node instanceof HTMLElement &&
|
|
198
|
+
node.classList.contains("slide") &&
|
|
199
|
+
node.dataset.slug
|
|
200
|
+
) {
|
|
201
|
+
replaced.add(node.dataset.slug);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (replaced.size === 0) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
for (const plugin of registered) {
|
|
209
|
+
for (const slug of replaced) {
|
|
210
|
+
mount(plugin, slug);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
render(options.position());
|
|
214
|
+
settled(options.position());
|
|
215
|
+
}).observe(deckEl, { childList: true });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A printed deck is the slides alone.
|
|
219
|
+
const style = document.createElement("style");
|
|
220
|
+
style.textContent = `@media print { .${LAYER_CLASS} { display: none !important; } }`;
|
|
221
|
+
document.head.append(style);
|
|
222
|
+
|
|
223
|
+
return { render, settled };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function readConfig(): PluginConfig {
|
|
227
|
+
const text = document.getElementById(PAGE_ID.plugins)?.textContent;
|
|
228
|
+
if (!text) {
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(text) as PluginConfig;
|
|
233
|
+
} catch {
|
|
234
|
+
return {};
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/runtime/start.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { CreateDevLine } from "./dev-line.ts";
|
|
|
9
9
|
import { bindInput } from "./input.ts";
|
|
10
10
|
import { createLaserView, type LaserView } from "./laser-view.ts";
|
|
11
11
|
import { createNavigator } from "./navigator.ts";
|
|
12
|
+
import { createPluginHost } from "./plugins.ts";
|
|
12
13
|
import { createPresenterView } from "./presenter-view.ts";
|
|
13
14
|
import { createRailView } from "./rail-view.ts";
|
|
14
15
|
import { deckChannelName } from "./routes.ts";
|
|
@@ -41,6 +42,7 @@ export function startPlayer(createDevLine?: CreateDevLine): void {
|
|
|
41
42
|
presenter.render(pos);
|
|
42
43
|
rail.markCurrent(pos.slideIndex);
|
|
43
44
|
marks?.render(pos);
|
|
45
|
+
plugins.render(pos);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/**
|
|
@@ -92,13 +94,26 @@ export function startPlayer(createDevLine?: CreateDevLine): void {
|
|
|
92
94
|
stage.showMove(from, to);
|
|
93
95
|
}
|
|
94
96
|
},
|
|
95
|
-
afterMove:
|
|
97
|
+
afterMove: () => {
|
|
98
|
+
presenter.startClock();
|
|
99
|
+
// A move that another is queued behind has not settled; the last one says where it stands.
|
|
100
|
+
if (positionsEqual(nav.target(), nav.position())) {
|
|
101
|
+
plugins.settled(nav.position());
|
|
102
|
+
}
|
|
103
|
+
},
|
|
96
104
|
route: (next) => control.request(next, "remote"),
|
|
97
105
|
...(laser ? { onPointer: laser.receive } : {}),
|
|
98
106
|
});
|
|
99
107
|
|
|
100
108
|
const marks = dev?.marks;
|
|
101
109
|
|
|
110
|
+
// Plugins' pages run after the player and call window.dek.plugin, which this defines.
|
|
111
|
+
const plugins = createPluginHost({
|
|
112
|
+
deck: page.deck,
|
|
113
|
+
slides,
|
|
114
|
+
position: () => nav.position(),
|
|
115
|
+
});
|
|
116
|
+
|
|
102
117
|
const rail = createRailView({
|
|
103
118
|
slides,
|
|
104
119
|
stage,
|
package/src/runtime/window.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Position } from "../core/step.ts";
|
|
2
|
+
import type { DekPage } from "../plugin-api.ts";
|
|
2
3
|
|
|
3
4
|
/** How capture tools seek the current slide's script, the way they seek Web Animations. */
|
|
4
5
|
export type DekMotionHandle = {
|
|
@@ -17,6 +18,8 @@ declare global {
|
|
|
17
18
|
dekGo?: (next: Position | null | undefined) => Promise<void>;
|
|
18
19
|
dekMotion?: DekMotionHandle;
|
|
19
20
|
dekLive?: (raw: unknown) => Promise<void>;
|
|
21
|
+
/** Page API 1, for plugins' pages (ADR 33). */
|
|
22
|
+
dek?: DekPage;
|
|
20
23
|
/** The go the video recorder started and has not awaited yet. */
|
|
21
24
|
__dekPendingGo?: Promise<void>;
|
|
22
25
|
/** The animations that go started, so capture tools seek those and nothing older. */
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
3
|
import { deckPaths } from "../core/deck-paths.ts";
|
|
4
4
|
import type { LiveEvent } from "../core/live-protocol.ts";
|
|
5
|
+
import { deckProjectRoot } from "../core/path.ts";
|
|
6
|
+
import { deckData, pinnedPlugins } from "../core/plugin-host.ts";
|
|
5
7
|
import { listSlideFiles, type SlideSidecar } from "../core/resolve.ts";
|
|
6
8
|
|
|
7
9
|
/** Modification times by name; a file that is gone is left out. */
|
|
@@ -19,6 +21,11 @@ export type DeckSnapshot = {
|
|
|
19
21
|
javascript: Mtimes;
|
|
20
22
|
/** `voice/voice.toml`, `voice/dict.toml`, and `voice/pin/*`. */
|
|
21
23
|
voice: Mtimes;
|
|
24
|
+
/**
|
|
25
|
+
* Each pinned plugin's lock, and each data file it declares in this deck, keyed by the path
|
|
26
|
+
* from the project's root: what the page's plugin layer is made from (ADR 33).
|
|
27
|
+
*/
|
|
28
|
+
plugins: Mtimes;
|
|
22
29
|
};
|
|
23
30
|
|
|
24
31
|
/** Which slides to lint: one slug, or the whole deck when it is left out. */
|
|
@@ -43,9 +50,29 @@ export function takeSnapshot(deckDir: string): DeckSnapshot {
|
|
|
43
50
|
scripts: slideMtimes(deckDir, ".ts"),
|
|
44
51
|
javascript: slideMtimes(deckDir, ".js"),
|
|
45
52
|
voice: voiceMtimes(paths.voice),
|
|
53
|
+
plugins: pluginMtimes(deckDir),
|
|
46
54
|
};
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
function pluginMtimes(deckDir: string): Mtimes {
|
|
58
|
+
const root = deckProjectRoot(deckDir);
|
|
59
|
+
const times: Mtimes = {};
|
|
60
|
+
const note = (path: string) => {
|
|
61
|
+
const time = mtime(path);
|
|
62
|
+
if (time !== 0) {
|
|
63
|
+
times[relative(root, path).split(sep).join("/")] = time;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
// A dek.toml mid-edit names no plugin; the next scan sees them again.
|
|
67
|
+
for (const { name, manifest } of pinnedPlugins(root)) {
|
|
68
|
+
note(join(root, "plugins", name, "plugin.json"));
|
|
69
|
+
for (const { path } of manifest ? deckData(manifest) : []) {
|
|
70
|
+
note(join(deckDir, path));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return times;
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
/**
|
|
50
77
|
* What changed from `prev` to `next`, as the events a page hears and the work that follows.
|
|
51
78
|
* Any change counts, an older mtime too: a file restored from a copy is still an edit.
|
|
@@ -88,6 +115,10 @@ export function diffSnapshot(prev: DeckSnapshot, next: DeckSnapshot): DeckChange
|
|
|
88
115
|
}
|
|
89
116
|
}
|
|
90
117
|
}
|
|
118
|
+
// A plugin's page reads its data once, as the page loads, so a change reloads the page.
|
|
119
|
+
if (!sync && changedKeys(prev.plugins, next.plugins).length > 0) {
|
|
120
|
+
events.push({ type: "reload-script", slugs: [] });
|
|
121
|
+
}
|
|
91
122
|
const synth = sync || changedKeys(prev.voice, next.voice).length > 0;
|
|
92
123
|
return { sync, events, ...(diagnose ? { diagnose } : {}), synth };
|
|
93
124
|
}
|
package/src/server/dev.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { DekError } from "../core/error.ts";
|
|
|
3
3
|
import type { PlaywrightRunner } from "../core/playwright.ts";
|
|
4
4
|
import { isRefName, refReadOnlyError, requireProject } from "../core/ref.ts";
|
|
5
5
|
import { locateDeck, type Project } from "../core/resolve.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
annotateScript,
|
|
8
|
+
livePlayerScript,
|
|
9
|
+
liveReloadScript,
|
|
10
|
+
pluginPageScripts,
|
|
11
|
+
} from "../runtime/player.ts";
|
|
7
12
|
import {
|
|
8
13
|
clearance,
|
|
9
14
|
keepToOwnOrigin,
|
|
@@ -76,14 +81,15 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
|
|
|
76
81
|
if (options.deck !== undefined && isRefName(options.deck)) {
|
|
77
82
|
throw refReadOnlyError(options.deck);
|
|
78
83
|
}
|
|
79
|
-
// Before the port and the watchers; writing the lock checks again, for a server that raced us.
|
|
80
|
-
assertNoDevServer(project.root);
|
|
81
84
|
const { scopedDeckName, deckDir } = serverScope(project, options);
|
|
85
|
+
// Before the port and the watchers; writing the lock checks again, for a server that raced us.
|
|
86
|
+
assertNoDevServer(project.root, scopedDeckName);
|
|
82
87
|
const access = accessFor(options);
|
|
83
88
|
const embed = {
|
|
84
89
|
playerScript: await livePlayerScript(),
|
|
85
90
|
liveReloadScript: liveReloadScript(),
|
|
86
91
|
annotateScript: await annotateScript(),
|
|
92
|
+
pluginScripts: await pluginPageScripts(),
|
|
87
93
|
};
|
|
88
94
|
|
|
89
95
|
const pages = createDevPages({
|
package/src/server/lock.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { DekError } from "../core/error.ts";
|
|
4
|
+
import { isDeckName } from "../core/path.ts";
|
|
4
5
|
import { readSourceIfExists, removeInside, writeInside } from "../core/safe-fs.ts";
|
|
5
6
|
|
|
6
7
|
export type DevServerLock = {
|
|
@@ -88,30 +89,59 @@ export function readDevServerLock(root: string): DevServerLock | undefined {
|
|
|
88
89
|
return undefined;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
/** The
|
|
92
|
-
const claimedRoots = new Map<string,
|
|
92
|
+
/** The server each root a server in this process holds, which two racing servers both see. */
|
|
93
|
+
const claimedRoots = new Map<string, Omit<DevServerLock, "password">>();
|
|
93
94
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Throw when a live server, in this process or another, already holds `root`. `deck` is the one
|
|
97
|
+
* deck the server about to start would serve, none for the whole project.
|
|
98
|
+
*/
|
|
99
|
+
export function assertNoDevServer(root: string, deck?: string): void {
|
|
96
100
|
const existing = readDevServerLock(root);
|
|
97
101
|
const running =
|
|
98
|
-
claimedRoots.get(root) ?? (existing && isPidAlive(existing.pid) ? existing
|
|
102
|
+
claimedRoots.get(root) ?? (existing && isPidAlive(existing.pid) ? existing : undefined);
|
|
99
103
|
if (running !== undefined) {
|
|
100
|
-
throw new DekError(`dev server already running at ${running}`, {
|
|
104
|
+
throw new DekError(`dev server already running at ${running.url}`, {
|
|
101
105
|
path: serverLockPath(root),
|
|
102
|
-
hint:
|
|
106
|
+
hint: runningServerHint(root, running, deck),
|
|
103
107
|
});
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Where the running server shows what was asked for, or, when it serves one other deck only, how
|
|
113
|
+
* to get one that serves both: `dekc` started inside a deck's folder serves that deck alone.
|
|
114
|
+
*/
|
|
115
|
+
function runningServerHint(
|
|
116
|
+
root: string,
|
|
117
|
+
running: Omit<DevServerLock, "password">,
|
|
118
|
+
deck: string | undefined,
|
|
119
|
+
): string {
|
|
120
|
+
if (running.deck === deck) {
|
|
121
|
+
return `open ${running.url}`;
|
|
122
|
+
}
|
|
123
|
+
if (running.deck === undefined && deck !== undefined) {
|
|
124
|
+
return `open ${running.url}decks/${encodeURIComponent(deck)}/`;
|
|
125
|
+
}
|
|
126
|
+
// The lock is a file a repository could bring along: name only a deck this project has.
|
|
127
|
+
const named =
|
|
128
|
+
running.deck !== undefined &&
|
|
129
|
+
isDeckName(running.deck) &&
|
|
130
|
+
existsSync(join(root, "decks", running.deck, "script.md"))
|
|
131
|
+
? `decks/${running.deck}`
|
|
132
|
+
: "one deck";
|
|
133
|
+
const wanted = deck === undefined ? "the whole project" : `decks/${deck}`;
|
|
134
|
+
return `it serves only ${named}; to serve ${wanted}, stop it (pid ${running.pid}) and run \`dekc\` at the project root, which serves every deck`;
|
|
135
|
+
}
|
|
136
|
+
|
|
107
137
|
export function writeDevServerLock(
|
|
108
138
|
root: string,
|
|
109
139
|
url: string,
|
|
110
140
|
password?: string,
|
|
111
141
|
deck?: string,
|
|
112
142
|
): void {
|
|
113
|
-
assertNoDevServer(root);
|
|
114
|
-
claimedRoots.set(root, url);
|
|
143
|
+
assertNoDevServer(root, deck);
|
|
144
|
+
claimedRoots.set(root, { url, pid: process.pid, ...(deck ? { deck } : {}) });
|
|
115
145
|
try {
|
|
116
146
|
// A fresh file renamed into place, so it may hold the --remote password: its owner's only.
|
|
117
147
|
writeInside(
|
package/src/server/pages.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderDeckHtml } from "../core/document.ts";
|
|
2
|
+
import { pluginPages } from "../core/plugin-host.ts";
|
|
2
3
|
import type { Project } from "../core/resolve.ts";
|
|
3
4
|
import { errorPage, htmlResponse, renderIndexHtml } from "./routes.ts";
|
|
4
5
|
|
|
@@ -13,7 +14,13 @@ export type DevPages = {
|
|
|
13
14
|
|
|
14
15
|
/** The pages a dev server renders, each kept until `clear`. */
|
|
15
16
|
export function createDevPages(options: {
|
|
16
|
-
embed: {
|
|
17
|
+
embed: {
|
|
18
|
+
playerScript: string;
|
|
19
|
+
liveReloadScript: string;
|
|
20
|
+
annotateScript: string;
|
|
21
|
+
/** The pages of the plugins dek ships, compiled, by name. */
|
|
22
|
+
pluginScripts?: Record<string, string>;
|
|
23
|
+
};
|
|
17
24
|
remote: boolean;
|
|
18
25
|
/** Embedded on the presenter page, so its stream and moves are let through. */
|
|
19
26
|
password: string | undefined;
|
|
@@ -39,6 +46,7 @@ export function createDevPages(options: {
|
|
|
39
46
|
cached(`${dir}:${mode}`, () =>
|
|
40
47
|
renderDeckHtml(dir, {
|
|
41
48
|
playerScript: embed.playerScript,
|
|
49
|
+
plugins: pluginPages(options.project().root, dir, embed.pluginScripts ?? {}),
|
|
42
50
|
target: {
|
|
43
51
|
kind: "dev",
|
|
44
52
|
mode,
|
package/tsconfig.json
CHANGED