@hajimism/dek 0.3.3 → 0.5.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.ja.md +2 -2
  3. package/README.md +2 -2
  4. package/package.json +5 -3
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +95 -18
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +57 -19
  11. package/src/cli/contract.ts +58 -4
  12. package/src/cli/files.ts +2 -5
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/format.ts +5 -4
  15. package/src/cli/goto.ts +44 -11
  16. package/src/cli/init.ts +10 -5
  17. package/src/cli/lint.ts +36 -16
  18. package/src/cli/ls.ts +5 -1
  19. package/src/cli/main.ts +51 -14
  20. package/src/cli/new.ts +28 -8
  21. package/src/cli/pptx.ts +9 -4
  22. package/src/cli/ref.ts +92 -9
  23. package/src/cli/result.ts +47 -6
  24. package/src/cli/scope.ts +57 -29
  25. package/src/cli/shot.ts +78 -6
  26. package/src/cli/show.ts +15 -2
  27. package/src/cli/text.ts +88 -11
  28. package/src/cli/theme.ts +58 -10
  29. package/src/cli/tty.ts +16 -2
  30. package/src/cli/voice.ts +15 -9
  31. package/src/core/alignment.ts +320 -0
  32. package/src/core/annotation-rows.ts +16 -4
  33. package/src/core/annotations.ts +19 -1
  34. package/src/core/assets.ts +18 -10
  35. package/src/core/attribute-value.ts +188 -0
  36. package/src/core/build.ts +3 -3
  37. package/src/core/config.ts +61 -12
  38. package/src/core/css-scan.ts +173 -39
  39. package/src/core/css-transform.ts +57 -11
  40. package/src/core/css.ts +19 -13
  41. package/src/core/diagnostic.ts +5 -1
  42. package/src/core/escape.ts +43 -0
  43. package/src/core/foreign-text.ts +138 -0
  44. package/src/core/github.ts +1 -0
  45. package/src/core/html-entities.ts +318 -0
  46. package/src/core/html-scan.ts +49 -27
  47. package/src/core/html.ts +21 -15
  48. package/src/core/line-break.ts +275 -0
  49. package/src/core/lint/asset-refs.ts +50 -8
  50. package/src/core/lint/files.ts +30 -5
  51. package/src/core/lint/keys.ts +7 -3
  52. package/src/core/lint/slide-html.ts +24 -19
  53. package/src/core/lint/slide-script.ts +7 -5
  54. package/src/core/mv.ts +57 -27
  55. package/src/core/order.ts +14 -0
  56. package/src/core/pack.ts +2 -2
  57. package/src/core/path.ts +8 -2
  58. package/src/core/playwright-module.d.ts +13 -1
  59. package/src/core/playwright-motion.ts +1 -0
  60. package/src/core/playwright-sheet.ts +1 -0
  61. package/src/core/playwright-visual.ts +207 -30
  62. package/src/core/playwright.ts +279 -44
  63. package/src/core/pptx-package.ts +461 -33
  64. package/src/core/pptx-paint.ts +1168 -0
  65. package/src/core/pptx-plan.ts +879 -0
  66. package/src/core/pptx-text.ts +204 -47
  67. package/src/core/pptx-theme.ts +222 -0
  68. package/src/core/pptx.ts +56 -8
  69. package/src/core/prose.ts +23 -0
  70. package/src/core/pseudo-text.ts +32 -10
  71. package/src/core/ref-name.ts +52 -8
  72. package/src/core/ref.ts +308 -54
  73. package/src/core/resolve.ts +23 -11
  74. package/src/core/rumdl.ts +51 -25
  75. package/src/core/safe-fs.ts +19 -2
  76. package/src/core/sandbox.ts +217 -0
  77. package/src/core/shot/morph.ts +1 -1
  78. package/src/core/shot/motion.ts +2 -3
  79. package/src/core/shot/still.ts +2 -2
  80. package/src/core/shot-cache.ts +26 -17
  81. package/src/core/slide-eval-worker.ts +1 -0
  82. package/src/core/slide-measure.ts +51 -1
  83. package/src/core/slide-script-eval.ts +12 -8
  84. package/src/core/slide-script.ts +114 -20
  85. package/src/core/spawn.ts +131 -32
  86. package/src/core/sync.ts +5 -14
  87. package/src/core/tar.ts +315 -0
  88. package/src/core/text-contrast.ts +2 -0
  89. package/src/core/toml-keys.ts +116 -51
  90. package/src/core/url-attributes.ts +27 -9
  91. package/src/core/visual-cache.ts +2 -0
  92. package/src/core/visual.ts +182 -24
  93. package/src/core/voice.ts +5 -6
  94. package/src/core/zod.ts +9 -1
  95. package/src/server/auth.ts +21 -0
  96. package/src/server/dev.ts +26 -7
  97. package/src/server/lock.ts +50 -7
  98. package/src/server/routes.ts +7 -2
  99. package/src/server/serial.ts +8 -2
  100. package/src/theme/default.css +4 -0
  101. package/src/video/bake.ts +6 -2
  102. package/src/video/mux.ts +27 -3
  103. package/src/video/recorder.ts +3 -2
  104. package/src/video/worker.ts +1 -0
  105. package/src/voice/engine.ts +81 -18
@@ -4,7 +4,6 @@ import { EDGES, type Edge } from "./overflow.ts";
4
4
  import { renderPdfHtml } from "./pdf.ts";
5
5
  import {
6
6
  askPlaywright,
7
- type ContrastOrigin,
8
7
  type FillFinding,
9
8
  type PageAction,
10
9
  type PagesResponse,
@@ -83,7 +82,7 @@ async function visualDeck(
83
82
  return { diagnostics: [], fills: [] };
84
83
  }
85
84
 
86
- const actions: PageAction[] = ["overflow", "contrast", "fill"];
85
+ const actions: PageAction[] = ["overflow", "contrast", "fill", "breaks", "alignment"];
87
86
  const viewport = logicalSize(deck.deck.ratio);
88
87
  const cache = visualCache(deck.dir, { viewport, actions });
89
88
  const keyed = pages.map((page) => {
@@ -137,6 +136,8 @@ async function visualDeck(
137
136
  contrasts: all.flatMap((page) => page.contrasts),
138
137
  drawErrors: all.flatMap((page) => page.drawErrors),
139
138
  collisions: all.flatMap((page) => page.collisions ?? []),
139
+ lineBreaks: all.flatMap((page) => page.lineBreaks ?? []),
140
+ misalignments: all.flatMap((page) => page.misalignments ?? []),
140
141
  },
141
142
  deck.dir,
142
143
  ),
@@ -147,13 +148,20 @@ async function visualDeck(
147
148
  };
148
149
  }
149
150
 
150
- const NOTHING_FOUND: PageFindings = {
151
- overflows: [],
152
- contrasts: [],
153
- drawErrors: [],
154
- collisions: [],
155
- fills: [],
156
- };
151
+ /** A page's findings before any is found, each list its own to push to. */
152
+ function noFindings(): PageFindings {
153
+ return {
154
+ overflows: [],
155
+ contrasts: [],
156
+ drawErrors: [],
157
+ collisions: [],
158
+ fills: [],
159
+ lineBreaks: [],
160
+ misalignments: [],
161
+ };
162
+ }
163
+
164
+ const NOTHING_FOUND: PageFindings = noFindings();
157
165
 
158
166
  /** A page's findings name its slide and step, which no two pages of one run share. */
159
167
  function pageId(page: { slug: string; step: string }): string {
@@ -165,13 +173,7 @@ function findingsByPage(response: PagesResponse): Map<string, PageFindings> {
165
173
  const pages = new Map<string, PageFindings>();
166
174
  const of = (finding: { slug: string; step: string }): PageFindings => {
167
175
  const id = pageId(finding);
168
- const found = pages.get(id) ?? {
169
- overflows: [],
170
- contrasts: [],
171
- drawErrors: [],
172
- collisions: [],
173
- fills: [],
174
- };
176
+ const found = pages.get(id) ?? noFindings();
175
177
  pages.set(id, found);
176
178
  return found;
177
179
  };
@@ -190,6 +192,12 @@ function findingsByPage(response: PagesResponse): Map<string, PageFindings> {
190
192
  for (const finding of response.fills ?? []) {
191
193
  of(finding).fills.push(finding);
192
194
  }
195
+ for (const finding of response.lineBreaks ?? []) {
196
+ of(finding).lineBreaks.push(finding);
197
+ }
198
+ for (const finding of response.misalignments ?? []) {
199
+ of(finding).misalignments.push(finding);
200
+ }
193
201
  return pages;
194
202
  }
195
203
 
@@ -306,9 +314,14 @@ function groupBySteps<T extends { step: string }>(items: T[], key: (item: T) =>
306
314
  /**
307
315
  * Where to fix low contrast. Text the theme alone draws too faint is fixed once in theme.css
308
316
  * for every slide that uses the pair; a slide that fixed it in its own CSS would leave the rest.
317
+ * Text only the slide's CSS shows was never measured on the theme alone, so both are named.
309
318
  */
310
- function contrastHint(origin: ContrastOrigin | undefined, threshold: number, slug: string): string {
319
+ function contrastHint(sample: ContrastSample, threshold: number): string {
320
+ const { origin, slug } = sample;
311
321
  const own = `slides/${slug}.css`;
322
+ if (sample.onlyWithSlideCss) {
323
+ return `without ${own} it does not show, so dek cannot tell whether theme.css alone draws it below ${threshold}:1: fix the pair in ${own}, or in theme.css if the colors are the theme's`;
324
+ }
312
325
  switch (origin) {
313
326
  case "theme":
314
327
  return `theme.css alone draws it below ${threshold}:1: fix the pair in theme.css, where one change reaches every slide that uses it`;
@@ -324,8 +337,17 @@ function contrastHint(origin: ContrastOrigin | undefined, threshold: number, slu
324
337
  }
325
338
  }
326
339
 
327
- /** The file to change: the one the origin names, or the slide itself when it is not known. */
328
- function contrastPath(origin: ContrastOrigin | undefined, slug: string, paths: DeckPaths): string {
340
+ /**
341
+ * The file to change: the one the origin names, or the slide itself when it is not known. Text
342
+ * only the slide's CSS shows goes to that CSS, which the hint names first.
343
+ */
344
+ function contrastPath(
345
+ { origin, slug, onlyWithSlideCss }: ContrastSample,
346
+ paths: DeckPaths,
347
+ ): string {
348
+ if (onlyWithSlideCss) {
349
+ return paths.slide(slug, ".css");
350
+ }
329
351
  switch (origin) {
330
352
  case "theme":
331
353
  return paths.theme;
@@ -352,9 +374,139 @@ function visualDiagnostics(response: PagesResponse, deckDir: string): Diagnostic
352
374
  ...groupBySteps(response.collisions ?? [], collisionKey).map((group) =>
353
375
  collisionDiagnostic(group, paths),
354
376
  ),
377
+ ...groupBySteps(response.lineBreaks ?? [], lineBreakKey).map((group) =>
378
+ lineBreakDiagnostic(group, paths),
379
+ ),
380
+ ...groupBySteps(response.misalignments ?? [], misalignmentKey).map((group) =>
381
+ misalignmentDiagnostic(group, paths),
382
+ ),
355
383
  ];
356
384
  }
357
385
 
386
+ type MisalignmentSample = NonNullable<PagesResponse["misalignments"]>[number];
387
+
388
+ function misalignmentKey(m: MisalignmentSample): string {
389
+ return [m.slug, m.box, m.text ?? "", m.other, m.otherText ?? "", m.edge, m.of].join("\0");
390
+ }
391
+
392
+ /** How the message names an edge two items share. */
393
+ const EDGE_NAMES: Record<MisalignmentSample["edge"], string> = {
394
+ top: "tops",
395
+ middle: "middles",
396
+ bottom: "bottoms",
397
+ left: "left edges",
398
+ center: "centers",
399
+ right: "right edges",
400
+ };
401
+
402
+ /**
403
+ * Two items that nearly line up and do not. The fix depends on the edge: a start lines up with
404
+ * the same space before it, an end with one size, a middle with one way of centering, and text in
405
+ * cards that line up with one padding.
406
+ */
407
+ function misalignmentDiagnostic(
408
+ { first, items, steps }: StepGroup<MisalignmentSample>,
409
+ paths: DeckPaths,
410
+ ): Diagnostic {
411
+ // By the most they miss at any step, so the fix covers the worst beat.
412
+ const by = Math.max(...items.map((item) => item.by));
413
+ const pair = `${describeTarget(first.box, first.text)}and ${describeTarget(first.other, first.otherText).trimEnd()}`;
414
+ const where =
415
+ first.of === "text"
416
+ ? `almost start their text ${first.edge === "left" ? "at one left edge" : "on one line"}`
417
+ : `almost line up at their ${EDGE_NAMES[first.edge]}`;
418
+ const css = `slides/${first.slug}.css`;
419
+ const both = `${first.box} and ${first.other}`;
420
+ const why = "an edge a few pixels off reads as a mistake, not a choice";
421
+ const hint =
422
+ first.of === "text"
423
+ ? `give ${both} the same padding in ${css}, so their text starts on one line`
424
+ : first.edge === "top" || first.edge === "left"
425
+ ? `line ${first.box} up with ${first.other} in ${css}, with the same margin, padding, or alignment: ${why}`
426
+ : first.edge === "bottom" || first.edge === "right"
427
+ ? `give ${both} one ${first.edge === "bottom" ? "height" : "width"} in ${css}, with align-items: stretch or the same size: ${why}`
428
+ : `center ${both} on one line in ${css}, with align-items: center or the same margins: ${why}`;
429
+ return diag("DEK036", {
430
+ message: `${pair} ${where}, ${by}px apart, ${atSteps(steps)}`,
431
+ path: paths.slide(first.slug, ".html"),
432
+ slug: first.slug,
433
+ hint,
434
+ data: {
435
+ box: first.box,
436
+ ...(first.text ? { text: first.text } : {}),
437
+ other: first.other,
438
+ ...(first.otherText ? { otherText: first.otherText } : {}),
439
+ edge: first.edge,
440
+ of: first.of,
441
+ by,
442
+ steps,
443
+ },
444
+ });
445
+ }
446
+
447
+ type LineBreakSample = NonNullable<PagesResponse["lineBreaks"]>[number];
448
+
449
+ function lineBreakKey(b: LineBreakSample): string {
450
+ return [b.slug, b.box, b.text ?? "", b.kind, ...b.lines].join("\0");
451
+ }
452
+
453
+ /** Where a line ends: the end of one line and the start of the next, as far as either reads. */
454
+ function breakAt(lines: string[], phrased: string[]): string {
455
+ const differs = lines.findIndex((line, at) => line !== phrased[at]);
456
+ const at = Math.max(0, Math.min(differs, lines.length - 2));
457
+ const end = [...(lines[at] ?? "")];
458
+ const start = [...(lines[at + 1] ?? "")];
459
+ const half = SNIPPET_CHARS / 2;
460
+ const before = end.length > half ? `…${end.slice(-half).join("")}` : end.join("");
461
+ const after = start.length > half ? `${start.slice(0, half).join("")}…` : start.join("");
462
+ return `${before} / ${after}`;
463
+ }
464
+
465
+ /**
466
+ * A line break the audience trips over. One inside a phrase is fixed where Japanese is told to
467
+ * break by phrase: on `.slide` in theme.css, for every slide, unless a rule takes it away from
468
+ * this text. A remnant on a last line is the text's own to fix, or the theme's text-wrap.
469
+ */
470
+ function lineBreakDiagnostic(
471
+ { first, steps }: StepGroup<LineBreakSample>,
472
+ paths: DeckPaths,
473
+ ): Diagnostic {
474
+ const target = describeTarget(first.box, first.text);
475
+ const css = `slides/${first.slug}.css`;
476
+ const yourself = "break the line yourself with <br>";
477
+ const common = {
478
+ slug: first.slug,
479
+ data: {
480
+ kind: first.kind,
481
+ box: first.box,
482
+ ...(first.text ? { text: first.text } : {}),
483
+ lines: first.lines,
484
+ ...(first.kind === "split" ? { phrased: first.phrased } : { tail: first.tail }),
485
+ steps,
486
+ },
487
+ };
488
+ if (first.kind === "split") {
489
+ const theme = first.wordBreak !== "auto-phrase";
490
+ return diag("DEK035", {
491
+ message: `${target}breaks a line inside a phrase, at "${breakAt(first.lines, first.phrased)}", ${atSteps(steps)}`,
492
+ path: theme ? paths.theme : paths.slide(first.slug, ".html"),
493
+ hint: theme
494
+ ? `add word-break: auto-phrase to .slide in theme.css, so Japanese breaks between phrases on every slide; or ${yourself} where its sense breaks`
495
+ : `a rule gives it word-break: normal where .slide breaks by phrase: remove it from ${css} or theme.css, or ${yourself} where its sense breaks`,
496
+ ...common,
497
+ });
498
+ }
499
+ const evened = first.textWrap === "pretty" || first.textWrap === "balance";
500
+ return diag("DEK035", {
501
+ message: `${target}leaves "${first.tail}" alone on its last line ${atSteps(steps)}`,
502
+ path: paths.slide(first.slug, ".html"),
503
+ hint: evened
504
+ ? `reword it so its last line holds more, or ${yourself} earlier`
505
+ : `even out its lines with text-wrap: pretty, or balance for a heading, in ${css} or for every slide in theme.css; or reword it, or ${yourself}`,
506
+ ...common,
507
+ });
508
+ }
509
+
358
510
  type CollisionSample = NonNullable<PagesResponse["collisions"]>[number];
359
511
 
360
512
  function collisionKey(c: CollisionSample): string {
@@ -444,9 +596,15 @@ function shownRatio(sample: ContrastSample): number {
444
596
  }
445
597
 
446
598
  function contrastKey(c: ContrastSample): string {
447
- return [c.slug, c.box, c.text ?? "", shownRatio(c), contrastThreshold(c), c.origin ?? ""].join(
448
- "\0",
449
- );
599
+ return [
600
+ c.slug,
601
+ c.box,
602
+ c.text ?? "",
603
+ shownRatio(c),
604
+ contrastThreshold(c),
605
+ c.origin ?? "",
606
+ c.onlyWithSlideCss ?? "",
607
+ ].join("\0");
450
608
  }
451
609
 
452
610
  function overflowWhere(amounts: Partial<Record<Edge, number>>): string {
@@ -542,9 +700,9 @@ function contrastDiagnostic(
542
700
  const colors = fg && bg ? ` (${fg} on ${bg})` : "";
543
701
  return diag("DEK031", {
544
702
  message: `${describeTarget(first.box, first.text)}has contrast ${ratio}${colors}, below ${threshold}:1${size} ${atSteps(steps)}`,
545
- path: contrastPath(first.origin, first.slug, paths),
703
+ path: contrastPath(first, paths),
546
704
  slug: first.slug,
547
- hint: contrastHint(first.origin, threshold, first.slug),
705
+ hint: contrastHint(first, threshold),
548
706
  data: {
549
707
  box: first.box,
550
708
  ...(first.text ? { text: first.text } : {}),
package/src/core/voice.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import type { VoiceDict } from "./cue.ts";
6
6
  import { deckPaths } from "./deck-paths.ts";
7
7
  import { DekError } from "./error.ts";
8
+ import { compareCodeUnits } from "./order.ts";
8
9
  import { cacheDir, deckProjectRoot } from "./path.ts";
9
10
  import { readDeckFile } from "./resolve.ts";
10
11
  import { writeInside } from "./safe-fs.ts";
@@ -184,7 +185,7 @@ export function loadVoiceDict(deckDir: string): VoiceDict {
184
185
  export function writeVoiceDict(deckDir: string, dict: VoiceDict): string {
185
186
  const path = deckPaths(deckDir).voiceDict;
186
187
  const body = Object.entries(dict)
187
- .sort(([a], [b]) => a.localeCompare(b))
188
+ .sort(([a], [b]) => compareCodeUnits(a, b))
188
189
  .map(([key, entry]) => {
189
190
  const accent = entry.accent !== undefined ? `\naccent = ${entry.accent}` : "";
190
191
  return `[${escapeTomlKey(key)}]\nkana = ${JSON.stringify(entry.kana)}${accent}\n`;
@@ -269,10 +270,8 @@ export function parseTimelineJson(text: string, path?: string): Timeline {
269
270
 
270
271
  export function loadCachedTimeline(deckDir: string): Timeline | undefined {
271
272
  const path = voiceCacheFile(deckDir, "timeline.json");
272
- if (!existsSync(path)) {
273
- return undefined;
274
- }
275
- return parseTimelineJson(readFileSync(path, "utf8"), path);
273
+ const source = readDeckFile(deckDir, path);
274
+ return source === undefined ? undefined : parseTimelineJson(source, path);
276
275
  }
277
276
 
278
277
  export function tryLoadCachedTimeline(deckDir: string): Timeline | undefined {
package/src/core/zod.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { z } from "zod";
2
2
  import { DekError } from "./error.ts";
3
+ import { isPlainWord } from "./escape.ts";
3
4
 
4
5
  const REFERENCE = "https://hajimism.github.io/dek/reference/config.html";
5
6
 
@@ -21,10 +22,17 @@ export function parseFailure(error: unknown): string {
21
22
  );
22
23
  }
23
24
 
25
+ /**
26
+ * Each issue as `path: message`. A key in the path is the file's text, which a cloned project or
27
+ * a pack brings along, so one that is no plain word shows as `<key>`: repeated, it could close a
28
+ * code span or start a line of its own.
29
+ */
24
30
  export function formatZodIssues(error: z.ZodError): string {
25
31
  return error.issues
26
32
  .map((issue) => {
27
- const path = issue.path.join(".");
33
+ const path = issue.path
34
+ .map((key) => (isPlainWord(String(key)) ? String(key) : "<key>"))
35
+ .join(".");
28
36
  // "expected string, received undefined" says less than "required".
29
37
  const message =
30
38
  issue.code === "invalid_type" && /received undefined$/.test(issue.message)
@@ -78,6 +78,27 @@ function forbidden(reason: string): Response {
78
78
  return new Response(`Forbidden: ${reason}\n`, { status: 403 });
79
79
  }
80
80
 
81
+ /**
82
+ * What every answer tells the browser: take its type as given, hand it to no other origin's
83
+ * page, and show it in no other origin's frame. Another site can still make the browser ask, but
84
+ * gets nothing to play, apply as a stylesheet, tell apart by whether it loaded, or frame. The
85
+ * frame rule goes on every answer, not only pages: the voice or a listing opened in a frame is
86
+ * a page too.
87
+ */
88
+ const SAME_ORIGIN_ONLY = {
89
+ "x-content-type-options": "nosniff",
90
+ "cross-origin-resource-policy": "same-origin",
91
+ "content-security-policy": "frame-ancestors 'self'",
92
+ } as const;
93
+
94
+ /** `response` with SAME_ORIGIN_ONLY set; the server passes every answer through it. */
95
+ export function keepToOwnOrigin(response: Response): Response {
96
+ for (const [name, value] of Object.entries(SAME_ORIGIN_ONLY)) {
97
+ response.headers.set(name, value);
98
+ }
99
+ return response;
100
+ }
101
+
81
102
  /**
82
103
  * Who may read a response. The audience sees the slides as they are shown; the presenter also
83
104
  * sees what they are made from, and may move the deck.
package/src/server/dev.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { join } from "node:path";
2
2
  import { DekError } from "../core/error.ts";
3
3
  import type { PlaywrightRunner } from "../core/playwright.ts";
4
- import { locateDeck, type Project, resolveProject } from "../core/resolve.ts";
4
+ import { isRefName, refReadOnlyError, requireProject } from "../core/ref.ts";
5
+ import { locateDeck, type Project } from "../core/resolve.ts";
5
6
  import { annotateScript, livePlayerScript, liveReloadScript } from "../runtime/player.ts";
6
7
  import {
7
8
  clearance,
9
+ keepToOwnOrigin,
8
10
  mayRead,
9
11
  pairingResponse,
10
12
  ROUTE_EXPOSURE,
@@ -13,7 +15,12 @@ import {
13
15
  } from "./auth.ts";
14
16
  import type { EventFeed } from "./hub.ts";
15
17
  import { generateRemotePassword, lanUrls } from "./lan.ts";
16
- import { assertNoDevServer, removeDevServerLock, writeDevServerLock } from "./lock.ts";
18
+ import {
19
+ assertNoDevServer,
20
+ devServerUrl,
21
+ removeDevServerLock,
22
+ writeDevServerLock,
23
+ } from "./lock.ts";
17
24
  import { createDevPages } from "./pages.ts";
18
25
  import { createPairings, type Pairings } from "./pairing.ts";
19
26
  import { createProjectWatch } from "./project-watch.ts";
@@ -63,7 +70,12 @@ type Access = {
63
70
  type ServedRoutes = Omit<RouteContext, "upgrade">;
64
71
 
65
72
  export async function startDevServer(options: DevServerOptions): Promise<DevServer> {
66
- const project = resolveProject(options.cwd);
73
+ // A ref is read where it is, never served: the server syncs into what it serves, and runs its
74
+ // slides' scripts in the browser at its own origin.
75
+ const project = requireProject(options.cwd);
76
+ if (options.deck !== undefined && isRefName(options.deck)) {
77
+ throw refReadOnlyError(options.deck);
78
+ }
67
79
  // Before the port and the watchers; writing the lock checks again, for a server that raced us.
68
80
  assertNoDevServer(project.root);
69
81
  const { scopedDeckName, deckDir } = serverScope(project, options);
@@ -113,7 +125,7 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
113
125
  }
114
126
 
115
127
  const port = server.port ?? 0;
116
- const url = `http://127.0.0.1:${port}/`;
128
+ const url = devServerUrl(port);
117
129
  const remoteUrls = [...new Set([url, ...(access.remote ? lanUrls(port) : [])])];
118
130
  try {
119
131
  writeDevServerLock(project.root, url, access.password, scopedDeckName);
@@ -188,10 +200,17 @@ function listen(port: number | undefined, access: Access, routes: ServedRoutes)
188
200
  port: port ?? 0,
189
201
  // Never Bun's development error page: it shows the source and absolute paths to anyone.
190
202
  development: false,
191
- fetch: (req, server) => answer(req, server, access, routes),
203
+ // Every answer dek writes leaves through one of these two, so none goes without
204
+ // keepToOwnOrigin. Bun answers a few requests itself before either runs: one its parser
205
+ // refuses (a bad request line, no Host, a body or headers too large) and an upgrade it
206
+ // cannot take; none of those has a body a page could use.
207
+ fetch: async (req, server) => {
208
+ const response = await answer(req, server, access, routes);
209
+ return response && keepToOwnOrigin(response);
210
+ },
192
211
  error(error) {
193
212
  console.error(error);
194
- return new Response("Internal Server Error\n", { status: 500 });
213
+ return keepToOwnOrigin(new Response("Internal Server Error\n", { status: 500 }));
195
214
  },
196
215
  websocket: roomSockets(routes.rooms),
197
216
  });
@@ -222,7 +241,7 @@ function answer(req: Request, server: Bun.Server<WsData>, access: Access, routes
222
241
  }
223
242
 
224
243
  /** Each socket joins its deck's room; only a presenter's socket may move it or point in it. */
225
- function roomSockets(rooms: RouteContext["rooms"]): Bun.WebSocketHandler<WsData> {
244
+ export function roomSockets(rooms: RouteContext["rooms"]): Bun.WebSocketHandler<WsData> {
226
245
  return {
227
246
  // A position or a point is a few dozen bytes; anything near this is neither.
228
247
  maxPayloadLength: 4 * 1024,
@@ -1,7 +1,7 @@
1
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { DekError } from "../core/error.ts";
4
- import { writeInside } from "../core/safe-fs.ts";
4
+ import { readSourceIfExists, removeInside, writeInside } from "../core/safe-fs.ts";
5
5
 
6
6
  export type DevServerLock = {
7
7
  url: string;
@@ -15,7 +15,14 @@ function serverLockPath(root: string): string {
15
15
  return join(root, ".dek", "server.json");
16
16
  }
17
17
 
18
+ /**
19
+ * Whether a process with this pid runs. Only a positive whole number names one process: 0 and a
20
+ * negative pid signal a whole group, which `kill` reports as alive.
21
+ */
18
22
  export function isPidAlive(pid: number): boolean {
23
+ if (!Number.isInteger(pid) || pid <= 0) {
24
+ return false;
25
+ }
19
26
  try {
20
27
  process.kill(pid, 0);
21
28
  return true;
@@ -24,16 +31,52 @@ export function isPidAlive(pid: number): boolean {
24
31
  }
25
32
  }
26
33
 
34
+ /** The hosts a dev server's URL in its lock names: it writes this machine's loopback address. */
35
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
36
+
37
+ /** The URL a dev server on `port` writes in its lock, as `URL` spells it: port 80 takes none. */
38
+ export function devServerUrl(port: number): string {
39
+ return new URL(`http://127.0.0.1:${port}/`).href;
40
+ }
41
+
42
+ /**
43
+ * The lock's URL in the form the dev server writes it, `http://<loopback>:<port>/`, or nothing
44
+ * for any other. A server.json a repository brings along is its text, and the URL goes into
45
+ * hints and into the requests `dekc goto` sends. Port 80 is read with or without `:80`.
46
+ */
47
+ function serverUrl(value: unknown): string | undefined {
48
+ if (typeof value !== "string") {
49
+ return undefined;
50
+ }
51
+ let url: URL;
52
+ try {
53
+ url = new URL(value);
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ const served =
58
+ url.protocol === "http:" &&
59
+ LOOPBACK_HOSTS.has(url.hostname) &&
60
+ url.pathname === "/" &&
61
+ url.search === "" &&
62
+ url.hash === "" &&
63
+ url.username === "" &&
64
+ url.password === "";
65
+ const written = url.port === "" ? [url.href, `http://${url.hostname}:80/`] : [url.href];
66
+ return served && written.includes(value) ? url.href : undefined;
67
+ }
68
+
27
69
  export function readDevServerLock(root: string): DevServerLock | undefined {
28
70
  const path = serverLockPath(root);
29
71
  if (!existsSync(path)) {
30
72
  return undefined;
31
73
  }
32
74
  try {
33
- const parsed = JSON.parse(readFileSync(path, "utf8")) as DevServerLock;
34
- if (typeof parsed.url === "string" && typeof parsed.pid === "number") {
75
+ const parsed = JSON.parse(readSourceIfExists(path, root) ?? "") as DevServerLock;
76
+ const url = serverUrl(parsed.url);
77
+ if (url !== undefined && typeof parsed.pid === "number") {
35
78
  return {
36
- url: parsed.url,
79
+ url,
37
80
  pid: parsed.pid,
38
81
  ...(typeof parsed.password === "string" ? { password: parsed.password } : {}),
39
82
  ...(typeof parsed.deck === "string" ? { deck: parsed.deck } : {}),
@@ -90,8 +133,8 @@ export function removeDevServerLock(root: string): void {
90
133
  return;
91
134
  }
92
135
  try {
93
- unlinkSync(serverLockPath(root));
136
+ removeInside(serverLockPath(root), root);
94
137
  } catch {
95
- /* already gone */
138
+ /* a lock left behind names a process that is gone, which the next server sees */
96
139
  }
97
140
  }
@@ -8,7 +8,9 @@ import { escapeAttr, escapeHtml } from "../core/escape.ts";
8
8
  import { deckSlides, htmlShell, slidePlace, stampSlide } from "../core/html.ts";
9
9
  import { decodePosition } from "../core/live-protocol.ts";
10
10
  import { markedPositions, toggleMark } from "../core/marks.ts";
11
+ import { deckProjectRoot } from "../core/path.ts";
11
12
  import type { Project, ProjectDeck } from "../core/resolve.ts";
13
+ import { followInside } from "../core/safe-fs.ts";
12
14
  import { voiceCacheFile } from "../core/voice.ts";
13
15
  import { deckRoutePath, parseDeckRoute, splitDeckPath, type VoiceFile } from "../runtime/routes.ts";
14
16
  import { EVENT_EXPOSURE, type Exposure, mayRead, type Route } from "./auth.ts";
@@ -149,7 +151,8 @@ export function routeRequest(req: Request, ctx: RouteContext): Routed {
149
151
  const MISSING: Routed = { route: "missing", respond: notFound };
150
152
 
151
153
  function voiceResponse(deck: ProjectDeck | undefined, file: VoiceFile): Response {
152
- const path = deck && voiceCacheFile(deck.dir, file);
154
+ // A link out of the project is no voice of the deck's, and is not served.
155
+ const path = deck && followInside(voiceCacheFile(deck.dir, file), deckProjectRoot(deck.dir));
153
156
  if (!path || !existsSync(path)) {
154
157
  return notFound();
155
158
  }
@@ -291,7 +294,9 @@ function jsonNotFound(deckName: string): Response {
291
294
  function safeDeckAsset(deckDir: string, relative: string): string | undefined {
292
295
  const root = join(deckDir, "assets");
293
296
  const found = fileInside(join(root, relative), root);
294
- return found.kind === "file" ? found.path : undefined;
297
+ // assets/ may itself be a link, and then everything under what it points to is "inside" it:
298
+ // what is served must also be in the project, as every file dek reads is.
299
+ return found.kind === "file" ? followInside(found.path, deckProjectRoot(deckDir)) : undefined;
295
300
  }
296
301
 
297
302
  function jsonResponse(body: unknown, status = 200): Response {
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Runs `run` one at a time: a call while it runs queues one more run after it, however many
3
+ * calls come. Each caller's `run` reports its own failures, as diagnostics or a line on stderr;
4
+ * one that still throws is a bug, logged to the dev server's stderr as Bun.serve's error handler
5
+ * logs one, and the queued run still runs.
6
+ */
1
7
  export function createSerialTask(run: () => Promise<void>): () => void {
2
8
  let running = false;
3
9
  let queued = false;
@@ -13,8 +19,8 @@ export function createSerialTask(run: () => Promise<void>): () => void {
13
19
  queued = false;
14
20
  try {
15
21
  await run();
16
- } catch {
17
- /* keep draining the trailing run */
22
+ } catch (error) {
23
+ console.error(error);
18
24
  }
19
25
  } while (queued);
20
26
  } finally {
@@ -20,6 +20,9 @@
20
20
  background: var(--bg);
21
21
  color: var(--fg);
22
22
  font-family: var(--font-body);
23
+ /* Japanese breaks between phrases, and no paragraph ends on a word left alone. */
24
+ word-break: auto-phrase;
25
+ text-wrap: pretty;
23
26
  }
24
27
 
25
28
  .slide .slide-title {
@@ -27,6 +30,7 @@
27
30
  font-family: var(--font-title);
28
31
  font-size: var(--size-title);
29
32
  line-height: 1.2;
33
+ text-wrap: balance;
30
34
  }
31
35
 
32
36
  /* @layout title
package/src/video/bake.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  type ResolvedDeck,
10
10
  requireSection,
11
11
  } from "../core/resolve.ts";
12
- import { dropLinks, outputDir, outputPath, replaceFile } from "../core/safe-fs.ts";
12
+ import { dropLinks, outputDir, outputPath, replaceFile, requireInside } from "../core/safe-fs.ts";
13
13
  import { logicalSize } from "../core/size.ts";
14
14
  import { sliceTimeline, slideTimeRange, type Timeline } from "../core/timeline.ts";
15
15
  import { videoDocument } from "../core/video-document.ts";
@@ -69,7 +69,11 @@ async function bakeProjectDeck(
69
69
  hint: "run `dekc voice`",
70
70
  });
71
71
  }
72
- let timeline = { ...loaded, audio: resolveTimelineAudio(loaded, timelinePath) };
72
+ // ffmpeg opens the audio itself, so it is resolved here: a link out of the project is an error.
73
+ let timeline = {
74
+ ...loaded,
75
+ audio: requireInside(resolveTimelineAudio(loaded, timelinePath), project.root),
76
+ };
73
77
  const fps = options.fps ?? 30;
74
78
  const size = logicalSize(deck.deck.ratio);
75
79
  const html = videoDocument({ project, deck }, await playerScript());