@hajimism/dek 0.1.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/LICENSE +21 -0
- package/README.ja.md +75 -0
- package/README.md +75 -0
- package/package.json +65 -0
- package/src/cli/address.ts +53 -0
- package/src/cli/build.ts +85 -0
- package/src/cli/check.ts +97 -0
- package/src/cli/commands.ts +851 -0
- package/src/cli/contract.ts +444 -0
- package/src/cli/cues.ts +18 -0
- package/src/cli/files.ts +309 -0
- package/src/cli/flags.ts +295 -0
- package/src/cli/format.ts +156 -0
- package/src/cli/goto.ts +96 -0
- package/src/cli/init.ts +136 -0
- package/src/cli/keep-alive.ts +72 -0
- package/src/cli/lint.ts +84 -0
- package/src/cli/ls.ts +119 -0
- package/src/cli/main.ts +231 -0
- package/src/cli/marks.ts +16 -0
- package/src/cli/mv.ts +37 -0
- package/src/cli/new.ts +64 -0
- package/src/cli/pdf.ts +16 -0
- package/src/cli/qr.ts +466 -0
- package/src/cli/ref.ts +165 -0
- package/src/cli/rehearse.ts +42 -0
- package/src/cli/result.ts +161 -0
- package/src/cli/scope.ts +308 -0
- package/src/cli/serve.ts +125 -0
- package/src/cli/shot.ts +134 -0
- package/src/cli/show.ts +103 -0
- package/src/cli/sync.ts +37 -0
- package/src/cli/text.ts +479 -0
- package/src/cli/theme.ts +56 -0
- package/src/cli/tty.ts +81 -0
- package/src/cli/usage.ts +198 -0
- package/src/cli/video.ts +17 -0
- package/src/cli/voice.ts +113 -0
- package/src/cli.ts +4 -0
- package/src/core/assets.ts +239 -0
- package/src/core/build.ts +77 -0
- package/src/core/bundled-theme.ts +15 -0
- package/src/core/capture-go.ts +72 -0
- package/src/core/chrome.ts +128 -0
- package/src/core/config-keys.ts +82 -0
- package/src/core/config.ts +95 -0
- package/src/core/css-scan.ts +158 -0
- package/src/core/css-transform.ts +140 -0
- package/src/core/css-value.ts +127 -0
- package/src/core/css.ts +545 -0
- package/src/core/cue.ts +187 -0
- package/src/core/deck-paths.ts +34 -0
- package/src/core/diagnostic.ts +114 -0
- package/src/core/document.ts +218 -0
- package/src/core/error.ts +31 -0
- package/src/core/escape.ts +16 -0
- package/src/core/fill.ts +100 -0
- package/src/core/finish-beat.ts +30 -0
- package/src/core/github.ts +234 -0
- package/src/core/html-scan.ts +253 -0
- package/src/core/html.ts +234 -0
- package/src/core/in-page-go.ts +128 -0
- package/src/core/lines.ts +43 -0
- package/src/core/lint/asset-refs.ts +109 -0
- package/src/core/lint/context.ts +116 -0
- package/src/core/lint/files.ts +117 -0
- package/src/core/lint/keys.ts +48 -0
- package/src/core/lint/morph.ts +91 -0
- package/src/core/lint/script.ts +102 -0
- package/src/core/lint/shared-styles.ts +165 -0
- package/src/core/lint/slide-html.ts +466 -0
- package/src/core/lint/slide-script.ts +179 -0
- package/src/core/lint/theme-css.ts +295 -0
- package/src/core/lint/timing.ts +66 -0
- package/src/core/lint/tokens.ts +520 -0
- package/src/core/lint/voice.ts +76 -0
- package/src/core/lint.ts +137 -0
- package/src/core/live-protocol.ts +130 -0
- package/src/core/marks.ts +198 -0
- package/src/core/mv.ts +293 -0
- package/src/core/ogp.ts +99 -0
- package/src/core/optional.ts +24 -0
- package/src/core/overflow.ts +220 -0
- package/src/core/page-pool.ts +53 -0
- package/src/core/page.ts +66 -0
- package/src/core/parse.ts +307 -0
- package/src/core/path.ts +58 -0
- package/src/core/pdf.ts +71 -0
- package/src/core/playwright-module.d.ts +43 -0
- package/src/core/playwright-motion.ts +42 -0
- package/src/core/playwright-sheet.ts +32 -0
- package/src/core/playwright-visual.ts +391 -0
- package/src/core/playwright-worker.ts +5 -0
- package/src/core/playwright.ts +431 -0
- package/src/core/position.ts +146 -0
- package/src/core/presenter-state.ts +43 -0
- package/src/core/presenter.ts +131 -0
- package/src/core/prose.ts +16 -0
- package/src/core/pseudo-text.ts +67 -0
- package/src/core/rail-width.ts +4 -0
- package/src/core/ref-name.ts +110 -0
- package/src/core/ref.ts +238 -0
- package/src/core/resolve.ts +228 -0
- package/src/core/rumdl.ts +99 -0
- package/src/core/safe-fs.ts +196 -0
- package/src/core/sarif.ts +135 -0
- package/src/core/schema.ts +89 -0
- package/src/core/script-lines.ts +53 -0
- package/src/core/sheet.ts +223 -0
- package/src/core/shot/morph.ts +80 -0
- package/src/core/shot/motion.ts +187 -0
- package/src/core/shot/still.ts +216 -0
- package/src/core/shot-cache.ts +140 -0
- package/src/core/size.ts +11 -0
- package/src/core/skeleton-record.ts +51 -0
- package/src/core/skeleton.ts +89 -0
- package/src/core/slide-draw.ts +165 -0
- package/src/core/slide-eval-worker.ts +58 -0
- package/src/core/slide-measure.ts +272 -0
- package/src/core/slide-script-eval.ts +258 -0
- package/src/core/slide-script.ts +252 -0
- package/src/core/spawn.ts +128 -0
- package/src/core/step.ts +94 -0
- package/src/core/still-page.ts +70 -0
- package/src/core/suggest.ts +33 -0
- package/src/core/sync.ts +316 -0
- package/src/core/text-contrast.ts +466 -0
- package/src/core/theme-excerpt.ts +187 -0
- package/src/core/theme-facts.ts +51 -0
- package/src/core/timeline.ts +216 -0
- package/src/core/timing.ts +88 -0
- package/src/core/toml-keys.ts +349 -0
- package/src/core/url-attributes.ts +103 -0
- package/src/core/video-document.ts +15 -0
- package/src/core/visual-cache.ts +100 -0
- package/src/core/visual.ts +558 -0
- package/src/core/voice.ts +321 -0
- package/src/core/zod.ts +79 -0
- package/src/runtime/browser.ts +203 -0
- package/src/runtime/clone.ts +52 -0
- package/src/runtime/deck-control.ts +43 -0
- package/src/runtime/fit.ts +32 -0
- package/src/runtime/go.ts +56 -0
- package/src/runtime/input.ts +128 -0
- package/src/runtime/laser-view.ts +110 -0
- package/src/runtime/laser.ts +145 -0
- package/src/runtime/live-host.ts +33 -0
- package/src/runtime/live-reload.ts +29 -0
- package/src/runtime/live.ts +88 -0
- package/src/runtime/marks-view.ts +74 -0
- package/src/runtime/motion.ts +113 -0
- package/src/runtime/navigator.ts +189 -0
- package/src/runtime/player.ts +28 -0
- package/src/runtime/presenter-view.ts +289 -0
- package/src/runtime/presenter.ts +52 -0
- package/src/runtime/rail-view.ts +229 -0
- package/src/runtime/rail.ts +52 -0
- package/src/runtime/rehearse.ts +264 -0
- package/src/runtime/routes.ts +142 -0
- package/src/runtime/settle.ts +45 -0
- package/src/runtime/slide.d.ts +28 -0
- package/src/runtime/socket.ts +143 -0
- package/src/runtime/stage.ts +259 -0
- package/src/runtime/step.ts +139 -0
- package/src/runtime/window.ts +38 -0
- package/src/server/auth.ts +210 -0
- package/src/server/deck-snapshot.ts +146 -0
- package/src/server/dev.ts +251 -0
- package/src/server/hub.ts +104 -0
- package/src/server/lan.ts +51 -0
- package/src/server/lock.ts +97 -0
- package/src/server/pages.ts +71 -0
- package/src/server/pairing.ts +40 -0
- package/src/server/project-watch.ts +142 -0
- package/src/server/rooms.ts +133 -0
- package/src/server/routes.ts +286 -0
- package/src/server/serial.ts +25 -0
- package/src/server/watch.ts +308 -0
- package/src/theme/default.css +175 -0
- package/src/video/bake.ts +152 -0
- package/src/video/mux.ts +103 -0
- package/src/video/recorder.ts +115 -0
- package/src/video/sidecar.ts +60 -0
- package/src/video/worker.ts +51 -0
- package/src/voice/engine.ts +177 -0
- package/src/voice/synth.ts +127 -0
- package/src/voice/wav.ts +115 -0
package/src/cli/usage.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import pkg from "../../package.json";
|
|
2
|
+
import { DekError } from "../core/error.ts";
|
|
3
|
+
import { suggest } from "../core/suggest.ts";
|
|
4
|
+
import {
|
|
5
|
+
allSpecs,
|
|
6
|
+
COMMANDS,
|
|
7
|
+
type CommandName,
|
|
8
|
+
commandFlags,
|
|
9
|
+
formOf,
|
|
10
|
+
type Group,
|
|
11
|
+
isTypedCommand,
|
|
12
|
+
subcommandsOf,
|
|
13
|
+
usageLines,
|
|
14
|
+
} from "./commands.ts";
|
|
15
|
+
import { CLI_SCHEMA_URL } from "./contract.ts";
|
|
16
|
+
import { type CommandLine, FLAGS, type FlagName, flagValue } from "./flags.ts";
|
|
17
|
+
|
|
18
|
+
const GROUP_TITLES: Record<Group, string> = {
|
|
19
|
+
Development: "Development",
|
|
20
|
+
Project: "Project",
|
|
21
|
+
Refs: "Refs (other people's decks to read as models; read-only)",
|
|
22
|
+
Slide: "Slide",
|
|
23
|
+
Output: "Output",
|
|
24
|
+
Help: "Help",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A call and what it does, on one line when the call is short, else the text under it. */
|
|
28
|
+
function overviewLine([call, text]: readonly [string, string]): string {
|
|
29
|
+
const column = 20;
|
|
30
|
+
return call.length < column
|
|
31
|
+
? ` ${call.padEnd(column)}${text}`
|
|
32
|
+
: ` ${call}\n${" ".repeat(column + 2)}${text}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The commands that read a ref where they read a deck, as their specs say. */
|
|
36
|
+
function refReaders(): string[] {
|
|
37
|
+
return allSpecs()
|
|
38
|
+
.filter(([, spec]) => spec.refs === true)
|
|
39
|
+
.map(([name]) => name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `dekc help`: every command by group, in the order COMMANDS lists them. */
|
|
43
|
+
export function helpText(): string {
|
|
44
|
+
const groups = new Map<Group, string[]>();
|
|
45
|
+
for (const [, spec] of allSpecs()) {
|
|
46
|
+
const lines = groups.get(spec.group) ?? [];
|
|
47
|
+
lines.push(...spec.overview.map(overviewLine));
|
|
48
|
+
groups.set(spec.group, lines);
|
|
49
|
+
}
|
|
50
|
+
groups
|
|
51
|
+
.get("Refs")
|
|
52
|
+
?.push(
|
|
53
|
+
overviewLine([`dekc ${refReaders().join("|")} <ref> ...`, "read a ref as you would a deck"]),
|
|
54
|
+
);
|
|
55
|
+
const body = [...groups].map(([group, lines]) => [GROUP_TITLES[group], ...lines].join("\n"));
|
|
56
|
+
return `dek — a build system for talks
|
|
57
|
+
|
|
58
|
+
${body.join("\n\n")}
|
|
59
|
+
|
|
60
|
+
Commands that print a result accept --json. dek and dekc rehearse stay running.
|
|
61
|
+
Pass a deck name or --deck <name> to target a deck from the project root.
|
|
62
|
+
Flags are checked per command: an unknown flag is an error, not ignored.
|
|
63
|
+
dekc <command> --help one command's usage and flags; dekc --version prints the version
|
|
64
|
+
dekc help --agent compact command reference for agents
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `dekc help --agent`: one terse line or two per command. */
|
|
69
|
+
export function agentHelpText(): string {
|
|
70
|
+
const lines = allSpecs().flatMap(([name, spec]) =>
|
|
71
|
+
name === "ref"
|
|
72
|
+
? [
|
|
73
|
+
...spec.agent,
|
|
74
|
+
`Refs are read-only decks to learn from: ${refReaders().join(", ")} take owner/repo/deck as the deck.`,
|
|
75
|
+
]
|
|
76
|
+
: spec.agent,
|
|
77
|
+
);
|
|
78
|
+
return `dek — agent interface
|
|
79
|
+
Result commands accept --json. dekc / rehearse do not (long-running). Diagnostics: dekc lint --format sarif.
|
|
80
|
+
Each command's --json shape: ${CLI_SCHEMA_URL}
|
|
81
|
+
Each diagnostic has severity (error | warning) and data; only errors exit 1.
|
|
82
|
+
Scope: project root = all decks; deck dir = that deck; NAME or --deck NAME.
|
|
83
|
+
|
|
84
|
+
${lines.join("\n")}
|
|
85
|
+
|
|
86
|
+
Errors include hint with the next command to run.
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const DOCS_URL = "https://hajimism.github.io/dek/reference/cli.html";
|
|
91
|
+
|
|
92
|
+
/** `dekc help <command>`: its usage, what it does, and every flag it takes. */
|
|
93
|
+
export function commandHelp(command: CommandName): string {
|
|
94
|
+
const flags: FlagName[] = [...commandFlags(command), "json", "help"];
|
|
95
|
+
const label = (flag: FlagName): string => {
|
|
96
|
+
if (flag === "help") {
|
|
97
|
+
return "--help, -h";
|
|
98
|
+
}
|
|
99
|
+
const value = flagValue(flag);
|
|
100
|
+
return value ? `--${flag} ${value}` : `--${flag}`;
|
|
101
|
+
};
|
|
102
|
+
const width = Math.max(...flags.map((flag) => label(flag).length)) + 3;
|
|
103
|
+
const [first, ...more] = usageLines(command);
|
|
104
|
+
return [
|
|
105
|
+
`usage: ${first}`,
|
|
106
|
+
...more.map((line) => ` ${line}`),
|
|
107
|
+
"",
|
|
108
|
+
COMMANDS[command].summary,
|
|
109
|
+
"",
|
|
110
|
+
"flags",
|
|
111
|
+
...flags.map((flag) => ` ${label(flag).padEnd(width)}${FLAGS[flag].text}`),
|
|
112
|
+
"",
|
|
113
|
+
DOCS_URL,
|
|
114
|
+
].join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type HelpRequest =
|
|
118
|
+
| { kind: "version" }
|
|
119
|
+
| { kind: "help"; topic?: CommandName; agent: boolean };
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether argv asks for help or the version instead of running a command.
|
|
123
|
+
* `dekc help <word>` names a command, so a word that is none fails as `dekc <word>` would.
|
|
124
|
+
*/
|
|
125
|
+
export function helpRequest(line: CommandLine): HelpRequest | undefined {
|
|
126
|
+
if (line.values.version === true) {
|
|
127
|
+
return { kind: "version" };
|
|
128
|
+
}
|
|
129
|
+
const asked = line.command === "help" || line.values.help === true;
|
|
130
|
+
if (!asked) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const word = line.command === "help" ? line.positionals[1] : line.command;
|
|
134
|
+
const agent = line.values.agent === true;
|
|
135
|
+
if (line.command !== "help") {
|
|
136
|
+
return isTypedCommand(word) ? { kind: "help", topic: word, agent } : { kind: "help", agent };
|
|
137
|
+
}
|
|
138
|
+
const extra = line.positionals[2];
|
|
139
|
+
if (extra !== undefined) {
|
|
140
|
+
throw usageError("help", `unexpected argument "${extra}" for dekc help`);
|
|
141
|
+
}
|
|
142
|
+
if (word === undefined || word === "help") {
|
|
143
|
+
return { kind: "help", agent };
|
|
144
|
+
}
|
|
145
|
+
// `serve` is no word to type, yet it is the topic for the bare `dekc [deck]`.
|
|
146
|
+
if (isTypedCommand(word) || word === "serve") {
|
|
147
|
+
return { kind: "help", topic: word, agent };
|
|
148
|
+
}
|
|
149
|
+
throw unknownCommandError(word, []);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function versionText(): string {
|
|
153
|
+
return `dekc ${pkg.version}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A word the user typed that is neither a command nor a deck, with the likeliest fix. */
|
|
157
|
+
export function unknownCommandError(word: string, decks: string[]): DekError {
|
|
158
|
+
if (word === "serve") {
|
|
159
|
+
return new DekError("unknown command: serve", {
|
|
160
|
+
hint: "the dev server is the bare `dekc [deck]`: drop `serve`",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const commands = allSpecs()
|
|
164
|
+
.map(([name]) => name)
|
|
165
|
+
.filter(isTypedCommand);
|
|
166
|
+
const guess = suggest(word, [...commands, ...decks]);
|
|
167
|
+
return new DekError(`unknown command: ${word}`, {
|
|
168
|
+
hint: guess ? `did you mean \`dekc ${guess}\`?` : "run `dekc help` to see the commands",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A command line that does not fit the spec, with the usage the spec gives: a subcommand's
|
|
174
|
+
* own, or the command's, narrowed to the lines that contain `match` when given.
|
|
175
|
+
*/
|
|
176
|
+
export function usageError(
|
|
177
|
+
command: CommandName,
|
|
178
|
+
message: string,
|
|
179
|
+
options: { subcommand?: string; match?: string } = {},
|
|
180
|
+
): DekError {
|
|
181
|
+
const lines = formOf(command, options.subcommand).usage.filter(
|
|
182
|
+
(line) => options.match === undefined || line.includes(options.match),
|
|
183
|
+
);
|
|
184
|
+
return new DekError(message, {
|
|
185
|
+
hint: `usage: ${lines.join(" | ")}; run \`dekc help ${command}\``,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A word where a command takes a subcommand, with the likeliest one. */
|
|
190
|
+
export function unknownSubcommandError(command: CommandName, word: string): DekError {
|
|
191
|
+
const subs = Object.keys(subcommandsOf(COMMANDS[command]));
|
|
192
|
+
const guess = suggest(word, subs);
|
|
193
|
+
return new DekError(`unknown subcommand "${word}" for dekc ${command}`, {
|
|
194
|
+
hint: guess
|
|
195
|
+
? `did you mean \`dekc ${command} ${guess}\`?`
|
|
196
|
+
: `dekc ${command} takes ${subs.join(", ")}; run \`dekc help ${command}\``,
|
|
197
|
+
});
|
|
198
|
+
}
|
package/src/cli/video.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { bakeVideo } from "../video/bake.ts";
|
|
2
|
+
import type { DeckTarget } from "./scope.ts";
|
|
3
|
+
|
|
4
|
+
export type VideoCliResult = {
|
|
5
|
+
out: string;
|
|
6
|
+
vtt?: string;
|
|
7
|
+
chapters?: string;
|
|
8
|
+
credits?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** `fps` is checked on the command line: a positive number, 30 when left out. */
|
|
12
|
+
export async function videoCommand(
|
|
13
|
+
target: DeckTarget,
|
|
14
|
+
options: { slug?: string; fps?: number; rootDist?: boolean } = {},
|
|
15
|
+
): Promise<VideoCliResult> {
|
|
16
|
+
return bakeVideo(target, options);
|
|
17
|
+
}
|
package/src/cli/voice.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DekError } from "../core/error.ts";
|
|
5
|
+
import { deckProjectRoot } from "../core/path.ts";
|
|
6
|
+
import { isCachedFile, writeInside } from "../core/safe-fs.ts";
|
|
7
|
+
import { loadVoiceDict, loadVoiceSettings, voiceCacheFile, writeVoiceDict } from "../core/voice.ts";
|
|
8
|
+
import {
|
|
9
|
+
engineBaseUrl,
|
|
10
|
+
fetchAudioQuery,
|
|
11
|
+
fetchSpeakers,
|
|
12
|
+
fetchSynthesis,
|
|
13
|
+
resolveStyleId,
|
|
14
|
+
withEngine,
|
|
15
|
+
} from "../voice/engine.ts";
|
|
16
|
+
import { synthDeck } from "../voice/synth.ts";
|
|
17
|
+
import type { DeckTarget } from "./scope.ts";
|
|
18
|
+
|
|
19
|
+
export type VoiceCliResult =
|
|
20
|
+
| {
|
|
21
|
+
action: "synth";
|
|
22
|
+
synthesized: number;
|
|
23
|
+
cached: number;
|
|
24
|
+
timelinePath: string;
|
|
25
|
+
audioPath: string;
|
|
26
|
+
}
|
|
27
|
+
| { action: "speakers"; speakers: Array<{ name: string; styles: string[] }> }
|
|
28
|
+
| { action: "say"; text: string; path: string }
|
|
29
|
+
| { action: "dict"; path: string; key: string; kana: string }
|
|
30
|
+
| { action: "pin"; timelinePath: string; audioPath: string };
|
|
31
|
+
|
|
32
|
+
/** `dekc voice`: synthesize the sentences that changed. */
|
|
33
|
+
export async function synthVoice(target: DeckTarget): Promise<VoiceCliResult> {
|
|
34
|
+
return { action: "synth", ...(await synthDeck(target)) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function listSpeakers({ deck }: DeckTarget): Promise<VoiceCliResult> {
|
|
38
|
+
const settings = loadVoiceSettings(deck.dir);
|
|
39
|
+
const baseUrl = engineBaseUrl(settings.engine);
|
|
40
|
+
const speakers = await withEngine(settings.engine, baseUrl, () => fetchSpeakers(baseUrl));
|
|
41
|
+
return {
|
|
42
|
+
action: "speakers",
|
|
43
|
+
speakers: speakers.map((speaker) => ({
|
|
44
|
+
name: speaker.name,
|
|
45
|
+
styles: speaker.styles.map((style) => style.name),
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Speak one sentence in the deck's voice, and keep the audio at .cache/voice/say.wav. */
|
|
51
|
+
export async function sayVoice({ deck }: DeckTarget, text: string): Promise<VoiceCliResult> {
|
|
52
|
+
const settings = loadVoiceSettings(deck.dir);
|
|
53
|
+
const baseUrl = engineBaseUrl(settings.engine);
|
|
54
|
+
const speakers = await withEngine(settings.engine, baseUrl, () => fetchSpeakers(baseUrl));
|
|
55
|
+
const styleId = resolveStyleId(speakers, settings.speaker);
|
|
56
|
+
const query = await fetchAudioQuery(baseUrl, text, styleId);
|
|
57
|
+
query.speedScale = settings.speed;
|
|
58
|
+
const wav = await fetchSynthesis(baseUrl, query, styleId);
|
|
59
|
+
const path = voiceCacheFile(deck.dir, "say.wav");
|
|
60
|
+
writeInside(path, wav, deckProjectRoot(deck.dir));
|
|
61
|
+
await playWav(path);
|
|
62
|
+
return { action: "say", text, path };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Add a reading to voice/dict.toml; `accent` is checked on the command line. */
|
|
66
|
+
export function addReading(
|
|
67
|
+
{ deck }: DeckTarget,
|
|
68
|
+
entry: { word: string; kana: string; accent?: number },
|
|
69
|
+
): VoiceCliResult {
|
|
70
|
+
const dict = loadVoiceDict(deck.dir);
|
|
71
|
+
dict[entry.word] = {
|
|
72
|
+
kana: entry.kana,
|
|
73
|
+
...(entry.accent !== undefined ? { accent: entry.accent } : {}),
|
|
74
|
+
};
|
|
75
|
+
const path = writeVoiceDict(deck.dir, dict);
|
|
76
|
+
return { action: "dict", path, key: entry.word, kana: entry.kana };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Copy the synthesized audio and timeline into voice/pin/, which is committed. */
|
|
80
|
+
export function pinVoice({ deck }: DeckTarget): VoiceCliResult {
|
|
81
|
+
const timelinePath = voiceCacheFile(deck.dir, "timeline.json");
|
|
82
|
+
const audioPath = voiceCacheFile(deck.dir, "audio.wav");
|
|
83
|
+
// What is pinned is committed; only what `dekc voice` wrote, never a link planted in the cache.
|
|
84
|
+
if (!isCachedFile(timelinePath) || !isCachedFile(audioPath)) {
|
|
85
|
+
throw new DekError("Timeline not found", {
|
|
86
|
+
path: timelinePath,
|
|
87
|
+
hint: "run `dekc voice`",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const root = deckProjectRoot(deck.dir);
|
|
91
|
+
const pinDir = join(deck.dir, "voice", "pin");
|
|
92
|
+
const pinTimeline = join(pinDir, "timeline.json");
|
|
93
|
+
const pinAudio = join(pinDir, "master.wav");
|
|
94
|
+
writeInside(pinTimeline, readFileSync(timelinePath), root);
|
|
95
|
+
writeInside(pinAudio, readFileSync(audioPath), root);
|
|
96
|
+
return { action: "pin", timelinePath: pinTimeline, audioPath: pinAudio };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function playWav(path: string): Promise<void> {
|
|
100
|
+
if (process.env.DEK_VOICE_PLAY === "0") {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const bin =
|
|
104
|
+
process.platform === "darwin" ? "afplay" : process.platform === "win32" ? "" : "aplay";
|
|
105
|
+
if (!bin) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await new Promise<void>((resolve) => {
|
|
109
|
+
const child = spawn(bin, [path], { stdio: "ignore" });
|
|
110
|
+
child.on("exit", () => resolve());
|
|
111
|
+
child.on("error", () => resolve());
|
|
112
|
+
});
|
|
113
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { extname, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { parseCss } from "./css.ts";
|
|
4
|
+
import {
|
|
5
|
+
type CssEditor,
|
|
6
|
+
minifyCss,
|
|
7
|
+
replaceUrls,
|
|
8
|
+
rewriteCss,
|
|
9
|
+
scopeToSlide,
|
|
10
|
+
shareTokensWithTransitions,
|
|
11
|
+
} from "./css-transform.ts";
|
|
12
|
+
import { deckPaths } from "./deck-paths.ts";
|
|
13
|
+
import { isInside } from "./path.ts";
|
|
14
|
+
import { listSlideFiles, readDeckFile } from "./resolve.ts";
|
|
15
|
+
import { attributeUrls } from "./url-attributes.ts";
|
|
16
|
+
|
|
17
|
+
/** Where one reference from a slide or a stylesheet points. */
|
|
18
|
+
export type AssetRef =
|
|
19
|
+
/** Nothing to resolve: empty, a fragment, a data: URI, or another scheme. */
|
|
20
|
+
| { kind: "skip" }
|
|
21
|
+
| { kind: "remote" }
|
|
22
|
+
/** A local path that leaves the deck directory. */
|
|
23
|
+
| { kind: "escape" }
|
|
24
|
+
/** A local path inside the deck that names no file. */
|
|
25
|
+
| { kind: "missing" }
|
|
26
|
+
| { kind: "file"; path: string; deckPath: string };
|
|
27
|
+
|
|
28
|
+
/** A network address: `http:` or `https:` with or without its slashes, or protocol-relative. */
|
|
29
|
+
const REMOTE_RE = /^(?:https?:|\/\/)/i;
|
|
30
|
+
/** A file on the machine that opens the deck, which no copy of the deck carries along. */
|
|
31
|
+
const FILE_RE = /^file:/i;
|
|
32
|
+
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolves a reference the one way lint, show, and the build agree on: the
|
|
36
|
+
* deck root first, which is where the dev server and the build look for
|
|
37
|
+
* `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.
|
|
39
|
+
*/
|
|
40
|
+
export function classifyAssetRef(
|
|
41
|
+
raw: string,
|
|
42
|
+
options: { deckDir: string; from: string },
|
|
43
|
+
): AssetRef {
|
|
44
|
+
const value = raw.trim().split(/[?#]/)[0] ?? "";
|
|
45
|
+
if (!value) {
|
|
46
|
+
return { kind: "skip" };
|
|
47
|
+
}
|
|
48
|
+
if (REMOTE_RE.test(value)) {
|
|
49
|
+
return { kind: "remote" };
|
|
50
|
+
}
|
|
51
|
+
if (FILE_RE.test(value)) {
|
|
52
|
+
return { kind: "escape" };
|
|
53
|
+
}
|
|
54
|
+
if (SCHEME_RE.test(value)) {
|
|
55
|
+
return { kind: "skip" };
|
|
56
|
+
}
|
|
57
|
+
const found = [resolve(options.deckDir, value), resolve(options.from, value)]
|
|
58
|
+
.filter((path) => isInside(path, options.deckDir))
|
|
59
|
+
.map((path) => fileInside(path, options.deckDir));
|
|
60
|
+
const file = found.find((ref) => ref.kind === "file");
|
|
61
|
+
if (file) {
|
|
62
|
+
return { ...file, deckPath: relative(options.deckDir, file.path).split(sep).join("/") };
|
|
63
|
+
}
|
|
64
|
+
// A link out of the deck outranks a path that names nothing: the author wrote a file that is there.
|
|
65
|
+
return found.length > 0 && found.every((ref) => ref.kind === "missing")
|
|
66
|
+
? { kind: "missing" }
|
|
67
|
+
: { kind: "escape" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Where `path` stands against `root`, judged by where it really lives: a symlink under `root`
|
|
72
|
+
* that leads out of it escapes, like `../` does, so neither the build nor the dev server reads
|
|
73
|
+
* a file the deck does not hold. The path itself comes back as written.
|
|
74
|
+
*/
|
|
75
|
+
export function fileInside(
|
|
76
|
+
path: string,
|
|
77
|
+
root: string,
|
|
78
|
+
): { kind: "escape" } | { kind: "missing" } | { kind: "file"; path: string } {
|
|
79
|
+
if (!isInside(path, root)) {
|
|
80
|
+
return { kind: "escape" };
|
|
81
|
+
}
|
|
82
|
+
let real: string;
|
|
83
|
+
try {
|
|
84
|
+
real = realpathSync(path);
|
|
85
|
+
} catch {
|
|
86
|
+
return { kind: "missing" };
|
|
87
|
+
}
|
|
88
|
+
if (!isInside(real, realpathSync(root))) {
|
|
89
|
+
return { kind: "escape" };
|
|
90
|
+
}
|
|
91
|
+
return statSync(real).isFile() ? { kind: "file", path } : { kind: "missing" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when a reference is written as `assets/...` from the deck root, or is not a local path at all. */
|
|
95
|
+
export function isCanonicalAssetPath(raw: string): boolean {
|
|
96
|
+
const value = raw.trim();
|
|
97
|
+
if (!value || value.startsWith("#") || SCHEME_RE.test(value) || REMOTE_RE.test(value)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return value.startsWith("assets/");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Slide markup with each local resource replaced by a data: URI, so the build stands alone; `from`
|
|
105
|
+
* is the directory the markup lives in.
|
|
106
|
+
*/
|
|
107
|
+
export function inlineAssets(
|
|
108
|
+
html: string,
|
|
109
|
+
deckDir: string,
|
|
110
|
+
from = deckPaths(deckDir).slides,
|
|
111
|
+
): string {
|
|
112
|
+
const output = new HTMLRewriter().on("*", assetInliner(deckDir, from)).transform(html);
|
|
113
|
+
if (typeof output !== "string") {
|
|
114
|
+
throw new Error("HTMLRewriter.transform expected a string");
|
|
115
|
+
}
|
|
116
|
+
return output;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 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: every candidate of a `srcset` keeps its
|
|
122
|
+
* descriptors, links and text stay as written.
|
|
123
|
+
*/
|
|
124
|
+
export function assetInliner(
|
|
125
|
+
deckDir: string,
|
|
126
|
+
from = deckPaths(deckDir).slides,
|
|
127
|
+
): HTMLRewriterTypes.HTMLRewriterElementContentHandlers {
|
|
128
|
+
return {
|
|
129
|
+
element(el) {
|
|
130
|
+
const tag = el.tagName.toLowerCase();
|
|
131
|
+
for (const [name, value] of [...el.attributes]) {
|
|
132
|
+
const named = attributeUrls(tag, name.toLowerCase(), value);
|
|
133
|
+
if (named?.use !== "resource") {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const inlined = named.urls.reduceRight((rewritten, { url, offset }) => {
|
|
137
|
+
const uri = inlinedUri(url, deckDir, from);
|
|
138
|
+
return uri === undefined
|
|
139
|
+
? rewritten
|
|
140
|
+
: rewritten.slice(0, offset) + uri + rewritten.slice(offset + url.length);
|
|
141
|
+
}, value);
|
|
142
|
+
if (inlined !== value) {
|
|
143
|
+
el.setAttribute(name, inlined);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Each local `url()` replaced by a data: URI; `from` is the directory the stylesheet lives in. */
|
|
151
|
+
export function inlineCssUrls(deckDir: string, from = deckDir): CssEditor {
|
|
152
|
+
return replaceUrls((url) => inlinedUri(url, deckDir, from));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The data: URI a local reference inlines to, keeping its fragment; none when it names no deck file. */
|
|
156
|
+
function inlinedUri(url: string, deckDir: string, from: string): string | undefined {
|
|
157
|
+
const ref = classifyAssetRef(url, { deckDir, from });
|
|
158
|
+
if (ref.kind !== "file") {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
const hash = url.indexOf("#");
|
|
162
|
+
return `data:${mimeOf(ref.path)};base64,${readFileSync(ref.path).toString("base64")}${hash === -1 ? "" : url.slice(hash)}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The deck's stylesheets apart: theme.css, and each slide's own CSS scoped to that slide. */
|
|
166
|
+
export type DeckStyles = { theme: string; slides: Map<string, string> };
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The deck's stylesheets, each read once and rewritten in one pass; a slide with no CSS of its
|
|
170
|
+
* own has no entry. A standalone page also gets its files as data: URIs.
|
|
171
|
+
*/
|
|
172
|
+
export function readDeckStyles(deckDir: string, standalone: boolean): DeckStyles {
|
|
173
|
+
const paths = deckPaths(deckDir);
|
|
174
|
+
const theme = rewriteCss(
|
|
175
|
+
parseCss(readDeckFile(deckDir, paths.theme) ?? ""),
|
|
176
|
+
shareTokensWithTransitions,
|
|
177
|
+
...(standalone ? [inlineCssUrls(deckDir)] : []),
|
|
178
|
+
);
|
|
179
|
+
const slides = new Map<string, string>();
|
|
180
|
+
for (const slide of listSlideFiles(deckDir, ".css")) {
|
|
181
|
+
const css = rewriteCss(
|
|
182
|
+
parseCss(readDeckFile(deckDir, slide.path) ?? ""),
|
|
183
|
+
scopeToSlide(slide.slug),
|
|
184
|
+
...(standalone ? [inlineCssUrls(deckDir, paths.slides)] : []),
|
|
185
|
+
);
|
|
186
|
+
if (css) {
|
|
187
|
+
slides.set(slide.slug, css);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { theme, slides };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The deck's stylesheet: theme.css, then each slide's own CSS scoped to that slide. A standalone
|
|
195
|
+
* page also gets its files as data: URIs, minified.
|
|
196
|
+
*/
|
|
197
|
+
export function readTheme(deckDir: string, standalone: boolean): string {
|
|
198
|
+
const { theme, slides } = readDeckStyles(deckDir, standalone);
|
|
199
|
+
const css = [theme, ...slides.values()].filter(Boolean).join("\n");
|
|
200
|
+
return standalone ? minifyCss(css) : css;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Media types by extension, for what a slide or a stylesheet commonly embeds. */
|
|
204
|
+
const MIME_TYPES: Readonly<Record<string, string>> = {
|
|
205
|
+
".apng": "image/apng",
|
|
206
|
+
".avif": "image/avif",
|
|
207
|
+
".bmp": "image/bmp",
|
|
208
|
+
".gif": "image/gif",
|
|
209
|
+
".ico": "image/x-icon",
|
|
210
|
+
".jpeg": "image/jpeg",
|
|
211
|
+
".jpg": "image/jpeg",
|
|
212
|
+
".png": "image/png",
|
|
213
|
+
".svg": "image/svg+xml",
|
|
214
|
+
".webp": "image/webp",
|
|
215
|
+
".m4v": "video/mp4",
|
|
216
|
+
".mov": "video/quicktime",
|
|
217
|
+
".mp4": "video/mp4",
|
|
218
|
+
".ogv": "video/ogg",
|
|
219
|
+
".webm": "video/webm",
|
|
220
|
+
".aac": "audio/aac",
|
|
221
|
+
".flac": "audio/flac",
|
|
222
|
+
".m4a": "audio/mp4",
|
|
223
|
+
".mp3": "audio/mpeg",
|
|
224
|
+
".oga": "audio/ogg",
|
|
225
|
+
".ogg": "audio/ogg",
|
|
226
|
+
".opus": "audio/ogg",
|
|
227
|
+
".wav": "audio/wav",
|
|
228
|
+
".eot": "application/vnd.ms-fontobject",
|
|
229
|
+
".otf": "font/otf",
|
|
230
|
+
".ttf": "font/ttf",
|
|
231
|
+
".woff": "font/woff",
|
|
232
|
+
".woff2": "font/woff2",
|
|
233
|
+
".vtt": "text/vtt",
|
|
234
|
+
".css": "text/css",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function mimeOf(filePath: string): string {
|
|
238
|
+
return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
239
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { copyFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { loadConfig } from "./config.ts";
|
|
4
|
+
import { renderDeckDocument } from "./document.ts";
|
|
5
|
+
import { type DistOptions, distFile } from "./path.ts";
|
|
6
|
+
import type { PlaywrightRunner } from "./playwright.ts";
|
|
7
|
+
import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
|
|
8
|
+
import { outputPath, removeInside, writeInside } from "./safe-fs.ts";
|
|
9
|
+
import { coverShot } from "./shot/still.ts";
|
|
10
|
+
import { logicalSize } from "./size.ts";
|
|
11
|
+
|
|
12
|
+
export type BuildResult = { outPath: string } & (
|
|
13
|
+
| {
|
|
14
|
+
/** dist/<deck>.png, the link preview image. */
|
|
15
|
+
image: string;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
/** Why there is no preview image: no public URL to point og:image at, or no Playwright. */
|
|
19
|
+
imageSkipped: "no-url" | "no-playwright";
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export type BuildOptions = DistOptions & {
|
|
24
|
+
playerScript: string;
|
|
25
|
+
/** Where dist/ is served from, ending in a slash; overrides `url` in dek.toml. */
|
|
26
|
+
url?: string;
|
|
27
|
+
/** For anyone with the link: the presenter view carries only what is said aloud. */
|
|
28
|
+
public?: boolean;
|
|
29
|
+
runner?: PlaywrightRunner;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function buildDeck(dir: string, options: BuildOptions): Promise<BuildResult>;
|
|
33
|
+
export async function buildDeck(source: ResolvedDeck, options: BuildOptions): Promise<BuildResult>;
|
|
34
|
+
export async function buildDeck(
|
|
35
|
+
input: string | ResolvedDeck,
|
|
36
|
+
options: BuildOptions,
|
|
37
|
+
): Promise<BuildResult> {
|
|
38
|
+
const { project, deck } = asResolvedDeck(input);
|
|
39
|
+
const config = loadConfig(project.configPath);
|
|
40
|
+
const outPath = distFile(project, deck, "html", options);
|
|
41
|
+
const imagePath = distFile(project, deck, "png", options);
|
|
42
|
+
const baseUrl = options.url ?? config.url;
|
|
43
|
+
const shot = baseUrl ? await coverShot(deck, options.runner) : undefined;
|
|
44
|
+
|
|
45
|
+
if (shot) {
|
|
46
|
+
copyFileSync(shot, outputPath(imagePath, project.root));
|
|
47
|
+
} else {
|
|
48
|
+
// An image from an earlier build would outlive the tag that pointed at it.
|
|
49
|
+
removeInside(imagePath, project.root);
|
|
50
|
+
}
|
|
51
|
+
const html = renderDeckDocument(deck, {
|
|
52
|
+
config,
|
|
53
|
+
playerScript: options.playerScript,
|
|
54
|
+
target: {
|
|
55
|
+
kind: "build",
|
|
56
|
+
...(options.public ? { public: true } : {}),
|
|
57
|
+
...(baseUrl
|
|
58
|
+
? {
|
|
59
|
+
publicUrl: new URL(encodeURIComponent(basename(outPath)), baseUrl).href,
|
|
60
|
+
...(shot
|
|
61
|
+
? {
|
|
62
|
+
previewImage: {
|
|
63
|
+
url: new URL(encodeURIComponent(basename(imagePath)), baseUrl).href,
|
|
64
|
+
...logicalSize(deck.deck.ratio),
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
: {}),
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
writeInside(outPath, html, project.root);
|
|
73
|
+
if (shot) {
|
|
74
|
+
return { outPath, image: imagePath };
|
|
75
|
+
}
|
|
76
|
+
return { outPath, imageSkipped: baseUrl ? "no-playwright" : "no-url" };
|
|
77
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseCss, publishedTokens } from "./css.ts";
|
|
4
|
+
|
|
5
|
+
const bundledThemePath = join(import.meta.dir, "..", "theme", "default.css");
|
|
6
|
+
|
|
7
|
+
/** The theme dek ships, which `dekc init` writes and every required token is set in. */
|
|
8
|
+
export function bundledTheme(): string {
|
|
9
|
+
return readFileSync(bundledThemePath, "utf8");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The value dek's own theme gives a token, for a hint that can be written as is. */
|
|
13
|
+
export function bundledTokenValue(name: string): string | undefined {
|
|
14
|
+
return publishedTokens(parseCss(bundledTheme())).find((token) => token.name === name)?.value;
|
|
15
|
+
}
|