@hajimism/dek 0.1.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 (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -0,0 +1,309 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { bundledTheme } from "../core/bundled-theme.ts";
4
+ import { deckPaths } from "../core/deck-paths.ts";
5
+ import { DekError } from "../core/error.ts";
6
+ import { walkUp } from "../core/optional.ts";
7
+ import { PLAYWRIGHT_INSTALL } from "../core/playwright.ts";
8
+ import { readSourceIfExists, writeInside } from "../core/safe-fs.ts";
9
+ import { formatVoiceToml } from "../core/voice.ts";
10
+
11
+ export function defaultTheme(): string {
12
+ return bundledTheme();
13
+ }
14
+
15
+ export function defaultToml(): string {
16
+ return "# dek project\n";
17
+ }
18
+
19
+ /**
20
+ * What dek keeps for itself under .dek/ while it runs: the dev server's lock, and the marks left
21
+ * while rehearsing. Neither belongs in the repository.
22
+ */
23
+ const LOCAL_STATE = [".dek/server.json", ".dek/marks.json"];
24
+
25
+ export function defaultGitignore(): string {
26
+ return `# dek
27
+ dist/
28
+ .cache/
29
+ ${LOCAL_STATE.join("\n")}
30
+ node_modules/
31
+ refs/
32
+ `;
33
+ }
34
+
35
+ /** The lines that ignore `entry` whole or through .dek/, with or without a leading slash. */
36
+ const ignores = (entry: string): string[] =>
37
+ [entry, ".dek", ".dek/", ".dek/*", ".dek/**"].flatMap((line) => [line, `/${line}`]);
38
+
39
+ /**
40
+ * Add what dek keeps for itself to the project's .gitignore, for a project made before dek kept
41
+ * it; whether the file changed. A project with no .gitignore is left without one.
42
+ */
43
+ export function ignoreLocalState(root: string): boolean {
44
+ const path = join(root, ".gitignore");
45
+ const current = readSourceIfExists(path, root);
46
+ if (current === undefined) {
47
+ return false;
48
+ }
49
+ const lines = new Set(current.split("\n").map((line) => line.trim()));
50
+ const missing = LOCAL_STATE.filter((entry) => !ignores(entry).some((line) => lines.has(line)));
51
+ if (missing.length === 0) {
52
+ return false;
53
+ }
54
+ const separator = current === "" || current.endsWith("\n") ? "" : "\n";
55
+ writeInside(path, `${current}${separator}${missing.join("\n")}\n`, root);
56
+ return true;
57
+ }
58
+
59
+ export function defaultRumdl(): string {
60
+ return `# Markdown rules for script.md
61
+ # ## is a slide. Frontmatter is allowed. Keep heading skips, line length, and broken links.
62
+ [global]
63
+ disable = ["MD041"]
64
+ `;
65
+ }
66
+
67
+ /** How to install dek into a project. The package is `@hajimism/dek`; its command is `dekc`. */
68
+ const INSTALL_DEK = "bun add -d @hajimism/dek";
69
+
70
+ /**
71
+ * The starter script: short, but a real talk, so the first `dekc ls` already
72
+ * shows sections, beats, and an estimate against the budget. A deck with a
73
+ * voice starts in Japanese, the language dek's voice reads; any other in English.
74
+ */
75
+ export function defaultScript(title: string, lang: "en" | "ja" = "en"): string {
76
+ return `---
77
+ # yaml-language-server: $schema=../../.dek/schema.json
78
+ title: ${JSON.stringify(title)}
79
+ duration: 1m
80
+ ---
81
+ ${STARTER_BODY[lang]}`;
82
+ }
83
+
84
+ const STARTER_BODY = {
85
+ en: `
86
+ ## intro
87
+
88
+ Hello, and thank you for coming.
89
+ This is a starter script: read it, then make it your own talk.
90
+
91
+ > A blockquote is a note to yourself. It is neither spoken nor timed.
92
+
93
+ ## How this file becomes slides {#how-it-works}
94
+
95
+ Every paragraph you write here is something you will say out loud.
96
+
97
+ ### One heading, one slide {#slides}
98
+
99
+ Each level-two heading is one slide,
100
+ and the paragraphs under it are what you say while it is on screen.
101
+
102
+ ### One beat, one pause {#beats}
103
+
104
+ Each level-three heading is a beat:
105
+ a pause in the speaking, and a moment where the screen can move on.
106
+
107
+ ## The clock {#timing}
108
+
109
+ dek estimates how long the talk takes from these words,
110
+ and checks the estimate against the duration at the top of this file.
111
+ Run \`dekc ls\` to see the budget and the estimate side by side.
112
+
113
+ ## Your turn {#next}
114
+
115
+ Rewrite these sections as your own talk.
116
+ The dev server keeps the slides in step every time you save.
117
+ `,
118
+ // No Latin words in what is spoken: the voice would need a reading for each one.
119
+ ja: `
120
+ ## intro
121
+
122
+ こんにちは、今日は来てくださってありがとうございます。
123
+ これは最初の台本です。読んだら、自分の話に書き換えてください。
124
+
125
+ > 引用は自分へのメモです。読み上げられず、時間にも数えません。
126
+
127
+ ## 台本がスライドになるまで {#how-it-works}
128
+
129
+ ここに書く段落は、どれも声に出して話す言葉です。
130
+
131
+ ### 見出しひとつで一枚 {#slides}
132
+
133
+ 二階層目の見出しがスライド一枚になり、
134
+ その下の段落は、そのスライドを映している間に話す内容です。
135
+
136
+ ### ビートひとつで一呼吸 {#beats}
137
+
138
+ 三階層目の見出しはビートです。
139
+ 話の区切りであり、画面が次へ進める瞬間でもあります。
140
+
141
+ ## 時間 {#timing}
142
+
143
+ 話にかかる時間は、ここに書いた言葉から見積もられます。
144
+ その見積もりを、ファイルの先頭にある持ち時間と照らし合わせます。
145
+
146
+ > \`dekc ls\` で持ち時間と見積もりを並べて見る。
147
+
148
+ ## あなたの番です {#next}
149
+
150
+ これらのセクションを、自分の話に書き換えてください。
151
+ 開発サーバーは、保存するたびにスライドを台本に合わせます。
152
+ `,
153
+ };
154
+
155
+ /** A path init or new writes: a directory, or a file with what goes in it. */
156
+ export type PlannedPath = { path: string; contents?: string };
157
+
158
+ /** What applying a plan did: paths it wrote, and files it left because they were already there. */
159
+ export type Applied = { created: string[]; kept: string[] };
160
+
161
+ /**
162
+ * Writes what the plan has and the disk lacks, and never overwrites. A file
163
+ * that is already there is kept as it is, and listed when it differs from
164
+ * what the plan would write. Every path is checked before the first write,
165
+ * so a plan that cannot land leaves nothing behind.
166
+ */
167
+ export function applyPlan(plan: PlannedPath[]): Applied {
168
+ const steps = plan.map((entry) => ({ entry, state: pathState(entry) }));
169
+ const created: string[] = [];
170
+ const kept: string[] = [];
171
+ for (const { entry, state } of steps) {
172
+ if (state === "kept") {
173
+ kept.push(entry.path);
174
+ }
175
+ if (state !== "missing") {
176
+ continue;
177
+ }
178
+ if (entry.contents === undefined) {
179
+ mkdirSync(entry.path, { recursive: true });
180
+ } else {
181
+ mkdirSync(dirname(entry.path), { recursive: true });
182
+ // Exclusive: a file, or a dangling link a repository planted, is never written through.
183
+ writeFileSync(entry.path, entry.contents, { flag: "wx" });
184
+ }
185
+ created.push(entry.path);
186
+ }
187
+ return { created, kept };
188
+ }
189
+
190
+ function pathState(entry: PlannedPath): "missing" | "same" | "kept" {
191
+ const kind = entry.contents === undefined ? "directory" : "file";
192
+ if (!placeable(entry.path, kind)) {
193
+ return "missing";
194
+ }
195
+ if (entry.contents === undefined) {
196
+ return "same";
197
+ }
198
+ return readFileSync(entry.path, "utf8") === entry.contents ? "same" : "kept";
199
+ }
200
+
201
+ /**
202
+ * Checks that each file can be written where it goes, before anything is: for
203
+ * files dek refreshes on every run, which a plan does not hold.
204
+ */
205
+ export function checkFileSlots(paths: string[]): void {
206
+ for (const path of paths) {
207
+ placeable(path, "file");
208
+ }
209
+ }
210
+
211
+ /** Whether something is already at the path; throws when it is the wrong kind of thing. */
212
+ function placeable(path: string, kind: "file" | "directory"): boolean {
213
+ if (!existsSync(path)) {
214
+ return false;
215
+ }
216
+ const isDir = statSync(path).isDirectory();
217
+ if (kind === "directory" && !isDir) {
218
+ throw new DekError("a file is in the way of a directory", {
219
+ path,
220
+ hint: "move the file aside and run again",
221
+ });
222
+ }
223
+ if (kind === "file" && isDir) {
224
+ throw new DekError("a directory is in the way of a file", {
225
+ path,
226
+ hint: "move the directory aside and run again",
227
+ });
228
+ }
229
+ return true;
230
+ }
231
+
232
+ /**
233
+ * A deck's files: its directories, the starter script, the theme it starts
234
+ * from, and voice settings when the project has a voice. The skeleton slides
235
+ * are sync's to write, once the theme they are drawn in is in place.
236
+ */
237
+ export function deckPlan(
238
+ root: string,
239
+ name: string,
240
+ theme: string,
241
+ voice?: { engine: string; speaker: string; speed?: number },
242
+ ): PlannedPath[] {
243
+ const paths = deckPaths(join(root, "decks", name));
244
+ const script = defaultScript(name, voice ? "ja" : "en");
245
+ const plan: PlannedPath[] = [
246
+ { path: paths.slides },
247
+ { path: paths.assets },
248
+ { path: paths.script, contents: script },
249
+ { path: paths.theme, contents: theme },
250
+ ];
251
+ if (voice) {
252
+ plan.push(
253
+ { path: paths.voiceToml, contents: formatVoiceToml(voice) },
254
+ { path: paths.voiceDict, contents: "# voice dictionary\n" },
255
+ );
256
+ }
257
+ return plan;
258
+ }
259
+
260
+ /**
261
+ * The commands to run next, from `cwd`, exactly as they can be pasted: dek
262
+ * installed into the project unless it already resolves there, then into the
263
+ * deck to write the script and start the dev server, or a first deck to add.
264
+ */
265
+ export function nextSteps(cwd: string, root: string, deckDir?: string): string[] {
266
+ const steps: string[] = [];
267
+ let at = cwd;
268
+ const go = (dir: string): void => {
269
+ const path = relative(at, dir);
270
+ if (path) {
271
+ steps.push(`cd ${shellQuote(path.length <= dir.length ? path : dir)}`);
272
+ }
273
+ at = dir;
274
+ };
275
+ if (!hasLocalDek(root)) {
276
+ go(root);
277
+ steps.push(INSTALL_DEK);
278
+ }
279
+ if (!deckDir) {
280
+ return [...steps, "bunx dekc new <name>"];
281
+ }
282
+ go(deckDir);
283
+ return [...steps, "$EDITOR script.md", "bunx dekc"];
284
+ }
285
+
286
+ /** Whether `bunx dekc` in the project runs this dek: bunx looks in node_modules/.bin up the tree. */
287
+ function hasLocalDek(root: string): boolean {
288
+ return inNodeModules(root, ".bin", "dekc");
289
+ }
290
+
291
+ /**
292
+ * The Playwright install, until the project has it: shots, the PDF, and the rendered lint rules
293
+ * need it, and dek does not bring it along.
294
+ */
295
+ export function playwrightStep(root: string): string | undefined {
296
+ return inNodeModules(root, "playwright", "package.json") ? undefined : PLAYWRIGHT_INSTALL;
297
+ }
298
+
299
+ /** Whether node_modules holds `path` in `root` or a directory above it, as Bun resolves it. */
300
+ function inNodeModules(root: string, ...path: string[]): boolean {
301
+ return (
302
+ walkUp(root, (dir) => existsSync(join(dir, "node_modules", ...path)) || undefined) === true
303
+ );
304
+ }
305
+
306
+ /** `word` as one shell word: as it is when that is safe, else single-quoted. */
307
+ export function shellQuote(word: string): string {
308
+ return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
309
+ }
@@ -0,0 +1,295 @@
1
+ import { parseArgs } from "node:util";
2
+ import { DekError } from "../core/error.ts";
3
+ import { acceptedFlags, isTypedCommand, subcommandAt } from "./commands.ts";
4
+
5
+ /** A flag's shape: a switch, a string, or a value dek checks before any command runs. */
6
+ type FlagSpec = { text: string } & (
7
+ | { type: "boolean"; short?: string }
8
+ | { type: "string"; value: string }
9
+ | { type: "int"; value: string; min: number; max?: number; example: string }
10
+ | { type: "number"; value: string; above: number; example: string }
11
+ | { type: "enum"; choices: readonly string[]; example: string; note?: string }
12
+ );
13
+
14
+ /**
15
+ * Every flag dek has: its type, the placeholder for its value, and what it means. Each command
16
+ * names the ones it takes; help, parsing, and the checks on a value all read this one table.
17
+ */
18
+ export const FLAGS = {
19
+ json: { type: "boolean", text: "print the result, or the error, as JSON" },
20
+ help: { type: "boolean", short: "h", text: "show help; dekc help <command> for one command" },
21
+ version: { type: "boolean", short: "v", text: "print the dek version" },
22
+ agent: { type: "boolean", text: "the compact reference for agents" },
23
+ deck: { type: "string", value: "NAME", text: "target a deck by name from the project root" },
24
+ "theme-from": {
25
+ type: "string",
26
+ value: "DECK",
27
+ text: "copy that deck's theme.css instead of the project's",
28
+ },
29
+ remote: {
30
+ type: "boolean",
31
+ text: "serve on the LAN; the presenter view needs the password dek prints",
32
+ },
33
+ port: {
34
+ type: "int",
35
+ value: "N",
36
+ min: 1,
37
+ max: 65_535,
38
+ example: "dekc --port 3030",
39
+ text: "listen on this port (the OS picks one when omitted)",
40
+ },
41
+ fix: { type: "boolean", text: "create missing skeleton slides before linting" },
42
+ visual: {
43
+ type: "boolean",
44
+ text: "add the rendered rules: overflow and contrast (needs Playwright)",
45
+ },
46
+ shot: { type: "boolean", text: "also write a screenshot and return its path" },
47
+ voice: { type: "boolean", text: "also return each sentence's reading and duration" },
48
+ format: {
49
+ type: "enum",
50
+ choices: ["sarif"],
51
+ example: "dekc lint --format sarif",
52
+ note: "for JSON, pass --json",
53
+ text: "print diagnostics as SARIF",
54
+ },
55
+ before: { type: "string", value: "SLUG", text: "move the section before this one" },
56
+ after: { type: "string", value: "SLUG", text: "move the section after this one" },
57
+ step: { type: "string", value: "ID|N", text: "the beat to capture (default: the last)" },
58
+ to: { type: "string", value: "SLUG", text: "capture the transition into this slide" },
59
+ // A string: core/shot.ts reads and checks it, as the one place that knows the range.
60
+ at: {
61
+ type: "string",
62
+ value: "0..1",
63
+ text: "how far into the view transition (default 0.5); the slide's own entrance can run on past 1",
64
+ },
65
+ sheet: { type: "boolean", text: "tile every slide on contact sheets an agent reads in one look" },
66
+ motion: {
67
+ type: "boolean",
68
+ text: "a row for the arrival and each beat, held at moments through all it moves, ending as shot does",
69
+ },
70
+ accent: {
71
+ type: "int",
72
+ value: "N",
73
+ min: 0,
74
+ example: "dekc voice dict add dek デック --accent 1",
75
+ text: "the accent position of the reading",
76
+ },
77
+ fps: {
78
+ type: "number",
79
+ value: "N",
80
+ above: 0,
81
+ example: "dekc video --fps 30",
82
+ text: "frames per second",
83
+ },
84
+ "root-dist": { type: "boolean", text: "write to <root>/dist/ instead of the deck's dist/" },
85
+ url: {
86
+ type: "string",
87
+ value: "<url>",
88
+ text: "the URL dist/ is served from, over url in dek.toml",
89
+ },
90
+ public: {
91
+ type: "boolean",
92
+ text: "a page for anyone with the link, without the script's stage directions and comments",
93
+ },
94
+ } as const satisfies Record<string, FlagSpec>;
95
+
96
+ export type FlagName = keyof typeof FLAGS;
97
+
98
+ type ValueOf<F> = F extends { type: "boolean" }
99
+ ? true
100
+ : F extends { type: "int" | "number" }
101
+ ? number
102
+ : F extends { choices: readonly (infer C)[] }
103
+ ? C
104
+ : string;
105
+
106
+ /** What the command line said, each value already of its flag's type. */
107
+ export type FlagValues = { -readonly [K in FlagName]?: ValueOf<(typeof FLAGS)[K]> };
108
+
109
+ /**
110
+ * Taken by every invocation: --json also shapes an error, and --help and --version turn any
111
+ * command line into help or the version.
112
+ */
113
+ const GLOBAL_FLAGS: readonly FlagName[] = ["json", "help", "version"];
114
+
115
+ /** The placeholder help prints after a flag, or nothing for a switch. */
116
+ export function flagValue(name: FlagName): string | undefined {
117
+ const flag: FlagSpec = FLAGS[name];
118
+ if (flag.type === "boolean") {
119
+ return undefined;
120
+ }
121
+ return flag.type === "enum" ? flag.choices.join("|") : flag.value;
122
+ }
123
+
124
+ type ParseOptions = Record<string, { type: "boolean" | "string"; short?: string }>;
125
+
126
+ function parseOptions(names: readonly FlagName[]): ParseOptions {
127
+ return Object.fromEntries(
128
+ names.map((name) => {
129
+ const flag: FlagSpec = FLAGS[name];
130
+ const type = flag.type === "boolean" ? "boolean" : "string";
131
+ return [name, flag.type === "boolean" && flag.short ? { type, short: flag.short } : { type }];
132
+ }),
133
+ );
134
+ }
135
+
136
+ const ALL_FLAGS = Object.keys(FLAGS) as FlagName[];
137
+
138
+ export type CommandLine = {
139
+ /** The first word: a command, a deck name, or nothing for the bare `dekc`. */
140
+ command?: string;
141
+ /** The subcommand named, such as `dict add` for `dekc voice dict add`. */
142
+ subcommand?: string;
143
+ /** Every word that is no flag, the command word first. */
144
+ positionals: string[];
145
+ values: FlagValues;
146
+ /**
147
+ * argv as typed with the command word moved first, which runs the same: what a hint retypes
148
+ * when it only needs to add a word after the command.
149
+ */
150
+ typed: string[];
151
+ };
152
+
153
+ function lenientParse(argv: string[]) {
154
+ return parseArgs({
155
+ args: argv,
156
+ options: parseOptions(ALL_FLAGS),
157
+ strict: false,
158
+ allowPositionals: true,
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Whether argv asks for JSON, read even from a command line too broken to parse: from the words
164
+ * themselves, since a value flag in front, as in `--port --json`, would take it for its value.
165
+ */
166
+ export function wantsJson(argv: string[]): boolean {
167
+ const end = argv.indexOf("--");
168
+ return (end < 0 ? argv : argv.slice(0, end)).some(
169
+ (word) => word === "--json" || word.startsWith("--json="),
170
+ );
171
+ }
172
+
173
+ /**
174
+ * argv after `dekc`, checked against the flags its command takes. Two passes:
175
+ * a lenient one only to find the command word (value flags must consume their
176
+ * value first, or `--deck talk ls` would read `talk` as the command), then a
177
+ * strict one with that command's flags, so a typo or a flag from another
178
+ * command is an error instead of a silent no-op. A value that does not fit its
179
+ * flag is refused here too, before any command runs.
180
+ */
181
+ export function parseCommandLine(argv: string[]): CommandLine {
182
+ const lenient = lenientParse(argv);
183
+ const command = lenient.positionals[0];
184
+ // An unknown word, `serve` included, is parsed as the bare `dekc [deck]` and judged later.
185
+ const name =
186
+ lenient.values.help === true && !isTypedCommand(command)
187
+ ? "help"
188
+ : isTypedCommand(command)
189
+ ? command
190
+ : "serve";
191
+ const sub = subcommandAt(name, lenient.positionals);
192
+ const names = [...acceptedFlags(name, sub?.name), ...GLOBAL_FLAGS];
193
+ const label = [command === undefined ? "dekc" : `dekc ${command}`, sub?.name]
194
+ .filter(Boolean)
195
+ .join(" ");
196
+ const hint = `${label} takes ${names.map((flag) => `--${flag}`).join(", ")}; run \`dekc help ${name}\``;
197
+
198
+ let parsed: ReturnType<typeof parseArgs>;
199
+ try {
200
+ parsed = parseArgs({
201
+ args: argv,
202
+ options: parseOptions(names),
203
+ strict: true,
204
+ allowPositionals: true,
205
+ tokens: true,
206
+ });
207
+ } catch (error) {
208
+ throw describeParseError(error, { argv, label, hint });
209
+ }
210
+ return {
211
+ command,
212
+ ...(sub ? { subcommand: sub.name } : {}),
213
+ positionals: parsed.positionals,
214
+ values: typedValues(parsed.values as Record<string, string | boolean>),
215
+ typed: commandFirst(argv, parsed.tokens?.find((token) => token.kind === "positional")?.index),
216
+ };
217
+ }
218
+
219
+ function commandFirst(argv: string[], at: number | undefined): string[] {
220
+ const word = at === undefined ? undefined : argv[at];
221
+ return word === undefined ? argv : [word, ...argv.slice(0, at), ...argv.slice((at ?? 0) + 1)];
222
+ }
223
+
224
+ function typedValues(raw: Record<string, string | boolean>): FlagValues {
225
+ const values: Record<string, unknown> = {};
226
+ for (const [name, value] of Object.entries(raw)) {
227
+ values[name] = typeof value === "string" ? typedValue(name as FlagName, value) : value;
228
+ }
229
+ return values as FlagValues;
230
+ }
231
+
232
+ /** A flag's value as its type, or the error that says what it may be. */
233
+ function typedValue(name: FlagName, value: string): string | number {
234
+ const flag: FlagSpec = FLAGS[name];
235
+ switch (flag.type) {
236
+ case "boolean":
237
+ case "string":
238
+ return value;
239
+ case "int": {
240
+ const n = /^-?\d+$/.test(value) ? Number(value) : Number.NaN;
241
+ if (Number.isInteger(n) && n >= flag.min && (flag.max === undefined || n <= flag.max)) {
242
+ return n;
243
+ }
244
+ const range =
245
+ flag.max === undefined ? `of ${flag.min} or more` : `from ${flag.min} to ${flag.max}`;
246
+ throw invalid(name, value, `a whole number ${range}, e.g. \`${flag.example}\``);
247
+ }
248
+ case "number": {
249
+ // Number("") is 0, so an empty value is refused before it can pass as one.
250
+ const n = value.trim() === "" ? Number.NaN : Number(value);
251
+ if (Number.isFinite(n) && n > flag.above) {
252
+ return n;
253
+ }
254
+ throw invalid(name, value, `a number above ${flag.above}, e.g. \`${flag.example}\``);
255
+ }
256
+ case "enum": {
257
+ if (flag.choices.includes(value)) {
258
+ return value;
259
+ }
260
+ const note = flag.note ? `; ${flag.note}` : "";
261
+ throw invalid(name, value, `${flag.choices.join(" or ")}, e.g. \`${flag.example}\`${note}`);
262
+ }
263
+ }
264
+ }
265
+
266
+ function invalid(name: FlagName, value: string, takes: string): DekError {
267
+ return new DekError(`invalid --${name} "${value}"`, { hint: `--${name} takes ${takes}` });
268
+ }
269
+
270
+ /** parseArgs' own messages, reworded around what the user typed. */
271
+ function describeParseError(
272
+ error: unknown,
273
+ { argv, label, hint }: { argv: string[]; label: string; hint: string },
274
+ ): DekError {
275
+ const message = error instanceof Error ? error.message : String(error);
276
+ const unknown = message.match(/^Unknown option '([^']+)'/);
277
+ if (unknown) {
278
+ return new DekError(`unknown flag ${unknown[1]} for ${label}`, { hint });
279
+ }
280
+ const missing = message.match(/^Option '(--[^ ']+)(?: <value>)?' argument missing/);
281
+ if (missing) {
282
+ return new DekError(`${missing[1]} needs a value`, { hint });
283
+ }
284
+ // `--to --deck`: parseArgs refuses to guess; say what it saw instead of a value.
285
+ const ambiguous = message.match(/^Option '(--[^']+)' argument is ambiguous/);
286
+ if (ambiguous) {
287
+ const next = argv[argv.indexOf(ambiguous[1] ?? "") + 1];
288
+ return new DekError(`${ambiguous[1]} needs a value, got ${next}`, { hint });
289
+ }
290
+ const extra = message.match(/^Option '(--[^']+)' does not take an argument/);
291
+ if (extra) {
292
+ return new DekError(`${extra[1]} takes no value`, { hint });
293
+ }
294
+ return new DekError(message, { hint });
295
+ }