@hajimism/dek 0.4.0 → 0.5.1

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 (77) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +8 -7
  5. package/src/cli/address.ts +59 -15
  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/keep-alive.ts +12 -6
  12. package/src/cli/lint.ts +32 -13
  13. package/src/cli/ls.ts +5 -1
  14. package/src/cli/main.ts +48 -13
  15. package/src/cli/new.ts +14 -1
  16. package/src/cli/ref.ts +1 -0
  17. package/src/cli/result.ts +42 -4
  18. package/src/cli/scope.ts +40 -1
  19. package/src/cli/shot.ts +77 -6
  20. package/src/cli/show.ts +15 -2
  21. package/src/cli/text.ts +61 -9
  22. package/src/cli/theme.ts +50 -16
  23. package/src/cli/tty.ts +16 -2
  24. package/src/cli/voice.ts +11 -6
  25. package/src/core/assets.ts +18 -10
  26. package/src/core/attribute-value.ts +188 -0
  27. package/src/core/build.ts +3 -3
  28. package/src/core/css-scan.ts +173 -39
  29. package/src/core/css-transform.ts +57 -11
  30. package/src/core/css.ts +19 -13
  31. package/src/core/foreign-text.ts +138 -0
  32. package/src/core/github.ts +1 -0
  33. package/src/core/html-entities.ts +318 -0
  34. package/src/core/html-scan.ts +49 -27
  35. package/src/core/html.ts +21 -15
  36. package/src/core/lint/asset-refs.ts +44 -6
  37. package/src/core/lint/context.ts +2 -0
  38. package/src/core/lint/shared-styles.ts +26 -1
  39. package/src/core/lint/slide-html.ts +21 -18
  40. package/src/core/lint/theme-css.ts +64 -3
  41. package/src/core/lint.ts +15 -2
  42. package/src/core/mv.ts +28 -18
  43. package/src/core/order.ts +14 -0
  44. package/src/core/path.ts +8 -2
  45. package/src/core/playwright-module.d.ts +12 -1
  46. package/src/core/playwright-motion.ts +1 -0
  47. package/src/core/playwright-sheet.ts +1 -0
  48. package/src/core/playwright-visual.ts +27 -10
  49. package/src/core/playwright.ts +77 -19
  50. package/src/core/pptx.ts +1 -0
  51. package/src/core/ref-name.ts +8 -3
  52. package/src/core/ref.ts +145 -47
  53. package/src/core/resolve.ts +3 -2
  54. package/src/core/rumdl.ts +51 -25
  55. package/src/core/safe-fs.ts +19 -2
  56. package/src/core/sandbox.ts +217 -0
  57. package/src/core/shot-cache.ts +1 -0
  58. package/src/core/slide-eval-worker.ts +1 -0
  59. package/src/core/slide-measure.ts +1 -0
  60. package/src/core/slide-script-eval.ts +2 -0
  61. package/src/core/spawn.ts +136 -35
  62. package/src/core/sync.ts +3 -12
  63. package/src/core/tar.ts +315 -0
  64. package/src/core/text-contrast.ts +2 -0
  65. package/src/core/toml-keys.ts +116 -51
  66. package/src/core/url-attributes.ts +27 -9
  67. package/src/core/visual.ts +28 -9
  68. package/src/core/voice.ts +2 -1
  69. package/src/server/auth.ts +21 -0
  70. package/src/server/dev.ts +13 -5
  71. package/src/server/lock.ts +43 -13
  72. package/src/server/serial.ts +8 -2
  73. package/src/video/mux.ts +27 -3
  74. package/src/video/recorder.ts +3 -2
  75. package/src/video/worker.ts +1 -0
  76. package/src/voice/engine.ts +81 -18
  77. package/tsconfig.json +1 -1
package/src/core/lint.ts CHANGED
@@ -18,6 +18,7 @@ import { timingDiagnostics } from "./lint/timing.ts";
18
18
  import { voiceDiagnostics } from "./lint/voice.ts";
19
19
  import { asResolvedDeck, type Project, type ProjectDeck } from "./resolve.ts";
20
20
  import type { Section } from "./schema.ts";
21
+ import type { SlideScriptProblem } from "./slide-script.ts";
21
22
  import { evaluateSlideScripts, slideScriptsProblems } from "./slide-script-eval.ts";
22
23
  import { RUNTIME_CLASSES } from "./theme-facts.ts";
23
24
 
@@ -53,7 +54,7 @@ export function lintDeck(
53
54
  export function lintDeck(input: DeckInput, options?: LintDeckOptions): Diagnostic[] {
54
55
  const { files, scripted } = readLintInput(input);
55
56
  const problems = slideScriptsProblems(scripted.map((entry) => entry.input));
56
- return runRules({ ...files, scripts: slideScriptDiagnostics(scripted, problems) }, options);
57
+ return runRules(lintContext(files, scripted, problems), options);
57
58
  }
58
59
 
59
60
  /**
@@ -66,7 +67,19 @@ export async function lintDeckAsync(
66
67
  ): Promise<Diagnostic[]> {
67
68
  const { files, scripted } = readLintInput(input);
68
69
  const problems = await evaluateSlideScripts(scripted.map((entry) => entry.input));
69
- return runRules({ ...files, scripts: slideScriptDiagnostics(scripted, problems) }, options);
70
+ return runRules(lintContext(files, scripted, problems), options);
71
+ }
72
+
73
+ function lintContext(
74
+ files: DeckFiles,
75
+ scripted: LintedScript[],
76
+ problems: SlideScriptProblem[][],
77
+ ): LintContext {
78
+ return {
79
+ ...files,
80
+ scripts: slideScriptDiagnostics(scripted, problems),
81
+ scriptCode: new Map(scripted.map(({ section, input }) => [section.slug, input.code])),
82
+ };
70
83
  }
71
84
 
72
85
  function readLintInput(input: DeckInput): { files: DeckFiles; scripted: LintedScript[] } {
package/src/core/mv.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ // biome-ignore lint/style/noRestrictedImports: renameSync and unlinkSync, for mv's steps, each undone on failure; safe-fs does not rename.
2
+ import { existsSync, renameSync, unlinkSync } from "node:fs";
2
3
  import { basename } from "node:path";
3
4
  import { deckPaths } from "./deck-paths.ts";
4
5
  import { DekError } from "./error.ts";
@@ -11,6 +12,7 @@ import {
11
12
  requireSection,
12
13
  SLIDE_SIDECARS,
13
14
  } from "./resolve.ts";
15
+ import { writeInside } from "./safe-fs.ts";
14
16
  import { Id } from "./schema.ts";
15
17
  import { deckLayouts, skeletonHtml } from "./skeleton.ts";
16
18
  import { skeletonRecord } from "./skeleton-record.ts";
@@ -22,11 +24,11 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
22
24
  if (!Id.safeParse(to).success) {
23
25
  throw new DekError(`invalid id "${to}"`, { hint: "use [a-z0-9-] with at least one letter" });
24
26
  }
25
- const { deck } = asResolvedDeck(input);
27
+ const { project, deck } = asResolvedDeck(input);
26
28
  const has = (slug: string): boolean => deck.deck.sections.some((entry) => entry.slug === slug);
27
29
  if (!has(from) && has(to)) {
28
30
  // script.md was edited first; only the files still carry the old id.
29
- const steps = slideFileSteps(deck, from, to);
31
+ const steps = slideFileSteps(project.root, deck, from, to);
30
32
  if (steps.length === 0 && !existsSync(deckPaths(deck.dir).slide(to, ".html"))) {
31
33
  throw new DekError(`slide "${from}" not found`, {
32
34
  path: deckPaths(deck.dir).slide(from, ".html"),
@@ -56,8 +58,8 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
56
58
  }
57
59
  lines[headingIndex] = rewriteHeadingId(heading, from, to);
58
60
  applySteps([
59
- writeStep(deck.scriptPath, source, joinLines(source, lines)),
60
- ...slideFileSteps(deck, from, to),
61
+ writeStep(project.root, deck.scriptPath, source, joinLines(source, lines)),
62
+ ...slideFileSteps(project.root, deck, from, to),
61
63
  ]);
62
64
  }
63
65
 
@@ -69,7 +71,12 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
69
71
  * the author wrote, one under each id, stop the rename; everything that could stop it is worked
70
72
  * out before any file changes.
71
73
  */
72
- function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): FileStep[] {
74
+ function slideFileSteps(
75
+ root: string,
76
+ deck: ResolvedDeck["deck"],
77
+ from: string,
78
+ to: string,
79
+ ): FileStep[] {
73
80
  const paths = deckPaths(deck.dir);
74
81
  const layouts = deckLayouts(deck.dir);
75
82
  const record = skeletonRecord(deck.dir);
@@ -100,16 +107,16 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
100
107
  const kept = source && (source.authored || !target) ? source : target;
101
108
  if (source && kept === source) {
102
109
  steps.push(
103
- target ? writeStep(toPath, target.html, source.html) : renameStep(source.path, toPath),
110
+ target ? writeStep(root, toPath, target.html, source.html) : renameStep(source.path, toPath),
104
111
  );
105
112
  }
106
113
  if (source && target) {
107
- steps.push(removeStep(source.path, source.html));
114
+ steps.push(removeStep(root, source.path, source.html));
108
115
  }
109
116
  if (kept) {
110
117
  const next = relabel(kept.html, from, to);
111
118
  if (next !== kept.html) {
112
- steps.push(writeStep(toPath, kept.html, next));
119
+ steps.push(writeStep(root, toPath, kept.html, next));
113
120
  }
114
121
  }
115
122
  for (const ext of SLIDE_SIDECARS) {
@@ -119,7 +126,7 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
119
126
  }
120
127
  }
121
128
  if (voice) {
122
- steps.push(writeStep(voice.path, voice.source, voice.next));
129
+ steps.push(writeStep(root, voice.path, voice.source, voice.next));
123
130
  }
124
131
  return steps;
125
132
  }
@@ -155,19 +162,22 @@ function again(from: string, to: string): string {
155
162
  return `\`dekc mv ${end}${source} ${target}\` again`;
156
163
  }
157
164
 
158
- /** One file change `dekc mv` makes, with how to take it back. */
165
+ /**
166
+ * One file change `dekc mv` makes, with how to take it back. A write goes to a new file renamed
167
+ * into place, so a crash part way leaves the old file or the new one, never half of one.
168
+ */
159
169
  type FileStep = { path: string; apply: () => void; undo: () => void };
160
170
 
161
- function writeStep(path: string, before: string, after: string): FileStep {
171
+ function writeStep(root: string, path: string, before: string, after: string): FileStep {
162
172
  return {
163
173
  path,
164
- apply: () => writeFileSync(path, after),
165
- undo: () => writeFileSync(path, before),
174
+ apply: () => writeInside(path, after, root),
175
+ undo: () => writeInside(path, before, root),
166
176
  };
167
177
  }
168
178
 
169
- function removeStep(path: string, before: string): FileStep {
170
- return { path, apply: () => unlinkSync(path), undo: () => writeFileSync(path, before) };
179
+ function removeStep(root: string, path: string, before: string): FileStep {
180
+ return { path, apply: () => unlinkSync(path), undo: () => writeInside(path, before, root) };
171
181
  }
172
182
 
173
183
  function renameStep(from: string, to: string): FileStep {
@@ -268,7 +278,7 @@ export function reorderSection(
268
278
  place: SectionPlace,
269
279
  ): void {
270
280
  const target = "before" in place ? place.before : place.after;
271
- const { deck } = asResolvedDeck(input);
281
+ const { project, deck } = asResolvedDeck(input);
272
282
  const sections = deck.deck.sections;
273
283
  const fromIndex = sections.findIndex((entry) => entry.slug === slug);
274
284
  if (fromIndex < 0) {
@@ -301,7 +311,7 @@ export function reorderSection(
301
311
  });
302
312
  const insertAt = "before" in place ? remainingTarget : remainingTarget + 1;
303
313
  chunks.splice(insertAt, 0, moved);
304
- writeFileSync(deck.scriptPath, joinLines(source, [...head, ...chunks.flat()]));
314
+ writeInside(deck.scriptPath, joinLines(source, [...head, ...chunks.flat()]), project.root);
305
315
  }
306
316
 
307
317
  function rewriteHeadingId(heading: string, from: string, to: string): string {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Orders two strings by their UTF-16 code units, as `Array.prototype.sort` does with no
3
+ * comparator, so every list dek sorts comes out in one order, the same on every machine.
4
+ *
5
+ * `localeCompare` with no locale follows the machine's, so a teammate set to Swedish or Turkish
6
+ * would write dict.toml in another order. A fixed locale is no cure: its collation comes from
7
+ * the ICU the runtime has, which Bun takes from the system on macOS and bundles on Linux, and it
8
+ * counts a precomposed `ä` and an `a` with a combining mark as equal, leaving their order to
9
+ * whichever came first. Code units cost a dictionary's order: `B` comes before `a`, `ä` after
10
+ * `z`, and Japanese reads hiragana, then katakana, then kanji by code point.
11
+ */
12
+ export function compareCodeUnits(a: string, b: string): number {
13
+ return a < b ? -1 : a > b ? 1 : 0;
14
+ }
package/src/core/path.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
+ import { holdsHidden } from "./foreign-text.ts";
3
4
 
4
5
  export function moduleFilePath(url: string | URL): string {
5
6
  return fileURLToPath(url);
@@ -16,8 +17,12 @@ export function deckProjectRoot(deckDir: string): string {
16
17
  }
17
18
 
18
19
  export const DECK_NAME_HINT =
19
- 'use a plain name like 2026-10-talk: no "/" or "\\", no control characters, no leading "-"';
20
+ 'use a plain name like 2026-10-talk: no "/" or "\\", no control character or one a person cannot see, no leading "-"';
20
21
 
22
+ /**
23
+ * A name `dekc new` and `dekc init` may give a deck. A pack's ref takes its deck's name, so, as
24
+ * `dekc pack` does, these refuse a control character and one a person cannot see.
25
+ */
21
26
  export function isDeckName(name: string): boolean {
22
27
  return (
23
28
  name.length > 0 &&
@@ -28,7 +33,8 @@ export function isDeckName(name: string): boolean {
28
33
  // A leading "-" reads as a flag to every tool the name is passed to.
29
34
  !name.startsWith("-") &&
30
35
  // biome-ignore lint/suspicious/noControlCharactersInRegex: a control in a name is the problem.
31
- !/[\x00-\x1f\x7f]/.test(name)
36
+ !/[\x00-\x1f\x7f-\x9f]/.test(name) &&
37
+ !holdsHidden(name)
32
38
  );
33
39
  }
34
40
 
@@ -27,6 +27,11 @@ declare module "playwright" {
27
27
  }): Promise<Buffer>;
28
28
  close(): Promise<void>;
29
29
  context(): BrowserContext;
30
+ mainFrame(): Frame;
31
+ frames(): Frame[];
32
+ };
33
+ export type Frame = {
34
+ url(): string;
30
35
  };
31
36
  /** A DevTools protocol session: Chromium's own answers, for what the page does not expose. */
32
37
  export type CDPSession = {
@@ -44,10 +49,16 @@ declare module "playwright" {
44
49
  viewport?: Viewport;
45
50
  deviceScaleFactor?: number;
46
51
  offline?: boolean;
52
+ httpCredentials?: { username: string; password: string };
47
53
  }): Promise<BrowserContext>;
54
+ newBrowserCDPSession(): Promise<CDPSession>;
48
55
  close(): Promise<void>;
49
56
  };
50
57
  export const chromium: {
51
- launch(options?: { headless?: boolean; chromiumSandbox?: boolean }): Promise<Browser>;
58
+ launch(options?: {
59
+ headless?: boolean;
60
+ chromiumSandbox?: boolean;
61
+ executablePath?: string;
62
+ }): Promise<Browser>;
52
63
  };
53
64
  }
@@ -1,3 +1,4 @@
1
+ // biome-ignore lint/style/noRestrictedImports: mkdirSync, in a worker, for the folder its parent chose in dek's cache, which Chromium writes into.
1
2
  import { mkdirSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import type { Page } from "playwright";
@@ -1,3 +1,4 @@
1
+ // biome-ignore lint/style/noRestrictedImports: mkdirSync, in a worker, for the folder its parent chose in dek's cache, which Chromium writes into.
1
2
  import { mkdirSync, readFileSync } from "node:fs";
2
3
  import { dirname } from "node:path";
3
4
  import type { Page } from "playwright";
@@ -581,13 +581,18 @@ async function visitPage(
581
581
  * measuring again: first what the slide's script drew, then the slide's own CSS. A text that
582
582
  * clears its threshold, or an element that no longer crosses an edge, once a layer is gone is
583
583
  * that layer's to fix. With both gone, a text that still fails is the theme's, and an element that
584
- * still overflows is its content's: too much for the theme's sizes. The page is not put back.
584
+ * still overflows is its content's: too much for the theme's sizes. A text that shows no glyph
585
+ * once a layer is gone is no longer measured as the audience sees it: one the slide's CSS alone
586
+ * shows is marked so, and one the draw alone shows is no layer's unless it still fails with both
587
+ * gone. The page is not put back.
585
588
  */
586
589
  async function attributeFindings(
587
590
  page: Page,
588
591
  pending: { failing: Failing[]; crossing: Crossing[] },
589
592
  ): Promise<void> {
590
593
  let { failing, crossing } = pending;
594
+ // Texts that showed no glyph the last time they were measured.
595
+ const unseen = new Set<Failing>();
591
596
  for (const layer of LAYERS) {
592
597
  if (failing.length === 0 && crossing.length === 0) {
593
598
  return;
@@ -615,20 +620,32 @@ async function attributeFindings(
615
620
  const texts = await pageTexts(page, measured);
616
621
  const contrasts = await textContrasts(page, texts);
617
622
  const now = new Map(texts.map((text, at) => [text.key, { text, contrast: contrasts[at] }]));
618
- failing = failing.filter(({ finding, key }) => {
619
- const measuredText = now.get(key);
620
- if (
621
- measuredText?.contrast &&
622
- measuredText.contrast.ratio >= contrastThreshold(measuredText.text)
623
- ) {
624
- finding.origin = layer.origin;
623
+ failing = failing.filter((entry) => {
624
+ const measuredText = now.get(entry.key);
625
+ const seen = !unseen.has(entry);
626
+ if (!measuredText?.contrast) {
627
+ if (layer.origin === "slide" && seen) {
628
+ entry.finding.onlyWithSlideCss = true;
629
+ return false;
630
+ }
631
+ unseen.add(entry);
632
+ return true;
633
+ }
634
+ if (measuredText.contrast.ratio >= contrastThreshold(measuredText.text)) {
635
+ // Hidden with the layer before gone, it may owe its low contrast to that layer as well.
636
+ if (seen) {
637
+ entry.finding.origin = layer.origin;
638
+ }
625
639
  return false;
626
640
  }
641
+ unseen.delete(entry);
627
642
  return true;
628
643
  });
629
644
  }
630
- for (const { finding } of failing) {
631
- finding.origin = "theme";
645
+ for (const entry of failing) {
646
+ if (!unseen.has(entry)) {
647
+ entry.finding.origin = "theme";
648
+ }
632
649
  }
633
650
  for (const { finding } of crossing) {
634
651
  finding.origin = "content";
@@ -10,8 +10,9 @@ import { type Collision, EDGES, type Overflow } from "./overflow.ts";
10
10
  import { moduleFilePath } from "./path.ts";
11
11
  import type { PptxFill, PptxLayer, PptxTextBox } from "./pptx-package.ts";
12
12
  import type { PptxPageTheme } from "./pptx-theme.ts";
13
+ import { chromiumFailure, launchFailure, runDrawingWorker, type SandboxMode } from "./sandbox.ts";
13
14
  import type { MotionBeat, SheetSpec } from "./sheet.ts";
14
- import { exitWorker, readWorkerRequest, runJsonWorker, workerCommand } from "./spawn.ts";
15
+ import { exitWorker, readWorkerRequest, workerCommand } from "./spawn.ts";
15
16
  import type { Position } from "./step.ts";
16
17
 
17
18
  /** One slide page to measure; a page that names a `screenshotPath` is shot there too. */
@@ -136,9 +137,15 @@ export type ContrastFinding = {
136
137
  /**
137
138
  * For text below its threshold, which stylesheet draws it so: "theme" when it stays below
138
139
  * with the slide's own CSS taken away, "slide" when that CSS brings it there. Absent when the
139
- * text passes, or shows no glyph without the slide's CSS.
140
+ * text passes, or shows no glyph without the slide's CSS. Absent too when it shows none with the
141
+ * slide script's draw undone and clears without the slide's CSS: either may have drawn it so.
140
142
  */
141
143
  origin?: ContrastOrigin;
144
+ /**
145
+ * For text below its threshold that shows with the slide's own CSS and no glyph without it: the
146
+ * theme alone was never measured, so either stylesheet may hold its colors.
147
+ */
148
+ onlyWithSlideCss?: true;
142
149
  };
143
150
 
144
151
  export type ContrastOrigin = "theme" | "slide" | "script";
@@ -231,6 +238,11 @@ type SpawnTimeoutOptions = {
231
238
  timeoutMs?: number;
232
239
  };
233
240
 
241
+ type RunnerOptions = SpawnTimeoutOptions & {
242
+ /** The pages hold a ref's deck: Chromium's sandbox only, unless the user accepted otherwise. */
243
+ ref?: boolean;
244
+ };
245
+
234
246
  const requireFromDek = createRequire(import.meta.url);
235
247
 
236
248
  export function resolvePlaywrightModule(): string | undefined {
@@ -293,49 +305,85 @@ export async function importPlaywright(): Promise<typeof import("playwright")> {
293
305
  return import(pathToFileURL(spec).href) as Promise<typeof import("playwright")>;
294
306
  }
295
307
 
308
+ /** Playwright's Chromium, or what stands in for it in tests. */
309
+ type Chromium = typeof import("playwright").chromium;
310
+
296
311
  /**
297
312
  * A Playwright worker's whole life: load Playwright, read the request, launch Chromium, answer
298
313
  * with what `handle` returns, and close the browser. Any failure exits with its reason on stderr.
314
+ * Where Chromium's sandbox did not start, the answer says why in `unsandboxed`, and is only that
315
+ * when the request's `sandbox` required it, or with why Chromium did not start without it either
316
+ * in `failed`. `chromium` stands in for Playwright's in tests.
299
317
  */
300
318
  export async function runPlaywrightWorker<Request, Response>(
301
319
  handle: (browser: Browser, request: Request) => Promise<Response>,
320
+ chromium?: Chromium,
302
321
  ): Promise<void> {
303
- let playwright: Awaited<ReturnType<typeof importPlaywright>>;
322
+ let launcher: Chromium;
304
323
  try {
305
- playwright = await importPlaywright();
324
+ launcher = chromium ?? (await importPlaywright()).chromium;
306
325
  } catch {
307
326
  exitWorker(`playwright not found; ${PLAYWRIGHT_INSTALL}`);
308
327
  }
309
- const request = await readWorkerRequest<Request>();
328
+ const request = await readWorkerRequest<Request & { sandbox?: unknown }>();
310
329
  try {
311
- const browser = await launchChromium(playwright.chromium);
330
+ // Anything but "preferred", or nothing, requires the sandbox.
331
+ const sandbox = request.sandbox === "preferred" ? "preferred" : "required";
332
+ const launched = await launchChromium(launcher, sandbox);
333
+ if (!launched.browser) {
334
+ const { browser: _none, ...said } = launched;
335
+ process.stdout.write(`${JSON.stringify(said)}\n`);
336
+ return;
337
+ }
338
+ const { browser, unsandboxed } = launched;
312
339
  try {
313
340
  const response = await handle(browser, request);
314
- process.stdout.write(`${JSON.stringify(response)}\n`);
341
+ const answer = unsandboxed === undefined ? response : { ...response, unsandboxed };
342
+ process.stdout.write(`${JSON.stringify(answer)}\n`);
315
343
  } finally {
316
344
  await browser.close();
317
345
  }
318
346
  } catch (error) {
319
- exitWorker(error);
347
+ // The parent's hint keeps the start of stderr, and Playwright's opens with a command line.
348
+ exitWorker(chromiumFailure(error) ?? error);
320
349
  }
321
350
  }
322
351
 
352
+ /**
353
+ * Chromium with its sandbox, or without it and why; or none: where the sandbox was required, or
354
+ * where Chromium did not start without it either.
355
+ */
356
+ type Launched =
357
+ | { browser: Browser; unsandboxed?: string }
358
+ | { browser?: never; unsandboxed: string; failed?: string };
359
+
323
360
  /**
324
361
  * Headless Chromium with its sandbox, which Playwright leaves off by default: slide scripts run in
325
- * it, a ref's included. Where the sandbox cannot start, as in a container running as root, it
326
- * runs without one rather than not at all.
362
+ * it, a ref's included. When that launch fails, as in a container running as root, it launches
363
+ * again without one only when `sandbox` is "preferred", and says why in Chromium's words, whether
364
+ * or not they name the sandbox; when that fails too, it says both.
327
365
  */
328
- async function launchChromium(chromium: typeof import("playwright").chromium): Promise<Browser> {
366
+ export async function launchChromium(chromium: Chromium, sandbox: SandboxMode): Promise<Launched> {
367
+ let first: unknown;
329
368
  try {
330
- return await chromium.launch({ headless: true, chromiumSandbox: true });
331
- } catch {
332
- return await chromium.launch({ headless: true });
369
+ return { browser: await chromium.launch({ headless: true, chromiumSandbox: true }) };
370
+ } catch (error) {
371
+ first = error;
372
+ }
373
+ const unsandboxed = launchFailure(first);
374
+ if (sandbox !== "preferred") {
375
+ return { unsandboxed };
376
+ }
377
+ try {
378
+ return { browser: await chromium.launch({ headless: true }), unsandboxed };
379
+ } catch (second) {
380
+ return { unsandboxed, failed: launchFailure(second) };
333
381
  }
334
382
  }
335
383
 
336
384
  export async function defaultPlaywrightRunner(
337
385
  request: VisualRequest,
338
- options: SpawnTimeoutOptions = {},
386
+ options: RunnerOptions = {},
339
387
  ): Promise<VisualResponse | null> {
340
388
  if (!playwrightResolved()) {
341
389
  return null;
@@ -343,6 +391,14 @@ export async function defaultPlaywrightRunner(
343
391
  return spawnRunner(workerCommand(process.env.DEK_PLAYWRIGHT || workerPath()), request, options);
344
392
  }
345
393
 
394
+ /**
395
+ * The default runner for a ref's pages: someone else's slide scripts, which Chromium runs only in
396
+ * its sandbox unless the user set DEK_ALLOW_UNSANDBOXED=1 (ADR 29).
397
+ */
398
+ export function refPlaywrightRunner(request: VisualRequest): Promise<VisualResponse | null> {
399
+ return defaultPlaywrightRunner(request, { ref: true });
400
+ }
401
+
346
402
  function workerPath(): string {
347
403
  return moduleFilePath(new URL("./playwright-worker.ts", import.meta.url));
348
404
  }
@@ -350,12 +406,13 @@ function workerPath(): string {
350
406
  function spawnRunner(
351
407
  cmd: string[],
352
408
  request: VisualRequest,
353
- options: SpawnTimeoutOptions,
409
+ options: RunnerOptions,
354
410
  ): Promise<VisualResponse> {
355
- return runJsonWorker(cmd, request, (text) => parseVisualResponse(text, request.kind), {
411
+ return runDrawingWorker(cmd, request, (text) => parseVisualResponse(text, request.kind), {
356
412
  label: "Playwright worker failed",
357
413
  hint: `${PLAYWRIGHT_INSTALL} or check DEK_PLAYWRIGHT`,
358
414
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
415
+ ...(options.ref ? { ref: true } : {}),
359
416
  });
360
417
  }
361
418
 
@@ -524,14 +581,15 @@ function isContrastFinding(value: unknown): value is ContrastFinding {
524
581
  if (!isFinding(value)) {
525
582
  return false;
526
583
  }
527
- const { ratio, fg, bg, fontSize, fontWeight, origin } = value;
584
+ const { ratio, fg, bg, fontSize, fontWeight, origin, onlyWithSlideCss } = value;
528
585
  return (
529
586
  isNumber(ratio) &&
530
587
  isString(fg) &&
531
588
  isString(bg) &&
532
589
  isNumber(fontSize) &&
533
590
  isNumber(fontWeight) &&
534
- (origin === undefined || origin === "theme" || origin === "slide" || origin === "script")
591
+ (origin === undefined || origin === "theme" || origin === "slide" || origin === "script") &&
592
+ (onlyWithSlideCss === undefined || onlyWithSlideCss === true)
535
593
  );
536
594
  }
537
595
 
package/src/core/pptx.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // biome-ignore lint/style/noRestrictedImports: mkdtempSync and rmSync, for a folder of the shots' own in the system's temp folder, removed after.
1
2
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
@@ -1,4 +1,5 @@
1
1
  import { DekError } from "./error.ts";
2
+ import { holdsHidden } from "./foreign-text.ts";
2
3
 
3
4
  /**
4
5
  * A ref is someone else's deck, kept as a read-only snapshot to read as a
@@ -50,7 +51,7 @@ const DIGEST_RE = /^[0-9a-f]{64}$/;
50
51
 
51
52
  /** What a ref's deck name may hold, as a hint says it. */
52
53
  export const REF_DECK_NAME_RULE =
53
- "a ref names its deck with up to 64 letters, digits, dots, dashes, and underscores, not first a dash, each accented letter one character (NFC)";
54
+ "a ref names its deck with up to 64 letters, digits, dots, dashes, and underscores, none a person cannot see, not first a dash, each accented letter one character (NFC)";
54
55
 
55
56
  const REF_HINT = `name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1, or a pack's as file/<deck>; ${REF_DECK_NAME_RULE}`;
56
57
 
@@ -91,8 +92,10 @@ export function isPinFor(source: RefSource, pin: string): boolean {
91
92
  }
92
93
 
93
94
  /**
94
- * True for a deck name a ref can carry: see DECK_RE; not . or ..; and composed (NFC), since a
95
- * file system that ignores the form would give two spellings of one name one folder.
95
+ * True for a deck name a ref can carry: see DECK_RE; not . or ..; composed (NFC), since a file
96
+ * system that ignores the form would give two spellings of one name one folder; and with no
97
+ * character a person cannot see. A pack's author chooses it, and dek prints it as the ref's name,
98
+ * as in AGENTS.md.
96
99
  */
97
100
  export function isRefDeckName(deck: string | undefined): deck is string {
98
101
  return (
@@ -100,6 +103,8 @@ export function isRefDeckName(deck: string | undefined): deck is string {
100
103
  DECK_RE.test(deck) &&
101
104
  deck === deck.normalize("NFC") &&
102
105
  Buffer.byteLength(deck) <= DECK_MAX_BYTES &&
106
+ // DECK_RE takes no control character; a letter or a mark can still be one a person cannot see.
107
+ !holdsHidden(deck) &&
103
108
  deck !== "." &&
104
109
  deck !== ".."
105
110
  );