@hajimism/dek 0.4.0 → 0.5.1

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 (77) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +8 -7
  5. package/src/cli/address.ts +59 -15
  6. package/src/cli/commands.ts +47 -14
  7. package/src/cli/contract.ts +49 -3
  8. package/src/cli/files.ts +1 -0
  9. package/src/cli/format.ts +5 -4
  10. package/src/cli/goto.ts +41 -10
  11. package/src/cli/keep-alive.ts +12 -6
  12. package/src/cli/lint.ts +32 -13
  13. package/src/cli/ls.ts +5 -1
  14. package/src/cli/main.ts +48 -13
  15. package/src/cli/new.ts +14 -1
  16. package/src/cli/ref.ts +1 -0
  17. package/src/cli/result.ts +42 -4
  18. package/src/cli/scope.ts +40 -1
  19. package/src/cli/shot.ts +77 -6
  20. package/src/cli/show.ts +15 -2
  21. package/src/cli/text.ts +61 -9
  22. package/src/cli/theme.ts +50 -16
  23. package/src/cli/tty.ts +16 -2
  24. package/src/cli/voice.ts +11 -6
  25. package/src/core/assets.ts +18 -10
  26. package/src/core/attribute-value.ts +188 -0
  27. package/src/core/build.ts +3 -3
  28. package/src/core/css-scan.ts +173 -39
  29. package/src/core/css-transform.ts +57 -11
  30. package/src/core/css.ts +19 -13
  31. package/src/core/foreign-text.ts +138 -0
  32. package/src/core/github.ts +1 -0
  33. package/src/core/html-entities.ts +318 -0
  34. package/src/core/html-scan.ts +49 -27
  35. package/src/core/html.ts +21 -15
  36. package/src/core/lint/asset-refs.ts +44 -6
  37. package/src/core/lint/context.ts +2 -0
  38. package/src/core/lint/shared-styles.ts +26 -1
  39. package/src/core/lint/slide-html.ts +21 -18
  40. package/src/core/lint/theme-css.ts +64 -3
  41. package/src/core/lint.ts +15 -2
  42. package/src/core/mv.ts +28 -18
  43. package/src/core/order.ts +14 -0
  44. package/src/core/path.ts +8 -2
  45. package/src/core/playwright-module.d.ts +12 -1
  46. package/src/core/playwright-motion.ts +1 -0
  47. package/src/core/playwright-sheet.ts +1 -0
  48. package/src/core/playwright-visual.ts +27 -10
  49. package/src/core/playwright.ts +77 -19
  50. package/src/core/pptx.ts +1 -0
  51. package/src/core/ref-name.ts +8 -3
  52. package/src/core/ref.ts +145 -47
  53. package/src/core/resolve.ts +3 -2
  54. package/src/core/rumdl.ts +51 -25
  55. package/src/core/safe-fs.ts +19 -2
  56. package/src/core/sandbox.ts +217 -0
  57. package/src/core/shot-cache.ts +1 -0
  58. package/src/core/slide-eval-worker.ts +1 -0
  59. package/src/core/slide-measure.ts +1 -0
  60. package/src/core/slide-script-eval.ts +2 -0
  61. package/src/core/spawn.ts +136 -35
  62. package/src/core/sync.ts +3 -12
  63. package/src/core/tar.ts +315 -0
  64. package/src/core/text-contrast.ts +2 -0
  65. package/src/core/toml-keys.ts +116 -51
  66. package/src/core/url-attributes.ts +27 -9
  67. package/src/core/visual.ts +28 -9
  68. package/src/core/voice.ts +2 -1
  69. package/src/server/auth.ts +21 -0
  70. package/src/server/dev.ts +13 -5
  71. package/src/server/lock.ts +43 -13
  72. package/src/server/serial.ts +8 -2
  73. package/src/video/mux.ts +27 -3
  74. package/src/video/recorder.ts +3 -2
  75. package/src/video/worker.ts +1 -0
  76. package/src/voice/engine.ts +81 -18
  77. package/tsconfig.json +1 -1
package/src/cli/shot.ts CHANGED
@@ -1,10 +1,15 @@
1
+ import { basename, dirname, extname } from "node:path";
2
+ import { deckPaths } from "../core/deck-paths.ts";
1
3
  import { DekError } from "../core/error.ts";
2
- import { shellQuote } from "../core/escape.ts";
4
+ import { shellName, shellQuote } from "../core/escape.ts";
5
+ import { foreignLine, type HiddenText, MESSAGE_MAX } from "../core/foreign-text.ts";
6
+ import { refPlaywrightRunner } from "../core/playwright.ts";
7
+ import { SLIDE_SIDECARS } from "../core/resolve.ts";
3
8
  import { parseMorphAt, shotMorph } from "../core/shot/morph.ts";
4
9
  import { type ShotMotion, shotMotion } from "../core/shot/motion.ts";
5
10
  import { type ShotFile, shotDeck, shotSheet } from "../core/shot/still.ts";
6
11
  import { playerScript } from "../runtime/player.ts";
7
- import type { ReadableDeck } from "./scope.ts";
12
+ import { hiddenField, hiddenInFiles, type ReadableDeck } from "./scope.ts";
8
13
  import { usageError } from "./usage.ts";
9
14
 
10
15
  export type ShotCliResult = {
@@ -13,6 +18,11 @@ export type ShotCliResult = {
13
18
  sheets?: string[];
14
19
  /** With --motion, each beat's frames and the ms into its go each shows. */
15
20
  motion?: ShotMotion["beats"];
21
+ /**
22
+ * A ref's lines, in the files of the slides drawn and in its theme, that hold characters a
23
+ * person cannot see.
24
+ */
25
+ hidden?: HiddenText[];
16
26
  };
17
27
 
18
28
  type ShotCommandOptions = {
@@ -32,13 +42,64 @@ export type ShotMode =
32
42
  | { kind: "morph"; from: string; to: string; at: number };
33
43
 
34
44
  export async function shotCommand(
35
- { project, deck }: ReadableDeck,
45
+ target: ReadableDeck,
36
46
  options: ShotCommandOptions,
37
47
  ): Promise<ShotCliResult> {
38
48
  const mode = parseShotMode(options);
49
+ try {
50
+ const result = await shoot(target, mode);
51
+ return target.ref
52
+ ? { ...result, ...hiddenField(target.ref, drawnHidden(target, mode)) }
53
+ : result;
54
+ } catch (error) {
55
+ throw refShotError(error, target);
56
+ }
57
+ }
58
+
59
+ /** What the files of the slides a shot drew, and its theme, hold that a person cannot see. */
60
+ function drawnHidden({ deck }: ReadableDeck, mode: ShotMode): HiddenText[] {
61
+ const all = deck.deck.sections.map((section) => section.slug);
62
+ const slugs =
63
+ mode.kind === "sheet"
64
+ ? all
65
+ : mode.kind === "morph"
66
+ ? [mode.from, mode.to]
67
+ : mode.slug === undefined
68
+ ? all
69
+ : [mode.slug];
70
+ const paths = deckPaths(deck.dir);
71
+ const files = slugs.flatMap((slug) =>
72
+ ([".html", ...SLIDE_SIDECARS] as const).map((ext) => paths.slide(slug, ext)),
73
+ );
74
+ return hiddenInFiles(deck.dir, [...new Set(files), paths.theme]);
75
+ }
76
+
77
+ /**
78
+ * A ref's slide script that does not compile stops its slide's shot, as one of the project's
79
+ * would; Bun's message quotes the ref's source, so it is one foreignLine, and the fix is not lint,
80
+ * which refuses a ref, but its author's.
81
+ */
82
+ function refShotError(error: unknown, { deck, ref }: ReadableDeck): unknown {
83
+ if (!ref || !(error instanceof DekError) || error.path === undefined) {
84
+ return error;
85
+ }
86
+ const path = error.path;
87
+ if (extname(path) !== ".ts" || dirname(path) !== deckPaths(deck.dir).slides) {
88
+ return error;
89
+ }
90
+ return new DekError(foreignLine(error.message, MESSAGE_MAX), {
91
+ path,
92
+ hint: `a ref's slide scripts are its author's to fix; \`dekc shot ${ref.name} <slug>\` still draws each of its other slides, and \`dekc show ${ref.name} ${shellName(basename(path, ".ts")) ?? "<slug>"}\` prints this one's`,
93
+ cause: error,
94
+ });
95
+ }
96
+
97
+ async function shoot({ project, deck, ref }: ReadableDeck, mode: ShotMode): Promise<ShotCliResult> {
98
+ // A ref's slide scripts are someone else's: Chromium runs them only in its sandbox (ADR 29).
99
+ const runner = ref ? { runner: refPlaywrightRunner } : {};
39
100
  switch (mode.kind) {
40
101
  case "sheet": {
41
- const { sheets, shots } = await shotSheet({ project, deck });
102
+ const { sheets, shots } = await shotSheet({ project, deck }, runner);
42
103
  return { shots, sheets };
43
104
  }
44
105
  case "motion": {
@@ -48,6 +109,7 @@ export async function shotCommand(
48
109
  slug: mode.slug,
49
110
  ...(mode.step === undefined ? {} : { step: mode.step }),
50
111
  playerScript: await playerScript(),
112
+ ...runner,
51
113
  },
52
114
  );
53
115
  return { shots: [], sheets, motion: beats };
@@ -55,12 +117,21 @@ export async function shotCommand(
55
117
  case "morph": {
56
118
  const shots = await shotMorph(
57
119
  { project, deck },
58
- { from: mode.from, to: mode.to, at: mode.at, playerScript: await playerScript() },
120
+ {
121
+ from: mode.from,
122
+ to: mode.to,
123
+ at: mode.at,
124
+ playerScript: await playerScript(),
125
+ ...runner,
126
+ },
59
127
  );
60
128
  return { shots };
61
129
  }
62
130
  case "still": {
63
- const shots = await shotDeck({ project, deck }, { slug: mode.slug, step: mode.step });
131
+ const shots = await shotDeck(
132
+ { project, deck },
133
+ { slug: mode.slug, step: mode.step, ...runner },
134
+ );
64
135
  return { shots };
65
136
  }
66
137
  }
package/src/cli/show.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { classifyAssetRef } from "../core/assets.ts";
2
2
  import { cssUrls, parseCss } from "../core/css.ts";
3
3
  import { deckPaths } from "../core/deck-paths.ts";
4
+ import { type HiddenText, hiddenText } from "../core/foreign-text.ts";
4
5
  import { type HtmlScan, scanSlideHtml } from "../core/html-scan.ts";
5
6
  import { sectionChunks, splitLines } from "../core/lines.ts";
6
7
  import { readDeckFile } from "../core/resolve.ts";
7
8
  import type { Section } from "../core/schema.ts";
8
9
  import { themeExcerpt } from "../core/theme-excerpt.ts";
9
- import { type ReadableDeck, type RefInfo, requireSection } from "./scope.ts";
10
+ import { hiddenField, type ReadableDeck, type RefInfo, requireSection } from "./scope.ts";
10
11
 
11
12
  /**
12
13
  * Everything one slide is made of, read in one call: enough to learn how it is
@@ -34,6 +35,8 @@ export type ShowResult = {
34
35
  assets: string[];
35
36
  /** Set when the deck is a ref. */
36
37
  ref?: RefInfo;
38
+ /** A ref's lines, in the files show read, that hold characters a person cannot see. */
39
+ hidden?: HiddenText[];
37
40
  };
38
41
 
39
42
  export function showCommand({ deck, ref }: ReadableDeck, slug: string): ShowResult {
@@ -64,11 +67,14 @@ export function showCommand({ deck, ref }: ReadableDeck, slug: string): ShowResu
64
67
  ...(scan?.refs.map((ref) => ref.value) ?? []),
65
68
  ...(sheet ? cssUrls(sheet).map((url) => url.value) : []),
66
69
  ];
70
+ const script = sectionSource(deck, section);
71
+ const hiddenIn = (path: string, text: string | null) =>
72
+ text === null ? [] : hiddenText(text, path);
67
73
 
68
74
  return {
69
75
  slug: section.slug,
70
76
  title: section.title,
71
- script: sectionSource(deck, section),
77
+ script,
72
78
  beats: section.beats.map(({ id, title, line }) => ({ ...(id ? { id } : {}), title, line })),
73
79
  html,
74
80
  css,
@@ -76,6 +82,13 @@ export function showCommand({ deck, ref }: ReadableDeck, slug: string): ShowResu
76
82
  theme,
77
83
  assets: existingRefs(refs, paths.slides, deck.dir),
78
84
  ...(ref ? { ref } : {}),
85
+ ...hiddenField(ref, [
86
+ ...hiddenText(script, deck.scriptPath, section.line),
87
+ ...hiddenIn(paths.slide(slug, ".html"), html),
88
+ ...hiddenIn(paths.slide(slug, ".css"), css),
89
+ ...hiddenIn(paths.slide(slug, ".ts"), ts),
90
+ ...hiddenIn(paths.theme, themeCss),
91
+ ]),
79
92
  };
80
93
  }
81
94
 
package/src/cli/text.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // The pieces the dev server's log shares, such as diagnostics and tables, are in format.ts.
3
3
  import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
4
4
  import { shellQuote } from "../core/escape.ts";
5
+ import { foreignLine, type HiddenText, TITLE_MAX } from "../core/foreign-text.ts";
6
+ import { compareCodeUnits } from "../core/order.ts";
5
7
  import { unprinted } from "../core/prose.ts";
6
8
  import { packFile, parseRefSource } from "../core/ref-name.ts";
7
9
  import { formatClock } from "../core/timing.ts";
@@ -53,7 +55,7 @@ export function formatReport(diagnostics: Diagnostic[], opts?: { color?: boolean
53
55
  byRule.set(id, (byRule.get(id) ?? 0) + 1);
54
56
  }
55
57
  const rules = [...byRule]
56
- .sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
58
+ .sort(([a, x], [b, y]) => y - x || compareCodeUnits(a, b))
57
59
  .map(([id, n]) => `${id} ×${n}`);
58
60
  return `${list}\n${countSummary(diagnostics)}: ${rules.join(", ")}`;
59
61
  }
@@ -69,6 +71,39 @@ export function formatSkipped(skipped: SkippedCheck[] | undefined, color = false
69
71
  .join("\n");
70
72
  }
71
73
 
74
+ /** How many code points a warning names before it leaves the rest to `--json`. */
75
+ const CODE_POINTS_SHOWN = 6;
76
+
77
+ /** What to do about the characters a person cannot see in a ref that a command read. */
78
+ const HIDDEN_HELP =
79
+ "text output shows each as ⟨U+XXXX⟩; a person reading the deck sees none of them, so leave them out of what you write from it";
80
+
81
+ /**
82
+ * A warning per line of a ref's files that holds characters a person cannot see, where they
83
+ * are and which, then `help`, what to do about them, once, for stderr.
84
+ */
85
+ export function formatHidden(
86
+ hidden: HiddenText[] | undefined,
87
+ color = false,
88
+ help = HIDDEN_HELP,
89
+ ): string {
90
+ if (hidden === undefined || hidden.length === 0) {
91
+ return "";
92
+ }
93
+ const c = ansi(color);
94
+ const lines = hidden.map(({ path, line, column, codePoints }) => {
95
+ const distinct = [...new Set(codePoints)];
96
+ const named = distinct.slice(0, CODE_POINTS_SHOWN).join(", ");
97
+ const rest = distinct.length > CODE_POINTS_SHOWN ? ", …" : "";
98
+ const count =
99
+ codePoints.length === 1
100
+ ? "1 character that shows as nothing or reorders the text"
101
+ : `${codePoints.length} characters that show as nothing or reorder the text`;
102
+ return `${c.yellow("warning:")} ${path}:${line}:${column}: ${count}: ${named}${rest}`;
103
+ });
104
+ return [...lines, ` ${c.yellow("help:")} ${help}`].join("\n");
105
+ }
106
+
72
107
  /**
73
108
  * The slides sync kept though their section is gone, for stderr: sync removes only what it wrote,
74
109
  * so saying nothing would read as a deck with nothing left over.
@@ -282,15 +317,17 @@ export function formatLs(data: LsListResult | LsDeckResult): string {
282
317
  ].join("\n");
283
318
  }
284
319
  case "deck": {
320
+ // A ref's frontmatter is someone else's: each value keeps to its line, as in AGENTS.md.
321
+ const line = (text: string) => (data.ref ? foreignLine(text, TITLE_MAX) : text);
285
322
  const meta: Array<[string, string]> = [];
286
323
  if (data.ref) {
287
324
  meta.push(["rev", data.ref.rev.slice(0, 7)]);
288
325
  }
289
326
  if (data.event) {
290
- meta.push(["event", data.event]);
327
+ meta.push(["event", line(data.event)]);
291
328
  }
292
329
  if (data.date) {
293
- meta.push(["date", data.date]);
330
+ meta.push(["date", line(data.date)]);
294
331
  }
295
332
  if (data.duration) {
296
333
  meta.push(["duration", data.duration]);
@@ -328,7 +365,7 @@ export function formatLs(data: LsListResult | LsDeckResult): string {
328
365
  ];
329
366
  const countLabel = formatSkipped(data.skipped) || diagnosticCount(data.diagnostics.length);
330
367
  return [
331
- `${data.ref?.name ?? data.name} ${data.title}`,
368
+ `${data.ref?.name ?? data.name} ${line(data.title)}`,
332
369
  ...meta.map(([key, value]) => `${padEndWidth(key, keyWidth)} ${value}`),
333
370
  "",
334
371
  formatTable(headers, rows, [...align]),
@@ -426,7 +463,11 @@ export function formatVoice(data: VoiceCliResult): string {
426
463
  }
427
464
  }
428
465
 
429
- /** One labeled part per file, so a reader never confuses where a line came from. */
466
+ /**
467
+ * One labeled part per file, so a reader never confuses where a line came from: a part starts
468
+ * with a line `--- <file>`, and a file's own line that starts the same way is shown one space
469
+ * further in, so no file can start a part. A ref's slide says first whose it is.
470
+ */
430
471
  export function formatShow(data: ShowResult): string {
431
472
  const parts: Array<[string, string | null]> = [
432
473
  ["script.md", data.script],
@@ -436,9 +477,20 @@ export function formatShow(data: ShowResult): string {
436
477
  ["theme.css (the rules this slide uses)", data.theme || null],
437
478
  ["assets", data.assets.length > 0 ? data.assets.join("\n") : null],
438
479
  ];
439
- return parts
440
- .flatMap(([label, body]) => (body === null ? [] : [`--- ${label}\n${body.replace(/\n$/, "")}`]))
441
- .join("\n\n");
480
+ const ref = data.ref
481
+ ? [`ref ${data.ref.name} at ${data.ref.rev.slice(0, 7)}: someone else's deck, read-only`]
482
+ : [];
483
+ return [
484
+ ...ref,
485
+ ...parts.flatMap(([label, body]) =>
486
+ body === null ? [] : [`--- ${label}\n${partLines(body.replace(/\n$/, ""))}`],
487
+ ),
488
+ ].join("\n\n");
489
+ }
490
+
491
+ /** A part's lines, each that would start a part, after any spaces, given one space more. */
492
+ function partLines(body: string): string {
493
+ return body.replace(/^( *)---(?=[^\S\n])/gm, " $1---");
442
494
  }
443
495
 
444
496
  /** What a read does for a ref whose snapshot is not there: fetch it, or unpack its pack. */
@@ -474,7 +526,7 @@ export function formatRef(data: RefCliResult): string {
474
526
  data.refs.map((ref) => [
475
527
  ref.name,
476
528
  short(ref.rev),
477
- ref.fetched ? (ref.title ?? "") : notFetched(ref.name),
529
+ ref.fetched ? foreignLine(ref.title ?? "", TITLE_MAX) : notFetched(ref.name),
478
530
  ref.slides === undefined ? "" : String(ref.slides),
479
531
  ]),
480
532
  ["left", "left", "left", "right"],
package/src/cli/theme.ts CHANGED
@@ -3,10 +3,11 @@ import { type CssToken, parseCss } from "../core/css.ts";
3
3
  import { deckPaths } from "../core/deck-paths.ts";
4
4
  import { DekError } from "../core/error.ts";
5
5
  import { isPlainWord } from "../core/escape.ts";
6
+ import { foreignList, type HiddenText, hiddenText } from "../core/foreign-text.ts";
6
7
  import { listShown, unprinted } from "../core/prose.ts";
7
8
  import { readDeckFile } from "../core/resolve.ts";
8
9
  import { type ThemeLayout, themeFacts } from "../core/theme-facts.ts";
9
- import type { ReadableDeck, RefInfo } from "./scope.ts";
10
+ import { hiddenField, type ReadableDeck, type RefInfo } from "./scope.ts";
10
11
 
11
12
  export type ThemeResult = {
12
13
  /** The deck's theme.css: the one lint and the slides use. */
@@ -25,6 +26,8 @@ export type ThemeResult = {
25
26
  layout?: { name: string; example: string };
26
27
  /** Set when the deck is a ref. */
27
28
  ref?: RefInfo;
29
+ /** A ref's lines of theme.css that hold characters a person cannot see. */
30
+ hidden?: HiddenText[];
28
31
  };
29
32
 
30
33
  /** The deck's theme, or one layout's example markup when `name` is given. */
@@ -33,12 +36,11 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
33
36
  if (!existsSync(path)) {
34
37
  throw new DekError("theme.css not found", {
35
38
  path,
36
- hint: "copy the project theme.css into the deck, or run `dekc new <name>` for a fresh deck",
39
+ hint: "copy the project's template, ../../theme.css, into the deck, or run `dekc new <name>` for a fresh deck",
37
40
  });
38
41
  }
39
- const { classes, stateClasses, layouts, tokens } = themeFacts(
40
- parseCss(readDeckFile(deck.dir, path) ?? ""),
41
- );
42
+ const css = readDeckFile(deck.dir, path) ?? "";
43
+ const { classes, stateClasses, layouts, tokens } = themeFacts(parseCss(css));
42
44
  const printed = layouts.filter((layout) => isPlainWord(layout.name));
43
45
  const left = layouts.length - printed.length;
44
46
  const result: ThemeResult = {
@@ -49,28 +51,60 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
49
51
  layouts: printed,
50
52
  ...(left > 0 ? { unprintedLayouts: left } : {}),
51
53
  ...(ref ? { ref } : {}),
54
+ ...hiddenField(ref, hiddenText(css, path)),
52
55
  };
53
56
  if (!name) {
54
57
  return result;
55
58
  }
56
59
  const layout = layouts.find((entry) => entry.name === name);
57
60
  if (!layout) {
58
- throw new DekError(`layout "${name}" is not defined in theme.css`, {
59
- path,
60
- hint: `use one of: ${
61
- listShown(
62
- layouts.map((entry) => entry.name),
63
- isPlainWord,
64
- unprinted,
65
- ) || "(none)"
66
- }`,
67
- });
61
+ throw layoutNotFound(name, layouts, path, ref);
68
62
  }
69
63
  if (layout.example === undefined) {
70
64
  throw new DekError(`layout "${name}" has no example in theme.css`, {
71
65
  path,
72
- hint: `add a /* @layout ${name} ... */ comment with its markup above .slide[data-layout="${name}"]`,
66
+ hint: ref
67
+ ? `a ref is read-only; read the markup of a slide that uses it with \`dekc show ${ref.name} <slug>\``
68
+ : `add a /* @layout ${name} ... */ comment with its markup above .slide[data-layout="${name}"]`,
73
69
  });
74
70
  }
75
71
  return { ...result, layout: { name, example: layout.example } };
76
72
  }
73
+
74
+ /**
75
+ * A layout the theme does not define, named among the layouts whose names are plain words. A
76
+ * ref's layout names are its author's, so for a ref they go in the message, as foreignList shows
77
+ * them, and the hint is dek's words.
78
+ */
79
+ function layoutNotFound(
80
+ name: string,
81
+ layouts: ThemeLayout[],
82
+ path: string,
83
+ ref: RefInfo | undefined,
84
+ ): DekError {
85
+ const names = layouts.map((entry) => entry.name);
86
+ if (!ref) {
87
+ return new DekError(`layout "${name}" is not defined in theme.css`, {
88
+ path,
89
+ hint: `use one of: ${listShown(names, isPlainWord, unprinted) || "(none)"}`,
90
+ });
91
+ }
92
+ const shown = names.filter(isPlainWord);
93
+ const left = names.length - shown.length;
94
+ const others = left > 0 ? unprinted(left) : "";
95
+ const defined =
96
+ shown.length === 0
97
+ ? others
98
+ : others
99
+ ? `${foreignList(shown)}, and ${others}`
100
+ : foreignList(shown);
101
+ return shown.length > 0
102
+ ? new DekError(`layout "${name}" is not defined in theme.css, which defines ${defined}`, {
103
+ path,
104
+ hint: `pass one of them: \`dekc theme ${ref.name} <layout>\``,
105
+ })
106
+ : new DekError(
107
+ `layout "${name}" is not defined in theme.css, which defines ${defined || "none"}`,
108
+ { path, hint: `read the markup of a slide with \`dekc show ${ref.name} <slug>\`` },
109
+ );
110
+ }
package/src/cli/tty.ts CHANGED
@@ -10,15 +10,29 @@ const codes = {
10
10
  bold: "\x1b[1m",
11
11
  } as const;
12
12
 
13
+ /** The codes dek's own palette writes, which are all terminalSafe lets through. */
14
+ const PALETTE = new Set<string>([reset, ...Object.values(codes)]);
15
+
16
+ /**
17
+ * Text with no control character but a newline and a tab: each other shows as U+FFFD, the escape
18
+ * that starts a color code included. A result's and an error's text go through it before dek
19
+ * paints them, so what a deck or a ref wrote carries no color, conceal, or cursor move of its own.
20
+ */
21
+ export function plainText(text: string): string {
22
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
23
+ return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "\uFFFD");
24
+ }
25
+
13
26
  /**
14
27
  * Text for a terminal: dek's own colors, newlines, and tabs pass, and every other control
15
28
  * character shows as U+FFFD. A deck title, a file name, or a ref's title fetched from GitHub
16
- * could otherwise set the window title, draw a fake link (OSC 8), or clear the screen.
29
+ * could otherwise set the window title, draw a fake link (OSC 8), clear the screen, or hide
30
+ * itself from a person while an agent reading the bytes sees it, as conceal (SGR 8) does.
17
31
  */
18
32
  export function terminalSafe(text: string): string {
19
33
  // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
20
34
  return text.replace(/\x1b\[[0-9;]*m|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (match) =>
21
- match.length > 1 ? match : "\uFFFD",
35
+ PALETTE.has(match) ? match : plainText(match),
22
36
  );
23
37
  }
24
38
 
package/src/cli/voice.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { spawn } from "node:child_process";
2
1
  import { readFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  import { DekError } from "../core/error.ts";
5
4
  import { deckProjectRoot } from "../core/path.ts";
6
5
  import { isCachedFile, requireInside, writeInside } from "../core/safe-fs.ts";
6
+ import { runPiped } from "../core/spawn.ts";
7
7
  import { loadVoiceDict, loadVoiceSettings, voiceCacheFile, writeVoiceDict } from "../core/voice.ts";
8
8
  import {
9
9
  engineBaseUrl,
@@ -97,6 +97,9 @@ export function pinVoice({ deck }: DeckTarget): VoiceCliResult {
97
97
  return { action: "pin", timelinePath: pinTimeline, audioPath: pinAudio };
98
98
  }
99
99
 
100
+ /** A clip of a sentence or two plays well inside this. */
101
+ const PLAY_TIMEOUT_MS = 5 * 60 * 1000;
102
+
100
103
  async function playWav(path: string): Promise<void> {
101
104
  if (process.env.DEK_VOICE_PLAY === "0") {
102
105
  return;
@@ -106,9 +109,11 @@ async function playWav(path: string): Promise<void> {
106
109
  if (!bin) {
107
110
  return;
108
111
  }
109
- await new Promise<void>((resolve) => {
110
- const child = spawn(bin, [path], { stdio: "ignore" });
111
- child.on("exit", () => resolve());
112
- child.on("error", () => resolve());
113
- });
112
+ // Playing is a courtesy: the clip is written either way, so a player that is missing, fails,
113
+ // or hangs is let go.
114
+ await runPiped([bin, path], {
115
+ label: `${bin} failed`,
116
+ hint: `play ${path} yourself`,
117
+ timeoutMs: PLAY_TIMEOUT_MS,
118
+ }).catch(() => {});
114
119
  }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { extname, relative, resolve, sep } from "node:path";
3
+ import { decodeAttribute } from "./attribute-value.ts";
3
4
  import { parseCss } from "./css.ts";
4
5
  import {
5
6
  type CssEditor,
@@ -10,9 +11,10 @@ import {
10
11
  shareTokensWithTransitions,
11
12
  } from "./css-transform.ts";
12
13
  import { deckPaths } from "./deck-paths.ts";
14
+ import { escapeAttr } from "./escape.ts";
13
15
  import { isInside } from "./path.ts";
14
16
  import { listSlideFiles, readDeckFile } from "./resolve.ts";
15
- import { attributeUrls } from "./url-attributes.ts";
17
+ import { attributeUrls, trimUrl } from "./url-attributes.ts";
16
18
 
17
19
  /** Where one reference from a slide or a stylesheet points. */
18
20
  export type AssetRef =
@@ -35,13 +37,14 @@ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
35
37
  * Resolves a reference the one way lint, show, and the build agree on: the
36
38
  * deck root first, which is where the dev server and the build look for
37
39
  * `assets/`, then next to the file that names it, so `../assets/x` from
38
- * slides/ still resolves. A query string or fragment is not part of the path.
40
+ * slides/ still resolves. A query string or fragment is not part of the path, and only the C0
41
+ * controls and spaces at its ends are trimmed, as the URL parser trims them.
39
42
  */
40
43
  export function classifyAssetRef(
41
44
  raw: string,
42
45
  options: { deckDir: string; from: string },
43
46
  ): AssetRef {
44
- const value = raw.trim().split(/[?#]/)[0] ?? "";
47
+ const value = trimUrl(raw).split(/[?#]/)[0] ?? "";
45
48
  if (!value) {
46
49
  return { kind: "skip" };
47
50
  }
@@ -93,7 +96,7 @@ export function fileInside(
93
96
 
94
97
  /** True when a reference is written as `assets/...` from the deck root, or is not a local path at all. */
95
98
  export function isCanonicalAssetPath(raw: string): boolean {
96
- const value = raw.trim();
99
+ const value = trimUrl(raw);
97
100
  if (!value || value.startsWith("#") || SCHEME_RE.test(value) || REMOTE_RE.test(value)) {
98
101
  return true;
99
102
  }
@@ -118,8 +121,10 @@ export function inlineAssets(
118
121
 
119
122
  /**
120
123
  * The rewriter handler `inlineAssets` runs, for a pass that rewrites more than URLs. The URLs are
121
- * the ones lint checks, read by the same parser: every candidate of a `srcset` keeps its
122
- * descriptors, links and text stay as written.
124
+ * the ones lint checks, read by the same parser and decoded as the browser decodes them: every
125
+ * candidate of a `srcset` keeps its descriptors, links and text stay as written. The rewriter
126
+ * writes a value as it is given, escaping only `"`, so each data: URI goes in escaped, in place
127
+ * of the URL as written, and an attribute with nothing to inline is never written at all.
123
128
  */
124
129
  export function assetInliner(
125
130
  deckDir: string,
@@ -128,7 +133,8 @@ export function assetInliner(
128
133
  return {
129
134
  element(el) {
130
135
  const tag = el.tagName.toLowerCase();
131
- for (const [name, value] of [...el.attributes]) {
136
+ for (const [name, written] of [...el.attributes]) {
137
+ const { value, writtenAt } = decodeAttribute(written);
132
138
  const named = attributeUrls(tag, name.toLowerCase(), value);
133
139
  if (named?.use !== "resource") {
134
140
  continue;
@@ -137,9 +143,11 @@ export function assetInliner(
137
143
  const uri = inlinedUri(url, deckDir, from);
138
144
  return uri === undefined
139
145
  ? rewritten
140
- : rewritten.slice(0, offset) + uri + rewritten.slice(offset + url.length);
141
- }, value);
142
- if (inlined !== value) {
146
+ : rewritten.slice(0, writtenAt(offset)) +
147
+ escapeAttr(uri) +
148
+ rewritten.slice(writtenAt(offset + url.length));
149
+ }, written);
150
+ if (inlined !== written) {
143
151
  el.setAttribute(name, inlined);
144
152
  }
145
153
  }