@hajimism/dek 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.ja.md +3 -1
  3. package/README.md +3 -1
  4. package/package.json +3 -1
  5. package/skills/dek/SKILL.md +44 -0
  6. package/src/cli/annotations.ts +26 -0
  7. package/src/cli/commands.ts +66 -0
  8. package/src/cli/contract.ts +75 -4
  9. package/src/cli/files.ts +3 -3
  10. package/src/cli/flags.ts +1 -1
  11. package/src/cli/format.ts +3 -0
  12. package/src/cli/pptx.ts +20 -0
  13. package/src/cli/serve.ts +1 -1
  14. package/src/cli/show.ts +17 -2
  15. package/src/cli/text.ts +43 -0
  16. package/src/cli/theme.ts +7 -1
  17. package/src/core/annotation-rows.ts +176 -0
  18. package/src/core/annotations.ts +362 -0
  19. package/src/core/chrome.ts +3 -1
  20. package/src/core/diagnostic.ts +1 -0
  21. package/src/core/document.ts +17 -2
  22. package/src/core/html-scan.ts +54 -0
  23. package/src/core/html.ts +144 -14
  24. package/src/core/lint/slide-html.ts +92 -2
  25. package/src/core/lint.ts +8 -1
  26. package/src/core/live-protocol.ts +4 -1
  27. package/src/core/page.ts +7 -0
  28. package/src/core/playwright-module.d.ts +4 -1
  29. package/src/core/playwright-visual.ts +74 -1
  30. package/src/core/playwright.ts +51 -1
  31. package/src/core/pptx-package.ts +312 -0
  32. package/src/core/pptx-text.ts +363 -0
  33. package/src/core/pptx.ts +75 -0
  34. package/src/core/slide-measure.ts +1 -0
  35. package/src/core/sync.ts +13 -5
  36. package/src/core/theme-excerpt.ts +61 -9
  37. package/src/core/theme-facts.ts +12 -2
  38. package/src/core/zip.ts +77 -0
  39. package/src/runtime/annotate-pick.ts +144 -0
  40. package/src/runtime/annotate-place.ts +46 -0
  41. package/src/runtime/annotate-view.ts +752 -0
  42. package/src/runtime/annotate.ts +19 -0
  43. package/src/runtime/browser.ts +5 -1
  44. package/src/runtime/live.ts +1 -0
  45. package/src/runtime/player.ts +15 -7
  46. package/src/runtime/routes.ts +3 -0
  47. package/src/server/auth.ts +3 -1
  48. package/src/server/dev.ts +2 -1
  49. package/src/server/pages.ts +2 -1
  50. package/src/server/routes.ts +77 -4
@@ -0,0 +1,19 @@
1
+ /// <reference lib="dom" />
2
+ // Annotate mode's entry: compiled apart from the player and embedded only on the dev server's
3
+ // pages for the speaker, so a built file carries none of it.
4
+
5
+ import { PAGE_ID, readPageConfig } from "../core/page.ts";
6
+ import type { PresenterSlide } from "../core/presenter-state.ts";
7
+ import { createAnnotateView } from "./annotate-view.ts";
8
+ import { deckUrl, liveTokenQuery } from "./routes.ts";
9
+
10
+ const dataEl = document.getElementById(PAGE_ID.data);
11
+ if (dataEl?.textContent) {
12
+ const page = readPageConfig(document.body);
13
+ createAnnotateView({
14
+ deck: page.deck,
15
+ slides: JSON.parse(dataEl.textContent) as PresenterSlide[],
16
+ url: `${deckUrl(location.pathname, { kind: "annotations" })}${liveTokenQuery(page.liveToken)}`,
17
+ fetch: (input, init) => fetch(input, init),
18
+ });
19
+ }
@@ -1,7 +1,7 @@
1
1
  /// <reference lib="dom" />
2
2
 
3
3
  import { isLiveEvent } from "../core/live-protocol.ts";
4
- import { PAGE_ID, readPageConfig } from "../core/page.ts";
4
+ import { ANNOTATIONS_CHANGED, PAGE_ID, readPageConfig } from "../core/page.ts";
5
5
  import { deckStops, moveTarget, positionsEqual } from "../core/position.ts";
6
6
  import type { PresenterSlide } from "../core/presenter-state.ts";
7
7
  import type { Position } from "../core/step.ts";
@@ -185,6 +185,10 @@ if (dataEl?.textContent) {
185
185
  }
186
186
  return;
187
187
  }
188
+ if (raw.type === "annotations") {
189
+ document.dispatchEvent(new Event(ANNOTATIONS_CHANGED));
190
+ return;
191
+ }
188
192
  const event = await hydrateLiveEvent(raw, location.pathname);
189
193
  if (!event) {
190
194
  location.reload();
@@ -62,6 +62,7 @@ export function applyLiveEvent(event: HydratedLiveEvent, host: LiveHost): { relo
62
62
  host.setDiagnostics(formatLiveDiagnostics(event.diagnostics));
63
63
  return { reload: false };
64
64
  case "timeline":
65
+ case "annotations":
65
66
  return { reload: false };
66
67
  }
67
68
  }
@@ -2,27 +2,35 @@ import { join } from "node:path";
2
2
 
3
3
  export { liveReloadScript } from "./live-reload.ts";
4
4
 
5
- let compiled: Promise<string> | undefined;
5
+ let player: Promise<string> | undefined;
6
+ let annotate: Promise<string> | undefined;
6
7
 
8
+ /** The player every deck page runs. */
7
9
  export function playerScript(): Promise<string> {
8
- compiled ??= compilePlayer();
9
- return compiled;
10
+ player ??= compile("browser.ts", "player");
11
+ return player;
10
12
  }
11
13
 
12
- async function compilePlayer(): Promise<string> {
14
+ /** Annotate mode, which only the dev server's pages for the speaker run, after the player. */
15
+ export function annotateScript(): Promise<string> {
16
+ annotate ??= compile("annotate.ts", "annotate mode");
17
+ return annotate;
18
+ }
19
+
20
+ async function compile(entry: string, what: string): Promise<string> {
13
21
  const result = await Bun.build({
14
- entrypoints: [join(import.meta.dir, "browser.ts")],
22
+ entrypoints: [join(import.meta.dir, entry)],
15
23
  target: "browser",
16
24
  format: "iife",
17
25
  minify: true,
18
26
  });
19
27
  if (!result.success) {
20
28
  const details = result.logs.map((log) => String(log)).join("\n");
21
- throw new Error(`failed to compile player\n${details}`);
29
+ throw new Error(`failed to compile ${what}\n${details}`);
22
30
  }
23
31
  const output = result.outputs[0];
24
32
  if (!output) {
25
- throw new Error("failed to compile player: no output");
33
+ throw new Error(`failed to compile ${what}: no output`);
26
34
  }
27
35
  return output.text();
28
36
  }
@@ -20,6 +20,8 @@ export type DeckRoute =
20
20
  | { kind: "goto" }
21
21
  /** The beats the presenter marked to rewrite: listed, and one marked or unmarked. */
22
22
  | { kind: "marks" }
23
+ /** The notes annotate mode keeps on elements of the slides: listed, and one added or changed. */
24
+ | { kind: "annotations" }
23
25
  /** A file under the deck's `assets/`, decoded. */
24
26
  | { kind: "asset"; path: string };
25
27
 
@@ -34,6 +36,7 @@ const FIXED: Record<Fixed, string> = {
34
36
  current: "/current",
35
37
  goto: "/goto",
36
38
  marks: "/marks",
39
+ annotations: "/annotations",
37
40
  };
38
41
 
39
42
  /** The path of `route` below its deck's prefix. */
@@ -86,7 +86,7 @@ export type Exposure = "audience" | "presenter";
86
86
 
87
87
  /**
88
88
  * What each dev server route reveals. The script, its notes, the voice read from it, moving the
89
- * deck, and the marks left on its beats belong to the presenter. The server routes only by these names, so a new route does
89
+ * deck, the marks left on its beats, and the annotations on its slides belong to the presenter. The server routes only by these names, so a new route does
90
90
  * not compile until it is placed here.
91
91
  */
92
92
  export const ROUTE_EXPOSURE = {
@@ -101,6 +101,7 @@ export const ROUTE_EXPOSURE = {
101
101
  voice: "presenter",
102
102
  control: "presenter",
103
103
  marks: "presenter",
104
+ annotations: "presenter",
104
105
  missing: "audience",
105
106
  } as const satisfies Record<string, Exposure>;
106
107
 
@@ -117,6 +118,7 @@ export const EVENT_EXPOSURE: Record<LiveEventType, Exposure> = {
117
118
  "reload-script": "audience",
118
119
  diagnostics: "presenter",
119
120
  timeline: "presenter",
121
+ annotations: "presenter",
120
122
  };
121
123
 
122
124
  /** True when a request cleared for `seen` may read what `exposure` names. */
package/src/server/dev.ts CHANGED
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import { DekError } from "../core/error.ts";
3
3
  import type { PlaywrightRunner } from "../core/playwright.ts";
4
4
  import { locateDeck, type Project, resolveProject } from "../core/resolve.ts";
5
- import { liveReloadScript, playerScript } from "../runtime/player.ts";
5
+ import { annotateScript, liveReloadScript, playerScript } from "../runtime/player.ts";
6
6
  import {
7
7
  clearance,
8
8
  mayRead,
@@ -71,6 +71,7 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
71
71
  const embed = {
72
72
  playerScript: await playerScript(),
73
73
  liveReloadScript: liveReloadScript(),
74
+ annotateScript: await annotateScript(),
74
75
  };
75
76
 
76
77
  const pages = createDevPages({
@@ -13,7 +13,7 @@ export type DevPages = {
13
13
 
14
14
  /** The pages a dev server renders, each kept until `clear`. */
15
15
  export function createDevPages(options: {
16
- embed: { playerScript: string; liveReloadScript: string };
16
+ embed: { playerScript: string; liveReloadScript: string; annotateScript: string };
17
17
  remote: boolean;
18
18
  /** Embedded on the presenter page, so its stream and moves are let through. */
19
19
  password: string | undefined;
@@ -44,6 +44,7 @@ export function createDevPages(options: {
44
44
  mode,
45
45
  includeNotes: mode === "presenter" || !remote,
46
46
  liveReloadScript: embed.liveReloadScript,
47
+ annotateScript: embed.annotateScript,
47
48
  ...(mode === "presenter" && password ? { liveToken: password } : {}),
48
49
  },
49
50
  }),
@@ -1,5 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, relative } from "node:path";
3
+ import type { AnnotationRow } from "../core/annotation-rows.ts";
4
+ import { AnnotationOp, applyAnnotationOp, listAnnotations } from "../core/annotations.ts";
3
5
  import { fileInside, readTheme } from "../core/assets.ts";
4
6
  import { DekError, errorFields } from "../core/error.ts";
5
7
  import { escapeAttr, escapeHtml } from "../core/escape.ts";
@@ -75,10 +77,16 @@ export function routeRequest(req: Request, ctx: RouteContext): Routed {
75
77
  // A slide the script does not list has no place in the deck, so no page shows it.
76
78
  const sections = deck?.deck.sections ?? [];
77
79
  const index = sections.findIndex((section) => section.slug === route.slug);
78
- const html = deck && index >= 0 && deckSlides(deck).written(route.slug);
79
- return html
80
+ const slides = deck && index >= 0 ? deckSlides(deck) : undefined;
81
+ const html = slides?.written(route.slug);
82
+ const source = slides?.source(route.slug);
83
+ return html && source
80
84
  ? htmlResponse(
81
- stampSlide(html, { slug: route.slug, place: slidePlace(sections, index) }),
85
+ stampSlide(html, {
86
+ slug: route.slug,
87
+ place: slidePlace(sections, index),
88
+ source,
89
+ }),
82
90
  )
83
91
  : notFound();
84
92
  },
@@ -110,6 +118,16 @@ export function routeRequest(req: Request, ctx: RouteContext): Routed {
110
118
  respond: () =>
111
119
  deck ? marksResponse(req, ctx.project().root, deck) : jsonNotFound(deckName),
112
120
  };
121
+ case "annotations":
122
+ return {
123
+ route: "annotations",
124
+ respond: () =>
125
+ deck
126
+ ? annotationsResponse(req, ctx.project().root, deck, () =>
127
+ ctx.hub.emit({ type: "annotations", deck: deckName }),
128
+ )
129
+ : jsonNotFound(deckName),
130
+ };
113
131
  case "asset": {
114
132
  const asset = dir && safeDeckAsset(dir, route.path);
115
133
  return asset
@@ -211,6 +229,61 @@ async function marksResponse(req: Request, root: string, deck: ProjectDeck): Pro
211
229
  }
212
230
  }
213
231
 
232
+ /**
233
+ * The deck's annotations: GET lists them as the files have them now, and a POST of an operation
234
+ * adds, edits, removes, clears, or puts back. Every change is told to the deck's other pages,
235
+ * so tabs and devices annotating one deck show the same notes. One that names nothing on the
236
+ * slide as it is now, sent from a page an edit left behind, is refused.
237
+ */
238
+ async function annotationsResponse(
239
+ req: Request,
240
+ root: string,
241
+ deck: ProjectDeck,
242
+ changed: () => void,
243
+ ): Promise<Response> {
244
+ const rows = (annotations: AnnotationRow[]) =>
245
+ annotations.map((row) => ({
246
+ ...row,
247
+ targets: row.targets.map((target) => ({ ...target, path: relative(root, target.path) })),
248
+ changed: row.changed.map((path) => relative(root, path)),
249
+ }));
250
+ try {
251
+ if (req.method === "GET") {
252
+ return jsonResponse({ ok: true, annotations: rows(listAnnotations(root, deck)) });
253
+ }
254
+ if (req.method !== "POST") {
255
+ return new Response("Method not allowed", { status: 405 });
256
+ }
257
+ let body: unknown;
258
+ try {
259
+ body = await req.json();
260
+ } catch {
261
+ body = undefined;
262
+ }
263
+ const op = AnnotationOp.safeParse(body);
264
+ if (!op.success) {
265
+ return jsonResponse(
266
+ {
267
+ ok: false,
268
+ error: {
269
+ message:
270
+ 'the request names no operation: send {"op": "add" | "edit" | "remove" | "clear" | "restore", …}',
271
+ },
272
+ },
273
+ 400,
274
+ );
275
+ }
276
+ const done = applyAnnotationOp(root, deck, op.data);
277
+ changed();
278
+ return jsonResponse({ ok: true, ...done, annotations: rows(done.annotations) });
279
+ } catch (error) {
280
+ if (!(error instanceof DekError)) {
281
+ throw error;
282
+ }
283
+ return jsonResponse({ ok: false, error: errorFields(error) }, 409);
284
+ }
285
+ }
286
+
214
287
  function jsonNotFound(deckName: string): Response {
215
288
  return jsonResponse({ ok: false, error: { message: `deck "${deckName}" not found` } }, 404);
216
289
  }