@hajimism/dek 0.3.2 → 0.4.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 (77) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +2 -1
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +84 -14
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +54 -13
  11. package/src/cli/contract.ts +18 -1
  12. package/src/cli/files.ts +9 -6
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/goto.ts +3 -1
  15. package/src/cli/init.ts +10 -5
  16. package/src/cli/lint.ts +4 -3
  17. package/src/cli/main.ts +7 -5
  18. package/src/cli/new.ts +41 -10
  19. package/src/cli/pack.ts +21 -0
  20. package/src/cli/pptx.ts +9 -4
  21. package/src/cli/ref.ts +243 -9
  22. package/src/cli/result.ts +5 -2
  23. package/src/cli/scope.ts +25 -27
  24. package/src/cli/shot.ts +2 -1
  25. package/src/cli/text.ts +45 -4
  26. package/src/cli/theme.ts +16 -2
  27. package/src/cli/voice.ts +4 -3
  28. package/src/core/alignment.ts +320 -0
  29. package/src/core/annotation-rows.ts +16 -4
  30. package/src/core/annotations.ts +19 -1
  31. package/src/core/config-keys.ts +6 -2
  32. package/src/core/config.ts +88 -12
  33. package/src/core/diagnostic.ts +5 -1
  34. package/src/core/escape.ts +43 -0
  35. package/src/core/licenses/CC-BY-4.0.txt +396 -0
  36. package/src/core/licenses/CC0-1.0.txt +121 -0
  37. package/src/core/licenses.ts +20 -0
  38. package/src/core/line-break.ts +275 -0
  39. package/src/core/lint/asset-refs.ts +6 -2
  40. package/src/core/lint/files.ts +30 -5
  41. package/src/core/lint/keys.ts +7 -3
  42. package/src/core/lint/slide-html.ts +3 -1
  43. package/src/core/lint/slide-script.ts +7 -5
  44. package/src/core/mv.ts +30 -10
  45. package/src/core/pack.ts +137 -0
  46. package/src/core/playwright-module.d.ts +1 -0
  47. package/src/core/playwright-visual.ts +180 -20
  48. package/src/core/playwright.ts +202 -25
  49. package/src/core/pptx-package.ts +461 -33
  50. package/src/core/pptx-paint.ts +1168 -0
  51. package/src/core/pptx-plan.ts +879 -0
  52. package/src/core/pptx-text.ts +204 -47
  53. package/src/core/pptx-theme.ts +222 -0
  54. package/src/core/pptx.ts +55 -8
  55. package/src/core/prose.ts +23 -0
  56. package/src/core/pseudo-text.ts +32 -10
  57. package/src/core/ref-name.ts +100 -17
  58. package/src/core/ref.ts +318 -34
  59. package/src/core/resolve.ts +20 -9
  60. package/src/core/shot/morph.ts +1 -1
  61. package/src/core/shot/motion.ts +2 -3
  62. package/src/core/shot/still.ts +2 -2
  63. package/src/core/shot-cache.ts +25 -17
  64. package/src/core/slide-measure.ts +51 -1
  65. package/src/core/slide-script-eval.ts +10 -8
  66. package/src/core/slide-script.ts +114 -20
  67. package/src/core/sync.ts +3 -3
  68. package/src/core/visual-cache.ts +2 -0
  69. package/src/core/visual.ts +154 -15
  70. package/src/core/voice.ts +3 -5
  71. package/src/core/zod.ts +9 -1
  72. package/src/runtime/annotate-view.ts +4 -2
  73. package/src/server/dev.ts +15 -4
  74. package/src/server/lock.ts +48 -5
  75. package/src/server/routes.ts +7 -2
  76. package/src/theme/default.css +4 -0
  77. package/src/video/bake.ts +6 -2
@@ -3,6 +3,7 @@ import { readdirSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { escapeRegExp } from "./escape.ts";
5
5
  import { cacheDir, deckProjectRoot } from "./path.ts";
6
+ import type { ProjectDeck } from "./resolve.ts";
6
7
  import { isCachedFile, outputDir } from "./safe-fs.ts";
7
8
  import type { Section } from "./schema.ts";
8
9
  import { lastStop, stepKey } from "./step.ts";
@@ -26,12 +27,22 @@ export type CacheEntry = {
26
27
  /** A cached folder of files, such as a set of contact sheets. */
27
28
  type CacheBundle = {
28
29
  dir: string;
30
+ /** The project the folder is in, for what a capture writes inside it. */
31
+ root: string;
29
32
  commit(): void;
30
33
  };
31
34
 
32
- /** `.cache/shots`, made if missing, where it really is; a link out of the project is an error. */
33
- function shotsDir(deckDir: string): string {
34
- return outputDir(cacheDir(deckDir, "shots"), deckProjectRoot(deckDir));
35
+ /** The deck a capture is of: its folder, and for a ref, where its cache is instead. */
36
+ type ShotDeck = Pick<ProjectDeck, "dir" | "cache">;
37
+
38
+ /**
39
+ * `.cache/shots`, made if missing, where it really is, and the project it is in; a link out of
40
+ * the project is an error. A ref's shots go in the cache of the project that reads it.
41
+ */
42
+ function shotsDir(deck: ShotDeck): { dir: string; root: string } {
43
+ const root = deck.cache?.root ?? deckProjectRoot(deck.dir);
44
+ const dir = deck.cache ? join(deck.cache.dir, "shots") : cacheDir(deck.dir, "shots");
45
+ return { dir: outputDir(dir, root), root };
35
46
  }
36
47
 
37
48
  function contentHash(text: string): string {
@@ -52,7 +63,7 @@ export function shotName(parts: string[], content: string): string {
52
63
  * `--step 2` and a still at the last beat, `dekc build`'s link preview included, are one file.
53
64
  */
54
65
  export function stillEntry(
55
- deckDir: string,
66
+ deck: ShotDeck,
56
67
  section: Section,
57
68
  beatIndex: number,
58
69
  html: string,
@@ -64,18 +75,18 @@ export function stillEntry(
64
75
  `${section.slug}-${step}`,
65
76
  `${section.slug}-${beatIndex}`,
66
77
  ];
67
- return fileEntry(deckDir, [section.slug, step], html, legacy);
78
+ return fileEntry(deck, [section.slug, step], html, legacy);
68
79
  }
69
80
 
70
81
  /** The frame `at` of the move from `from` into `to`. */
71
82
  export function morphEntry(
72
- deckDir: string,
83
+ deck: ShotDeck,
73
84
  from: string,
74
85
  to: string,
75
86
  at: number,
76
87
  html: string,
77
88
  ): CacheEntry {
78
- return fileEntry(deckDir, [from, to, String(at)], `${at}\0${html}`, [`${from}-to-${to}-${at}`]);
89
+ return fileEntry(deck, [from, to, String(at)], `${at}\0${html}`, [`${from}-to-${to}-${at}`]);
79
90
  }
80
91
 
81
92
  /**
@@ -83,29 +94,26 @@ export function morphEntry(
83
94
  * folder, so runs of different captures neither evict nor race each other.
84
95
  */
85
96
  export function bundleEntry(
86
- deckDir: string,
97
+ deck: ShotDeck,
87
98
  kind: "sheets" | "motion",
88
99
  parts: string[],
89
100
  content: string,
90
101
  ): CacheBundle {
91
- const root = deckProjectRoot(deckDir);
92
- const parent = outputDir(join(shotsDir(deckDir), kind), root);
102
+ const shots = shotsDir(deck);
103
+ const { root } = shots;
104
+ const parent = outputDir(join(shots.dir, kind), root);
93
105
  const base = parts.join(SEPARATOR);
94
106
  const name = base ? `${base}.${contentHash(content)}` : contentHash(content);
95
107
  const pattern = new RegExp(`^${base ? `${escapeRegExp(base)}\\.` : ""}${HASH}$`);
96
108
  return {
97
109
  dir: outputDir(join(parent, name), root),
110
+ root,
98
111
  commit: () => prune(parent, [pattern], name, { recursive: true }),
99
112
  };
100
113
  }
101
114
 
102
- function fileEntry(
103
- deckDir: string,
104
- parts: string[],
105
- content: string,
106
- legacy: string[],
107
- ): CacheEntry {
108
- const dir = shotsDir(deckDir);
115
+ function fileEntry(deck: ShotDeck, parts: string[], content: string, legacy: string[]): CacheEntry {
116
+ const { dir } = shotsDir(deck);
109
117
  const name = shotName(parts, content);
110
118
  const path = join(dir, name);
111
119
  const patterns = [
@@ -45,6 +45,22 @@ export type MeasuredElement = {
45
45
  picture: boolean;
46
46
  /** Whether it paints its own box: a background, or a border in a color that shows. */
47
47
  paints: boolean;
48
+ /**
49
+ * Whether it is laid out on a line of text, as `<strong>` or an inline-block chip is, rather
50
+ * than as a box of its own: it sits on the line's baseline.
51
+ */
52
+ inline: boolean;
53
+ /**
54
+ * Whether a transform turns or skews it, its own or an ancestor's: its box is then the box
55
+ * around its shape, not the shape the audience sees.
56
+ */
57
+ tilted: boolean;
58
+ /**
59
+ * Where the first line of its own text sits, in viewport pixels from the top: the baseline of
60
+ * its first text node's first line, which is not its first line when an inline element such as
61
+ * `<strong>` opens it.
62
+ */
63
+ baseline?: number;
48
64
  /**
49
65
  * What an ancestor inside the slide cuts it to, with `overflow` other than `visible`: the
50
66
  * nearest such ancestor, and the box every one of them leaves visible. `intended` when the cut is
@@ -196,6 +212,35 @@ export function measureSlideInPage(): SlideMeasure {
196
212
  .map(toBox);
197
213
  });
198
214
 
215
+ // Turned or skewed, by its own transform or rotate, or an ancestor's.
216
+ const tilts = new Map<Element, boolean>();
217
+ const tilted = (el: Element | null): boolean => {
218
+ if (!el || el === slide) {
219
+ return false;
220
+ }
221
+ const known = tilts.get(el);
222
+ if (known !== undefined) {
223
+ return known;
224
+ }
225
+ const style = getComputedStyle(el);
226
+ const matrix = style.transform === "none" ? undefined : new DOMMatrixReadOnly(style.transform);
227
+ const own =
228
+ (matrix !== undefined && (matrix.b !== 0 || matrix.c !== 0)) ||
229
+ (style.rotate !== "none" && style.rotate !== "");
230
+ const found = own || tilted(el.parentElement);
231
+ tilts.set(el, found);
232
+ return found;
233
+ };
234
+ // A line's baseline sits the font's ascent below the top of the box its text is drawn in.
235
+ const metrics = document.createElement("canvas").getContext("2d");
236
+ const baselineOf = (style: CSSStyleDeclaration, line: Box | undefined): number | undefined => {
237
+ if (!line || !metrics) {
238
+ return undefined;
239
+ }
240
+ metrics.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
241
+ return line.top + metrics.measureText("x").fontBoundingBoxAscent;
242
+ };
243
+
199
244
  const all = [...slide.querySelectorAll("*")];
200
245
  const indexOf = new Map(all.map((el, index) => [el, index]));
201
246
  const elements = all.map((el): MeasuredElement => {
@@ -209,19 +254,24 @@ export function measureSlideInPage(): SlideMeasure {
209
254
  const whole = el instanceof HTMLElement ? el.innerText : el.textContent;
210
255
  const text = own || squash(whole ?? "");
211
256
  const parentEl = el.parentElement;
257
+ const textRects = lineBoxes(textNodes);
258
+ const baseline = baselineOf(style, textRects[0]);
212
259
  return {
213
260
  box: describe(el),
214
261
  parent: parentEl ? (indexOf.get(parentEl) ?? -1) : -1,
215
262
  rect: extent(el, textNodes),
216
263
  ...(text ? { text } : {}),
217
264
  ownText: own !== "",
218
- textRects: lineBoxes(textNodes),
265
+ textRects,
266
+ ...(baseline !== undefined ? { baseline } : {}),
219
267
  opacity: opacityOf(el),
220
268
  fontSize: Number.parseFloat(style.fontSize),
221
269
  fontWeight: Number(style.fontWeight) || 400,
222
270
  decorative: el.closest('[aria-hidden="true"]') !== null,
223
271
  picture: pictures.has(el.tagName.toLowerCase()) || style.backgroundImage.includes("url("),
224
272
  paints: paints(style),
273
+ inline: style.display.startsWith("inline"),
274
+ tilted: tilted(el),
225
275
  ...clipped(clipOf(el)),
226
276
  };
227
277
  });
@@ -6,6 +6,7 @@ import {
6
6
  lineMatching,
7
7
  type SlideScriptProblem,
8
8
  staticProblems,
9
+ withoutImports,
9
10
  } from "./slide-script.ts";
10
11
  import { BUN_FLAGS } from "./spawn.ts";
11
12
  import { suggest } from "./suggest.ts";
@@ -77,13 +78,6 @@ type PendingScript = {
77
78
  withoutImports: boolean;
78
79
  };
79
80
 
80
- const IMPORT_RE = /^[ \t]*import\s+(?:type\s+)?(?:[\s\S]*?\sfrom\s*)?["'][^"'\n]+["'][ \t]*;?/gm;
81
-
82
- /** The code with its import declarations blanked out, lines kept in place. */
83
- function withoutImports(code: string): string {
84
- return code.replace(IMPORT_RE, (match) => match.replace(/[^\n]/g, " "));
85
- }
86
-
87
81
  function prepareScripts(scripts: ScriptInput[]): {
88
82
  results: SlideScriptProblem[][];
89
83
  pending: PendingScript[];
@@ -97,7 +91,15 @@ function prepareScripts(scripts: ScriptInput[]): {
97
91
  const source = importsOnly ? withoutImports(script.code) : script.code;
98
92
  const compiled =
99
93
  problems.length > 0 && !importsOnly ? undefined : compileSlideScript(source, "slide");
100
- if (compiled && "problem" in compiled) {
94
+ // The blanked code can fail for the blanking's sake: an import it left is refused again, and
95
+ // already reported, and a syntax error is its own, since the script as written parsed.
96
+ const blanking =
97
+ importsOnly &&
98
+ compiled &&
99
+ "problem" in compiled &&
100
+ (compiled.problem.message === IMPORTS_MESSAGE ||
101
+ compiled.problem.message.startsWith("syntax error: "));
102
+ if (compiled && "problem" in compiled && !blanking) {
101
103
  problems.push(compiled.problem);
102
104
  }
103
105
  results.push(problems);
@@ -4,8 +4,35 @@ import { escapeRegExp } from "./escape.ts";
4
4
  import { listSlideFiles } from "./resolve.ts";
5
5
  import { stillDrawScript } from "./slide-draw.ts";
6
6
 
7
- /** Slide scripts are `slides/<slug>.ts`; plain JavaScript is valid TypeScript. */
8
- const transpiler = new Bun.Transpiler({ loader: "ts" });
7
+ /** Thrown in place of reading a script that imports; see `transpiler`. */
8
+ class ImportsRefused extends Error {}
9
+
10
+ /** A first `#!` line, a comment that `scanImports` rejects; Bun ends it at any line terminator. */
11
+ const HASHBANG_RE = /^#![^\n\r\u2028\u2029]*/;
12
+
13
+ /**
14
+ * Bun's transpiler, which nothing else in dek holds. Bun's own `scan` and `transformSync` run a
15
+ * macro, an import marked `type: "macro"`, in this process while they read the code, so a ref's
16
+ * slide script or a cloned project's would run inside dek. `scanImports` runs none: both methods
17
+ * here ask it first, and throw `ImportsRefused` for code that imports anything.
18
+ * Slide scripts are `slides/<slug>.ts`; plain JavaScript is valid TypeScript.
19
+ */
20
+ const transpiler = (() => {
21
+ const bun = new Bun.Transpiler({ loader: "ts" });
22
+ /** The code to read, once it imports nothing; a syntax error throws as Bun reports it. */
23
+ function importFree(code: string): string {
24
+ const source = code.replace(HASHBANG_RE, "");
25
+ // A `require(…)` calls a function a page does not have, and Bun reads nothing for it.
26
+ if (bun.scanImports(source).some((found) => found.kind !== "require-call")) {
27
+ throw new ImportsRefused();
28
+ }
29
+ return source;
30
+ }
31
+ return {
32
+ scan: (code: string) => bun.scan(importFree(code)),
33
+ transformSync: (code: string) => bun.transformSync(importFree(code)),
34
+ };
35
+ })();
9
36
 
10
37
  const DEFAULT_EXPORT_RE = /\bexport\s+default\b/;
11
38
  /** In transpiled output, comments are gone and each statement starts a line. */
@@ -35,30 +62,94 @@ function withLine(problem: SlideScriptProblem, line: number | undefined): SlideS
35
62
  return line === undefined ? problem : { ...problem, line };
36
63
  }
37
64
 
65
+ function importsProblem(code: string): SlideScriptProblem {
66
+ return withLine(
67
+ {
68
+ message: IMPORTS_MESSAGE,
69
+ hint: "remove the import and write what it gave in this file; DekSlide is global, from .dek/slide.d.ts",
70
+ },
71
+ lineMatching(code, /^\s*import\b/),
72
+ );
73
+ }
74
+
75
+ /** A string literal, which can hold a quote, a brace, or a semicolon. */
76
+ const STRING = String.raw`"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'`;
77
+ /**
78
+ * A name's character. Each part below takes only what it can hold, and none starts with a space
79
+ * where the part before it ends with any, so no text is read more than once from each place.
80
+ */
81
+ const NAME = String.raw`[\p{ID_Continue}$]`;
82
+ /** A specifier, then `with { … }` or the older `assert { … }`, to the end of the statement. */
83
+ const SOURCE = String.raw`(?:${STRING})(?:\s*(?:with|assert)\s*\{(?:${NAME}|[\s,:]|${STRING})*\})?[ \t]*;?`;
84
+ /** Where a statement can start: a line's start, or after a statement or a comment on its line. */
85
+ const STATEMENT = String.raw`(?<=^|[;}]|\*\/)[ \t]*`;
86
+ /** What an import declaration binds, up to its `from`, which no other import comes before. */
87
+ const BINDINGS = String.raw`(?:(?!\bimport\b)(?:${NAME}|[\s,*{}])|${STRING})*?\bfrom\s*`;
88
+ /** An `export { … }` clause, or the name of an `export * as`, captured. */
89
+ const EXPORT_NAMES = String.raw`export\s*(?:\{((?:${NAME}|[\s,]|${STRING})*)\}|\*\s*as\s+(${NAME}+|${STRING}))`;
90
+
91
+ /**
92
+ * What `scanImports` counts as an import, as the text shows it: an import declaration, an
93
+ * `export … from`, and the `import` of an `import(…)`.
94
+ */
95
+ const IMPORT_RE = new RegExp(
96
+ String.raw`${STATEMENT}(?:import(?!${NAME})\s*(?:type\b\s*)?(?:(?=\S)${BINDINGS})?|(?:${EXPORT_NAMES}|export\s*\*)\s*from\s*)${SOURCE}|(?<!${NAME}|\.)import(?=\s*\()`,
97
+ "gmu",
98
+ );
99
+ const EXPORT_NAMES_RE = new RegExp(STATEMENT + EXPORT_NAMES, "gmu");
100
+
101
+ /** The code with its imports blanked out, attributes included, lines kept in place. */
102
+ export function withoutImports(code: string): string {
103
+ return code.replace(IMPORT_RE, (match) => match.replace(/[^\n]/g, " "));
104
+ }
105
+
106
+ /**
107
+ * The names `export { … }` and `export * as` give the module: what follows `as`, or the name
108
+ * itself. Bun leaves out of a scan one whose binding, or whose `from`, the blanking took.
109
+ */
110
+ function exportedNames(code: string): string[] {
111
+ return [...code.matchAll(EXPORT_NAMES_RE)].flatMap(([, clause = "", star]) =>
112
+ (star === undefined ? clause.split(",") : [star])
113
+ .map((item) => item.trim())
114
+ .filter((item) => item !== "" && !/^type\s+(?!as\b)/.test(item))
115
+ .map((item) => (item.split(/\s+as\s+/).at(-1) ?? item).replace(/^(["'])(.*)\1$/, "$2")),
116
+ );
117
+ }
118
+
38
119
  /**
39
120
  * What stops a script from being one self-contained module whose only export is the default
40
121
  * object, found without running it.
41
122
  */
42
123
  export function staticProblems(code: string): SlideScriptProblem[] {
43
- let scan: ReturnType<Bun.Transpiler["scan"]>;
124
+ const problems: SlideScriptProblem[] = [];
125
+ let exports: string[] | undefined;
44
126
  try {
45
- scan = transpiler.scan(code);
127
+ const scan = transpiler.scan(code);
128
+ // `require.resolve(…)` is the import scanImports does not report; nothing runs for it.
129
+ if (scan.imports.length > 0) {
130
+ problems.push(importsProblem(code));
131
+ }
132
+ exports = scan.exports;
46
133
  } catch (error) {
47
- return [syntaxProblem(error)];
48
- }
49
- const problems: SlideScriptProblem[] = [];
50
- if (scan.imports.length > 0) {
51
- problems.push(
52
- withLine(
53
- {
54
- message: IMPORTS_MESSAGE,
55
- hint: "remove the import and write what it gave in this file; DekSlide is global, from .dek/slide.d.ts",
56
- },
57
- lineMatching(code, /^\s*import\b/),
58
- ),
59
- );
134
+ if (!(error instanceof ImportsRefused)) {
135
+ return [syntaxProblem(error)];
136
+ }
137
+ // Imports alone do not stop the rest of the checks, which read the script without them.
138
+ problems.push(importsProblem(code));
139
+ const blanked = withoutImports(code);
140
+ try {
141
+ // In Bun's order, which sorts the names.
142
+ exports = [...new Set([...transpiler.scan(blanked).exports, ...exportedNames(code)])].sort();
143
+ } catch (blankedError) {
144
+ // An error on a line the blanking left alone is the script's own, and all Bun said before.
145
+ const own = blankedError instanceof ImportsRefused ? undefined : syntaxProblem(blankedError);
146
+ const at = own?.line === undefined ? undefined : own.line - 1;
147
+ if (own && at !== undefined && code.split("\n")[at] === blanked.split("\n")[at]) {
148
+ return [own];
149
+ }
150
+ }
60
151
  }
61
- for (const name of scan.exports.filter((entry) => entry !== "default")) {
152
+ for (const name of (exports ?? []).filter((entry) => entry !== "default")) {
62
153
  problems.push(
63
154
  withLine(
64
155
  {
@@ -72,7 +163,8 @@ export function staticProblems(code: string): SlideScriptProblem[] {
72
163
  ),
73
164
  );
74
165
  }
75
- if (!scan.exports.includes("default") || !DEFAULT_EXPORT_RE.test(code)) {
166
+ // Exports the blanking left unreadable leave the source to say whether one is the default.
167
+ if (!(exports?.includes("default") ?? true) || !DEFAULT_EXPORT_RE.test(code)) {
76
168
  problems.push({
77
169
  message: "missing export default",
78
170
  hint: "end the script with export default { draw(slide, { t }) {} } satisfies DekSlide",
@@ -116,7 +208,9 @@ export function compileSlideScript(
116
208
  try {
117
209
  js = transpiler.transformSync(code);
118
210
  } catch (error) {
119
- return { problem: syntaxProblem(error) };
211
+ return {
212
+ problem: error instanceof ImportsRefused ? importsProblem(code) : syntaxProblem(error),
213
+ };
120
214
  }
121
215
  let firstError: unknown;
122
216
  for (const match of js.matchAll(EXPORT_DEFAULT_LINE_RE)) {
package/src/core/sync.ts CHANGED
@@ -132,7 +132,7 @@ export function syncDeck(input: string | ResolvedDeck): SyncResult {
132
132
  const skeleton = sectionSkeleton(deck.deck, index, layouts);
133
133
  const current = existing.get(section.slug);
134
134
  if (current) {
135
- const html = readFileSync(current, "utf8");
135
+ const html = readSourceIfExists(current, project.root) ?? "";
136
136
  if (!record.owns(html, skeleton)) {
137
137
  continue;
138
138
  }
@@ -170,7 +170,7 @@ function removeOrphanSkeletons(
170
170
  const removed: string[] = [];
171
171
  const kept: string[] = [];
172
172
  for (const [slug, path] of orphans) {
173
- const html = readFileSync(path, "utf8");
173
+ const html = readSourceIfExists(path, root) ?? "";
174
174
  const beside = ([...SLIDE_SIDECARS, ".js"] as const).some((ext) =>
175
175
  existsSync(paths.slide(slug, ext)),
176
176
  );
@@ -336,7 +336,7 @@ function referencesSection(root: string): string {
336
336
  return `
337
337
  ## References
338
338
 
339
- Other people's decks, pinned in \`dek.toml\` \`[refs]\`, to read as models. They are read-only: read a slide with \`dekc show <ref> <slug>\`, then write your own in this deck's theme.
339
+ Other people's decks, pinned in \`dek.toml\` \`[refs]\`, to read as models. They are read-only. To build a deck on a ref's layouts, start it with \`dekc new <name> --theme-from <ref>\`, which copies the ref's theme into it. Otherwise read a slide with \`dekc show <ref> <slug>\`, then write your own in this deck's theme.
340
340
 
341
341
  ${lines.join("\n")}
342
342
  `;
@@ -19,10 +19,12 @@ export type PageFindings = Required<PagesResponse>;
19
19
  * its source is part of every key, and a cache written by another dek is never read.
20
20
  */
21
21
  const MEASURERS = [
22
+ "alignment.ts",
22
23
  "playwright-visual.ts",
23
24
  "slide-measure.ts",
24
25
  "text-contrast.ts",
25
26
  "fill.ts",
27
+ "line-break.ts",
26
28
  "overflow.ts",
27
29
  "pseudo-text.ts",
28
30
  "finish-beat.ts",
@@ -83,7 +83,7 @@ async function visualDeck(
83
83
  return { diagnostics: [], fills: [] };
84
84
  }
85
85
 
86
- const actions: PageAction[] = ["overflow", "contrast", "fill"];
86
+ const actions: PageAction[] = ["overflow", "contrast", "fill", "breaks", "alignment"];
87
87
  const viewport = logicalSize(deck.deck.ratio);
88
88
  const cache = visualCache(deck.dir, { viewport, actions });
89
89
  const keyed = pages.map((page) => {
@@ -137,6 +137,8 @@ async function visualDeck(
137
137
  contrasts: all.flatMap((page) => page.contrasts),
138
138
  drawErrors: all.flatMap((page) => page.drawErrors),
139
139
  collisions: all.flatMap((page) => page.collisions ?? []),
140
+ lineBreaks: all.flatMap((page) => page.lineBreaks ?? []),
141
+ misalignments: all.flatMap((page) => page.misalignments ?? []),
140
142
  },
141
143
  deck.dir,
142
144
  ),
@@ -147,13 +149,20 @@ async function visualDeck(
147
149
  };
148
150
  }
149
151
 
150
- const NOTHING_FOUND: PageFindings = {
151
- overflows: [],
152
- contrasts: [],
153
- drawErrors: [],
154
- collisions: [],
155
- fills: [],
156
- };
152
+ /** A page's findings before any is found, each list its own to push to. */
153
+ function noFindings(): PageFindings {
154
+ return {
155
+ overflows: [],
156
+ contrasts: [],
157
+ drawErrors: [],
158
+ collisions: [],
159
+ fills: [],
160
+ lineBreaks: [],
161
+ misalignments: [],
162
+ };
163
+ }
164
+
165
+ const NOTHING_FOUND: PageFindings = noFindings();
157
166
 
158
167
  /** A page's findings name its slide and step, which no two pages of one run share. */
159
168
  function pageId(page: { slug: string; step: string }): string {
@@ -165,13 +174,7 @@ function findingsByPage(response: PagesResponse): Map<string, PageFindings> {
165
174
  const pages = new Map<string, PageFindings>();
166
175
  const of = (finding: { slug: string; step: string }): PageFindings => {
167
176
  const id = pageId(finding);
168
- const found = pages.get(id) ?? {
169
- overflows: [],
170
- contrasts: [],
171
- drawErrors: [],
172
- collisions: [],
173
- fills: [],
174
- };
177
+ const found = pages.get(id) ?? noFindings();
175
178
  pages.set(id, found);
176
179
  return found;
177
180
  };
@@ -190,6 +193,12 @@ function findingsByPage(response: PagesResponse): Map<string, PageFindings> {
190
193
  for (const finding of response.fills ?? []) {
191
194
  of(finding).fills.push(finding);
192
195
  }
196
+ for (const finding of response.lineBreaks ?? []) {
197
+ of(finding).lineBreaks.push(finding);
198
+ }
199
+ for (const finding of response.misalignments ?? []) {
200
+ of(finding).misalignments.push(finding);
201
+ }
193
202
  return pages;
194
203
  }
195
204
 
@@ -352,9 +361,139 @@ function visualDiagnostics(response: PagesResponse, deckDir: string): Diagnostic
352
361
  ...groupBySteps(response.collisions ?? [], collisionKey).map((group) =>
353
362
  collisionDiagnostic(group, paths),
354
363
  ),
364
+ ...groupBySteps(response.lineBreaks ?? [], lineBreakKey).map((group) =>
365
+ lineBreakDiagnostic(group, paths),
366
+ ),
367
+ ...groupBySteps(response.misalignments ?? [], misalignmentKey).map((group) =>
368
+ misalignmentDiagnostic(group, paths),
369
+ ),
355
370
  ];
356
371
  }
357
372
 
373
+ type MisalignmentSample = NonNullable<PagesResponse["misalignments"]>[number];
374
+
375
+ function misalignmentKey(m: MisalignmentSample): string {
376
+ return [m.slug, m.box, m.text ?? "", m.other, m.otherText ?? "", m.edge, m.of].join("\0");
377
+ }
378
+
379
+ /** How the message names an edge two items share. */
380
+ const EDGE_NAMES: Record<MisalignmentSample["edge"], string> = {
381
+ top: "tops",
382
+ middle: "middles",
383
+ bottom: "bottoms",
384
+ left: "left edges",
385
+ center: "centers",
386
+ right: "right edges",
387
+ };
388
+
389
+ /**
390
+ * Two items that nearly line up and do not. The fix depends on the edge: a start lines up with
391
+ * the same space before it, an end with one size, a middle with one way of centering, and text in
392
+ * cards that line up with one padding.
393
+ */
394
+ function misalignmentDiagnostic(
395
+ { first, items, steps }: StepGroup<MisalignmentSample>,
396
+ paths: DeckPaths,
397
+ ): Diagnostic {
398
+ // By the most they miss at any step, so the fix covers the worst beat.
399
+ const by = Math.max(...items.map((item) => item.by));
400
+ const pair = `${describeTarget(first.box, first.text)}and ${describeTarget(first.other, first.otherText).trimEnd()}`;
401
+ const where =
402
+ first.of === "text"
403
+ ? `almost start their text ${first.edge === "left" ? "at one left edge" : "on one line"}`
404
+ : `almost line up at their ${EDGE_NAMES[first.edge]}`;
405
+ const css = `slides/${first.slug}.css`;
406
+ const both = `${first.box} and ${first.other}`;
407
+ const why = "an edge a few pixels off reads as a mistake, not a choice";
408
+ const hint =
409
+ first.of === "text"
410
+ ? `give ${both} the same padding in ${css}, so their text starts on one line`
411
+ : first.edge === "top" || first.edge === "left"
412
+ ? `line ${first.box} up with ${first.other} in ${css}, with the same margin, padding, or alignment: ${why}`
413
+ : first.edge === "bottom" || first.edge === "right"
414
+ ? `give ${both} one ${first.edge === "bottom" ? "height" : "width"} in ${css}, with align-items: stretch or the same size: ${why}`
415
+ : `center ${both} on one line in ${css}, with align-items: center or the same margins: ${why}`;
416
+ return diag("DEK036", {
417
+ message: `${pair} ${where}, ${by}px apart, ${atSteps(steps)}`,
418
+ path: paths.slide(first.slug, ".html"),
419
+ slug: first.slug,
420
+ hint,
421
+ data: {
422
+ box: first.box,
423
+ ...(first.text ? { text: first.text } : {}),
424
+ other: first.other,
425
+ ...(first.otherText ? { otherText: first.otherText } : {}),
426
+ edge: first.edge,
427
+ of: first.of,
428
+ by,
429
+ steps,
430
+ },
431
+ });
432
+ }
433
+
434
+ type LineBreakSample = NonNullable<PagesResponse["lineBreaks"]>[number];
435
+
436
+ function lineBreakKey(b: LineBreakSample): string {
437
+ return [b.slug, b.box, b.text ?? "", b.kind, ...b.lines].join("\0");
438
+ }
439
+
440
+ /** Where a line ends: the end of one line and the start of the next, as far as either reads. */
441
+ function breakAt(lines: string[], phrased: string[]): string {
442
+ const differs = lines.findIndex((line, at) => line !== phrased[at]);
443
+ const at = Math.max(0, Math.min(differs, lines.length - 2));
444
+ const end = [...(lines[at] ?? "")];
445
+ const start = [...(lines[at + 1] ?? "")];
446
+ const half = SNIPPET_CHARS / 2;
447
+ const before = end.length > half ? `…${end.slice(-half).join("")}` : end.join("");
448
+ const after = start.length > half ? `${start.slice(0, half).join("")}…` : start.join("");
449
+ return `${before} / ${after}`;
450
+ }
451
+
452
+ /**
453
+ * A line break the audience trips over. One inside a phrase is fixed where Japanese is told to
454
+ * break by phrase: on `.slide` in theme.css, for every slide, unless a rule takes it away from
455
+ * this text. A remnant on a last line is the text's own to fix, or the theme's text-wrap.
456
+ */
457
+ function lineBreakDiagnostic(
458
+ { first, steps }: StepGroup<LineBreakSample>,
459
+ paths: DeckPaths,
460
+ ): Diagnostic {
461
+ const target = describeTarget(first.box, first.text);
462
+ const css = `slides/${first.slug}.css`;
463
+ const yourself = "break the line yourself with <br>";
464
+ const common = {
465
+ slug: first.slug,
466
+ data: {
467
+ kind: first.kind,
468
+ box: first.box,
469
+ ...(first.text ? { text: first.text } : {}),
470
+ lines: first.lines,
471
+ ...(first.kind === "split" ? { phrased: first.phrased } : { tail: first.tail }),
472
+ steps,
473
+ },
474
+ };
475
+ if (first.kind === "split") {
476
+ const theme = first.wordBreak !== "auto-phrase";
477
+ return diag("DEK035", {
478
+ message: `${target}breaks a line inside a phrase, at "${breakAt(first.lines, first.phrased)}", ${atSteps(steps)}`,
479
+ path: theme ? paths.theme : paths.slide(first.slug, ".html"),
480
+ hint: theme
481
+ ? `add word-break: auto-phrase to .slide in theme.css, so Japanese breaks between phrases on every slide; or ${yourself} where its sense breaks`
482
+ : `a rule gives it word-break: normal where .slide breaks by phrase: remove it from ${css} or theme.css, or ${yourself} where its sense breaks`,
483
+ ...common,
484
+ });
485
+ }
486
+ const evened = first.textWrap === "pretty" || first.textWrap === "balance";
487
+ return diag("DEK035", {
488
+ message: `${target}leaves "${first.tail}" alone on its last line ${atSteps(steps)}`,
489
+ path: paths.slide(first.slug, ".html"),
490
+ hint: evened
491
+ ? `reword it so its last line holds more, or ${yourself} earlier`
492
+ : `even out its lines with text-wrap: pretty, or balance for a heading, in ${css} or for every slide in theme.css; or reword it, or ${yourself}`,
493
+ ...common,
494
+ });
495
+ }
496
+
358
497
  type CollisionSample = NonNullable<PagesResponse["collisions"]>[number];
359
498
 
360
499
  function collisionKey(c: CollisionSample): string {
package/src/core/voice.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import type { VoiceDict } from "./cue.ts";
@@ -269,10 +269,8 @@ export function parseTimelineJson(text: string, path?: string): Timeline {
269
269
 
270
270
  export function loadCachedTimeline(deckDir: string): Timeline | undefined {
271
271
  const path = voiceCacheFile(deckDir, "timeline.json");
272
- if (!existsSync(path)) {
273
- return undefined;
274
- }
275
- return parseTimelineJson(readFileSync(path, "utf8"), path);
272
+ const source = readDeckFile(deckDir, path);
273
+ return source === undefined ? undefined : parseTimelineJson(source, path);
276
274
  }
277
275
 
278
276
  export function tryLoadCachedTimeline(deckDir: string): Timeline | undefined {