@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
package/src/cli/scope.ts CHANGED
@@ -1,25 +1,28 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { join, relative } from "node:path";
3
2
  import { DekError } from "../core/error.ts";
3
+ import { shellName, shellQuote } from "../core/escape.ts";
4
+ import { foreignLine, type HiddenText, hiddenText, MESSAGE_MAX } from "../core/foreign-text.ts";
4
5
  import {
5
6
  isRefName,
6
7
  packFile,
7
8
  parseRefSource,
8
- REF_MARKER,
9
- readRefMeta,
9
+ type RefSource,
10
10
  refLicense,
11
+ refReadOnlyError,
11
12
  refState,
13
+ requireProject,
12
14
  } from "../core/ref.ts";
13
15
  import {
14
16
  locateDeck,
15
17
  type Project,
16
18
  type ProjectDeck,
19
+ readDeckFile,
17
20
  requireSection,
18
21
  resolveProject,
19
22
  } from "../core/resolve.ts";
20
23
  import { suggest } from "../core/suggest.ts";
21
24
 
22
- export { requireSection };
25
+ export { requireProject, requireSection };
23
26
 
24
27
  export type Scope = {
25
28
  project: Project;
@@ -52,18 +55,6 @@ export type DecksTarget = {
52
55
  ref?: RefInfo;
53
56
  };
54
57
 
55
- export function requireProject(cwd: string): Project {
56
- const project = resolveProject(cwd);
57
- if (existsSync(join(project.root, REF_MARKER))) {
58
- const name = readRefMeta(project.root)?.name ?? "<ref>";
59
- throw new DekError("this directory is inside a ref; refs are read-only", {
60
- path: project.root,
61
- hint: `run dek from your own project instead: \`dekc show ${name} <slug>\``,
62
- });
63
- }
64
- return project;
65
- }
66
-
67
58
  export function inferDeckName(project: Project, cwd: string): string | undefined {
68
59
  const located = locateDeck(project.root, cwd);
69
60
  if (!located) {
@@ -139,9 +130,7 @@ export function resolveScope(cwd: string, options: { deck?: string } = {}): Scop
139
130
  return { project };
140
131
  }
141
132
  if (isRefName(name)) {
142
- throw new DekError(`"${name}" is a ref; refs are read-only`, {
143
- hint: `start a deck on its theme with \`dekc new <name> --theme-from ${name}\`, or read it with \`dekc show ${name} <slug>\` and write what you need in your own deck's theme`,
144
- });
133
+ throw refReadOnlyError(name);
145
134
  }
146
135
  const deck = project.decks.find((entry) => entry.name === name);
147
136
  if (!deck) {
@@ -149,10 +138,11 @@ export function resolveScope(cwd: string, options: { deck?: string } = {}): Scop
149
138
  if (failed) {
150
139
  throw failed.error;
151
140
  }
152
- const guess = suggest(name, [
141
+ const near = suggest(name, [
153
142
  ...project.decks.map((entry) => entry.name),
154
143
  ...project.failed.map((entry) => entry.name),
155
144
  ]);
145
+ const guess = near === undefined ? undefined : shellName(near);
156
146
  throw new DekError(`deck "${name}" not found`, {
157
147
  path: join(project.root, "decks", name),
158
148
  hint: guess ? `did you mean \`${guess}\`? run \`dekc ls\` for every deck` : "run `dekc ls`",
@@ -235,6 +225,25 @@ export type RefInfo = {
235
225
  /** One deck to read: the project's own, or a ref. */
236
226
  export type ReadableDeck = DeckTarget & { ref?: RefInfo };
237
227
 
228
+ /**
229
+ * `{ hidden }` when a ref's files hold characters a person cannot see, nothing otherwise. Only a
230
+ * ref's are reported: a deck of the project's own is its author's, emoji joiners and all.
231
+ */
232
+ export function hiddenField(
233
+ ref: RefInfo | undefined,
234
+ found: HiddenText[],
235
+ ): { hidden?: HiddenText[] } {
236
+ return ref && found.length > 0 ? { hidden: found } : {};
237
+ }
238
+
239
+ /** What the files at `paths` in the deck hold that a person cannot see, file by file. */
240
+ export function hiddenInFiles(deckDir: string, paths: string[]): HiddenText[] {
241
+ return paths.flatMap((path) => {
242
+ const text = readDeckFile(deckDir, path);
243
+ return text === undefined ? [] : hiddenText(text, path);
244
+ });
245
+ }
246
+
238
247
  /**
239
248
  * What a command declares it works on, resolved: one deck or the decks in scope. Only a command
240
249
  * whose spec says `refs` reads a ref; it never writes, so a ref cannot reach a command that
@@ -277,18 +286,16 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
277
286
  hint:
278
287
  source.kind === "file"
279
288
  ? "add it from the pack you were sent: `dekc ref <file>.tgz`"
280
- : `run \`dekc ref ${arg}\``,
289
+ : `run \`dekc ref ${shellQuote(arg)}\``,
281
290
  });
282
291
  }
283
292
  // What adds the ref again: a pack's ref from its file, any other from its name.
284
- const again =
285
- source.kind === "file"
286
- ? `dekc ref ${packFile(source.deck)}`
287
- : `dekc ref ${source.name}@${pinned}`;
293
+ const added = source.kind === "file" ? packFile(source.deck) : `${source.name}@${pinned}`;
294
+ const again = `dekc ref ${shellQuote(added)}`;
288
295
  if (source.kind === "github" && source.rev !== undefined && source.rev !== pinned) {
289
296
  throw new DekError(`"${arg}" is not the pinned version, ${pinned.slice(0, 7)}`, {
290
297
  path: project.configPath,
291
- hint: `run \`dekc ref ${arg}\` to pin that version, or drop @${source.rev}`,
298
+ hint: `run \`dekc ref ${shellQuote(arg)}\` to pin that version, or drop @${source.rev}`,
292
299
  });
293
300
  }
294
301
  if (!fetched) {
@@ -302,7 +309,7 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
302
309
  if (!found) {
303
310
  const failed = snapshot.failed.find((entry) => entry.name === source.deck);
304
311
  if (failed) {
305
- throw failed.error;
312
+ throw unreadableRef(failed.error, source);
306
313
  }
307
314
  throw new DekError(`ref "${source.name}" has no deck "${source.deck}"`, {
308
315
  path: dir,
@@ -311,7 +318,28 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
311
318
  }
312
319
  return {
313
320
  project: snapshot,
314
- deck: found,
321
+ // Read where it is, but cached in this project: nothing is written in a snapshot.
322
+ deck: {
323
+ ...found,
324
+ cache: { dir: join(project.root, ".cache", relative(project.root, dir)), root: project.root },
325
+ },
315
326
  ref: { name: source.name, rev: pinned, dir, license: refLicense(dir) },
316
327
  };
317
328
  }
329
+
330
+ /**
331
+ * A ref whose script.md cannot be read. The error's hint would say how to fix the file, quoting
332
+ * it, and a ref is not the reader's to fix: the message stays, on one line, and the hint is dek's.
333
+ */
334
+ function unreadableRef(error: Error, source: RefSource): DekError {
335
+ const fields = error instanceof DekError ? error : undefined;
336
+ return new DekError(foreignLine(error.message, MESSAGE_MAX), {
337
+ ...(fields?.path !== undefined ? { path: fields.path } : {}),
338
+ ...(fields?.line !== undefined ? { line: fields.line } : {}),
339
+ hint:
340
+ source.kind === "file"
341
+ ? "a ref is read-only, so its script.md is its author's to fix; ask them for a pack that reads, and add it with `dekc ref <file>.tgz`"
342
+ : `a ref is read-only, so its script.md is its author's to fix; pin a commit where it reads with \`dekc ref ${source.name}@<rev>\``,
343
+ cause: error,
344
+ });
345
+ }
package/src/cli/shot.ts CHANGED
@@ -1,9 +1,15 @@
1
+ import { basename, dirname, extname } from "node:path";
2
+ import { deckPaths } from "../core/deck-paths.ts";
1
3
  import { DekError } from "../core/error.ts";
4
+ import { shellName, shellQuote } from "../core/escape.ts";
5
+ import { foreignLine, type HiddenText, MESSAGE_MAX } from "../core/foreign-text.ts";
6
+ import { refPlaywrightRunner } from "../core/playwright.ts";
7
+ import { SLIDE_SIDECARS } from "../core/resolve.ts";
2
8
  import { parseMorphAt, shotMorph } from "../core/shot/morph.ts";
3
9
  import { type ShotMotion, shotMotion } from "../core/shot/motion.ts";
4
10
  import { type ShotFile, shotDeck, shotSheet } from "../core/shot/still.ts";
5
11
  import { playerScript } from "../runtime/player.ts";
6
- import type { ReadableDeck } from "./scope.ts";
12
+ import { hiddenField, hiddenInFiles, type ReadableDeck } from "./scope.ts";
7
13
  import { usageError } from "./usage.ts";
8
14
 
9
15
  export type ShotCliResult = {
@@ -12,6 +18,11 @@ export type ShotCliResult = {
12
18
  sheets?: string[];
13
19
  /** With --motion, each beat's frames and the ms into its go each shows. */
14
20
  motion?: ShotMotion["beats"];
21
+ /**
22
+ * A ref's lines, in the files of the slides drawn and in its theme, that hold characters a
23
+ * person cannot see.
24
+ */
25
+ hidden?: HiddenText[];
15
26
  };
16
27
 
17
28
  type ShotCommandOptions = {
@@ -31,13 +42,64 @@ export type ShotMode =
31
42
  | { kind: "morph"; from: string; to: string; at: number };
32
43
 
33
44
  export async function shotCommand(
34
- { project, deck }: ReadableDeck,
45
+ target: ReadableDeck,
35
46
  options: ShotCommandOptions,
36
47
  ): Promise<ShotCliResult> {
37
48
  const mode = parseShotMode(options);
49
+ try {
50
+ const result = await shoot(target, mode);
51
+ return target.ref
52
+ ? { ...result, ...hiddenField(target.ref, drawnHidden(target, mode)) }
53
+ : result;
54
+ } catch (error) {
55
+ throw refShotError(error, target);
56
+ }
57
+ }
58
+
59
+ /** What the files of the slides a shot drew, and its theme, hold that a person cannot see. */
60
+ function drawnHidden({ deck }: ReadableDeck, mode: ShotMode): HiddenText[] {
61
+ const all = deck.deck.sections.map((section) => section.slug);
62
+ const slugs =
63
+ mode.kind === "sheet"
64
+ ? all
65
+ : mode.kind === "morph"
66
+ ? [mode.from, mode.to]
67
+ : mode.slug === undefined
68
+ ? all
69
+ : [mode.slug];
70
+ const paths = deckPaths(deck.dir);
71
+ const files = slugs.flatMap((slug) =>
72
+ ([".html", ...SLIDE_SIDECARS] as const).map((ext) => paths.slide(slug, ext)),
73
+ );
74
+ return hiddenInFiles(deck.dir, [...new Set(files), paths.theme]);
75
+ }
76
+
77
+ /**
78
+ * A ref's slide script that does not compile stops its slide's shot, as one of the project's
79
+ * would; Bun's message quotes the ref's source, so it is one foreignLine, and the fix is not lint,
80
+ * which refuses a ref, but its author's.
81
+ */
82
+ function refShotError(error: unknown, { deck, ref }: ReadableDeck): unknown {
83
+ if (!ref || !(error instanceof DekError) || error.path === undefined) {
84
+ return error;
85
+ }
86
+ const path = error.path;
87
+ if (extname(path) !== ".ts" || dirname(path) !== deckPaths(deck.dir).slides) {
88
+ return error;
89
+ }
90
+ return new DekError(foreignLine(error.message, MESSAGE_MAX), {
91
+ path,
92
+ hint: `a ref's slide scripts are its author's to fix; \`dekc shot ${ref.name} <slug>\` still draws each of its other slides, and \`dekc show ${ref.name} ${shellName(basename(path, ".ts")) ?? "<slug>"}\` prints this one's`,
93
+ cause: error,
94
+ });
95
+ }
96
+
97
+ async function shoot({ project, deck, ref }: ReadableDeck, mode: ShotMode): Promise<ShotCliResult> {
98
+ // A ref's slide scripts are someone else's: Chromium runs them only in its sandbox (ADR 29).
99
+ const runner = ref ? { runner: refPlaywrightRunner } : {};
38
100
  switch (mode.kind) {
39
101
  case "sheet": {
40
- const { sheets, shots } = await shotSheet({ project, deck });
102
+ const { sheets, shots } = await shotSheet({ project, deck }, runner);
41
103
  return { shots, sheets };
42
104
  }
43
105
  case "motion": {
@@ -47,6 +109,7 @@ export async function shotCommand(
47
109
  slug: mode.slug,
48
110
  ...(mode.step === undefined ? {} : { step: mode.step }),
49
111
  playerScript: await playerScript(),
112
+ ...runner,
50
113
  },
51
114
  );
52
115
  return { shots: [], sheets, motion: beats };
@@ -54,12 +117,21 @@ export async function shotCommand(
54
117
  case "morph": {
55
118
  const shots = await shotMorph(
56
119
  { project, deck },
57
- { from: mode.from, to: mode.to, at: mode.at, playerScript: await playerScript() },
120
+ {
121
+ from: mode.from,
122
+ to: mode.to,
123
+ at: mode.at,
124
+ playerScript: await playerScript(),
125
+ ...runner,
126
+ },
58
127
  );
59
128
  return { shots };
60
129
  }
61
130
  case "still": {
62
- const shots = await shotDeck({ project, deck }, { slug: mode.slug, step: mode.step });
131
+ const shots = await shotDeck(
132
+ { project, deck },
133
+ { slug: mode.slug, step: mode.step, ...runner },
134
+ );
63
135
  return { shots };
64
136
  }
65
137
  }
@@ -91,7 +163,7 @@ export function parseShotMode(options: ShotCommandOptions): ShotMode {
91
163
  if (slug) {
92
164
  refuse(
93
165
  "--sheet with a slug",
94
- `--sheet tiles every slide; for one slide's beats, run \`dekc shot ${slug} --motion\``,
166
+ `--sheet tiles every slide; for one slide's beats, run \`dekc shot ${shellQuote(slug)} --motion\``,
95
167
  );
96
168
  }
97
169
  return { kind: "sheet" };
package/src/cli/show.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { classifyAssetRef } from "../core/assets.ts";
2
2
  import { cssUrls, parseCss } from "../core/css.ts";
3
3
  import { deckPaths } from "../core/deck-paths.ts";
4
+ import { type HiddenText, hiddenText } from "../core/foreign-text.ts";
4
5
  import { type HtmlScan, scanSlideHtml } from "../core/html-scan.ts";
5
6
  import { sectionChunks, splitLines } from "../core/lines.ts";
6
7
  import { readDeckFile } from "../core/resolve.ts";
7
8
  import type { Section } from "../core/schema.ts";
8
9
  import { themeExcerpt } from "../core/theme-excerpt.ts";
9
- import { type ReadableDeck, type RefInfo, requireSection } from "./scope.ts";
10
+ import { hiddenField, type ReadableDeck, type RefInfo, requireSection } from "./scope.ts";
10
11
 
11
12
  /**
12
13
  * Everything one slide is made of, read in one call: enough to learn how it is
@@ -34,6 +35,8 @@ export type ShowResult = {
34
35
  assets: string[];
35
36
  /** Set when the deck is a ref. */
36
37
  ref?: RefInfo;
38
+ /** A ref's lines, in the files show read, that hold characters a person cannot see. */
39
+ hidden?: HiddenText[];
37
40
  };
38
41
 
39
42
  export function showCommand({ deck, ref }: ReadableDeck, slug: string): ShowResult {
@@ -64,11 +67,14 @@ export function showCommand({ deck, ref }: ReadableDeck, slug: string): ShowResu
64
67
  ...(scan?.refs.map((ref) => ref.value) ?? []),
65
68
  ...(sheet ? cssUrls(sheet).map((url) => url.value) : []),
66
69
  ];
70
+ const script = sectionSource(deck, section);
71
+ const hiddenIn = (path: string, text: string | null) =>
72
+ text === null ? [] : hiddenText(text, path);
67
73
 
68
74
  return {
69
75
  slug: section.slug,
70
76
  title: section.title,
71
- script: sectionSource(deck, section),
77
+ script,
72
78
  beats: section.beats.map(({ id, title, line }) => ({ ...(id ? { id } : {}), title, line })),
73
79
  html,
74
80
  css,
@@ -76,6 +82,13 @@ export function showCommand({ deck, ref }: ReadableDeck, slug: string): ShowResu
76
82
  theme,
77
83
  assets: existingRefs(refs, paths.slides, deck.dir),
78
84
  ...(ref ? { ref } : {}),
85
+ ...hiddenField(ref, [
86
+ ...hiddenText(script, deck.scriptPath, section.line),
87
+ ...hiddenIn(paths.slide(slug, ".html"), html),
88
+ ...hiddenIn(paths.slide(slug, ".css"), css),
89
+ ...hiddenIn(paths.slide(slug, ".ts"), ts),
90
+ ...hiddenIn(paths.theme, themeCss),
91
+ ]),
79
92
  };
80
93
  }
81
94
 
package/src/cli/text.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  // How each command's result reads as text; commands.ts says which command prints which.
2
2
  // The pieces the dev server's log shares, such as diagnostics and tables, are in format.ts.
3
3
  import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
4
+ import { shellQuote } from "../core/escape.ts";
5
+ import { foreignLine, type HiddenText, TITLE_MAX } from "../core/foreign-text.ts";
6
+ import { compareCodeUnits } from "../core/order.ts";
7
+ import { unprinted } from "../core/prose.ts";
4
8
  import { packFile, parseRefSource } from "../core/ref-name.ts";
5
9
  import { formatClock } from "../core/timing.ts";
6
10
  import type { AnnotationsCliResult } from "./annotations.ts";
@@ -16,6 +20,7 @@ import type { MvResult } from "./mv.ts";
16
20
  import type { NewResult } from "./new.ts";
17
21
  import type { PackCliResult } from "./pack.ts";
18
22
  import type { PdfCliResult } from "./pdf.ts";
23
+ import type { PptxCliResult } from "./pptx.ts";
19
24
  import type { RefCliResult } from "./ref.ts";
20
25
  import type { ShotCliResult } from "./shot.ts";
21
26
  import type { ShowResult } from "./show.ts";
@@ -50,7 +55,7 @@ export function formatReport(diagnostics: Diagnostic[], opts?: { color?: boolean
50
55
  byRule.set(id, (byRule.get(id) ?? 0) + 1);
51
56
  }
52
57
  const rules = [...byRule]
53
- .sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
58
+ .sort(([a, x], [b, y]) => y - x || compareCodeUnits(a, b))
54
59
  .map(([id, n]) => `${id} ×${n}`);
55
60
  return `${list}\n${countSummary(diagnostics)}: ${rules.join(", ")}`;
56
61
  }
@@ -66,6 +71,39 @@ export function formatSkipped(skipped: SkippedCheck[] | undefined, color = false
66
71
  .join("\n");
67
72
  }
68
73
 
74
+ /** How many code points a warning names before it leaves the rest to `--json`. */
75
+ const CODE_POINTS_SHOWN = 6;
76
+
77
+ /** What to do about the characters a person cannot see in a ref that a command read. */
78
+ const HIDDEN_HELP =
79
+ "text output shows each as ⟨U+XXXX⟩; a person reading the deck sees none of them, so leave them out of what you write from it";
80
+
81
+ /**
82
+ * A warning per line of a ref's files that holds characters a person cannot see, where they
83
+ * are and which, then `help`, what to do about them, once, for stderr.
84
+ */
85
+ export function formatHidden(
86
+ hidden: HiddenText[] | undefined,
87
+ color = false,
88
+ help = HIDDEN_HELP,
89
+ ): string {
90
+ if (hidden === undefined || hidden.length === 0) {
91
+ return "";
92
+ }
93
+ const c = ansi(color);
94
+ const lines = hidden.map(({ path, line, column, codePoints }) => {
95
+ const distinct = [...new Set(codePoints)];
96
+ const named = distinct.slice(0, CODE_POINTS_SHOWN).join(", ");
97
+ const rest = distinct.length > CODE_POINTS_SHOWN ? ", …" : "";
98
+ const count =
99
+ codePoints.length === 1
100
+ ? "1 character that shows as nothing or reorders the text"
101
+ : `${codePoints.length} characters that show as nothing or reorder the text`;
102
+ return `${c.yellow("warning:")} ${path}:${line}:${column}: ${count}: ${named}${rest}`;
103
+ });
104
+ return [...lines, ` ${c.yellow("help:")} ${help}`].join("\n");
105
+ }
106
+
69
107
  /**
70
108
  * The slides sync kept though their section is gone, for stderr: sync removes only what it wrote,
71
109
  * so saying nothing would read as a deck with nothing left over.
@@ -230,6 +268,27 @@ export function formatPdf(data: PdfCliResult): string {
230
268
  return data.outs.map((path) => `wrote ${path}`).join("\n");
231
269
  }
232
270
 
271
+ /**
272
+ * Each file written, and what it laid over its pictures, naming the slides that kept everything
273
+ * in the picture, which are the ones to rewrite toward shapes.
274
+ */
275
+ export function formatPptx(data: PptxCliResult): string {
276
+ return data.outs
277
+ .flatMap((path, index) => {
278
+ const lifted = data.lifted?.[index];
279
+ if (!lifted) {
280
+ return [`wrote ${path}`];
281
+ }
282
+ const none = lifted.untouched.length > 0 ? `; nothing on ${lifted.untouched.join(", ")}` : "";
283
+ const count = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
284
+ return [
285
+ `wrote ${path}`,
286
+ ` laid ${count(lifted.shapes, "shape")} and ${count(lifted.pictures, "picture")} over the picture${none}`,
287
+ ];
288
+ })
289
+ .join("\n");
290
+ }
291
+
233
292
  export function formatPack(data: PackCliResult): string {
234
293
  return [formatPdf(data), ...data.warnings.map((warning) => `warning: ${warning}`)].join("\n");
235
294
  }
@@ -258,15 +317,17 @@ export function formatLs(data: LsListResult | LsDeckResult): string {
258
317
  ].join("\n");
259
318
  }
260
319
  case "deck": {
320
+ // A ref's frontmatter is someone else's: each value keeps to its line, as in AGENTS.md.
321
+ const line = (text: string) => (data.ref ? foreignLine(text, TITLE_MAX) : text);
261
322
  const meta: Array<[string, string]> = [];
262
323
  if (data.ref) {
263
324
  meta.push(["rev", data.ref.rev.slice(0, 7)]);
264
325
  }
265
326
  if (data.event) {
266
- meta.push(["event", data.event]);
327
+ meta.push(["event", line(data.event)]);
267
328
  }
268
329
  if (data.date) {
269
- meta.push(["date", data.date]);
330
+ meta.push(["date", line(data.date)]);
270
331
  }
271
332
  if (data.duration) {
272
333
  meta.push(["duration", data.duration]);
@@ -304,7 +365,7 @@ export function formatLs(data: LsListResult | LsDeckResult): string {
304
365
  ];
305
366
  const countLabel = formatSkipped(data.skipped) || diagnosticCount(data.diagnostics.length);
306
367
  return [
307
- `${data.ref?.name ?? data.name} ${data.title}`,
368
+ `${data.ref?.name ?? data.name} ${line(data.title)}`,
308
369
  ...meta.map(([key, value]) => `${padEndWidth(key, keyWidth)} ${value}`),
309
370
  "",
310
371
  formatTable(headers, rows, [...align]),
@@ -346,6 +407,7 @@ export function formatTheme(data: ThemeResult): string {
346
407
  ...data.layouts.map(
347
408
  (layout) => ` ${layout.name}${layout.example === undefined ? " (no example)" : ""}`,
348
409
  ),
410
+ ...(data.unprintedLayouts ? [` and ${unprinted(data.unprintedLayouts)}`] : []),
349
411
  "",
350
412
  "CLASSES",
351
413
  ` ${data.classes.join(" ")}`,
@@ -360,7 +422,7 @@ export function formatTheme(data: ThemeResult): string {
360
422
  "TOKENS",
361
423
  ...data.tokens.map((token) => ` ${token.name.padEnd(width)} ${token.value}`),
362
424
  "",
363
- "Markup for a layout: dekc theme <layout>",
425
+ `Markup for a layout: dekc theme ${data.ref ? `${shellQuote(data.ref.name)} ` : ""}<layout>`,
364
426
  ].join("\n");
365
427
  }
366
428
 
@@ -401,7 +463,11 @@ export function formatVoice(data: VoiceCliResult): string {
401
463
  }
402
464
  }
403
465
 
404
- /** One labeled part per file, so a reader never confuses where a line came from. */
466
+ /**
467
+ * One labeled part per file, so a reader never confuses where a line came from: a part starts
468
+ * with a line `--- <file>`, and a file's own line that starts the same way is shown one space
469
+ * further in, so no file can start a part. A ref's slide says first whose it is.
470
+ */
405
471
  export function formatShow(data: ShowResult): string {
406
472
  const parts: Array<[string, string | null]> = [
407
473
  ["script.md", data.script],
@@ -411,9 +477,20 @@ export function formatShow(data: ShowResult): string {
411
477
  ["theme.css (the rules this slide uses)", data.theme || null],
412
478
  ["assets", data.assets.length > 0 ? data.assets.join("\n") : null],
413
479
  ];
414
- return parts
415
- .flatMap(([label, body]) => (body === null ? [] : [`--- ${label}\n${body.replace(/\n$/, "")}`]))
416
- .join("\n\n");
480
+ const ref = data.ref
481
+ ? [`ref ${data.ref.name} at ${data.ref.rev.slice(0, 7)}: someone else's deck, read-only`]
482
+ : [];
483
+ return [
484
+ ...ref,
485
+ ...parts.flatMap(([label, body]) =>
486
+ body === null ? [] : [`--- ${label}\n${partLines(body.replace(/\n$/, ""))}`],
487
+ ),
488
+ ].join("\n\n");
489
+ }
490
+
491
+ /** A part's lines, each that would start a part, after any spaces, given one space more. */
492
+ function partLines(body: string): string {
493
+ return body.replace(/^( *)---(?=[^\S\n])/gm, " $1---");
417
494
  }
418
495
 
419
496
  /** What a read does for a ref whose snapshot is not there: fetch it, or unpack its pack. */
@@ -437,7 +514,7 @@ export function formatRef(data: RefCliResult): string {
437
514
  head,
438
515
  ...data.warnings.map((warning) => `warning: ${warning}`),
439
516
  ...(data.pack ? [`commit ${data.pack}: a clone unpacks the ref from it`] : []),
440
- `read it with \`dekc ls ${data.name}\` and \`dekc show ${data.name} <slug>\``,
517
+ `read it with \`dekc ls ${shellQuote(data.name)}\` and \`dekc show ${shellQuote(data.name)} <slug>\``,
441
518
  ].join("\n");
442
519
  }
443
520
  case "list":
@@ -449,7 +526,7 @@ export function formatRef(data: RefCliResult): string {
449
526
  data.refs.map((ref) => [
450
527
  ref.name,
451
528
  short(ref.rev),
452
- ref.fetched ? (ref.title ?? "") : notFetched(ref.name),
529
+ ref.fetched ? foreignLine(ref.title ?? "", TITLE_MAX) : notFetched(ref.name),
453
530
  ref.slides === undefined ? "" : String(ref.slides),
454
531
  ]),
455
532
  ["left", "left", "left", "right"],
package/src/cli/theme.ts CHANGED
@@ -2,9 +2,12 @@ import { existsSync } from "node:fs";
2
2
  import { type CssToken, parseCss } from "../core/css.ts";
3
3
  import { deckPaths } from "../core/deck-paths.ts";
4
4
  import { DekError } from "../core/error.ts";
5
+ import { isPlainWord } from "../core/escape.ts";
6
+ import { foreignList, type HiddenText, hiddenText } from "../core/foreign-text.ts";
7
+ import { listShown, unprinted } from "../core/prose.ts";
5
8
  import { readDeckFile } from "../core/resolve.ts";
6
9
  import { type ThemeLayout, themeFacts } from "../core/theme-facts.ts";
7
- import type { ReadableDeck, RefInfo } from "./scope.ts";
10
+ import { hiddenField, type ReadableDeck, type RefInfo } from "./scope.ts";
8
11
 
9
12
  export type ThemeResult = {
10
13
  /** The deck's theme.css: the one lint and the slides use. */
@@ -15,11 +18,16 @@ export type ThemeResult = {
15
18
  stateClasses: string[];
16
19
  /** The tokens every slide can `var()`. */
17
20
  tokens: CssToken[];
21
+ /** The layouts whose names are plain words. A name is the theme's text, maybe a pack's. */
18
22
  layouts: ThemeLayout[];
23
+ /** How many layouts the theme has whose names are no plain word, left out of `layouts`. */
24
+ unprintedLayouts?: number;
19
25
  /** Set when one layout was asked for; text output prints its example alone. */
20
26
  layout?: { name: string; example: string };
21
27
  /** Set when the deck is a ref. */
22
28
  ref?: RefInfo;
29
+ /** A ref's lines of theme.css that hold characters a person cannot see. */
30
+ hidden?: HiddenText[];
23
31
  };
24
32
 
25
33
  /** The deck's theme, or one layout's example markup when `name` is given. */
@@ -31,32 +39,72 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
31
39
  hint: "copy the project theme.css into the deck, or run `dekc new <name>` for a fresh deck",
32
40
  });
33
41
  }
34
- const { classes, stateClasses, layouts, tokens } = themeFacts(
35
- parseCss(readDeckFile(deck.dir, path) ?? ""),
36
- );
42
+ const css = readDeckFile(deck.dir, path) ?? "";
43
+ const { classes, stateClasses, layouts, tokens } = themeFacts(parseCss(css));
44
+ const printed = layouts.filter((layout) => isPlainWord(layout.name));
45
+ const left = layouts.length - printed.length;
37
46
  const result: ThemeResult = {
38
47
  path,
39
48
  classes: [...classes].sort(),
40
49
  stateClasses: [...stateClasses].sort(),
41
50
  tokens,
42
- layouts,
51
+ layouts: printed,
52
+ ...(left > 0 ? { unprintedLayouts: left } : {}),
43
53
  ...(ref ? { ref } : {}),
54
+ ...hiddenField(ref, hiddenText(css, path)),
44
55
  };
45
56
  if (!name) {
46
57
  return result;
47
58
  }
48
59
  const layout = layouts.find((entry) => entry.name === name);
49
60
  if (!layout) {
50
- throw new DekError(`layout "${name}" is not defined in theme.css`, {
51
- path,
52
- hint: `use one of: ${layouts.map((entry) => entry.name).join(", ") || "(none)"}`,
53
- });
61
+ throw layoutNotFound(name, layouts, path, ref);
54
62
  }
55
63
  if (layout.example === undefined) {
56
64
  throw new DekError(`layout "${name}" has no example in theme.css`, {
57
65
  path,
58
- hint: `add a /* @layout ${name} ... */ comment with its markup above .slide[data-layout="${name}"]`,
66
+ hint: ref
67
+ ? `a ref is read-only; read the markup of a slide that uses it with \`dekc show ${ref.name} <slug>\``
68
+ : `add a /* @layout ${name} ... */ comment with its markup above .slide[data-layout="${name}"]`,
59
69
  });
60
70
  }
61
71
  return { ...result, layout: { name, example: layout.example } };
62
72
  }
73
+
74
+ /**
75
+ * A layout the theme does not define, named among the layouts whose names are plain words. A
76
+ * ref's layout names are its author's, so for a ref they go in the message, as foreignList shows
77
+ * them, and the hint is dek's words.
78
+ */
79
+ function layoutNotFound(
80
+ name: string,
81
+ layouts: ThemeLayout[],
82
+ path: string,
83
+ ref: RefInfo | undefined,
84
+ ): DekError {
85
+ const names = layouts.map((entry) => entry.name);
86
+ if (!ref) {
87
+ return new DekError(`layout "${name}" is not defined in theme.css`, {
88
+ path,
89
+ hint: `use one of: ${listShown(names, isPlainWord, unprinted) || "(none)"}`,
90
+ });
91
+ }
92
+ const shown = names.filter(isPlainWord);
93
+ const left = names.length - shown.length;
94
+ const others = left > 0 ? unprinted(left) : "";
95
+ const defined =
96
+ shown.length === 0
97
+ ? others
98
+ : others
99
+ ? `${foreignList(shown)}, and ${others}`
100
+ : foreignList(shown);
101
+ return shown.length > 0
102
+ ? new DekError(`layout "${name}" is not defined in theme.css, which defines ${defined}`, {
103
+ path,
104
+ hint: `pass one of them: \`dekc theme ${ref.name} <layout>\``,
105
+ })
106
+ : new DekError(
107
+ `layout "${name}" is not defined in theme.css, which defines ${defined || "none"}`,
108
+ { path, hint: `read the markup of a slide with \`dekc show ${ref.name} <slug>\`` },
109
+ );
110
+ }