@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.
- package/CHANGELOG.md +92 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +8 -7
- package/src/cli/address.ts +59 -15
- package/src/cli/commands.ts +47 -14
- package/src/cli/contract.ts +49 -3
- package/src/cli/files.ts +1 -0
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +41 -10
- package/src/cli/keep-alive.ts +12 -6
- package/src/cli/lint.ts +32 -13
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +48 -13
- package/src/cli/new.ts +14 -1
- package/src/cli/ref.ts +1 -0
- package/src/cli/result.ts +42 -4
- package/src/cli/scope.ts +40 -1
- package/src/cli/shot.ts +77 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +61 -9
- package/src/cli/theme.ts +50 -16
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +11 -6
- package/src/core/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/lint/asset-refs.ts +44 -6
- package/src/core/lint/context.ts +2 -0
- package/src/core/lint/shared-styles.ts +26 -1
- package/src/core/lint/slide-html.ts +21 -18
- package/src/core/lint/theme-css.ts +64 -3
- package/src/core/lint.ts +15 -2
- package/src/core/mv.ts +28 -18
- package/src/core/order.ts +14 -0
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +12 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +27 -10
- package/src/core/playwright.ts +77 -19
- package/src/core/pptx.ts +1 -0
- package/src/core/ref-name.ts +8 -3
- package/src/core/ref.ts +145 -47
- package/src/core/resolve.ts +3 -2
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- package/src/core/shot-cache.ts +1 -0
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +1 -0
- package/src/core/slide-script-eval.ts +2 -0
- package/src/core/spawn.ts +136 -35
- package/src/core/sync.ts +3 -12
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual.ts +28 -9
- package/src/core/voice.ts +2 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +13 -5
- package/src/server/lock.ts +43 -13
- package/src/server/serial.ts +8 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- package/src/voice/engine.ts +81 -18
- package/tsconfig.json +1 -1
package/src/cli/shot.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import { basename, dirname, extname } from "node:path";
|
|
2
|
+
import { deckPaths } from "../core/deck-paths.ts";
|
|
1
3
|
import { DekError } from "../core/error.ts";
|
|
2
|
-
import { shellQuote } from "../core/escape.ts";
|
|
4
|
+
import { shellName, shellQuote } from "../core/escape.ts";
|
|
5
|
+
import { foreignLine, type HiddenText, MESSAGE_MAX } from "../core/foreign-text.ts";
|
|
6
|
+
import { refPlaywrightRunner } from "../core/playwright.ts";
|
|
7
|
+
import { SLIDE_SIDECARS } from "../core/resolve.ts";
|
|
3
8
|
import { parseMorphAt, shotMorph } from "../core/shot/morph.ts";
|
|
4
9
|
import { type ShotMotion, shotMotion } from "../core/shot/motion.ts";
|
|
5
10
|
import { type ShotFile, shotDeck, shotSheet } from "../core/shot/still.ts";
|
|
6
11
|
import { playerScript } from "../runtime/player.ts";
|
|
7
|
-
import type
|
|
12
|
+
import { hiddenField, hiddenInFiles, type ReadableDeck } from "./scope.ts";
|
|
8
13
|
import { usageError } from "./usage.ts";
|
|
9
14
|
|
|
10
15
|
export type ShotCliResult = {
|
|
@@ -13,6 +18,11 @@ export type ShotCliResult = {
|
|
|
13
18
|
sheets?: string[];
|
|
14
19
|
/** With --motion, each beat's frames and the ms into its go each shows. */
|
|
15
20
|
motion?: ShotMotion["beats"];
|
|
21
|
+
/**
|
|
22
|
+
* A ref's lines, in the files of the slides drawn and in its theme, that hold characters a
|
|
23
|
+
* person cannot see.
|
|
24
|
+
*/
|
|
25
|
+
hidden?: HiddenText[];
|
|
16
26
|
};
|
|
17
27
|
|
|
18
28
|
type ShotCommandOptions = {
|
|
@@ -32,13 +42,64 @@ export type ShotMode =
|
|
|
32
42
|
| { kind: "morph"; from: string; to: string; at: number };
|
|
33
43
|
|
|
34
44
|
export async function shotCommand(
|
|
35
|
-
|
|
45
|
+
target: ReadableDeck,
|
|
36
46
|
options: ShotCommandOptions,
|
|
37
47
|
): Promise<ShotCliResult> {
|
|
38
48
|
const mode = parseShotMode(options);
|
|
49
|
+
try {
|
|
50
|
+
const result = await shoot(target, mode);
|
|
51
|
+
return target.ref
|
|
52
|
+
? { ...result, ...hiddenField(target.ref, drawnHidden(target, mode)) }
|
|
53
|
+
: result;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw refShotError(error, target);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What the files of the slides a shot drew, and its theme, hold that a person cannot see. */
|
|
60
|
+
function drawnHidden({ deck }: ReadableDeck, mode: ShotMode): HiddenText[] {
|
|
61
|
+
const all = deck.deck.sections.map((section) => section.slug);
|
|
62
|
+
const slugs =
|
|
63
|
+
mode.kind === "sheet"
|
|
64
|
+
? all
|
|
65
|
+
: mode.kind === "morph"
|
|
66
|
+
? [mode.from, mode.to]
|
|
67
|
+
: mode.slug === undefined
|
|
68
|
+
? all
|
|
69
|
+
: [mode.slug];
|
|
70
|
+
const paths = deckPaths(deck.dir);
|
|
71
|
+
const files = slugs.flatMap((slug) =>
|
|
72
|
+
([".html", ...SLIDE_SIDECARS] as const).map((ext) => paths.slide(slug, ext)),
|
|
73
|
+
);
|
|
74
|
+
return hiddenInFiles(deck.dir, [...new Set(files), paths.theme]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A ref's slide script that does not compile stops its slide's shot, as one of the project's
|
|
79
|
+
* would; Bun's message quotes the ref's source, so it is one foreignLine, and the fix is not lint,
|
|
80
|
+
* which refuses a ref, but its author's.
|
|
81
|
+
*/
|
|
82
|
+
function refShotError(error: unknown, { deck, ref }: ReadableDeck): unknown {
|
|
83
|
+
if (!ref || !(error instanceof DekError) || error.path === undefined) {
|
|
84
|
+
return error;
|
|
85
|
+
}
|
|
86
|
+
const path = error.path;
|
|
87
|
+
if (extname(path) !== ".ts" || dirname(path) !== deckPaths(deck.dir).slides) {
|
|
88
|
+
return error;
|
|
89
|
+
}
|
|
90
|
+
return new DekError(foreignLine(error.message, MESSAGE_MAX), {
|
|
91
|
+
path,
|
|
92
|
+
hint: `a ref's slide scripts are its author's to fix; \`dekc shot ${ref.name} <slug>\` still draws each of its other slides, and \`dekc show ${ref.name} ${shellName(basename(path, ".ts")) ?? "<slug>"}\` prints this one's`,
|
|
93
|
+
cause: error,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function shoot({ project, deck, ref }: ReadableDeck, mode: ShotMode): Promise<ShotCliResult> {
|
|
98
|
+
// A ref's slide scripts are someone else's: Chromium runs them only in its sandbox (ADR 29).
|
|
99
|
+
const runner = ref ? { runner: refPlaywrightRunner } : {};
|
|
39
100
|
switch (mode.kind) {
|
|
40
101
|
case "sheet": {
|
|
41
|
-
const { sheets, shots } = await shotSheet({ project, deck });
|
|
102
|
+
const { sheets, shots } = await shotSheet({ project, deck }, runner);
|
|
42
103
|
return { shots, sheets };
|
|
43
104
|
}
|
|
44
105
|
case "motion": {
|
|
@@ -48,6 +109,7 @@ export async function shotCommand(
|
|
|
48
109
|
slug: mode.slug,
|
|
49
110
|
...(mode.step === undefined ? {} : { step: mode.step }),
|
|
50
111
|
playerScript: await playerScript(),
|
|
112
|
+
...runner,
|
|
51
113
|
},
|
|
52
114
|
);
|
|
53
115
|
return { shots: [], sheets, motion: beats };
|
|
@@ -55,12 +117,21 @@ export async function shotCommand(
|
|
|
55
117
|
case "morph": {
|
|
56
118
|
const shots = await shotMorph(
|
|
57
119
|
{ project, deck },
|
|
58
|
-
{
|
|
120
|
+
{
|
|
121
|
+
from: mode.from,
|
|
122
|
+
to: mode.to,
|
|
123
|
+
at: mode.at,
|
|
124
|
+
playerScript: await playerScript(),
|
|
125
|
+
...runner,
|
|
126
|
+
},
|
|
59
127
|
);
|
|
60
128
|
return { shots };
|
|
61
129
|
}
|
|
62
130
|
case "still": {
|
|
63
|
-
const shots = await shotDeck(
|
|
131
|
+
const shots = await shotDeck(
|
|
132
|
+
{ project, deck },
|
|
133
|
+
{ slug: mode.slug, step: mode.step, ...runner },
|
|
134
|
+
);
|
|
64
135
|
return { shots };
|
|
65
136
|
}
|
|
66
137
|
}
|
package/src/cli/show.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { classifyAssetRef } from "../core/assets.ts";
|
|
2
2
|
import { cssUrls, parseCss } from "../core/css.ts";
|
|
3
3
|
import { deckPaths } from "../core/deck-paths.ts";
|
|
4
|
+
import { type HiddenText, hiddenText } from "../core/foreign-text.ts";
|
|
4
5
|
import { type HtmlScan, scanSlideHtml } from "../core/html-scan.ts";
|
|
5
6
|
import { sectionChunks, splitLines } from "../core/lines.ts";
|
|
6
7
|
import { readDeckFile } from "../core/resolve.ts";
|
|
7
8
|
import type { Section } from "../core/schema.ts";
|
|
8
9
|
import { themeExcerpt } from "../core/theme-excerpt.ts";
|
|
9
|
-
import { type ReadableDeck, type RefInfo, requireSection } from "./scope.ts";
|
|
10
|
+
import { hiddenField, type ReadableDeck, type RefInfo, requireSection } from "./scope.ts";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Everything one slide is made of, read in one call: enough to learn how it is
|
|
@@ -34,6 +35,8 @@ export type ShowResult = {
|
|
|
34
35
|
assets: string[];
|
|
35
36
|
/** Set when the deck is a ref. */
|
|
36
37
|
ref?: RefInfo;
|
|
38
|
+
/** A ref's lines, in the files show read, that hold characters a person cannot see. */
|
|
39
|
+
hidden?: HiddenText[];
|
|
37
40
|
};
|
|
38
41
|
|
|
39
42
|
export function showCommand({ deck, ref }: ReadableDeck, slug: string): ShowResult {
|
|
@@ -64,11 +67,14 @@ export function showCommand({ deck, ref }: ReadableDeck, slug: string): ShowResu
|
|
|
64
67
|
...(scan?.refs.map((ref) => ref.value) ?? []),
|
|
65
68
|
...(sheet ? cssUrls(sheet).map((url) => url.value) : []),
|
|
66
69
|
];
|
|
70
|
+
const script = sectionSource(deck, section);
|
|
71
|
+
const hiddenIn = (path: string, text: string | null) =>
|
|
72
|
+
text === null ? [] : hiddenText(text, path);
|
|
67
73
|
|
|
68
74
|
return {
|
|
69
75
|
slug: section.slug,
|
|
70
76
|
title: section.title,
|
|
71
|
-
script
|
|
77
|
+
script,
|
|
72
78
|
beats: section.beats.map(({ id, title, line }) => ({ ...(id ? { id } : {}), title, line })),
|
|
73
79
|
html,
|
|
74
80
|
css,
|
|
@@ -76,6 +82,13 @@ export function showCommand({ deck, ref }: ReadableDeck, slug: string): ShowResu
|
|
|
76
82
|
theme,
|
|
77
83
|
assets: existingRefs(refs, paths.slides, deck.dir),
|
|
78
84
|
...(ref ? { ref } : {}),
|
|
85
|
+
...hiddenField(ref, [
|
|
86
|
+
...hiddenText(script, deck.scriptPath, section.line),
|
|
87
|
+
...hiddenIn(paths.slide(slug, ".html"), html),
|
|
88
|
+
...hiddenIn(paths.slide(slug, ".css"), css),
|
|
89
|
+
...hiddenIn(paths.slide(slug, ".ts"), ts),
|
|
90
|
+
...hiddenIn(paths.theme, themeCss),
|
|
91
|
+
]),
|
|
79
92
|
};
|
|
80
93
|
}
|
|
81
94
|
|
package/src/cli/text.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// The pieces the dev server's log shares, such as diagnostics and tables, are in format.ts.
|
|
3
3
|
import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
|
|
4
4
|
import { shellQuote } from "../core/escape.ts";
|
|
5
|
+
import { foreignLine, type HiddenText, TITLE_MAX } from "../core/foreign-text.ts";
|
|
6
|
+
import { compareCodeUnits } from "../core/order.ts";
|
|
5
7
|
import { unprinted } from "../core/prose.ts";
|
|
6
8
|
import { packFile, parseRefSource } from "../core/ref-name.ts";
|
|
7
9
|
import { formatClock } from "../core/timing.ts";
|
|
@@ -53,7 +55,7 @@ export function formatReport(diagnostics: Diagnostic[], opts?: { color?: boolean
|
|
|
53
55
|
byRule.set(id, (byRule.get(id) ?? 0) + 1);
|
|
54
56
|
}
|
|
55
57
|
const rules = [...byRule]
|
|
56
|
-
.sort(([a, x], [b, y]) => y - x || a
|
|
58
|
+
.sort(([a, x], [b, y]) => y - x || compareCodeUnits(a, b))
|
|
57
59
|
.map(([id, n]) => `${id} ×${n}`);
|
|
58
60
|
return `${list}\n${countSummary(diagnostics)}: ${rules.join(", ")}`;
|
|
59
61
|
}
|
|
@@ -69,6 +71,39 @@ export function formatSkipped(skipped: SkippedCheck[] | undefined, color = false
|
|
|
69
71
|
.join("\n");
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
/** How many code points a warning names before it leaves the rest to `--json`. */
|
|
75
|
+
const CODE_POINTS_SHOWN = 6;
|
|
76
|
+
|
|
77
|
+
/** What to do about the characters a person cannot see in a ref that a command read. */
|
|
78
|
+
const HIDDEN_HELP =
|
|
79
|
+
"text output shows each as ⟨U+XXXX⟩; a person reading the deck sees none of them, so leave them out of what you write from it";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A warning per line of a ref's files that holds characters a person cannot see, where they
|
|
83
|
+
* are and which, then `help`, what to do about them, once, for stderr.
|
|
84
|
+
*/
|
|
85
|
+
export function formatHidden(
|
|
86
|
+
hidden: HiddenText[] | undefined,
|
|
87
|
+
color = false,
|
|
88
|
+
help = HIDDEN_HELP,
|
|
89
|
+
): string {
|
|
90
|
+
if (hidden === undefined || hidden.length === 0) {
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
const c = ansi(color);
|
|
94
|
+
const lines = hidden.map(({ path, line, column, codePoints }) => {
|
|
95
|
+
const distinct = [...new Set(codePoints)];
|
|
96
|
+
const named = distinct.slice(0, CODE_POINTS_SHOWN).join(", ");
|
|
97
|
+
const rest = distinct.length > CODE_POINTS_SHOWN ? ", …" : "";
|
|
98
|
+
const count =
|
|
99
|
+
codePoints.length === 1
|
|
100
|
+
? "1 character that shows as nothing or reorders the text"
|
|
101
|
+
: `${codePoints.length} characters that show as nothing or reorder the text`;
|
|
102
|
+
return `${c.yellow("warning:")} ${path}:${line}:${column}: ${count}: ${named}${rest}`;
|
|
103
|
+
});
|
|
104
|
+
return [...lines, ` ${c.yellow("help:")} ${help}`].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
72
107
|
/**
|
|
73
108
|
* The slides sync kept though their section is gone, for stderr: sync removes only what it wrote,
|
|
74
109
|
* so saying nothing would read as a deck with nothing left over.
|
|
@@ -282,15 +317,17 @@ export function formatLs(data: LsListResult | LsDeckResult): string {
|
|
|
282
317
|
].join("\n");
|
|
283
318
|
}
|
|
284
319
|
case "deck": {
|
|
320
|
+
// A ref's frontmatter is someone else's: each value keeps to its line, as in AGENTS.md.
|
|
321
|
+
const line = (text: string) => (data.ref ? foreignLine(text, TITLE_MAX) : text);
|
|
285
322
|
const meta: Array<[string, string]> = [];
|
|
286
323
|
if (data.ref) {
|
|
287
324
|
meta.push(["rev", data.ref.rev.slice(0, 7)]);
|
|
288
325
|
}
|
|
289
326
|
if (data.event) {
|
|
290
|
-
meta.push(["event", data.event]);
|
|
327
|
+
meta.push(["event", line(data.event)]);
|
|
291
328
|
}
|
|
292
329
|
if (data.date) {
|
|
293
|
-
meta.push(["date", data.date]);
|
|
330
|
+
meta.push(["date", line(data.date)]);
|
|
294
331
|
}
|
|
295
332
|
if (data.duration) {
|
|
296
333
|
meta.push(["duration", data.duration]);
|
|
@@ -328,7 +365,7 @@ export function formatLs(data: LsListResult | LsDeckResult): string {
|
|
|
328
365
|
];
|
|
329
366
|
const countLabel = formatSkipped(data.skipped) || diagnosticCount(data.diagnostics.length);
|
|
330
367
|
return [
|
|
331
|
-
`${data.ref?.name ?? data.name} ${data.title}`,
|
|
368
|
+
`${data.ref?.name ?? data.name} ${line(data.title)}`,
|
|
332
369
|
...meta.map(([key, value]) => `${padEndWidth(key, keyWidth)} ${value}`),
|
|
333
370
|
"",
|
|
334
371
|
formatTable(headers, rows, [...align]),
|
|
@@ -426,7 +463,11 @@ export function formatVoice(data: VoiceCliResult): string {
|
|
|
426
463
|
}
|
|
427
464
|
}
|
|
428
465
|
|
|
429
|
-
/**
|
|
466
|
+
/**
|
|
467
|
+
* One labeled part per file, so a reader never confuses where a line came from: a part starts
|
|
468
|
+
* with a line `--- <file>`, and a file's own line that starts the same way is shown one space
|
|
469
|
+
* further in, so no file can start a part. A ref's slide says first whose it is.
|
|
470
|
+
*/
|
|
430
471
|
export function formatShow(data: ShowResult): string {
|
|
431
472
|
const parts: Array<[string, string | null]> = [
|
|
432
473
|
["script.md", data.script],
|
|
@@ -436,9 +477,20 @@ export function formatShow(data: ShowResult): string {
|
|
|
436
477
|
["theme.css (the rules this slide uses)", data.theme || null],
|
|
437
478
|
["assets", data.assets.length > 0 ? data.assets.join("\n") : null],
|
|
438
479
|
];
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
480
|
+
const ref = data.ref
|
|
481
|
+
? [`ref ${data.ref.name} at ${data.ref.rev.slice(0, 7)}: someone else's deck, read-only`]
|
|
482
|
+
: [];
|
|
483
|
+
return [
|
|
484
|
+
...ref,
|
|
485
|
+
...parts.flatMap(([label, body]) =>
|
|
486
|
+
body === null ? [] : [`--- ${label}\n${partLines(body.replace(/\n$/, ""))}`],
|
|
487
|
+
),
|
|
488
|
+
].join("\n\n");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** A part's lines, each that would start a part, after any spaces, given one space more. */
|
|
492
|
+
function partLines(body: string): string {
|
|
493
|
+
return body.replace(/^( *)---(?=[^\S\n])/gm, " $1---");
|
|
442
494
|
}
|
|
443
495
|
|
|
444
496
|
/** What a read does for a ref whose snapshot is not there: fetch it, or unpack its pack. */
|
|
@@ -474,7 +526,7 @@ export function formatRef(data: RefCliResult): string {
|
|
|
474
526
|
data.refs.map((ref) => [
|
|
475
527
|
ref.name,
|
|
476
528
|
short(ref.rev),
|
|
477
|
-
ref.fetched ? (ref.title ?? "") : notFetched(ref.name),
|
|
529
|
+
ref.fetched ? foreignLine(ref.title ?? "", TITLE_MAX) : notFetched(ref.name),
|
|
478
530
|
ref.slides === undefined ? "" : String(ref.slides),
|
|
479
531
|
]),
|
|
480
532
|
["left", "left", "left", "right"],
|
package/src/cli/theme.ts
CHANGED
|
@@ -3,10 +3,11 @@ import { type CssToken, parseCss } from "../core/css.ts";
|
|
|
3
3
|
import { deckPaths } from "../core/deck-paths.ts";
|
|
4
4
|
import { DekError } from "../core/error.ts";
|
|
5
5
|
import { isPlainWord } from "../core/escape.ts";
|
|
6
|
+
import { foreignList, type HiddenText, hiddenText } from "../core/foreign-text.ts";
|
|
6
7
|
import { listShown, unprinted } from "../core/prose.ts";
|
|
7
8
|
import { readDeckFile } from "../core/resolve.ts";
|
|
8
9
|
import { type ThemeLayout, themeFacts } from "../core/theme-facts.ts";
|
|
9
|
-
import type
|
|
10
|
+
import { hiddenField, type ReadableDeck, type RefInfo } from "./scope.ts";
|
|
10
11
|
|
|
11
12
|
export type ThemeResult = {
|
|
12
13
|
/** The deck's theme.css: the one lint and the slides use. */
|
|
@@ -25,6 +26,8 @@ export type ThemeResult = {
|
|
|
25
26
|
layout?: { name: string; example: string };
|
|
26
27
|
/** Set when the deck is a ref. */
|
|
27
28
|
ref?: RefInfo;
|
|
29
|
+
/** A ref's lines of theme.css that hold characters a person cannot see. */
|
|
30
|
+
hidden?: HiddenText[];
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
/** The deck's theme, or one layout's example markup when `name` is given. */
|
|
@@ -33,12 +36,11 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
|
|
|
33
36
|
if (!existsSync(path)) {
|
|
34
37
|
throw new DekError("theme.css not found", {
|
|
35
38
|
path,
|
|
36
|
-
hint: "copy the project theme.css into the deck, or run `dekc new <name>` for a fresh deck",
|
|
39
|
+
hint: "copy the project's template, ../../theme.css, into the deck, or run `dekc new <name>` for a fresh deck",
|
|
37
40
|
});
|
|
38
41
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
);
|
|
42
|
+
const css = readDeckFile(deck.dir, path) ?? "";
|
|
43
|
+
const { classes, stateClasses, layouts, tokens } = themeFacts(parseCss(css));
|
|
42
44
|
const printed = layouts.filter((layout) => isPlainWord(layout.name));
|
|
43
45
|
const left = layouts.length - printed.length;
|
|
44
46
|
const result: ThemeResult = {
|
|
@@ -49,28 +51,60 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
|
|
|
49
51
|
layouts: printed,
|
|
50
52
|
...(left > 0 ? { unprintedLayouts: left } : {}),
|
|
51
53
|
...(ref ? { ref } : {}),
|
|
54
|
+
...hiddenField(ref, hiddenText(css, path)),
|
|
52
55
|
};
|
|
53
56
|
if (!name) {
|
|
54
57
|
return result;
|
|
55
58
|
}
|
|
56
59
|
const layout = layouts.find((entry) => entry.name === name);
|
|
57
60
|
if (!layout) {
|
|
58
|
-
throw
|
|
59
|
-
path,
|
|
60
|
-
hint: `use one of: ${
|
|
61
|
-
listShown(
|
|
62
|
-
layouts.map((entry) => entry.name),
|
|
63
|
-
isPlainWord,
|
|
64
|
-
unprinted,
|
|
65
|
-
) || "(none)"
|
|
66
|
-
}`,
|
|
67
|
-
});
|
|
61
|
+
throw layoutNotFound(name, layouts, path, ref);
|
|
68
62
|
}
|
|
69
63
|
if (layout.example === undefined) {
|
|
70
64
|
throw new DekError(`layout "${name}" has no example in theme.css`, {
|
|
71
65
|
path,
|
|
72
|
-
hint:
|
|
66
|
+
hint: ref
|
|
67
|
+
? `a ref is read-only; read the markup of a slide that uses it with \`dekc show ${ref.name} <slug>\``
|
|
68
|
+
: `add a /* @layout ${name} ... */ comment with its markup above .slide[data-layout="${name}"]`,
|
|
73
69
|
});
|
|
74
70
|
}
|
|
75
71
|
return { ...result, layout: { name, example: layout.example } };
|
|
76
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A layout the theme does not define, named among the layouts whose names are plain words. A
|
|
76
|
+
* ref's layout names are its author's, so for a ref they go in the message, as foreignList shows
|
|
77
|
+
* them, and the hint is dek's words.
|
|
78
|
+
*/
|
|
79
|
+
function layoutNotFound(
|
|
80
|
+
name: string,
|
|
81
|
+
layouts: ThemeLayout[],
|
|
82
|
+
path: string,
|
|
83
|
+
ref: RefInfo | undefined,
|
|
84
|
+
): DekError {
|
|
85
|
+
const names = layouts.map((entry) => entry.name);
|
|
86
|
+
if (!ref) {
|
|
87
|
+
return new DekError(`layout "${name}" is not defined in theme.css`, {
|
|
88
|
+
path,
|
|
89
|
+
hint: `use one of: ${listShown(names, isPlainWord, unprinted) || "(none)"}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const shown = names.filter(isPlainWord);
|
|
93
|
+
const left = names.length - shown.length;
|
|
94
|
+
const others = left > 0 ? unprinted(left) : "";
|
|
95
|
+
const defined =
|
|
96
|
+
shown.length === 0
|
|
97
|
+
? others
|
|
98
|
+
: others
|
|
99
|
+
? `${foreignList(shown)}, and ${others}`
|
|
100
|
+
: foreignList(shown);
|
|
101
|
+
return shown.length > 0
|
|
102
|
+
? new DekError(`layout "${name}" is not defined in theme.css, which defines ${defined}`, {
|
|
103
|
+
path,
|
|
104
|
+
hint: `pass one of them: \`dekc theme ${ref.name} <layout>\``,
|
|
105
|
+
})
|
|
106
|
+
: new DekError(
|
|
107
|
+
`layout "${name}" is not defined in theme.css, which defines ${defined || "none"}`,
|
|
108
|
+
{ path, hint: `read the markup of a slide with \`dekc show ${ref.name} <slug>\`` },
|
|
109
|
+
);
|
|
110
|
+
}
|
package/src/cli/tty.ts
CHANGED
|
@@ -10,15 +10,29 @@ const codes = {
|
|
|
10
10
|
bold: "\x1b[1m",
|
|
11
11
|
} as const;
|
|
12
12
|
|
|
13
|
+
/** The codes dek's own palette writes, which are all terminalSafe lets through. */
|
|
14
|
+
const PALETTE = new Set<string>([reset, ...Object.values(codes)]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Text with no control character but a newline and a tab: each other shows as U+FFFD, the escape
|
|
18
|
+
* that starts a color code included. A result's and an error's text go through it before dek
|
|
19
|
+
* paints them, so what a deck or a ref wrote carries no color, conceal, or cursor move of its own.
|
|
20
|
+
*/
|
|
21
|
+
export function plainText(text: string): string {
|
|
22
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
|
|
23
|
+
return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "\uFFFD");
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
/**
|
|
14
27
|
* Text for a terminal: dek's own colors, newlines, and tabs pass, and every other control
|
|
15
28
|
* character shows as U+FFFD. A deck title, a file name, or a ref's title fetched from GitHub
|
|
16
|
-
* could otherwise set the window title, draw a fake link (OSC 8),
|
|
29
|
+
* could otherwise set the window title, draw a fake link (OSC 8), clear the screen, or hide
|
|
30
|
+
* itself from a person while an agent reading the bytes sees it, as conceal (SGR 8) does.
|
|
17
31
|
*/
|
|
18
32
|
export function terminalSafe(text: string): string {
|
|
19
33
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
|
|
20
34
|
return text.replace(/\x1b\[[0-9;]*m|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (match) =>
|
|
21
|
-
match
|
|
35
|
+
PALETTE.has(match) ? match : plainText(match),
|
|
22
36
|
);
|
|
23
37
|
}
|
|
24
38
|
|
package/src/cli/voice.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { DekError } from "../core/error.ts";
|
|
5
4
|
import { deckProjectRoot } from "../core/path.ts";
|
|
6
5
|
import { isCachedFile, requireInside, writeInside } from "../core/safe-fs.ts";
|
|
6
|
+
import { runPiped } from "../core/spawn.ts";
|
|
7
7
|
import { loadVoiceDict, loadVoiceSettings, voiceCacheFile, writeVoiceDict } from "../core/voice.ts";
|
|
8
8
|
import {
|
|
9
9
|
engineBaseUrl,
|
|
@@ -97,6 +97,9 @@ export function pinVoice({ deck }: DeckTarget): VoiceCliResult {
|
|
|
97
97
|
return { action: "pin", timelinePath: pinTimeline, audioPath: pinAudio };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** A clip of a sentence or two plays well inside this. */
|
|
101
|
+
const PLAY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
102
|
+
|
|
100
103
|
async function playWav(path: string): Promise<void> {
|
|
101
104
|
if (process.env.DEK_VOICE_PLAY === "0") {
|
|
102
105
|
return;
|
|
@@ -106,9 +109,11 @@ async function playWav(path: string): Promise<void> {
|
|
|
106
109
|
if (!bin) {
|
|
107
110
|
return;
|
|
108
111
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// Playing is a courtesy: the clip is written either way, so a player that is missing, fails,
|
|
113
|
+
// or hangs is let go.
|
|
114
|
+
await runPiped([bin, path], {
|
|
115
|
+
label: `${bin} failed`,
|
|
116
|
+
hint: `play ${path} yourself`,
|
|
117
|
+
timeoutMs: PLAY_TIMEOUT_MS,
|
|
118
|
+
}).catch(() => {});
|
|
114
119
|
}
|
package/src/core/assets.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { extname, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { decodeAttribute } from "./attribute-value.ts";
|
|
3
4
|
import { parseCss } from "./css.ts";
|
|
4
5
|
import {
|
|
5
6
|
type CssEditor,
|
|
@@ -10,9 +11,10 @@ import {
|
|
|
10
11
|
shareTokensWithTransitions,
|
|
11
12
|
} from "./css-transform.ts";
|
|
12
13
|
import { deckPaths } from "./deck-paths.ts";
|
|
14
|
+
import { escapeAttr } from "./escape.ts";
|
|
13
15
|
import { isInside } from "./path.ts";
|
|
14
16
|
import { listSlideFiles, readDeckFile } from "./resolve.ts";
|
|
15
|
-
import { attributeUrls } from "./url-attributes.ts";
|
|
17
|
+
import { attributeUrls, trimUrl } from "./url-attributes.ts";
|
|
16
18
|
|
|
17
19
|
/** Where one reference from a slide or a stylesheet points. */
|
|
18
20
|
export type AssetRef =
|
|
@@ -35,13 +37,14 @@ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
|
35
37
|
* Resolves a reference the one way lint, show, and the build agree on: the
|
|
36
38
|
* deck root first, which is where the dev server and the build look for
|
|
37
39
|
* `assets/`, then next to the file that names it, so `../assets/x` from
|
|
38
|
-
* slides/ still resolves. A query string or fragment is not part of the path
|
|
40
|
+
* slides/ still resolves. A query string or fragment is not part of the path, and only the C0
|
|
41
|
+
* controls and spaces at its ends are trimmed, as the URL parser trims them.
|
|
39
42
|
*/
|
|
40
43
|
export function classifyAssetRef(
|
|
41
44
|
raw: string,
|
|
42
45
|
options: { deckDir: string; from: string },
|
|
43
46
|
): AssetRef {
|
|
44
|
-
const value = raw
|
|
47
|
+
const value = trimUrl(raw).split(/[?#]/)[0] ?? "";
|
|
45
48
|
if (!value) {
|
|
46
49
|
return { kind: "skip" };
|
|
47
50
|
}
|
|
@@ -93,7 +96,7 @@ export function fileInside(
|
|
|
93
96
|
|
|
94
97
|
/** True when a reference is written as `assets/...` from the deck root, or is not a local path at all. */
|
|
95
98
|
export function isCanonicalAssetPath(raw: string): boolean {
|
|
96
|
-
const value = raw
|
|
99
|
+
const value = trimUrl(raw);
|
|
97
100
|
if (!value || value.startsWith("#") || SCHEME_RE.test(value) || REMOTE_RE.test(value)) {
|
|
98
101
|
return true;
|
|
99
102
|
}
|
|
@@ -118,8 +121,10 @@ export function inlineAssets(
|
|
|
118
121
|
|
|
119
122
|
/**
|
|
120
123
|
* The rewriter handler `inlineAssets` runs, for a pass that rewrites more than URLs. The URLs are
|
|
121
|
-
* the ones lint checks, read by the same parser
|
|
122
|
-
* descriptors, links and text stay as written.
|
|
124
|
+
* the ones lint checks, read by the same parser and decoded as the browser decodes them: every
|
|
125
|
+
* candidate of a `srcset` keeps its descriptors, links and text stay as written. The rewriter
|
|
126
|
+
* writes a value as it is given, escaping only `"`, so each data: URI goes in escaped, in place
|
|
127
|
+
* of the URL as written, and an attribute with nothing to inline is never written at all.
|
|
123
128
|
*/
|
|
124
129
|
export function assetInliner(
|
|
125
130
|
deckDir: string,
|
|
@@ -128,7 +133,8 @@ export function assetInliner(
|
|
|
128
133
|
return {
|
|
129
134
|
element(el) {
|
|
130
135
|
const tag = el.tagName.toLowerCase();
|
|
131
|
-
for (const [name,
|
|
136
|
+
for (const [name, written] of [...el.attributes]) {
|
|
137
|
+
const { value, writtenAt } = decodeAttribute(written);
|
|
132
138
|
const named = attributeUrls(tag, name.toLowerCase(), value);
|
|
133
139
|
if (named?.use !== "resource") {
|
|
134
140
|
continue;
|
|
@@ -137,9 +143,11 @@ export function assetInliner(
|
|
|
137
143
|
const uri = inlinedUri(url, deckDir, from);
|
|
138
144
|
return uri === undefined
|
|
139
145
|
? rewritten
|
|
140
|
-
: rewritten.slice(0, offset) +
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
: rewritten.slice(0, writtenAt(offset)) +
|
|
147
|
+
escapeAttr(uri) +
|
|
148
|
+
rewritten.slice(writtenAt(offset + url.length));
|
|
149
|
+
}, written);
|
|
150
|
+
if (inlined !== written) {
|
|
143
151
|
el.setAttribute(name, inlined);
|
|
144
152
|
}
|
|
145
153
|
}
|