@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,479 @@
1
+ // How each command's result reads as text; commands.ts says which command prints which.
2
+ // The pieces the dev server's log shares, such as diagnostics and tables, are in format.ts.
3
+ import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
4
+ import { formatClock } from "../core/timing.ts";
5
+ import type { BuildCliResult } from "./build.ts";
6
+ import type { CheckCliResult } from "./check.ts";
7
+ import type { CuesResult } from "./cues.ts";
8
+ import { formatDiagnostics, formatTable } from "./format.ts";
9
+ import type { NavResult } from "./goto.ts";
10
+ import type { InitResult } from "./init.ts";
11
+ import type { LsDeckResult, LsListResult } from "./ls.ts";
12
+ import type { MarksCliResult } from "./marks.ts";
13
+ import type { MvResult } from "./mv.ts";
14
+ import type { NewResult } from "./new.ts";
15
+ import type { PdfCliResult } from "./pdf.ts";
16
+ import type { RefCliResult } from "./ref.ts";
17
+ import type { ShotCliResult } from "./shot.ts";
18
+ import type { ShowResult } from "./show.ts";
19
+ import type { ThemeResult } from "./theme.ts";
20
+ import { ansi, padEndWidth } from "./tty.ts";
21
+ import type { VideoCliResult } from "./video.ts";
22
+ import type { VoiceCliResult } from "./voice.ts";
23
+
24
+ /** "2 errors and 1 warning"; empty when there are none. */
25
+ export function countSummary(diagnostics: Diagnostic[]): string {
26
+ const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
27
+ const warnings = diagnostics.length - errors;
28
+ const count = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? "" : "s"}`;
29
+ return [
30
+ ...(errors > 0 ? [count(errors, "error")] : []),
31
+ ...(warnings > 0 ? [count(warnings, "warning")] : []),
32
+ ].join(" and ");
33
+ }
34
+
35
+ /**
36
+ * The diagnostics, then how many there are and of which rules, most first: "2 errors and 1
37
+ * warning: DEK033 ×2, DEK014 ×1". An agent reads a command's last lines, so the verdict ends the
38
+ * report, whatever was cut above it.
39
+ */
40
+ export function formatReport(diagnostics: Diagnostic[], opts?: { color?: boolean }): string {
41
+ const list = formatDiagnostics(diagnostics, opts);
42
+ if (diagnostics.length === 0) {
43
+ return list;
44
+ }
45
+ const byRule = new Map<string, number>();
46
+ for (const { id } of diagnostics) {
47
+ byRule.set(id, (byRule.get(id) ?? 0) + 1);
48
+ }
49
+ const rules = [...byRule]
50
+ .sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
51
+ .map(([id, n]) => `${id} ×${n}`);
52
+ return `${list}\n${countSummary(diagnostics)}: ${rules.join(", ")}`;
53
+ }
54
+
55
+ /** One `<check>: skipped (<reason>)` line per skipped check, each followed by its hint. */
56
+ export function formatSkipped(skipped: SkippedCheck[] | undefined, color = false): string {
57
+ const c = ansi(color);
58
+ return (skipped ?? [])
59
+ .flatMap((entry) => [
60
+ `${entry.check}: skipped (${entry.reason})`,
61
+ ...(entry.hint ? [` ${c.yellow("help:")} ${entry.hint}`] : []),
62
+ ])
63
+ .join("\n");
64
+ }
65
+
66
+ /**
67
+ * The slides sync kept though their section is gone, for stderr: sync removes only what it wrote,
68
+ * so saying nothing would read as a deck with nothing left over.
69
+ */
70
+ export function formatKept(kept: string[], color = false): string {
71
+ const c = ansi(color);
72
+ return kept
73
+ .flatMap((path) => [
74
+ `kept ${path}: its section is gone from script.md, and the file is yours`,
75
+ ` ${c.yellow("help:")} ${ORPHAN_HINT}`,
76
+ ])
77
+ .join("\n");
78
+ }
79
+
80
+ /**
81
+ * A theme.css init kept that is no dekc theme, for stderr: every deck copies it, so each would fail
82
+ * lint on its first run with a DEK015 per token.
83
+ */
84
+ export function formatKeptTheme(data: InitResult, color = false): string {
85
+ const missing = data.missingTokens ?? [];
86
+ if (missing.length === 0) {
87
+ return "";
88
+ }
89
+ const c = ansi(color);
90
+ const theme = data.kept.find((path) => path.endsWith("theme.css")) ?? "theme.css";
91
+ const named = missing.length > 3 ? [...missing.slice(0, 3), "…"] : missing;
92
+ return [
93
+ `${theme} was kept and lacks ${missing.length} ${missing.length === 1 ? "token" : "tokens"} every deck needs (${named.join(", ")})`,
94
+ ` ${c.yellow("help:")} add them to its .slide rule, or move it aside and run \`dekc init\` again for dek's own theme`,
95
+ ].join("\n");
96
+ }
97
+
98
+ // Lint alone weighs the sections a kept slide could be renamed to (DEK002), so sync defers to it
99
+ // rather than suggest the section or the deletion a rename would make wrong.
100
+ const ORPHAN_HINT =
101
+ "run `dekc lint`, which names the `dekc mv` if its section was renamed, or what else to do with it";
102
+
103
+ function withNext(done: string, next: string[]): string {
104
+ return next.length === 0
105
+ ? done
106
+ : `${done}\n\nnext:\n${next.map((step) => ` ${step}`).join("\n")}`;
107
+ }
108
+
109
+ /**
110
+ * What init wrote, then each file it found already there and left as it was, the commands to
111
+ * run next, and the Playwright install when the project lacks it.
112
+ */
113
+ export function formatInit(data: InitResult): string {
114
+ const header =
115
+ data.created.length > 0
116
+ ? `created project at ${data.root}`
117
+ : `project at ${data.root} is already set up`;
118
+ const lines = [
119
+ ...data.created,
120
+ ...data.updated.map((path) => `${path} (updated)`),
121
+ ...data.kept.map((path) => `${path} (kept)`),
122
+ ];
123
+ const done = withNext([header, ...lines.map((line) => ` ${line}`)].join("\n"), data.next);
124
+ return data.playwright
125
+ ? `${done}\n\nfor dekc shot, dekc pdf, and --visual:\n ${data.playwright}`
126
+ : done;
127
+ }
128
+
129
+ /** The deck, and any of dek's own files it brought up to date: the rest is the deck's, and new. */
130
+ export function formatNew(data: NewResult): string {
131
+ const updated = data.updated.map((path) => ` ${path} (updated)`);
132
+ return withNext([`created deck ${data.name}`, ...updated].join("\n"), data.next);
133
+ }
134
+
135
+ export function formatMv(data: MvResult): string {
136
+ if (data.to) {
137
+ return `renamed ${data.from} -> ${data.to}`;
138
+ }
139
+ if (data.before) {
140
+ return `moved ${data.from} before ${data.before}`;
141
+ }
142
+ if (data.after) {
143
+ return `moved ${data.from} after ${data.after}`;
144
+ }
145
+ return `moved ${data.from}`;
146
+ }
147
+
148
+ export function formatNav(data: NavResult): string {
149
+ return data.beatIndex > 0 ? `${data.slug} ${data.beatIndex}` : data.slug;
150
+ }
151
+
152
+ /**
153
+ * Each mark at its line in script.md, the way an editor jumps to it, and the first line of what
154
+ * the beat says now; a beat gone since it was marked shows what it said.
155
+ */
156
+ export function formatMarks(data: MarksCliResult): string {
157
+ if (data.action === "clear") {
158
+ return data.cleared === 1 ? "cleared 1 mark" : `cleared ${data.cleared} marks`;
159
+ }
160
+ if (data.marks.length === 0) {
161
+ return "no marks: press m in the presenter view to mark a beat";
162
+ }
163
+ return data.marks
164
+ .flatMap((mark) => {
165
+ const where = mark.line === null ? mark.path : `${mark.path}:${mark.line}`;
166
+ const beat = mark.beat === null ? mark.slug : `${mark.slug} › ${mark.beat}`;
167
+ const status = mark.status === "open" ? "" : ` (${mark.status})`;
168
+ const words = (mark.text ?? mark.was).split("\n").find((line) => line.trim() !== "");
169
+ return [`${where} ${beat}${status}`, ...(words ? [` ${words.trim()}`] : [])];
170
+ })
171
+ .join("\n");
172
+ }
173
+
174
+ /** The contact sheets when there are any, else each screenshot. */
175
+ export function formatShot(data: ShotCliResult): string {
176
+ return (data.sheets ?? data.shots.map((shot) => shot.path)).join("\n");
177
+ }
178
+
179
+ /** What the build wrote and what lint found; why an image is missing goes to stderr. */
180
+ export function formatBuild(data: BuildCliResult): string {
181
+ const summary = lintSummary(data.diagnostics);
182
+ const wrote = [...data.outs, ...data.images].map((path) => `wrote ${path}`);
183
+ return [...wrote, ...(summary ? [summary] : [])].join("\n");
184
+ }
185
+
186
+ export function formatVideo(data: VideoCliResult): string {
187
+ return [`wrote ${data.out}`, data.vtt, data.chapters, data.credits].filter(Boolean).join("\n");
188
+ }
189
+
190
+ export function formatPdf(data: PdfCliResult): string {
191
+ return data.outs.map((path) => `wrote ${path}`).join("\n");
192
+ }
193
+
194
+ export function formatLs(data: LsListResult | LsDeckResult): string {
195
+ switch (data.kind) {
196
+ case "list": {
197
+ const table = formatTable(
198
+ ["NAME", "TITLE", "SECTIONS", "SLIDES", "DIAGNOSTICS"],
199
+ data.decks.map((deck) => [
200
+ deck.name,
201
+ deck.title,
202
+ String(deck.sections),
203
+ String(deck.slides),
204
+ String(deck.diagnostics.length),
205
+ ]),
206
+ ["left", "left", "right", "right", "right"],
207
+ );
208
+ const project = data.diagnostics.length;
209
+ return [
210
+ table,
211
+ ...(data.failed.length > 0
212
+ ? [`failed ${data.failed.map((entry) => entry.name).join(", ")}`]
213
+ : []),
214
+ ...(project > 0 ? [`project ${diagnosticCount(project)}`] : []),
215
+ ].join("\n");
216
+ }
217
+ case "deck": {
218
+ const meta: Array<[string, string]> = [];
219
+ if (data.ref) {
220
+ meta.push(["rev", data.ref.rev.slice(0, 7)]);
221
+ }
222
+ if (data.event) {
223
+ meta.push(["event", data.event]);
224
+ }
225
+ if (data.date) {
226
+ meta.push(["date", data.date]);
227
+ }
228
+ if (data.duration) {
229
+ meta.push(["duration", data.duration]);
230
+ }
231
+ meta.push(["estimate", formatClock(data.estimateSeconds)]);
232
+ if (data.videoSeconds !== undefined) {
233
+ meta.push(["video", formatClock(data.videoSeconds)]);
234
+ }
235
+ const keyWidth = Math.max(...meta.map(([key]) => key.length));
236
+ const hasBudget = data.sections.some((section) => section.budgetSeconds !== undefined);
237
+ const hasVideo = data.sections.some((section) => section.videoSeconds !== undefined);
238
+ const headers = ["SLUG", "TITLE", "ESTIMATE"];
239
+ if (hasVideo) {
240
+ headers.push("VIDEO");
241
+ }
242
+ if (hasBudget) {
243
+ headers.push("BUDGET");
244
+ }
245
+ const rows = data.sections.map((section) => {
246
+ const row = [section.slug, section.title, formatClock(section.estimateSeconds)];
247
+ if (hasVideo) {
248
+ row.push(section.videoSeconds !== undefined ? formatClock(section.videoSeconds) : "");
249
+ }
250
+ if (hasBudget) {
251
+ row.push(section.budgetSeconds !== undefined ? formatClock(section.budgetSeconds) : "");
252
+ }
253
+ return row;
254
+ });
255
+ const align: Array<"left" | "right"> = [
256
+ "left",
257
+ "left",
258
+ "right",
259
+ ...(hasVideo ? (["right"] as const) : []),
260
+ ...(hasBudget ? (["right"] as const) : []),
261
+ ];
262
+ const countLabel = formatSkipped(data.skipped) || diagnosticCount(data.diagnostics.length);
263
+ return [
264
+ `${data.ref?.name ?? data.name} ${data.title}`,
265
+ ...meta.map(([key, value]) => `${padEndWidth(key, keyWidth)} ${value}`),
266
+ "",
267
+ formatTable(headers, rows, [...align]),
268
+ "",
269
+ countLabel,
270
+ ].join("\n");
271
+ }
272
+ }
273
+ }
274
+
275
+ function diagnosticCount(count: number): string {
276
+ return count === 1 ? "1 diagnostic" : `${count} diagnostics`;
277
+ }
278
+
279
+ /**
280
+ * "lint: 1 error from dek's rules; run `dekc lint` to see it", or nothing when clean. A build
281
+ * runs dek's rules only; `dekc lint` adds rumdl, so the count names what it covers.
282
+ */
283
+ function lintSummary(diagnostics: Diagnostic[]): string | undefined {
284
+ if (diagnostics.length === 0) {
285
+ return undefined;
286
+ }
287
+ const them = diagnostics.length === 1 ? "it" : "them";
288
+ return `lint: ${countSummary(diagnostics)} ${DEK_RULES}; run \`dekc lint\` to see ${them}`;
289
+ }
290
+
291
+ /** What build and ls count: dek's own rules, without the rumdl pass `dekc lint` adds. */
292
+ const DEK_RULES = "from dek's rules";
293
+
294
+ export function formatTheme(data: ThemeResult): string {
295
+ if (data.layout) {
296
+ return data.layout.example;
297
+ }
298
+ const width = Math.max(0, ...data.tokens.map((token) => token.name.length));
299
+ return [
300
+ data.path,
301
+ "",
302
+ "LAYOUTS",
303
+ ...data.layouts.map(
304
+ (layout) => ` ${layout.name}${layout.example === undefined ? " (no example)" : ""}`,
305
+ ),
306
+ "",
307
+ "CLASSES",
308
+ ` ${data.classes.join(" ")}`,
309
+ "",
310
+ "TOKENS",
311
+ ...data.tokens.map((token) => ` ${token.name.padEnd(width)} ${token.value}`),
312
+ "",
313
+ "Markup for a layout: dekc theme <layout>",
314
+ ].join("\n");
315
+ }
316
+
317
+ export function formatCues(data: CuesResult, color = false): string {
318
+ const body =
319
+ data.cues.length === 0
320
+ ? `${data.name} 0 cues`
321
+ : data.cues
322
+ .map((cue) => {
323
+ const header =
324
+ cue.position.beatIndex > 0 ? `${cue.slug} #${cue.position.beatIndex}` : cue.slug;
325
+ if (cue.paragraphs.length === 0) {
326
+ return header;
327
+ }
328
+ return `${header}\n${cue.paragraphs.map((paragraph) => ` ${paragraph}`).join("\n")}`;
329
+ })
330
+ .join("\n\n");
331
+ if (data.diagnostics.length === 0) {
332
+ return body;
333
+ }
334
+ return `${body}\n\n${formatReport(data.diagnostics, { color })}`;
335
+ }
336
+
337
+ export function formatVoice(data: VoiceCliResult): string {
338
+ switch (data.action) {
339
+ case "synth":
340
+ return `synthesized ${data.synthesized}, cached ${data.cached}\n${data.timelinePath}`;
341
+ case "speakers":
342
+ return data.speakers
343
+ .map((speaker) => `${speaker.name}: ${speaker.styles.join(", ")}`)
344
+ .join("\n");
345
+ case "say":
346
+ return data.path;
347
+ case "dict":
348
+ return `added ${data.key} -> ${data.kana}`;
349
+ case "pin":
350
+ return `pinned ${data.audioPath}`;
351
+ }
352
+ }
353
+
354
+ /** One labeled part per file, so a reader never confuses where a line came from. */
355
+ export function formatShow(data: ShowResult): string {
356
+ const parts: Array<[string, string | null]> = [
357
+ ["script.md", data.script],
358
+ [`slides/${data.slug}.html`, data.html],
359
+ [`slides/${data.slug}.css`, data.css],
360
+ [`slides/${data.slug}.ts`, data.ts],
361
+ ["theme.css (the rules this slide uses)", data.theme || null],
362
+ ["assets", data.assets.length > 0 ? data.assets.join("\n") : null],
363
+ ];
364
+ return parts
365
+ .flatMap(([label, body]) => (body === null ? [] : [`--- ${label}\n${body.replace(/\n$/, "")}`]))
366
+ .join("\n\n");
367
+ }
368
+
369
+ export function formatRef(data: RefCliResult): string {
370
+ const short = (rev: string) => rev.slice(0, 7);
371
+ switch (data.action) {
372
+ case "add": {
373
+ const head = !data.changed
374
+ ? `${data.name} is already at ${short(data.rev)}`
375
+ : data.from
376
+ ? `pinned ${data.name} at ${short(data.rev)} (was ${short(data.from)})`
377
+ : `pinned ${data.name} at ${short(data.rev)}`;
378
+ return [
379
+ head,
380
+ ...data.warnings.map((warning) => `warning: ${warning}`),
381
+ `read it with \`dekc ls ${data.name}\` and \`dekc show ${data.name} <slug>\``,
382
+ ].join("\n");
383
+ }
384
+ case "list":
385
+ if (data.refs.length === 0) {
386
+ return "no refs; add one with `dekc ref owner/repo/deck`";
387
+ }
388
+ return formatTable(
389
+ ["NAME", "REV", "TITLE", "SLIDES"],
390
+ data.refs.map((ref) => [
391
+ ref.name,
392
+ short(ref.rev),
393
+ ref.fetched ? (ref.title ?? "") : "(not fetched; a read fetches it)",
394
+ ref.slides === undefined ? "" : String(ref.slides),
395
+ ]),
396
+ ["left", "left", "left", "right"],
397
+ );
398
+ case "rm":
399
+ return `removed ${data.name}`;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * What a check measured (the fill, each beat's reading), then its findings and their count, then
405
+ * the screenshot. An agent reads the last lines: the shot's path is the last, the verdict above it.
406
+ * What it skipped goes to stderr.
407
+ */
408
+ export function formatCheck(data: CheckCliResult, color = false): string {
409
+ const lines: string[] = [];
410
+ if (data.fill) {
411
+ lines.push(...formatFill(data.fill));
412
+ }
413
+ if (data.voice) {
414
+ for (const beat of data.voice.beats) {
415
+ const kana = beat.sentences.map((sentence) => sentence.kana).join(" ");
416
+ const note = beat.empty ? " (empty beat)" : "";
417
+ lines.push(`#${beat.beatIndex} ${(beat.durationMs / 1000).toFixed(1)}s ${kana}${note}`);
418
+ }
419
+ }
420
+ lines.push(formatReport(data.diagnostics, { color }));
421
+ if (data.shot) {
422
+ lines.push(data.shot);
423
+ }
424
+ return lines.join("\n");
425
+ }
426
+
427
+ /** A fill as whole percentages: the share of the frame, the box it lies in, then its bands. */
428
+ function formatFill({
429
+ coverage,
430
+ box,
431
+ rows,
432
+ columns,
433
+ }: NonNullable<CheckCliResult["fill"]>): string[] {
434
+ if (!box) {
435
+ return ["fill: nothing to read or look at on the slide"];
436
+ }
437
+ const percent = (share: number): string => String(Math.round(share * 100));
438
+ const within = (["left", "top", "right", "bottom"] as const)
439
+ .map((side) => `${side} ${percent(box[side])}%`)
440
+ .join(" ");
441
+ return [
442
+ `fill: ${percent(coverage)}% of the frame, within ${within}`,
443
+ ` rows, top to bottom: ${rows.map(percent).join(" ")}`,
444
+ ` columns, left to right: ${columns.map(percent).join(" ")}`,
445
+ ...emptyBands("rows", rows, ["top", "bottom"]),
446
+ ...emptyBands("columns", columns, ["left", "right"]),
447
+ ];
448
+ }
449
+
450
+ /**
451
+ * The runs of bands nothing fills, as spans of the frame, each named a margin at the edge it
452
+ * touches or a gap between content. Nothing when every band holds some.
453
+ */
454
+ function emptyBands(name: string, bands: number[], [start, end]: [string, string]): string[] {
455
+ const runs: string[] = [];
456
+ let from: number | undefined;
457
+ for (let at = 0; at <= bands.length; at++) {
458
+ const empty = at < bands.length && bands[at] === 0;
459
+ if (empty && from === undefined) {
460
+ from = at;
461
+ } else if (!empty && from !== undefined) {
462
+ const where = from === 0 ? start : at === bands.length ? end : "between";
463
+ const tenth = 100 / bands.length;
464
+ runs.push(`${from * tenth}–${at * tenth}% (${where})`);
465
+ from = undefined;
466
+ }
467
+ }
468
+ return runs.length === 0 ? [] : [` empty ${name}: ${runs.join(", ")}`];
469
+ }
470
+
471
+ /** Each diagnostic with its path as `display` shows it. */
472
+ export function displayDiagnostics(
473
+ diagnostics: Diagnostic[],
474
+ display: (path: string) => string,
475
+ ): Diagnostic[] {
476
+ return diagnostics.map((diagnostic) =>
477
+ diagnostic.path === undefined ? diagnostic : { ...diagnostic, path: display(diagnostic.path) },
478
+ );
479
+ }
@@ -0,0 +1,56 @@
1
+ import { existsSync } from "node:fs";
2
+ import { type CssToken, parseCss } from "../core/css.ts";
3
+ import { deckPaths } from "../core/deck-paths.ts";
4
+ import { DekError } from "../core/error.ts";
5
+ import { readDeckFile } from "../core/resolve.ts";
6
+ import { type ThemeLayout, themeFacts } from "../core/theme-facts.ts";
7
+ import type { ReadableDeck, RefInfo } from "./scope.ts";
8
+
9
+ export type ThemeResult = {
10
+ /** The deck's theme.css: the one lint and the slides use. */
11
+ path: string;
12
+ classes: string[];
13
+ /** The tokens every slide can `var()`. */
14
+ tokens: CssToken[];
15
+ layouts: ThemeLayout[];
16
+ /** Set when one layout was asked for; text output prints its example alone. */
17
+ layout?: { name: string; example: string };
18
+ /** Set when the deck is a ref. */
19
+ ref?: RefInfo;
20
+ };
21
+
22
+ /** The deck's theme, or one layout's example markup when `name` is given. */
23
+ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeResult {
24
+ const path = deckPaths(deck.dir).theme;
25
+ if (!existsSync(path)) {
26
+ throw new DekError("theme.css not found", {
27
+ path,
28
+ hint: "copy the project theme.css into the deck, or run `dekc new <name>` for a fresh deck",
29
+ });
30
+ }
31
+ const { classes, layouts, tokens } = themeFacts(parseCss(readDeckFile(deck.dir, path) ?? ""));
32
+ const result: ThemeResult = {
33
+ path,
34
+ classes: [...classes].sort(),
35
+ tokens,
36
+ layouts,
37
+ ...(ref ? { ref } : {}),
38
+ };
39
+ if (!name) {
40
+ return result;
41
+ }
42
+ const layout = layouts.find((entry) => entry.name === name);
43
+ if (!layout) {
44
+ throw new DekError(`layout "${name}" is not defined in theme.css`, {
45
+ path,
46
+ hint: `use one of: ${layouts.map((entry) => entry.name).join(", ") || "(none)"}`,
47
+ });
48
+ }
49
+ if (layout.example === undefined) {
50
+ throw new DekError(`layout "${name}" has no example in theme.css`, {
51
+ path,
52
+ hint: `add a /* @layout ${name} ... */ comment with its markup above .slide[data-layout="${name}"]`,
53
+ });
54
+ }
55
+ return { ...result, layout: { name, example: layout.example } };
56
+ }
package/src/cli/tty.ts ADDED
@@ -0,0 +1,81 @@
1
+ export type ColorStream = { isTTY?: boolean };
2
+
3
+ const reset = "\x1b[0m";
4
+ const codes = {
5
+ red: "\x1b[31m",
6
+ yellow: "\x1b[33m",
7
+ green: "\x1b[32m",
8
+ cyan: "\x1b[36m",
9
+ dim: "\x1b[2m",
10
+ bold: "\x1b[1m",
11
+ } as const;
12
+
13
+ /**
14
+ * Text for a terminal: dek's own colors, newlines, and tabs pass, and every other control
15
+ * 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.
17
+ */
18
+ export function terminalSafe(text: string): string {
19
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
20
+ return text.replace(/\x1b\[[0-9;]*m|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (match) =>
21
+ match.length > 1 ? match : "\uFFFD",
22
+ );
23
+ }
24
+
25
+ export function shouldColor(stream: ColorStream): boolean {
26
+ if (process.env.NO_COLOR) {
27
+ return false;
28
+ }
29
+ if (process.env.FORCE_COLOR) {
30
+ return true;
31
+ }
32
+ if (process.env.CI) {
33
+ return false;
34
+ }
35
+ return stream.isTTY === true;
36
+ }
37
+
38
+ function paint(enabled: boolean, code: string, text: string): string {
39
+ if (!enabled) {
40
+ return text;
41
+ }
42
+ return `${code}${text}${reset}`;
43
+ }
44
+
45
+ export function ansi(enabled: boolean): {
46
+ red(s: string): string;
47
+ yellow(s: string): string;
48
+ green(s: string): string;
49
+ cyan(s: string): string;
50
+ dim(s: string): string;
51
+ bold(s: string): string;
52
+ } {
53
+ return {
54
+ red: (s) => paint(enabled, codes.red, s),
55
+ yellow: (s) => paint(enabled, codes.yellow, s),
56
+ green: (s) => paint(enabled, codes.green, s),
57
+ cyan: (s) => paint(enabled, codes.cyan, s),
58
+ dim: (s) => paint(enabled, codes.dim, s),
59
+ bold: (s) => paint(enabled, codes.bold, s),
60
+ };
61
+ }
62
+
63
+ export function displayWidth(s: string): number {
64
+ return Bun.stringWidth(s);
65
+ }
66
+
67
+ export function padEndWidth(s: string, width: number): string {
68
+ const extra = width - displayWidth(s);
69
+ if (extra <= 0) {
70
+ return s;
71
+ }
72
+ return `${s}${" ".repeat(extra)}`;
73
+ }
74
+
75
+ export function padStartWidth(s: string, width: number): string {
76
+ const extra = width - displayWidth(s);
77
+ if (extra <= 0) {
78
+ return s;
79
+ }
80
+ return `${" ".repeat(extra)}${s}`;
81
+ }