@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
@@ -1,4 +1,6 @@
1
1
  import { hasErrors } from "../core/diagnostic.ts";
2
+ import { shellQuote } from "../core/escape.ts";
3
+ import type { HiddenText } from "../core/foreign-text.ts";
2
4
  import { isRefName } from "../core/ref-name.ts";
3
5
  import { mergeSarif, toSarif } from "../core/sarif.ts";
4
6
  import type { CheckCliResult } from "./check.ts";
@@ -14,6 +16,7 @@ import {
14
16
  formatBuild,
15
17
  formatCheck,
16
18
  formatCues,
19
+ formatHidden,
17
20
  formatInit,
18
21
  formatKept,
19
22
  formatKeptTheme,
@@ -24,6 +27,7 @@ import {
24
27
  formatNew,
25
28
  formatPack,
26
29
  formatPdf,
30
+ formatPptx,
27
31
  formatRef,
28
32
  formatReport,
29
33
  formatShot,
@@ -63,14 +67,17 @@ type AnyCtx = Omit<Ctx<[], DeckScope | undefined>, "args"> & {
63
67
  /** How a command's result is printed. Only `text` is required. */
64
68
  export type Output<D> = {
65
69
  text(data: D, color: boolean): string;
66
- /** What did not happen, for stderr next to the text: the result on stdout stays the result. */
70
+ /**
71
+ * What did not happen, or what is worth a look, such as what a ref hides, for stderr next to the
72
+ * text: the result on stdout stays the result.
73
+ */
67
74
  notes?(data: D, color: boolean): string | undefined;
68
75
  /** The `--json` fields next to `ok`; the data as-is when absent. */
69
76
  json?(data: D): object;
70
77
  /** The data with every path into the source tree as `display` shows it. */
71
78
  paths?(data: D, display: (path: string) => string): D;
72
- /** The error of a run that did its job and found one. */
73
- failure?(data: D): FormattedError | undefined;
79
+ /** The error of a run that did its job and found one; `flags` are the run's, for its rerun. */
80
+ failure?(data: D, flags: FlagValues): FormattedError | undefined;
74
81
  /** `--format sarif`. */
75
82
  sarif?(data: D): object;
76
83
  };
@@ -192,6 +199,20 @@ function withDisplayDiagnostics<D extends { diagnostics: CheckCliResult["diagnos
192
199
  return { ...data, diagnostics: displayDiagnostics(data.diagnostics, display) };
193
200
  }
194
201
 
202
+ /** What to do about the characters a person cannot see that a ref's theme brought into a new deck. */
203
+ const COPIED_HIDDEN_HELP =
204
+ "they came in with the ref's theme, and a person reading the deck sees none of them; remove them from theme.css unless you mean them";
205
+
206
+ /** The data with each hidden-character report's path as `display` shows it. */
207
+ function withDisplayHidden<D extends { hidden?: HiddenText[] }>(
208
+ data: D,
209
+ display: (path: string) => string,
210
+ ): D {
211
+ return data.hidden === undefined
212
+ ? data
213
+ : { ...data, hidden: data.hidden.map((entry) => ({ ...entry, path: display(entry.path) })) };
214
+ }
215
+
195
216
  /**
196
217
  * Every command, in the order `dekc help` lists them: the words and flags it takes, its help,
197
218
  * what it works on, how it runs, and how its result prints. Each module loads only when its
@@ -287,11 +308,12 @@ export const COMMANDS = {
287
308
  },
288
309
  output: {
289
310
  text: formatNew,
290
- paths: (data, display) => ({
291
- ...data,
292
- created: data.created.map(display),
293
- updated: data.updated.map(display),
294
- }),
311
+ notes: (data, color) => formatHidden(data.hidden, color, COPIED_HIDDEN_HELP) || undefined,
312
+ paths: (data, display) =>
313
+ withDisplayHidden(
314
+ { ...data, created: data.created.map(display), updated: data.updated.map(display) },
315
+ display,
316
+ ),
295
317
  },
296
318
  }),
297
319
  ls: result({
@@ -310,10 +332,12 @@ export const COMMANDS = {
310
332
  },
311
333
  output: {
312
334
  text: formatLs,
335
+ notes: (data, color) =>
336
+ (data.kind === "deck" && formatHidden(data.hidden, color)) || undefined,
313
337
  json: ({ kind: _kind, ...data }) => data,
314
338
  paths: (data, display) =>
315
339
  data.kind === "deck"
316
- ? withDisplayDiagnostics(data, display)
340
+ ? withDisplayHidden(withDisplayDiagnostics(data, display), display)
317
341
  : {
318
342
  ...withDisplayDiagnostics(data, display),
319
343
  decks: data.decks.map((deck) => withDisplayDiagnostics(deck, display)),
@@ -397,7 +421,11 @@ export const COMMANDS = {
397
421
  const { showCommand } = await import("./show.ts");
398
422
  return showCommand(target, args.slug);
399
423
  },
400
- output: { text: formatShow },
424
+ output: {
425
+ text: formatShow,
426
+ notes: (data, color) => formatHidden(data.hidden, color) || undefined,
427
+ paths: withDisplayHidden,
428
+ },
401
429
  }),
402
430
  theme: result({
403
431
  scope: "deck",
@@ -422,7 +450,8 @@ export const COMMANDS = {
422
450
  },
423
451
  output: {
424
452
  text: formatTheme,
425
- paths: (data, display) => ({ ...data, path: display(data.path) }),
453
+ notes: (data, color) => formatHidden(data.hidden, color) || undefined,
454
+ paths: (data, display) => withDisplayHidden({ ...data, path: display(data.path) }, display),
426
455
  },
427
456
  }),
428
457
  check: result({
@@ -444,7 +473,7 @@ export const COMMANDS = {
444
473
  text: formatCheck,
445
474
  notes: (data, color) => formatSkipped(data.skipped, color) || undefined,
446
475
  paths: withDisplayDiagnostics,
447
- failure: (data) => diagnosticFailure("check", data, `dekc check ${data.slug}`),
476
+ failure: (data) => diagnosticFailure("check", data, `dekc check ${shellQuote(data.slug)}`),
448
477
  },
449
478
  }),
450
479
  shot: result({
@@ -484,7 +513,11 @@ export const COMMANDS = {
484
513
  motion: flags.motion,
485
514
  });
486
515
  },
487
- output: { text: formatShot },
516
+ output: {
517
+ text: formatShot,
518
+ notes: (data, color) => formatHidden(data.hidden, color) || undefined,
519
+ paths: withDisplayHidden,
520
+ },
488
521
  }),
489
522
  mv: result({
490
523
  scope: "deck",
@@ -659,7 +692,7 @@ export const COMMANDS = {
659
692
  group: "Output",
660
693
  overview: [
661
694
  ["dekc lint [--fix]", "check script.md against slides; --fix syncs first"],
662
- ["dekc lint --visual", "add overflow and contrast rules"],
695
+ ["dekc lint --visual", "add overflow, contrast, line-break, and alignment rules"],
663
696
  ["dekc lint --format sarif", "print diagnostics as SARIF, for code scanning"],
664
697
  ],
665
698
  agent: ["dekc lint [--fix] [--visual] [--format sarif]"],
@@ -672,7 +705,9 @@ export const COMMANDS = {
672
705
  notes: (data, color) => formatSkipped(data.skipped, color) || undefined,
673
706
  json: ({ rumdlSarif: _rumdlSarif, ...data }) => data,
674
707
  paths: withDisplayDiagnostics,
675
- failure: (data) => diagnosticFailure("lint", data, "dekc lint"),
708
+ // The rerun measures what this run did: without --visual, the errors it found may not show.
709
+ failure: (data, flags) =>
710
+ diagnosticFailure("lint", data, flags.visual ? "dekc lint --visual" : "dekc lint"),
676
711
  sarif: (data) => {
677
712
  const dek = data.diagnostics.filter((diagnostic) => diagnostic.id.startsWith("DEK"));
678
713
  return mergeSarif(toSarif(dek, { skipped: data.skipped ?? [] }), data.rumdlSarif);
@@ -827,19 +862,22 @@ export const COMMANDS = {
827
862
  flags: ["root-dist", "public"],
828
863
  usage: ["dekc pptx [deck] [--root-dist] [--public]"],
829
864
  summary:
830
- "Write dist/<deck>.pptx, one slide per slide at its last beat: a picture of it with its text\nin text boxes to edit, and its script in the notes. Needs Playwright.",
865
+ "Write dist/<deck>.pptx, one slide per slide at its last beat: its plain boxes, pictures, and\ntext as PowerPoint's own over a picture of the rest, in the deck's theme, and its script in\nthe notes. Needs Playwright.",
831
866
  group: "Output",
832
867
  overview: [
833
- ["dekc pptx [--root-dist] [--public]", "write a PowerPoint file with editable text"],
868
+ [
869
+ "dekc pptx [--root-dist] [--public]",
870
+ "write a PowerPoint file with editable shapes and text",
871
+ ],
834
872
  ],
835
873
  agent: [
836
- "dekc pptx [--root-dist] [--public] text editable over a picture of each slide; script in notes",
874
+ "dekc pptx [--root-dist] [--public] boxes, pictures, and text editable over a picture of the rest; script in notes",
837
875
  ],
838
876
  run: async ({ target, flags }) => {
839
877
  const { pptxCommand } = await import("./pptx.ts");
840
878
  return pptxCommand(target, { rootDist: flags["root-dist"], public: flags.public });
841
879
  },
842
- output: { text: formatPdf },
880
+ output: { text: formatPptx, json: ({ lifted: _lifted, ...data }) => data },
843
881
  }),
844
882
  help: {
845
883
  kind: "help",
@@ -50,6 +50,19 @@ const SkippedCheck = z
50
50
  description: "A check that did not run, so an empty list of findings is no pass for it.",
51
51
  });
52
52
 
53
+ const Unsandboxed = z
54
+ .strictObject({
55
+ reason: z.string().describe("What Chromium said when it did not start with its sandbox."),
56
+ hint: z
57
+ .string()
58
+ .describe("What lets the sandbox start, and, unless already set, DEK_ALLOW_UNSANDBOXED=1."),
59
+ })
60
+ .meta({
61
+ id: "Unsandboxed",
62
+ description:
63
+ "Chromium drew without its sandbox, which did not start here, as in a container running as root. A ref is never drawn so unless DEK_ALLOW_UNSANDBOXED=1.",
64
+ });
65
+
53
66
  const Failure = z
54
67
  .strictObject({
55
68
  message: z.string(),
@@ -68,6 +81,27 @@ const RefInfo = z
68
81
  })
69
82
  .meta({ id: "RefInfo", description: "Set when the deck read is a ref." });
70
83
 
84
+ const HiddenText = z
85
+ .strictObject({
86
+ path: z.string(),
87
+ line: z.number().int(),
88
+ column: z
89
+ .number()
90
+ .int()
91
+ .describe("1-based, in UTF-16 units as editors count: where the first of them is."),
92
+ codePoints: z.array(z.string()).describe("Each of them on the line, in order, as U+XXXX."),
93
+ })
94
+ .meta({
95
+ id: "HiddenText",
96
+ description:
97
+ "A line of a ref's file that holds characters a person cannot see: those Unicode marks default-ignorable, such as tag characters, zero-width characters, the byte order mark, bidi controls, and Hangul fillers, but not variation selectors; the line and paragraph separators; and the interlinear annotation marks. Text output shows each as ⟨U+XXXX⟩.",
98
+ });
99
+
100
+ const hidden = z
101
+ .array(HiddenText)
102
+ .optional()
103
+ .describe("A ref's lines that hold characters a person cannot see; absent when none does.");
104
+
71
105
  const Position = z
72
106
  .strictObject({ slideIndex: z.number().int(), beatIndex: z.number().int() })
73
107
  .meta({ id: "Position" });
@@ -75,6 +109,9 @@ const Position = z
75
109
  const paths = z.array(z.string());
76
110
  const diagnostics = z.array(Diagnostic);
77
111
  const skipped = z.array(SkippedCheck).optional().describe("Absent when every check ran.");
112
+ const unsandboxed = Unsandboxed.optional().describe(
113
+ "Absent when every page drawn was drawn in Chromium's sandbox, or none was.",
114
+ );
78
115
 
79
116
  const nav = z.strictObject({
80
117
  slug: z.string(),
@@ -112,6 +149,7 @@ export const RESULT_FIELDS = {
112
149
  updated: paths,
113
150
  next: z.array(z.string()),
114
151
  warnings: z.array(z.string()).describe("Set when the theme came from a ref with no license."),
152
+ hidden,
115
153
  }),
116
154
  ls: z.union([
117
155
  z.strictObject({
@@ -149,6 +187,7 @@ export const RESULT_FIELDS = {
149
187
  diagnostics,
150
188
  skipped,
151
189
  ref: RefInfo.optional(),
190
+ hidden,
152
191
  }),
153
192
  ]),
154
193
  ref: z.union([
@@ -202,6 +241,7 @@ export const RESULT_FIELDS = {
202
241
  ),
203
242
  assets: paths,
204
243
  ref: RefInfo.optional(),
244
+ hidden,
205
245
  }),
206
246
  theme: z.strictObject({
207
247
  path: z.string(),
@@ -214,14 +254,24 @@ export const RESULT_FIELDS = {
214
254
  "State classes the theme selects that the player sets at runtime (is-current, is-shown); never written in markup.",
215
255
  ),
216
256
  tokens: z.array(z.strictObject({ name: z.string(), value: z.string() })),
217
- layouts: z.array(z.strictObject({ name: z.string(), example: z.string().optional() })),
257
+ layouts: z
258
+ .array(z.strictObject({ name: z.string(), example: z.string().optional() }))
259
+ .describe("The layouts whose names are plain words, which dek prints."),
260
+ unprintedLayouts: z
261
+ .number()
262
+ .optional()
263
+ .describe(
264
+ "How many layouts the theme has whose names are no plain word, left out of layouts; absent when none.",
265
+ ),
218
266
  layout: z.strictObject({ name: z.string(), example: z.string() }).optional(),
219
267
  ref: RefInfo.optional(),
268
+ hidden,
220
269
  }),
221
270
  check: z.strictObject({
222
271
  slug: z.string(),
223
272
  diagnostics,
224
273
  skipped,
274
+ unsandboxed,
225
275
  shot: z.string().optional(),
226
276
  fill: z
227
277
  .strictObject({
@@ -281,6 +331,8 @@ export const RESULT_FIELDS = {
281
331
  )
282
332
  .optional()
283
333
  .describe("With --motion, each beat in order with its frames, the settled end last."),
334
+ unsandboxed,
335
+ hidden,
284
336
  }),
285
337
  mv: z.strictObject({
286
338
  from: z.string(),
@@ -370,7 +422,7 @@ export const RESULT_FIELDS = {
370
422
  z.strictObject({ action: z.literal("clear"), cleared: z.number().int() }),
371
423
  ]),
372
424
  sync: z.strictObject({ created: paths, updated: paths, removed: paths, kept: paths }),
373
- lint: z.strictObject({ diagnostics, skipped }),
425
+ lint: z.strictObject({ diagnostics, skipped, unsandboxed }),
374
426
  cues: z.strictObject({
375
427
  name: z.string(),
376
428
  cues: z.array(
@@ -409,15 +461,17 @@ export const RESULT_FIELDS = {
409
461
  images: paths.describe("dist/<deck>.png for each deck that got a link preview image."),
410
462
  diagnostics,
411
463
  skipped,
464
+ unsandboxed,
412
465
  }),
413
466
  video: z.strictObject({
414
467
  out: z.string(),
415
468
  vtt: z.string().optional(),
416
469
  chapters: z.string().optional(),
417
470
  credits: z.string().optional(),
471
+ unsandboxed,
418
472
  }),
419
- pdf: z.strictObject({ outs: paths }),
420
- pptx: z.strictObject({ outs: paths }),
473
+ pdf: z.strictObject({ outs: paths, unsandboxed }),
474
+ pptx: z.strictObject({ outs: paths, unsandboxed }),
421
475
  pack: z.strictObject({
422
476
  outs: paths,
423
477
  warnings: z.array(z.string()).describe("Once per project: the packs carry no license."),
package/src/cli/files.ts CHANGED
@@ -1,9 +1,11 @@
1
+ // biome-ignore lint/style/noRestrictedImports: mkdirSync and writeFileSync, for init and new, which create a file only where none is (wx); safe-fs replaces one.
1
2
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
3
  import { dirname, join, relative } from "node:path";
3
4
  import { bundledTheme } from "../core/bundled-theme.ts";
4
5
  import type { DekConfig } from "../core/config.ts";
5
6
  import { deckPaths } from "../core/deck-paths.ts";
6
7
  import { DekError } from "../core/error.ts";
8
+ import { shellQuote } from "../core/escape.ts";
7
9
  import { walkUp } from "../core/optional.ts";
8
10
  import { PLAYWRIGHT_INSTALL } from "../core/playwright.ts";
9
11
  import { readSourceIfExists, writeInside } from "../core/safe-fs.ts";
@@ -316,8 +318,3 @@ function inNodeModules(root: string, ...path: string[]): boolean {
316
318
  walkUp(root, (dir) => existsSync(join(dir, "node_modules", ...path)) || undefined) === true
317
319
  );
318
320
  }
319
-
320
- /** `word` as one shell word: as it is when that is safe, else single-quoted. */
321
- export function shellQuote(word: string): string {
322
- return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
323
- }
package/src/cli/flags.ts CHANGED
@@ -41,7 +41,7 @@ export const FLAGS = {
41
41
  fix: { type: "boolean", text: "create missing skeleton slides before linting" },
42
42
  visual: {
43
43
  type: "boolean",
44
- text: "add the rendered rules: overflow and contrast (needs Playwright)",
44
+ text: "add the rendered rules: overflow, contrast, line breaks, and alignment (needs Playwright)",
45
45
  },
46
46
  shot: { type: "boolean", text: "also write a screenshot and return its path" },
47
47
  voice: { type: "boolean", text: "also return each sentence's reading and duration" },
package/src/cli/format.ts CHANGED
@@ -2,7 +2,7 @@ import { isAbsolute, relative } from "node:path";
2
2
  import type { Diagnostic } from "../core/diagnostic.ts";
3
3
  import { type ErrorFields, errorFields } from "../core/error.ts";
4
4
  import type { LiveEvent } from "../core/live-protocol.ts";
5
- import { ansi, displayWidth, padEndWidth, padStartWidth } from "./tty.ts";
5
+ import { ansi, displayWidth, padEndWidth, padStartWidth, plainText } from "./tty.ts";
6
6
 
7
7
  function formatLocation(location: { path?: string; line?: number; column?: number }): string {
8
8
  if (location.path !== undefined && location.line !== undefined) {
@@ -56,17 +56,18 @@ export function formatError(error: unknown, opts?: { cwd?: string }): FormattedE
56
56
  : { ...fields, path: displayPath(fields.path, opts?.cwd) };
57
57
  }
58
58
 
59
+ /** An error as the terminal shows it: its own text plain, and dek's colors around it. */
59
60
  export function formatErrorText(error: unknown, opts?: { color?: boolean; cwd?: string }): string {
60
61
  const formatted = formatError(error, opts);
61
62
  const c = ansi(opts?.color === true);
62
- const parts = [`${c.bold(c.red("error:"))} ${formatted.message}`];
63
+ const parts = [`${c.bold(c.red("error:"))} ${plainText(formatted.message)}`];
63
64
  if (formatted.path) {
64
65
  const loc =
65
66
  formatted.line !== undefined ? `${formatted.path}:${formatted.line}` : formatted.path;
66
- parts.push(` ${c.cyan("-->")} ${loc}`);
67
+ parts.push(` ${c.cyan("-->")} ${plainText(loc)}`);
67
68
  }
68
69
  if (formatted.hint) {
69
- parts.push(` ${c.yellow("help:")} ${formatted.hint}`);
70
+ parts.push(` ${c.yellow("help:")} ${plainText(formatted.hint)}`);
70
71
  }
71
72
  return parts.join("\n");
72
73
  }
package/src/cli/goto.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { DekError } from "../core/error.ts";
2
2
  import { deckRoutePath } from "../runtime/routes.ts";
3
3
  import { isPidAlive, readDevServerLock } from "../server/lock.ts";
4
+ import { deckFlag, deckWord } from "./address.ts";
5
+ import { isTypedCommand } from "./commands.ts";
4
6
  import { type DeckTarget, requireSection } from "./scope.ts";
5
7
 
6
8
  export type NavResult = {
@@ -11,22 +13,42 @@ export type NavResult = {
11
13
  viewers: number;
12
14
  };
13
15
 
14
- export async function gotoCommand(target: DeckTarget, slug: string): Promise<NavResult> {
16
+ type NavOptions = {
17
+ /** Covers the whole request, from connecting to the last byte of the answer. */
18
+ timeoutMs?: number;
19
+ };
20
+
21
+ /** The dev server answers a nav request at once; a stall this long is a server that is stuck. */
22
+ const NAV_TIMEOUT_MS = 10 * 1000;
23
+
24
+ export async function gotoCommand(
25
+ target: DeckTarget,
26
+ slug: string,
27
+ options: NavOptions = {},
28
+ ): Promise<NavResult> {
15
29
  requireSection(target.deck, slug);
16
- return requestDevServer(target, {
17
- method: "POST",
18
- path: "goto",
19
- body: { slug },
20
- });
30
+ return requestDevServer(
31
+ target,
32
+ { method: "POST", path: "goto", body: { slug } },
33
+ options.timeoutMs ?? NAV_TIMEOUT_MS,
34
+ );
21
35
  }
22
36
 
23
- export async function currentCommand(target: DeckTarget): Promise<NavResult> {
24
- return requestDevServer(target, { method: "GET", path: "current" });
37
+ export async function currentCommand(
38
+ target: DeckTarget,
39
+ options: NavOptions = {},
40
+ ): Promise<NavResult> {
41
+ return requestDevServer(
42
+ target,
43
+ { method: "GET", path: "current" },
44
+ options.timeoutMs ?? NAV_TIMEOUT_MS,
45
+ );
25
46
  }
26
47
 
27
48
  async function requestDevServer(
28
49
  { project, deck }: DeckTarget,
29
50
  request: { method: "GET" | "POST"; path: "goto" | "current"; body?: { slug: string } },
51
+ timeoutMs: number,
30
52
  ): Promise<NavResult> {
31
53
  const deckName = deck.name;
32
54
  const lock = readDevServerLock(project.root);
@@ -43,16 +65,27 @@ async function requestDevServer(
43
65
  if (lock.password) {
44
66
  headers.authorization = `Basic ${btoa(`:${lock.password}`)}`;
45
67
  }
68
+ // A server that takes the connection but never answers is stuck; say so instead of waiting.
69
+ const signal = AbortSignal.timeout(timeoutMs);
70
+ const unlessStuck = (error: unknown): never => {
71
+ throw signal.aborted
72
+ ? new DekError(`the dev server at ${lock.url} did not answer within ${timeoutMs / 1000}s`, {
73
+ hint: "restart it: stop `dekc` and run it again",
74
+ })
75
+ : error;
76
+ };
77
+ // biome-ignore lint/style/noRestrictedGlobals: signal, an AbortSignal.timeout, bounds the request and the read of its answer.
46
78
  const response = await fetch(url, {
47
79
  method: request.method,
48
80
  headers: Object.keys(headers).length > 0 ? headers : undefined,
49
81
  body: request.body ? JSON.stringify(request.body) : undefined,
50
- });
82
+ signal,
83
+ }).catch(unlessStuck);
51
84
  // Only the nav routes answer JSON; a 404, 401, or 405 from the server itself is plain text.
52
85
  if (!response.headers.get("content-type")?.includes("application/json")) {
53
86
  throw refusal(response.status, lock, deckName, request.path);
54
87
  }
55
- const json = (await response.json()) as {
88
+ const json = (await response.json().catch(unlessStuck)) as {
56
89
  ok?: boolean;
57
90
  slug?: string;
58
91
  slideIndex?: number;
@@ -82,7 +115,7 @@ function refusal(
82
115
  ): DekError {
83
116
  if (status === 404 && lock.deck !== undefined && lock.deck !== deckName) {
84
117
  return new DekError(`the running dev server serves deck "${lock.deck}", not "${deckName}"`, {
85
- hint: `pass --deck ${lock.deck}, or stop the server at ${lock.url} and run \`dekc ${deckName}\``,
118
+ hint: `pass ${deckFlag(lock.deck)}, or stop the server at ${lock.url} and run \`dekc ${isTypedCommand(deckName) ? deckFlag(deckName) : deckWord(deckName)}\``,
86
119
  });
87
120
  }
88
121
  if (status === 401) {
package/src/cli/init.ts CHANGED
@@ -3,10 +3,12 @@ import { basename, join, resolve } from "node:path";
3
3
  import { parseCss, publishedTokens } from "../core/css.ts";
4
4
  import { deckPaths } from "../core/deck-paths.ts";
5
5
  import { DekError } from "../core/error.ts";
6
+ import { shellQuote } from "../core/escape.ts";
6
7
  import { REQUIRED_TOKENS } from "../core/lint/tokens.ts";
7
8
  import { walkUp } from "../core/optional.ts";
8
9
  import { DECK_NAME_HINT, isDeckName } from "../core/path.ts";
9
- import { readTextIfExists } from "../core/resolve.ts";
10
+ import { refuseInsideRef } from "../core/ref.ts";
11
+ import { readSourceIfExists } from "../core/safe-fs.ts";
10
12
  import {
11
13
  defaultTsconfig,
12
14
  dekFilePaths,
@@ -25,7 +27,6 @@ import {
25
27
  nextSteps,
26
28
  type PlannedPath,
27
29
  playwrightStep,
28
- shellQuote,
29
30
  } from "./files.ts";
30
31
 
31
32
  export type InitResult = {
@@ -84,7 +85,7 @@ export function initCommand(options: { cwd: string; dir?: string; deck?: string
84
85
  /** The tokens every deck needs that the project's theme.css does not publish on `.slide`. */
85
86
  function missingThemeTokens(root: string): string[] {
86
87
  const published = new Set(
87
- publishedTokens(parseCss(readTextIfExists(join(root, "theme.css")) ?? "")).map(
88
+ publishedTokens(parseCss(readSourceIfExists(join(root, "theme.css"), root) ?? "")).map(
88
89
  (token) => token.name,
89
90
  ),
90
91
  );
@@ -100,8 +101,10 @@ function syncDeckFiles(deckDir: string, created: string[]): FileChanges {
100
101
  /** Everything init writes but `.dek/` and AGENTS.md, in the order a reader meets it. */
101
102
  function projectPlan(root: string, deck: string | undefined): PlannedPath[] {
102
103
  const themePath = join(root, "theme.css");
103
- // A first deck is drawn in the theme that will be there: the one kept, or dek's.
104
- const theme = readTextIfExists(themePath) ?? defaultTheme();
104
+ // A first deck is drawn in the theme that will be there: the one kept, or dek's. A folder that
105
+ // is not there yet holds no theme.
106
+ const theme =
107
+ (existsSync(root) ? readSourceIfExists(themePath, root) : undefined) ?? defaultTheme();
105
108
  return [
106
109
  { path: join(root, "dek.toml"), contents: defaultToml() },
107
110
  { path: themePath, contents: defaultTheme() },
@@ -123,6 +126,8 @@ function checkTarget(root: string): void {
123
126
  if (existsSync(root) && !statSync(root).isDirectory()) {
124
127
  throw new DekError("not a directory", { path: root, hint: "pass a directory to init" });
125
128
  }
129
+ // A ref is someone else's project, read where it is: init would write its files into it.
130
+ refuseInsideRef(root);
126
131
  const outer = walkUp(root, (dir) => {
127
132
  const config = join(dir, "dek.toml");
128
133
  return existsSync(config) && statSync(config).isFile() ? dir : undefined;
package/src/cli/lint.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { relative } from "node:path";
3
3
  import type { Diagnostic } from "../core/diagnostic.ts";
4
+ import { shellQuote } from "../core/escape.ts";
4
5
  import { lintDeck, lintProject, unreadableScriptDiagnostics } from "../core/lint.ts";
5
6
  import { playwrightMissingError } from "../core/playwright.ts";
6
- import { resolveRumdlBin, runRumdl } from "../core/rumdl.ts";
7
+ import { type RumdlRunner, type RumdlSkip, resolveRumdlBin, runRumdl } from "../core/rumdl.ts";
7
8
  import { mergeSarif, type SarifLog } from "../core/sarif.ts";
8
9
  import { syncDeck } from "../core/sync.ts";
9
10
  import { lintVisualDeck } from "../core/visual.ts";
10
- import { type SkippedCheck, skippedChecks, visualSkipped } from "./result.ts";
11
+ import { RENDERED_RULES, type SkippedCheck, skippedChecks, visualSkipped } from "./result.ts";
11
12
  import type { DecksTarget } from "./scope.ts";
12
13
 
13
14
  export type LintCliResult = {
@@ -17,10 +18,10 @@ export type LintCliResult = {
17
18
  rumdlSarif?: SarifLog;
18
19
  };
19
20
 
20
- /** `cwd` is where a skipped rumdl's hint says to run it from. */
21
+ /** `cwd` is where a skipped rumdl's hint says to run it from; `rumdl` runs it, dek's by default. */
21
22
  export async function lintCommand(
22
23
  { project, decks, failed = [] }: DecksTarget,
23
- options: { cwd: string; fix?: boolean; visual?: boolean },
24
+ options: { cwd: string; fix?: boolean; visual?: boolean; rumdl?: RumdlRunner },
24
25
  ): Promise<LintCliResult> {
25
26
  // dek.toml's findings once, then each deck's.
26
27
  const diagnostics: Diagnostic[] = [
@@ -29,19 +30,25 @@ export async function lintCommand(
29
30
  ];
30
31
  let rumdlSkip: SkippedCheck | undefined;
31
32
  let rumdlSarif: SarifLog | undefined;
33
+ // A rumdl that hung on one deck would hang on each, half a minute apiece.
34
+ let rumdlHung = false;
32
35
 
33
36
  for (const deck of decks) {
34
37
  if (options.fix) {
35
38
  syncDeck({ project, deck });
36
39
  }
37
40
  diagnostics.push(...lintDeck({ project, deck }));
38
- const markdown = await runRumdl(deck.scriptPath);
39
- diagnostics.push(...markdown.diagnostics);
40
- if (markdown.skipped) {
41
- rumdlSkip ??= rumdlSkipped(deck.scriptPath, options.cwd);
42
- }
43
- if (markdown.sarif) {
44
- rumdlSarif = rumdlSarif ? mergeSarif(rumdlSarif, markdown.sarif) : markdown.sarif;
41
+ if (!rumdlHung) {
42
+ const markdown = await runRumdl(deck.scriptPath, options.rumdl);
43
+ diagnostics.push(...markdown.diagnostics);
44
+ // One entry for the run: the first reason, unless rumdl hung, which covers every deck after.
45
+ if (markdown.skipped && (!rumdlSkip || markdown.skipped.hung)) {
46
+ rumdlSkip = rumdlSkipped(deck.scriptPath, options.cwd, markdown.skipped);
47
+ }
48
+ rumdlHung = markdown.skipped?.hung === true;
49
+ if (markdown.sarif) {
50
+ rumdlSarif = rumdlSarif ? mergeSarif(rumdlSarif, markdown.sarif) : markdown.sarif;
51
+ }
45
52
  }
46
53
  if (options.visual) {
47
54
  const visual = await lintVisualDeck({ project, deck });
@@ -60,7 +67,7 @@ export async function lintCommand(
60
67
  ? []
61
68
  : [
62
69
  visualSkipped("dekc lint --visual", {
63
- reason: "overflow and contrast are measured only with --visual",
70
+ reason: `${RENDERED_RULES} are measured only with --visual`,
64
71
  }),
65
72
  ]),
66
73
  ]),
@@ -70,15 +77,28 @@ export async function lintCommand(
70
77
 
71
78
  const RUMDL_INSTALL = "bun add -d rumdl; or put rumdl on PATH, or set DEK_RUMDL to its path";
72
79
 
73
- /** Why rumdl gave no result: dek looks for it on PATH, in node_modules/.bin, and at DEK_RUMDL. */
74
- function rumdlSkipped(scriptPath: string, cwd: string): SkippedCheck {
80
+ const RUMDL_HUNG =
81
+ "update or reinstall rumdl (`bun add -d rumdl@latest`), or set DEK_RUMDL to a rumdl that finishes; lint skips it for every deck after one it hangs on";
82
+
83
+ /**
84
+ * Why rumdl gave no result: whether dek found it, on PATH, in node_modules/.bin, or at DEK_RUMDL,
85
+ * and the reason when dek knows it. One that hung would hang again, so its hint is not to rerun.
86
+ */
87
+ function rumdlSkipped(scriptPath: string, cwd: string, skip: RumdlSkip): SkippedCheck {
75
88
  const bin = resolveRumdlBin();
76
89
  if (bin === undefined || !existsSync(bin)) {
77
90
  return { check: "rumdl", reason: "rumdl is not installed", hint: RUMDL_INSTALL };
78
91
  }
92
+ if (skip.hung) {
93
+ return {
94
+ check: "rumdl",
95
+ reason: skip.reason ?? "rumdl hung and was stopped",
96
+ hint: RUMDL_HUNG,
97
+ };
98
+ }
79
99
  return {
80
100
  check: "rumdl",
81
- reason: "rumdl ran but gave no result",
82
- hint: `run \`${bin} check ${relative(cwd, scriptPath) || scriptPath}\` to see why`,
101
+ reason: skip.reason ?? "rumdl ran but gave no result",
102
+ hint: `run \`${shellQuote(bin)} check ${shellQuote(relative(cwd, scriptPath) || scriptPath)}\` to see why`,
83
103
  };
84
104
  }