@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.
Files changed (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -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
+ }
@@ -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
+ }
@@ -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,4 @@
1
+ #!/usr/bin/env bun
2
+ import { main } from "./cli/main.ts";
3
+
4
+ await main(Bun.argv.slice(2), process.cwd());
@@ -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
+ }