@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.
- package/CHANGELOG.md +136 -0
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/package.json +5 -3
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +95 -18
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +57 -19
- package/src/cli/contract.ts +58 -4
- package/src/cli/files.ts +2 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +44 -11
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +36 -16
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +51 -14
- package/src/cli/new.ts +28 -8
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +92 -9
- package/src/cli/result.ts +47 -6
- package/src/cli/scope.ts +57 -29
- package/src/cli/shot.ts +78 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +88 -11
- package/src/cli/theme.ts +58 -10
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +15 -9
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/config.ts +61 -12
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +50 -8
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +24 -19
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +57 -27
- package/src/core/order.ts +14 -0
- package/src/core/pack.ts +2 -2
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +13 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +207 -30
- package/src/core/playwright.ts +279 -44
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +56 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +52 -8
- package/src/core/ref.ts +308 -54
- package/src/core/resolve.ts +23 -11
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +26 -17
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +12 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/spawn.ts +131 -32
- package/src/core/sync.ts +5 -14
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +182 -24
- package/src/core/voice.ts +5 -6
- package/src/core/zod.ts +9 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +26 -7
- package/src/server/lock.ts +50 -7
- package/src/server/routes.ts +7 -2
- package/src/server/serial.ts +8 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- 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
|
-
|
|
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);
|
package/src/core/slide-script.ts
CHANGED
|
@@ -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
|
-
/**
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
problems.push(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 = {
|
|
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, {
|
|
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
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
/**
|