@hajimism/dek 0.3.3 → 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.
- package/CHANGELOG.md +63 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +2 -1
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +84 -14
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +11 -6
- package/src/cli/contract.ts +9 -1
- package/src/cli/files.ts +1 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/goto.ts +3 -1
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +4 -3
- package/src/cli/main.ts +7 -5
- package/src/cli/new.ts +14 -7
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +91 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +17 -28
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +27 -2
- package/src/cli/theme.ts +16 -2
- package/src/cli/voice.ts +4 -3
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/config.ts +61 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +6 -2
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +3 -1
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +30 -10
- package/src/core/pack.ts +2 -2
- package/src/core/playwright-module.d.ts +1 -0
- package/src/core/playwright-visual.ts +180 -20
- package/src/core/playwright.ts +202 -25
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +55 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +47 -8
- package/src/core/ref.ts +172 -16
- package/src/core/resolve.ts +20 -9
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +25 -17
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +10 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/sync.ts +2 -2
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +154 -15
- package/src/core/voice.ts +3 -5
- package/src/core/zod.ts +9 -1
- package/src/server/dev.ts +15 -4
- package/src/server/lock.ts +48 -5
- package/src/server/routes.ts +7 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
package/src/core/shot-cache.ts
CHANGED
|
@@ -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
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
97
|
+
deck: ShotDeck,
|
|
87
98
|
kind: "sheets" | "motion",
|
|
88
99
|
parts: string[],
|
|
89
100
|
content: string,
|
|
90
101
|
): CacheBundle {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
package/src/core/slide-script.ts
CHANGED
|
@@ -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
|
-
/**
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
problems.push(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
);
|
package/src/core/visual-cache.ts
CHANGED
|
@@ -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",
|
package/src/core/visual.ts
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
273
|
-
|
|
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 {
|