@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
@@ -6,6 +6,7 @@ import {
6
6
  lineMatching,
7
7
  type SlideScriptProblem,
8
8
  staticProblems,
9
+ withoutImports,
9
10
  } from "./slide-script.ts";
10
11
  import { BUN_FLAGS } from "./spawn.ts";
11
12
  import { suggest } from "./suggest.ts";
@@ -27,6 +28,7 @@ export function slideScriptsProblems(scripts: ScriptInput[]): SlideScriptProblem
27
28
  const { results, pending } = prepareScripts(scripts);
28
29
  const codes = uncached(pending);
29
30
  if (codes.length > 0) {
31
+ // biome-ignore lint/nursery/noJsRestrictedProperties: lint's path is synchronous, the scripts get an empty environment, and a killed worker's lines still count; runPiped does none of these.
30
32
  const stdout = Bun.spawnSync(evaluatorCommand(), {
31
33
  ...EVALUATOR_OPTIONS,
32
34
  stdin: Buffer.from(JSON.stringify(codes)),
@@ -47,6 +49,7 @@ export async function evaluateSlideScripts(
47
49
  const { results, pending } = prepareScripts(scripts);
48
50
  const codes = uncached(pending);
49
51
  if (codes.length > 0) {
52
+ // biome-ignore lint/nursery/noJsRestrictedProperties: as for spawnSync above; SIGTERM ends the worker, as no host object reaches its scripts.
50
53
  const child = Bun.spawn(evaluatorCommand(), {
51
54
  ...EVALUATOR_OPTIONS,
52
55
  stdin: Buffer.from(JSON.stringify(codes)),
@@ -77,13 +80,6 @@ type PendingScript = {
77
80
  withoutImports: boolean;
78
81
  };
79
82
 
80
- const IMPORT_RE = /^[ \t]*import\s+(?:type\s+)?(?:[\s\S]*?\sfrom\s*)?["'][^"'\n]+["'][ \t]*;?/gm;
81
-
82
- /** The code with its import declarations blanked out, lines kept in place. */
83
- function withoutImports(code: string): string {
84
- return code.replace(IMPORT_RE, (match) => match.replace(/[^\n]/g, " "));
85
- }
86
-
87
83
  function prepareScripts(scripts: ScriptInput[]): {
88
84
  results: SlideScriptProblem[][];
89
85
  pending: PendingScript[];
@@ -97,7 +93,15 @@ function prepareScripts(scripts: ScriptInput[]): {
97
93
  const source = importsOnly ? withoutImports(script.code) : script.code;
98
94
  const compiled =
99
95
  problems.length > 0 && !importsOnly ? undefined : compileSlideScript(source, "slide");
100
- if (compiled && "problem" in compiled) {
96
+ // The blanked code can fail for the blanking's sake: an import it left is refused again, and
97
+ // already reported, and a syntax error is its own, since the script as written parsed.
98
+ const blanking =
99
+ importsOnly &&
100
+ compiled &&
101
+ "problem" in compiled &&
102
+ (compiled.problem.message === IMPORTS_MESSAGE ||
103
+ compiled.problem.message.startsWith("syntax error: "));
104
+ if (compiled && "problem" in compiled && !blanking) {
101
105
  problems.push(compiled.problem);
102
106
  }
103
107
  results.push(problems);
@@ -4,8 +4,35 @@ import { escapeRegExp } from "./escape.ts";
4
4
  import { listSlideFiles } from "./resolve.ts";
5
5
  import { stillDrawScript } from "./slide-draw.ts";
6
6
 
7
- /** Slide scripts are `slides/<slug>.ts`; plain JavaScript is valid TypeScript. */
8
- const transpiler = new Bun.Transpiler({ loader: "ts" });
7
+ /** Thrown in place of reading a script that imports; see `transpiler`. */
8
+ class ImportsRefused extends Error {}
9
+
10
+ /** A first `#!` line, a comment that `scanImports` rejects; Bun ends it at any line terminator. */
11
+ const HASHBANG_RE = /^#![^\n\r\u2028\u2029]*/;
12
+
13
+ /**
14
+ * Bun's transpiler, which nothing else in dek holds. Bun's own `scan` and `transformSync` run a
15
+ * macro, an import marked `type: "macro"`, in this process while they read the code, so a ref's
16
+ * slide script or a cloned project's would run inside dek. `scanImports` runs none: both methods
17
+ * here ask it first, and throw `ImportsRefused` for code that imports anything.
18
+ * Slide scripts are `slides/<slug>.ts`; plain JavaScript is valid TypeScript.
19
+ */
20
+ const transpiler = (() => {
21
+ const bun = new Bun.Transpiler({ loader: "ts" });
22
+ /** The code to read, once it imports nothing; a syntax error throws as Bun reports it. */
23
+ function importFree(code: string): string {
24
+ const source = code.replace(HASHBANG_RE, "");
25
+ // A `require(…)` calls a function a page does not have, and Bun reads nothing for it.
26
+ if (bun.scanImports(source).some((found) => found.kind !== "require-call")) {
27
+ throw new ImportsRefused();
28
+ }
29
+ return source;
30
+ }
31
+ return {
32
+ scan: (code: string) => bun.scan(importFree(code)),
33
+ transformSync: (code: string) => bun.transformSync(importFree(code)),
34
+ };
35
+ })();
9
36
 
10
37
  const DEFAULT_EXPORT_RE = /\bexport\s+default\b/;
11
38
  /** In transpiled output, comments are gone and each statement starts a line. */
@@ -35,30 +62,94 @@ function withLine(problem: SlideScriptProblem, line: number | undefined): SlideS
35
62
  return line === undefined ? problem : { ...problem, line };
36
63
  }
37
64
 
65
+ function importsProblem(code: string): SlideScriptProblem {
66
+ return withLine(
67
+ {
68
+ message: IMPORTS_MESSAGE,
69
+ hint: "remove the import and write what it gave in this file; DekSlide is global, from .dek/slide.d.ts",
70
+ },
71
+ lineMatching(code, /^\s*import\b/),
72
+ );
73
+ }
74
+
75
+ /** A string literal, which can hold a quote, a brace, or a semicolon. */
76
+ const STRING = String.raw`"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'`;
77
+ /**
78
+ * A name's character. Each part below takes only what it can hold, and none starts with a space
79
+ * where the part before it ends with any, so no text is read more than once from each place.
80
+ */
81
+ const NAME = String.raw`[\p{ID_Continue}$]`;
82
+ /** A specifier, then `with { … }` or the older `assert { … }`, to the end of the statement. */
83
+ const SOURCE = String.raw`(?:${STRING})(?:\s*(?:with|assert)\s*\{(?:${NAME}|[\s,:]|${STRING})*\})?[ \t]*;?`;
84
+ /** Where a statement can start: a line's start, or after a statement or a comment on its line. */
85
+ const STATEMENT = String.raw`(?<=^|[;}]|\*\/)[ \t]*`;
86
+ /** What an import declaration binds, up to its `from`, which no other import comes before. */
87
+ const BINDINGS = String.raw`(?:(?!\bimport\b)(?:${NAME}|[\s,*{}])|${STRING})*?\bfrom\s*`;
88
+ /** An `export { … }` clause, or the name of an `export * as`, captured. */
89
+ const EXPORT_NAMES = String.raw`export\s*(?:\{((?:${NAME}|[\s,]|${STRING})*)\}|\*\s*as\s+(${NAME}+|${STRING}))`;
90
+
91
+ /**
92
+ * What `scanImports` counts as an import, as the text shows it: an import declaration, an
93
+ * `export … from`, and the `import` of an `import(…)`.
94
+ */
95
+ const IMPORT_RE = new RegExp(
96
+ String.raw`${STATEMENT}(?:import(?!${NAME})\s*(?:type\b\s*)?(?:(?=\S)${BINDINGS})?|(?:${EXPORT_NAMES}|export\s*\*)\s*from\s*)${SOURCE}|(?<!${NAME}|\.)import(?=\s*\()`,
97
+ "gmu",
98
+ );
99
+ const EXPORT_NAMES_RE = new RegExp(STATEMENT + EXPORT_NAMES, "gmu");
100
+
101
+ /** The code with its imports blanked out, attributes included, lines kept in place. */
102
+ export function withoutImports(code: string): string {
103
+ return code.replace(IMPORT_RE, (match) => match.replace(/[^\n]/g, " "));
104
+ }
105
+
106
+ /**
107
+ * The names `export { … }` and `export * as` give the module: what follows `as`, or the name
108
+ * itself. Bun leaves out of a scan one whose binding, or whose `from`, the blanking took.
109
+ */
110
+ function exportedNames(code: string): string[] {
111
+ return [...code.matchAll(EXPORT_NAMES_RE)].flatMap(([, clause = "", star]) =>
112
+ (star === undefined ? clause.split(",") : [star])
113
+ .map((item) => item.trim())
114
+ .filter((item) => item !== "" && !/^type\s+(?!as\b)/.test(item))
115
+ .map((item) => (item.split(/\s+as\s+/).at(-1) ?? item).replace(/^(["'])(.*)\1$/, "$2")),
116
+ );
117
+ }
118
+
38
119
  /**
39
120
  * What stops a script from being one self-contained module whose only export is the default
40
121
  * object, found without running it.
41
122
  */
42
123
  export function staticProblems(code: string): SlideScriptProblem[] {
43
- let scan: ReturnType<Bun.Transpiler["scan"]>;
124
+ const problems: SlideScriptProblem[] = [];
125
+ let exports: string[] | undefined;
44
126
  try {
45
- scan = transpiler.scan(code);
127
+ const scan = transpiler.scan(code);
128
+ // `require.resolve(…)` is the import scanImports does not report; nothing runs for it.
129
+ if (scan.imports.length > 0) {
130
+ problems.push(importsProblem(code));
131
+ }
132
+ exports = scan.exports;
46
133
  } catch (error) {
47
- return [syntaxProblem(error)];
48
- }
49
- const problems: SlideScriptProblem[] = [];
50
- if (scan.imports.length > 0) {
51
- problems.push(
52
- withLine(
53
- {
54
- message: IMPORTS_MESSAGE,
55
- hint: "remove the import and write what it gave in this file; DekSlide is global, from .dek/slide.d.ts",
56
- },
57
- lineMatching(code, /^\s*import\b/),
58
- ),
59
- );
134
+ if (!(error instanceof ImportsRefused)) {
135
+ return [syntaxProblem(error)];
136
+ }
137
+ // Imports alone do not stop the rest of the checks, which read the script without them.
138
+ problems.push(importsProblem(code));
139
+ const blanked = withoutImports(code);
140
+ try {
141
+ // In Bun's order, which sorts the names.
142
+ exports = [...new Set([...transpiler.scan(blanked).exports, ...exportedNames(code)])].sort();
143
+ } catch (blankedError) {
144
+ // An error on a line the blanking left alone is the script's own, and all Bun said before.
145
+ const own = blankedError instanceof ImportsRefused ? undefined : syntaxProblem(blankedError);
146
+ const at = own?.line === undefined ? undefined : own.line - 1;
147
+ if (own && at !== undefined && code.split("\n")[at] === blanked.split("\n")[at]) {
148
+ return [own];
149
+ }
150
+ }
60
151
  }
61
- for (const name of scan.exports.filter((entry) => entry !== "default")) {
152
+ for (const name of (exports ?? []).filter((entry) => entry !== "default")) {
62
153
  problems.push(
63
154
  withLine(
64
155
  {
@@ -72,7 +163,8 @@ export function staticProblems(code: string): SlideScriptProblem[] {
72
163
  ),
73
164
  );
74
165
  }
75
- if (!scan.exports.includes("default") || !DEFAULT_EXPORT_RE.test(code)) {
166
+ // Exports the blanking left unreadable leave the source to say whether one is the default.
167
+ if (!(exports?.includes("default") ?? true) || !DEFAULT_EXPORT_RE.test(code)) {
76
168
  problems.push({
77
169
  message: "missing export default",
78
170
  hint: "end the script with export default { draw(slide, { t }) {} } satisfies DekSlide",
@@ -116,7 +208,9 @@ export function compileSlideScript(
116
208
  try {
117
209
  js = transpiler.transformSync(code);
118
210
  } catch (error) {
119
- return { problem: syntaxProblem(error) };
211
+ return {
212
+ problem: error instanceof ImportsRefused ? importsProblem(code) : syntaxProblem(error),
213
+ };
120
214
  }
121
215
  let firstError: unknown;
122
216
  for (const match of js.matchAll(EXPORT_DEFAULT_LINE_RE)) {
package/src/core/spawn.ts CHANGED
@@ -1,18 +1,5 @@
1
1
  import { DekError } from "./error.ts";
2
2
 
3
- export async function awaitPiped(proc: {
4
- stdout: ReadableStream<Uint8Array> | number;
5
- stderr: ReadableStream<Uint8Array> | number;
6
- exited: Promise<number>;
7
- }): Promise<{ stdout: string; stderr: string; exitCode: number }> {
8
- const [stdout, stderr, exitCode] = await Promise.all([
9
- new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
10
- new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
11
- proc.exited,
12
- ]);
13
- return { stdout, stderr, exitCode };
14
- }
15
-
16
3
  /**
17
4
  * How dek runs bun: with no auto install, and without the working directory's `.env` or
18
5
  * `bunfig.toml`, which a repository someone else wrote can commit to set variables or preload code.
@@ -28,6 +15,15 @@ export function workerCommand(bin: string, args: string[] = []): string[] {
28
15
  /** A browser that hangs must not hang dek; a whole visual run or capture fits well inside this. */
29
16
  const DEFAULT_WORKER_TIMEOUT_MS = 10 * 60 * 1000;
30
17
 
18
+ /** How long a command stopped at its timeout has to exit on SIGTERM before it gets SIGKILL. */
19
+ const KILL_GRACE_MS = 2000;
20
+
21
+ /**
22
+ * How long the output is still read once the command has exited. A process it started and left
23
+ * running holds the pipes open as long as it lives, so what came by then is the output.
24
+ */
25
+ const DRAIN_MS = 1000;
26
+
31
27
  type PipedOptions = {
32
28
  /** The error message when the command cannot start or overruns, such as "ffmpeg failed". */
33
29
  label: string;
@@ -37,9 +33,20 @@ type PipedOptions = {
37
33
  stdin?: string;
38
34
  /** Covers the whole run, from spawn to exit. */
39
35
  timeoutMs: number;
36
+ /** Where the command runs; dek's working directory when left out. */
37
+ cwd?: string;
40
38
  };
41
39
 
42
- type Piped = { stdout: string; stderr: string; exitCode: number };
40
+ type Piped = {
41
+ stdout: string;
42
+ stderr: string;
43
+ exitCode: number;
44
+ /** Set when the output was let go before it ended, `DRAIN_MS` after the command exited. */
45
+ cutShort?: true;
46
+ };
47
+
48
+ /** What `runPiped` throws for a command it stopped at its timeout, unlike one that cannot start. */
49
+ export class OverrunError extends DekError {}
43
50
 
44
51
  /**
45
52
  * Runs `cmd` to its exit with its output piped back. A command that cannot start, or runs past
@@ -48,36 +55,123 @@ type Piped = { stdout: string; stderr: string; exitCode: number };
48
55
  export async function runPiped(cmd: string[], options: PipedOptions): Promise<Piped> {
49
56
  let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
50
57
  try {
51
- proc = Bun.spawn(cmd, { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
58
+ proc = Bun.spawn(cmd, {
59
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
60
+ // The environment as it is now: left out, Bun passes the one dek started with, which holds
61
+ // what a `.env` set before dek took it out (see dropEnvFileVariables).
62
+ env: process.env,
63
+ stdin: "pipe",
64
+ stdout: "pipe",
65
+ stderr: "pipe",
66
+ });
52
67
  } catch (error) {
53
68
  throw new DekError(options.label, { hint: options.hint, cause: error });
54
69
  }
55
- let timedOut = false;
56
- const timer = setTimeout(() => {
57
- timedOut = true;
58
- proc.kill();
59
- }, options.timeoutMs);
70
+ // Read before writing: a command that answers while still reading must not block on a full pipe.
71
+ const output = readOutput(proc);
72
+ const stop = stopAfter(proc, options.timeoutMs);
73
+ const overrun = () =>
74
+ new OverrunError(options.label, {
75
+ hint: `it did not finish within ${Math.round(options.timeoutMs / 1000)}s and was stopped`,
76
+ });
60
77
  try {
61
- // Read before writing: a command that answers while still reading must not block on a full pipe.
62
- const piped = awaitPiped(proc);
63
78
  if (options.stdin !== undefined) {
64
79
  proc.stdin.write(options.stdin);
65
80
  }
66
81
  await proc.stdin.end();
67
- const result = await piped;
68
- if (timedOut) {
69
- throw new DekError(options.label, {
70
- hint: `it did not finish within ${Math.round(options.timeoutMs / 1000)}s and was stopped`,
71
- });
82
+ const exitCode = await proc.exited;
83
+ // It finished in time, however long what it left behind keeps the pipes open.
84
+ stop.clear();
85
+ const { stdout, stderr, cutShort } = await output.drain();
86
+ if (stop.fired()) {
87
+ throw overrun();
72
88
  }
73
- return result;
89
+ return { stdout, stderr, exitCode, ...(cutShort ? { cutShort } : {}) };
74
90
  } catch (error) {
75
91
  if (error instanceof DekError) {
76
92
  throw error;
77
93
  }
94
+ // Killing the command breaks the pipe a write to its stdin was waiting on.
95
+ if (stop.fired()) {
96
+ throw overrun();
97
+ }
78
98
  throw new DekError(options.label, { hint: options.hint, cause: error });
79
99
  } finally {
80
- clearTimeout(timer);
100
+ stop.clear();
101
+ output.cancel();
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Stops `proc` once `timeoutMs` passes: SIGTERM, then SIGKILL if it is still running
107
+ * `KILL_GRACE_MS` later. `clear` calls off the timeout, never a SIGKILL already on its way.
108
+ *
109
+ * Only the command is signalled, not what it started: a command that forks a child and is killed
110
+ * leaves the child running. `bun add -d rumdl` puts a Node wrapper in front of rumdl that runs the
111
+ * binary with spawnSync, so a rumdl that hangs is stopped and its binary lives on as an orphan;
112
+ * the drain keeps it from holding dek. Killing the whole group would need the command in a session
113
+ * of its own (`detached`), where the terminal's Ctrl-C no longer reaches it.
114
+ */
115
+ function stopAfter(
116
+ proc: Bun.Subprocess,
117
+ timeoutMs: number,
118
+ ): { fired: () => boolean; clear: () => void } {
119
+ let fired = false;
120
+ const timer = setTimeout(() => {
121
+ fired = true;
122
+ proc.kill();
123
+ const kill = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
124
+ void proc.exited.finally(() => clearTimeout(kill));
125
+ }, timeoutMs);
126
+ return { fired: () => fired, clear: () => clearTimeout(timer) };
127
+ }
128
+
129
+ /**
130
+ * The command's stdout and stderr, read as they come. `drain`, once it has exited, waits
131
+ * `DRAIN_MS` at most for both to end, and says when it had to let go first (`cutShort`); `cancel`
132
+ * lets go of both, which an open pipe needs before dek can exit.
133
+ */
134
+ function readOutput(proc: Bun.Subprocess<"pipe", "pipe", "pipe">): {
135
+ drain: () => Promise<{ stdout: string; stderr: string; cutShort: boolean }>;
136
+ cancel: () => void;
137
+ } {
138
+ const readers = [proc.stdout.getReader(), proc.stderr.getReader()];
139
+ const texts = Promise.all(readers.map(readToEnd));
140
+ // Awaited by `drain`; a read that fails before then must not go unhandled.
141
+ texts.catch(() => {});
142
+ const cancel = () => {
143
+ for (const reader of readers) {
144
+ reader.cancel().catch(() => {});
145
+ }
146
+ };
147
+ return {
148
+ drain: async () => {
149
+ let cutShort = false;
150
+ const timer = setTimeout(() => {
151
+ cutShort = true;
152
+ cancel();
153
+ }, DRAIN_MS);
154
+ try {
155
+ const [stdout = "", stderr = ""] = await texts;
156
+ return { stdout, stderr, cutShort };
157
+ } finally {
158
+ clearTimeout(timer);
159
+ }
160
+ },
161
+ cancel,
162
+ };
163
+ }
164
+
165
+ /** The text a reader reads until its stream ends or is cancelled. */
166
+ async function readToEnd(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
167
+ const decoder = new TextDecoder();
168
+ let text = "";
169
+ for (;;) {
170
+ const { done, value } = await reader.read();
171
+ if (done) {
172
+ return text + decoder.decode();
173
+ }
174
+ text += decoder.decode(value, { stream: true });
81
175
  }
82
176
  }
83
177
 
@@ -94,7 +188,8 @@ type JsonWorkerOptions = {
94
188
  * Runs a worker that takes one JSON request on stdin and answers with JSON on
95
189
  * stdout. Every failure is a DekError named `label`: a worker that exits
96
190
  * non-zero contributes its stderr as the hint, one that overruns the timeout
97
- * is killed and says so, and output `parse` rejects is invalid JSON.
191
+ * is killed and says so, and output `parse` rejects is invalid JSON, or cut
192
+ * short when a process the worker left behind held it open.
98
193
  */
99
194
  export async function runJsonWorker<T>(
100
195
  cmd: string[],
@@ -102,7 +197,7 @@ export async function runJsonWorker<T>(
102
197
  parse: (stdout: string) => T | null,
103
198
  options: JsonWorkerOptions,
104
199
  ): Promise<T> {
105
- const { stdout, stderr, exitCode } = await runPiped(cmd, {
200
+ const { stdout, stderr, exitCode, cutShort } = await runPiped(cmd, {
106
201
  label: options.label,
107
202
  hint: options.hint,
108
203
  stdin: JSON.stringify(request),
@@ -113,7 +208,11 @@ export async function runJsonWorker<T>(
113
208
  }
114
209
  const parsed = parse(stdout);
115
210
  if (parsed === null) {
116
- throw new DekError(options.label, { hint: "worker returned invalid JSON" });
211
+ throw new DekError(options.label, {
212
+ hint: cutShort
213
+ ? "the worker exited while a process it started still held its output, so its answer was cut short; run the command again"
214
+ : "worker returned invalid JSON",
215
+ });
117
216
  }
118
217
  return parsed;
119
218
  }
package/src/core/sync.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig } from "./config.ts";
4
4
  import { type DeckPaths, deckPaths } from "./deck-paths.ts";
5
+ import { foreignLine, TITLE_MAX } from "./foreign-text.ts";
5
6
  import { parseRefSource, refDir, refTitle } from "./ref.ts";
6
7
  import { asResolvedDeck, listSlides, type ResolvedDeck, SLIDE_SIDECARS } from "./resolve.ts";
7
8
  import { outputDir, readSourceIfExists, removeInside, writeInside } from "./safe-fs.ts";
@@ -132,7 +133,7 @@ export function syncDeck(input: string | ResolvedDeck): SyncResult {
132
133
  const skeleton = sectionSkeleton(deck.deck, index, layouts);
133
134
  const current = existing.get(section.slug);
134
135
  if (current) {
135
- const html = readFileSync(current, "utf8");
136
+ const html = readSourceIfExists(current, project.root) ?? "";
136
137
  if (!record.owns(html, skeleton)) {
137
138
  continue;
138
139
  }
@@ -170,7 +171,7 @@ function removeOrphanSkeletons(
170
171
  const removed: string[] = [];
171
172
  const kept: string[] = [];
172
173
  for (const [slug, path] of orphans) {
173
- const html = readFileSync(path, "utf8");
174
+ const html = readSourceIfExists(path, root) ?? "";
174
175
  const beside = ([...SLIDE_SIDECARS, ".js"] as const).some((ext) =>
175
176
  existsSync(paths.slide(slug, ext)),
176
177
  );
@@ -293,23 +294,13 @@ For commands, run \`dekc help --agent\`.
293
294
  `;
294
295
  }
295
296
 
296
- /** The longest ref title AGENTS.md shows: a title names a deck, it does not describe one. */
297
- const REF_TITLE_MAX = 80;
298
-
299
297
  /**
300
298
  * A ref's title as it goes into AGENTS.md. Someone else wrote it, and agents read dek's block as
301
299
  * dek's own guidance, so a title must not start a line of its own there, such as a heading or an
302
- * instruction: line breaks and control characters become one space, and it is cut to
303
- * REF_TITLE_MAX characters.
300
+ * instruction, nor hold what a person cannot see: it is one foreignLine of TITLE_MAX.
304
301
  */
305
302
  function refTitleLine(title: string | undefined): string | undefined {
306
- // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
307
- const line = title?.replace(/[\s\x00-\x1f\x7f-\x9f]+/gu, " ").trim();
308
- if (!line) {
309
- return undefined;
310
- }
311
- const chars = [...line];
312
- return chars.length > REF_TITLE_MAX ? `${chars.slice(0, REF_TITLE_MAX - 1).join("")}…` : line;
303
+ return title === undefined ? undefined : foreignLine(title, TITLE_MAX) || undefined;
313
304
  }
314
305
 
315
306
  /**