@hajimism/dek 0.4.0 → 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 (70) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +4 -3
  5. package/src/cli/address.ts +11 -4
  6. package/src/cli/commands.ts +47 -14
  7. package/src/cli/contract.ts +49 -3
  8. package/src/cli/files.ts +1 -0
  9. package/src/cli/format.ts +5 -4
  10. package/src/cli/goto.ts +41 -10
  11. package/src/cli/lint.ts +32 -13
  12. package/src/cli/ls.ts +5 -1
  13. package/src/cli/main.ts +46 -11
  14. package/src/cli/new.ts +14 -1
  15. package/src/cli/ref.ts +1 -0
  16. package/src/cli/result.ts +42 -4
  17. package/src/cli/scope.ts +40 -1
  18. package/src/cli/shot.ts +77 -6
  19. package/src/cli/show.ts +15 -2
  20. package/src/cli/text.ts +61 -9
  21. package/src/cli/theme.ts +49 -15
  22. package/src/cli/tty.ts +16 -2
  23. package/src/cli/voice.ts +11 -6
  24. package/src/core/assets.ts +18 -10
  25. package/src/core/attribute-value.ts +188 -0
  26. package/src/core/build.ts +3 -3
  27. package/src/core/css-scan.ts +173 -39
  28. package/src/core/css-transform.ts +57 -11
  29. package/src/core/css.ts +19 -13
  30. package/src/core/foreign-text.ts +138 -0
  31. package/src/core/github.ts +1 -0
  32. package/src/core/html-entities.ts +318 -0
  33. package/src/core/html-scan.ts +49 -27
  34. package/src/core/html.ts +21 -15
  35. package/src/core/lint/asset-refs.ts +44 -6
  36. package/src/core/lint/slide-html.ts +21 -18
  37. package/src/core/mv.ts +28 -18
  38. package/src/core/order.ts +14 -0
  39. package/src/core/path.ts +8 -2
  40. package/src/core/playwright-module.d.ts +12 -1
  41. package/src/core/playwright-motion.ts +1 -0
  42. package/src/core/playwright-sheet.ts +1 -0
  43. package/src/core/playwright-visual.ts +27 -10
  44. package/src/core/playwright.ts +77 -19
  45. package/src/core/pptx.ts +1 -0
  46. package/src/core/ref-name.ts +8 -3
  47. package/src/core/ref.ts +145 -47
  48. package/src/core/resolve.ts +3 -2
  49. package/src/core/rumdl.ts +51 -25
  50. package/src/core/safe-fs.ts +19 -2
  51. package/src/core/sandbox.ts +217 -0
  52. package/src/core/shot-cache.ts +1 -0
  53. package/src/core/slide-eval-worker.ts +1 -0
  54. package/src/core/slide-script-eval.ts +2 -0
  55. package/src/core/spawn.ts +131 -32
  56. package/src/core/sync.ts +3 -12
  57. package/src/core/tar.ts +315 -0
  58. package/src/core/text-contrast.ts +2 -0
  59. package/src/core/toml-keys.ts +116 -51
  60. package/src/core/url-attributes.ts +27 -9
  61. package/src/core/visual.ts +28 -9
  62. package/src/core/voice.ts +2 -1
  63. package/src/server/auth.ts +21 -0
  64. package/src/server/dev.ts +11 -3
  65. package/src/server/lock.ts +4 -4
  66. package/src/server/serial.ts +8 -2
  67. package/src/video/mux.ts +27 -3
  68. package/src/video/recorder.ts +3 -2
  69. package/src/video/worker.ts +1 -0
  70. package/src/voice/engine.ts +81 -18
package/src/cli/ls.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { Diagnostic } from "../core/diagnostic.ts";
2
+ import type { HiddenText } from "../core/foreign-text.ts";
2
3
  import { lintDeck, lintProject } from "../core/lint.ts";
3
4
  import { listSlides, type Project, type ProjectDeck } from "../core/resolve.ts";
4
5
  import { slideVideoSeconds, timelineSeconds } from "../core/timeline.ts";
5
6
  import { sectionTiming } from "../core/timing.ts";
6
7
  import { tryLoadCachedTimeline } from "../core/voice.ts";
7
8
  import { type SkippedCheck, skippedChecks } from "./result.ts";
8
- import type { DecksTarget, RefInfo } from "./scope.ts";
9
+ import { type DecksTarget, hiddenField, hiddenInFiles, type RefInfo } from "./scope.ts";
9
10
 
10
11
  export type LsListResult = {
11
12
  kind: "list";
@@ -46,6 +47,8 @@ export type LsDeckResult = {
46
47
  skipped?: SkippedCheck[];
47
48
  /** Set when the deck is a ref. */
48
49
  ref?: RefInfo;
50
+ /** A ref's lines of script.md that hold characters a person cannot see. */
51
+ hidden?: HiddenText[];
49
52
  };
50
53
 
51
54
  /** The one deck in scope, a ref included, or else every deck of the project. */
@@ -58,6 +61,7 @@ export function lsCommand(target: DecksTarget): LsListResult | LsDeckResult {
58
61
  { check: "lint", reason: "a ref is read-only; its problems are not yours to fix" },
59
62
  ]),
60
63
  ref,
64
+ ...hiddenField(ref, hiddenInFiles(deck.dir, [deck.scriptPath])),
61
65
  };
62
66
  }
63
67
  if (deck) {
package/src/cli/main.ts CHANGED
@@ -1,8 +1,12 @@
1
+ import { parseCss } from "../core/css.ts";
2
+ import { deckPaths } from "../core/deck-paths.ts";
1
3
  import { DekError } from "../core/error.ts";
2
4
  import { shellQuote } from "../core/escape.ts";
3
5
  import { either } from "../core/prose.ts";
4
6
  import { isRefName } from "../core/ref-name.ts";
5
- import { resolveProject } from "../core/resolve.ts";
7
+ import { type ProjectDeck, readDeckFile, resolveProject } from "../core/resolve.ts";
8
+ import { takeUnsandboxed } from "../core/sandbox.ts";
9
+ import { themeFacts } from "../core/theme-facts.ts";
6
10
  import { addressHint, deckWord, type HintDeck, withPlaceholderNote } from "./address.ts";
7
11
  import {
8
12
  type AnySpec,
@@ -158,7 +162,7 @@ async function run(cwd: string, line: CommandLine): Promise<void> {
158
162
  }
159
163
  const scope = spec.scope;
160
164
  const target = scope
161
- ? retypedWithDeck(line, cwd, () =>
165
+ ? retypedWithDeck(line, cwd, call, () =>
162
166
  resolveTarget(cwd, scope, {
163
167
  deck: call.deck,
164
168
  refs: spec.refs === true,
@@ -170,10 +174,13 @@ async function run(cwd: string, line: CommandLine): Promise<void> {
170
174
  const ctx = { cwd, flags: line.values, args: call.args, target };
171
175
  const data = await form.run?.(ctx);
172
176
  if (spec.kind === "result") {
173
- writeSuccess({ command: call.name, data } as CliResult, {
177
+ // Said on stderr as Chromium drew without its sandbox, and kept here for --json.
178
+ const drawn = { ...(data as object), ...takeUnsandboxed() };
179
+ writeSuccess({ command: call.name, data: drawn } as CliResult, {
174
180
  json: line.values.json === true,
175
181
  format: line.values.format,
176
182
  cwd,
183
+ flags: line.values,
177
184
  ...(target?.deck && {
178
185
  deck: { name: target.ref?.name ?? target.deck.name, dir: target.deck.dir },
179
186
  }),
@@ -186,9 +193,10 @@ const NAMED_DECKS = 3;
186
193
 
187
194
  /**
188
195
  * `resolve`, with a missing deck hinted as the line that was typed, the deck named where the
189
- * command takes it: one command per deck of the project, so the hint runs as written.
196
+ * command takes it: one command per deck of the project that has what the line names, so the
197
+ * hint runs as written.
190
198
  */
191
- function retypedWithDeck<T>(line: CommandLine, cwd: string, resolve: () => T): T {
199
+ function retypedWithDeck<T>(line: CommandLine, cwd: string, call: Call, resolve: () => T): T {
192
200
  try {
193
201
  return resolve();
194
202
  } catch (error) {
@@ -196,18 +204,43 @@ function retypedWithDeck<T>(line: CommandLine, cwd: string, resolve: () => T): T
196
204
  throw error;
197
205
  }
198
206
  const typed = `\`dekc ${line.typed.map(shellQuote).join(" ")}\``;
199
- const [first] = error.decks;
207
+ const decks = error.decks.filter((deck) => runsIn(call, deck));
208
+ const [first] = decks;
200
209
  // Each command is retyped for one deck; where to find a name left as <deck> is said once.
201
210
  const retyped = (deck: HintDeck) => addressHint(typed, deck, cwd, { note: false });
202
- const hint = !first
203
- ? "run `dekc new <name>` to make a deck"
204
- : error.decks.length > NAMED_DECKS
205
- ? `run ${retyped(first)}, or name another deck in place of ${deckWord(first.name)}; \`dekc ls\` lists them`
206
- : `run ${either(error.decks.map(retyped))}`;
211
+ const hint =
212
+ error.decks.length === 0
213
+ ? "run `dekc new <name>` to make a deck"
214
+ : !first
215
+ ? "run `dekc ls`"
216
+ : decks.length > NAMED_DECKS
217
+ ? `run ${retyped(first)}, or name another deck in place of ${deckWord(first.name)}; \`dekc ls\` lists them`
218
+ : `run ${either(decks.map(retyped))}`;
207
219
  throw new DekError(error.message, { hint: withPlaceholderNote(hint), cause: error });
208
220
  }
209
221
  }
210
222
 
223
+ /**
224
+ * Whether `call` finds in `deck` what it names: a section, the one `dekc mv` renames or the name
225
+ * script.md already gives it, and a layout of the deck's theme.
226
+ */
227
+ function runsIn(call: Call, deck: ProjectDeck): boolean {
228
+ const { slug, old, layout } = call.args;
229
+ const has = (name: string | undefined) =>
230
+ deck.deck.sections.some((section) => section.slug === name);
231
+ return (
232
+ (slug === undefined || has(slug)) &&
233
+ (old === undefined || has(old) || has(call.args.new)) &&
234
+ (layout === undefined || themeLayouts(deck).includes(layout))
235
+ );
236
+ }
237
+
238
+ /** The layouts a deck's theme defines; none when it has no theme.css. */
239
+ function themeLayouts(deck: ProjectDeck): string[] {
240
+ const css = readDeckFile(deck.dir, deckPaths(deck.dir).theme) ?? "";
241
+ return themeFacts(parseCss(css)).layouts.map((entry) => entry.name);
242
+ }
243
+
211
244
  /** A session prints no result, so --json has nothing to shape, and a reader would wait forever. */
212
245
  function sessionJsonError(name: string): DekError {
213
246
  return new DekError(`${name} keeps running until stopped and prints no JSON`, {
@@ -220,6 +253,8 @@ function sessionJsonError(name: string): DekError {
220
253
  * prints once, here: as JSON when the line asks for it, even a line too broken to parse.
221
254
  */
222
255
  export async function main(argv: string[], cwd: string): Promise<void> {
256
+ // What a run before this one in the process drew is its own, even one that failed.
257
+ takeUnsandboxed();
223
258
  let json = wantsJson(argv);
224
259
  try {
225
260
  const line = parseCommandLine(argv);
package/src/cli/new.ts CHANGED
@@ -3,13 +3,20 @@ import { join } from "node:path";
3
3
  import { loadConfig } from "../core/config.ts";
4
4
  import { deckPaths } from "../core/deck-paths.ts";
5
5
  import { DekError } from "../core/error.ts";
6
+ import { type HiddenText, hiddenText } from "../core/foreign-text.ts";
6
7
  import { DECK_NAME_HINT, isDeckName } from "../core/path.ts";
7
8
  import { isRefName } from "../core/ref-name.ts";
8
9
  import { readDeckFile } from "../core/resolve.ts";
9
10
  import { readSourceIfExists } from "../core/safe-fs.ts";
10
11
  import { syncDeck } from "../core/sync.ts";
11
12
  import { applyPlan, deckPlan, nextSteps } from "./files.ts";
12
- import { type RefInfo, requireDeckFromCwd, requireProject, resolveTarget } from "./scope.ts";
13
+ import {
14
+ hiddenField,
15
+ type RefInfo,
16
+ requireDeckFromCwd,
17
+ requireProject,
18
+ resolveTarget,
19
+ } from "./scope.ts";
13
20
 
14
21
  export type NewResult = {
15
22
  name: string;
@@ -21,6 +28,11 @@ export type NewResult = {
21
28
  next: string[];
22
29
  /** Set when the theme came from a ref with no license. */
23
30
  warnings: string[];
31
+ /**
32
+ * A ref's theme's lines, now the new deck's theme.css, that hold characters a person cannot
33
+ * see: the copy is the deck's own, so they are its author's to remove.
34
+ */
35
+ hidden?: HiddenText[];
24
36
  };
25
37
 
26
38
  export function newCommand(options: { cwd: string; name: string; themeFrom?: string }): NewResult {
@@ -87,6 +99,7 @@ export function newCommand(options: { cwd: string; name: string; themeFrom?: str
87
99
  `${ref.name} has no license; ask its author before you present or share a deck built on its theme`,
88
100
  ]
89
101
  : [],
102
+ ...hiddenField(ref, ref ? hiddenText(theme, deckPaths(dir).theme) : []),
90
103
  };
91
104
  }
92
105
 
package/src/cli/ref.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // biome-ignore lint/style/noRestrictedImports: rmdirSync, which takes packs/ and refs/ folders only while empty; removeInside would take what is in them.
1
2
  import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, statSync } from "node:fs";
2
3
  import { dirname, join, relative, resolve, sep } from "node:path";
3
4
  import { loadConfig } from "../core/config.ts";
package/src/cli/result.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
2
2
  import { DekError } from "../core/error.ts";
3
+ import { revealHidden } from "../core/foreign-text.ts";
3
4
  import { PLAYWRIGHT_INSTALL, type PlaywrightRunner, playwrightReady } from "../core/playwright.ts";
4
5
  import { addressHint, deckAround, type HintDeck } from "./address.ts";
5
6
  import { type CliResult, outputOf } from "./commands.ts";
7
+ import type { FlagValues } from "./flags.ts";
6
8
  import { displayPath, formatError, formatErrorText } from "./format.ts";
7
- import { shouldColor, terminalSafe } from "./tty.ts";
9
+ import { plainText, shouldColor, terminalSafe } from "./tty.ts";
8
10
 
9
11
  export type { CliResult, SkippedCheck };
10
12
 
@@ -41,6 +43,8 @@ export type WriteSuccessOptions = {
41
43
  cwd?: string;
42
44
  /** The one deck the command worked on, which hints that name no path are about. */
43
45
  deck?: HintDeck;
46
+ /** The flags it ran with, which a failure's rerun repeats where they change what is checked. */
47
+ flags?: FlagValues;
44
48
  };
45
49
 
46
50
  /** Where a run's hints are read: the directory it ran in, and the deck it worked on, if one. */
@@ -86,7 +90,7 @@ export function writeSuccess(original: CliResult, options: WriteSuccessOptions):
86
90
  const { cwd } = options;
87
91
  const addressed = cwd === undefined ? original : addressResult(original, { cwd, ...options });
88
92
  const result = cwd === undefined ? addressed : displayPaths(addressed, cwd);
89
- const failed = output.failure?.(result.data);
93
+ const failed = output.failure?.(result.data, options.flags ?? {});
90
94
  const failure =
91
95
  failed?.hint === undefined || cwd === undefined
92
96
  ? failed
@@ -98,9 +102,13 @@ export function writeSuccess(original: CliResult, options: WriteSuccessOptions):
98
102
  const data = output.json ? output.json(result.data) : (result.data as object);
99
103
  process.stdout.write(`${JSON.stringify({ ...envelope, ...data })}\n`);
100
104
  } else {
101
- const text = formatText(result, shouldColor(process.stdout));
105
+ const plain = {
106
+ ...result,
107
+ data: plainStrings(result.data, isAboutRef(result.data)),
108
+ } as CliResult;
109
+ const text = formatText(plain, shouldColor(process.stdout));
102
110
  process.stdout.write(`${terminalSafe(text)}\n`);
103
- const notes = output.notes?.(result.data, shouldColor(process.stderr));
111
+ const notes = output.notes?.(plain.data, shouldColor(process.stderr));
104
112
  if (notes) {
105
113
  process.stderr.write(`${terminalSafe(notes)}\n`);
106
114
  }
@@ -126,6 +134,36 @@ export function displayPaths(result: CliResult, cwd: string): CliResult {
126
134
  return { ...result, data: paths(result.data, (path) => displayPath(path, cwd)) } as CliResult;
127
135
  }
128
136
 
137
+ /** A result that read a ref, as `ls`, `show`, and `theme` mark theirs. */
138
+ function isAboutRef(data: unknown): boolean {
139
+ return typeof data === "object" && data !== null && "ref" in data && data.ref !== undefined;
140
+ }
141
+
142
+ /**
143
+ * Every string in a result as plainText, so the text a deck or a ref wrote reaches the terminal
144
+ * with no escape of its own; the text formatters paint dek's colors after. In a result about a
145
+ * ref, each character a person cannot see shows as `⟨U+XXXX⟩` too.
146
+ */
147
+ function plainStrings<T>(value: T, reveal: boolean): T {
148
+ if (typeof value === "string") {
149
+ const plain = plainText(value);
150
+ return (reveal ? revealHidden(plain) : plain) as T;
151
+ }
152
+ if (Array.isArray(value)) {
153
+ return value.map((entry) => plainStrings(entry, reveal)) as T;
154
+ }
155
+ if (
156
+ value !== null &&
157
+ typeof value === "object" &&
158
+ Object.getPrototypeOf(value) === Object.prototype
159
+ ) {
160
+ return Object.fromEntries(
161
+ Object.entries(value).map(([key, entry]) => [key, plainStrings(entry, reveal)]),
162
+ ) as T;
163
+ }
164
+ return value;
165
+ }
166
+
129
167
  /** A result as text, without what did not run; see writeSuccess. */
130
168
  export function formatText(result: CliResult, color = false): string {
131
169
  return outputOf(result.command).text(result.data, color);
package/src/cli/scope.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { join, relative } from "node:path";
2
2
  import { DekError } from "../core/error.ts";
3
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,
9
+ type RefSource,
8
10
  refLicense,
9
11
  refReadOnlyError,
10
12
  refState,
@@ -14,6 +16,7 @@ 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";
@@ -222,6 +225,25 @@ export type RefInfo = {
222
225
  /** One deck to read: the project's own, or a ref. */
223
226
  export type ReadableDeck = DeckTarget & { ref?: RefInfo };
224
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
+
225
247
  /**
226
248
  * What a command declares it works on, resolved: one deck or the decks in scope. Only a command
227
249
  * whose spec says `refs` reads a ref; it never writes, so a ref cannot reach a command that
@@ -287,7 +309,7 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
287
309
  if (!found) {
288
310
  const failed = snapshot.failed.find((entry) => entry.name === source.deck);
289
311
  if (failed) {
290
- throw failed.error;
312
+ throw unreadableRef(failed.error, source);
291
313
  }
292
314
  throw new DekError(`ref "${source.name}" has no deck "${source.deck}"`, {
293
315
  path: dir,
@@ -304,3 +326,20 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
304
326
  ref: { name: source.name, rev: pinned, dir, license: refLicense(dir) },
305
327
  };
306
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,10 +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";
2
- import { shellQuote } from "../core/escape.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";
3
8
  import { parseMorphAt, shotMorph } from "../core/shot/morph.ts";
4
9
  import { type ShotMotion, shotMotion } from "../core/shot/motion.ts";
5
10
  import { type ShotFile, shotDeck, shotSheet } from "../core/shot/still.ts";
6
11
  import { playerScript } from "../runtime/player.ts";
7
- import type { ReadableDeck } from "./scope.ts";
12
+ import { hiddenField, hiddenInFiles, type ReadableDeck } from "./scope.ts";
8
13
  import { usageError } from "./usage.ts";
9
14
 
10
15
  export type ShotCliResult = {
@@ -13,6 +18,11 @@ export type ShotCliResult = {
13
18
  sheets?: string[];
14
19
  /** With --motion, each beat's frames and the ms into its go each shows. */
15
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[];
16
26
  };
17
27
 
18
28
  type ShotCommandOptions = {
@@ -32,13 +42,64 @@ export type ShotMode =
32
42
  | { kind: "morph"; from: string; to: string; at: number };
33
43
 
34
44
  export async function shotCommand(
35
- { project, deck }: ReadableDeck,
45
+ target: ReadableDeck,
36
46
  options: ShotCommandOptions,
37
47
  ): Promise<ShotCliResult> {
38
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 } : {};
39
100
  switch (mode.kind) {
40
101
  case "sheet": {
41
- const { sheets, shots } = await shotSheet({ project, deck });
102
+ const { sheets, shots } = await shotSheet({ project, deck }, runner);
42
103
  return { shots, sheets };
43
104
  }
44
105
  case "motion": {
@@ -48,6 +109,7 @@ export async function shotCommand(
48
109
  slug: mode.slug,
49
110
  ...(mode.step === undefined ? {} : { step: mode.step }),
50
111
  playerScript: await playerScript(),
112
+ ...runner,
51
113
  },
52
114
  );
53
115
  return { shots: [], sheets, motion: beats };
@@ -55,12 +117,21 @@ export async function shotCommand(
55
117
  case "morph": {
56
118
  const shots = await shotMorph(
57
119
  { project, deck },
58
- { 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
+ },
59
127
  );
60
128
  return { shots };
61
129
  }
62
130
  case "still": {
63
- 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
+ );
64
135
  return { shots };
65
136
  }
66
137
  }
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
@@ -2,6 +2,8 @@
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
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";
5
7
  import { unprinted } from "../core/prose.ts";
6
8
  import { packFile, parseRefSource } from "../core/ref-name.ts";
7
9
  import { formatClock } from "../core/timing.ts";
@@ -53,7 +55,7 @@ export function formatReport(diagnostics: Diagnostic[], opts?: { color?: boolean
53
55
  byRule.set(id, (byRule.get(id) ?? 0) + 1);
54
56
  }
55
57
  const rules = [...byRule]
56
- .sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
58
+ .sort(([a, x], [b, y]) => y - x || compareCodeUnits(a, b))
57
59
  .map(([id, n]) => `${id} ×${n}`);
58
60
  return `${list}\n${countSummary(diagnostics)}: ${rules.join(", ")}`;
59
61
  }
@@ -69,6 +71,39 @@ export function formatSkipped(skipped: SkippedCheck[] | undefined, color = false
69
71
  .join("\n");
70
72
  }
71
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
+
72
107
  /**
73
108
  * The slides sync kept though their section is gone, for stderr: sync removes only what it wrote,
74
109
  * so saying nothing would read as a deck with nothing left over.
@@ -282,15 +317,17 @@ export function formatLs(data: LsListResult | LsDeckResult): string {
282
317
  ].join("\n");
283
318
  }
284
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);
285
322
  const meta: Array<[string, string]> = [];
286
323
  if (data.ref) {
287
324
  meta.push(["rev", data.ref.rev.slice(0, 7)]);
288
325
  }
289
326
  if (data.event) {
290
- meta.push(["event", data.event]);
327
+ meta.push(["event", line(data.event)]);
291
328
  }
292
329
  if (data.date) {
293
- meta.push(["date", data.date]);
330
+ meta.push(["date", line(data.date)]);
294
331
  }
295
332
  if (data.duration) {
296
333
  meta.push(["duration", data.duration]);
@@ -328,7 +365,7 @@ export function formatLs(data: LsListResult | LsDeckResult): string {
328
365
  ];
329
366
  const countLabel = formatSkipped(data.skipped) || diagnosticCount(data.diagnostics.length);
330
367
  return [
331
- `${data.ref?.name ?? data.name} ${data.title}`,
368
+ `${data.ref?.name ?? data.name} ${line(data.title)}`,
332
369
  ...meta.map(([key, value]) => `${padEndWidth(key, keyWidth)} ${value}`),
333
370
  "",
334
371
  formatTable(headers, rows, [...align]),
@@ -426,7 +463,11 @@ export function formatVoice(data: VoiceCliResult): string {
426
463
  }
427
464
  }
428
465
 
429
- /** 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
+ */
430
471
  export function formatShow(data: ShowResult): string {
431
472
  const parts: Array<[string, string | null]> = [
432
473
  ["script.md", data.script],
@@ -436,9 +477,20 @@ export function formatShow(data: ShowResult): string {
436
477
  ["theme.css (the rules this slide uses)", data.theme || null],
437
478
  ["assets", data.assets.length > 0 ? data.assets.join("\n") : null],
438
479
  ];
439
- return parts
440
- .flatMap(([label, body]) => (body === null ? [] : [`--- ${label}\n${body.replace(/\n$/, "")}`]))
441
- .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---");
442
494
  }
443
495
 
444
496
  /** What a read does for a ref whose snapshot is not there: fetch it, or unpack its pack. */
@@ -474,7 +526,7 @@ export function formatRef(data: RefCliResult): string {
474
526
  data.refs.map((ref) => [
475
527
  ref.name,
476
528
  short(ref.rev),
477
- ref.fetched ? (ref.title ?? "") : notFetched(ref.name),
529
+ ref.fetched ? foreignLine(ref.title ?? "", TITLE_MAX) : notFetched(ref.name),
478
530
  ref.slides === undefined ? "" : String(ref.slides),
479
531
  ]),
480
532
  ["left", "left", "left", "right"],