@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
package/src/core/mv.ts ADDED
@@ -0,0 +1,293 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { deckPaths } from "./deck-paths.ts";
4
+ import { DekError } from "./error.ts";
5
+ import { escapeRegExp } from "./escape.ts";
6
+ import { joinLines, sectionChunks, splitLines } from "./lines.ts";
7
+ import { asResolvedDeck, type ResolvedDeck, requireSection, SLIDE_SIDECARS } from "./resolve.ts";
8
+ import { Id } from "./schema.ts";
9
+ import { deckLayouts, skeletonHtml } from "./skeleton.ts";
10
+ import { skeletonRecord } from "./skeleton-record.ts";
11
+ import { renameTableKeys } from "./toml-keys.ts";
12
+
13
+ export function renameSection(dir: string, from: string, to: string): void;
14
+ export function renameSection(source: ResolvedDeck, from: string, to: string): void;
15
+ export function renameSection(input: string | ResolvedDeck, from: string, to: string): void {
16
+ if (!Id.safeParse(to).success) {
17
+ throw new DekError(`invalid id "${to}"`, { hint: "use [a-z0-9-] with at least one letter" });
18
+ }
19
+ const { deck } = asResolvedDeck(input);
20
+ const has = (slug: string): boolean => deck.deck.sections.some((entry) => entry.slug === slug);
21
+ if (!has(from) && has(to)) {
22
+ // script.md was edited first; only the files still carry the old id.
23
+ const steps = slideFileSteps(deck, from, to);
24
+ if (steps.length === 0 && !existsSync(deckPaths(deck.dir).slide(to, ".html"))) {
25
+ throw new DekError(`slide "${from}" not found`, {
26
+ path: deckPaths(deck.dir).slide(from, ".html"),
27
+ hint: "run `dekc lint` to see which slides have no section",
28
+ });
29
+ }
30
+ applySteps(steps);
31
+ return;
32
+ }
33
+ const section = requireSection(deck, from);
34
+ if (has(to)) {
35
+ throw new DekError(`section "${to}" already exists`, {
36
+ path: deck.scriptPath,
37
+ hint: "run `dekc ls`",
38
+ });
39
+ }
40
+
41
+ const source = readFileSync(deck.scriptPath, "utf8");
42
+ const lines = splitLines(source);
43
+ const headingIndex = section.line - 1;
44
+ const heading = lines[headingIndex];
45
+ if (heading === undefined) {
46
+ throw new DekError(`section "${from}" heading not found`, {
47
+ path: deck.scriptPath,
48
+ hint: "run `dekc ls`",
49
+ });
50
+ }
51
+ lines[headingIndex] = rewriteHeadingId(heading, from, to);
52
+ applySteps([
53
+ writeStep(deck.scriptPath, source, joinLines(source, lines)),
54
+ ...slideFileSteps(deck, from, to),
55
+ ]);
56
+ }
57
+
58
+ /**
59
+ * The steps that leave `slides/<to>.*` holding the slide and voice.toml pointing at it, from
60
+ * whichever files the author already moved. Each file is settled on its own: one that is only at
61
+ * `<from>` moves, one already at `<to>` stays, and a skeleton dek wrote gives way to the author's
62
+ * file on the other side, as the dev server writes one for any id the script names. Only two files
63
+ * the author wrote, one under each id, stop the rename; everything that could stop it is worked
64
+ * out before any file changes.
65
+ */
66
+ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): FileStep[] {
67
+ const paths = deckPaths(deck.dir);
68
+ const layouts = deckLayouts(deck.dir);
69
+ const record = skeletonRecord(deck.dir);
70
+ const read = (slug: string): SlideFile | undefined => {
71
+ const path = paths.slide(slug, ".html");
72
+ if (!existsSync(path)) {
73
+ return undefined;
74
+ }
75
+ const html = readFileSync(path, "utf8");
76
+ return { path, html, authored: !record.owns(html, skeletonHtml(deck.deck, slug, layouts)) };
77
+ };
78
+ const source = read(from);
79
+ const target = read(to);
80
+ if (source?.authored && target?.authored) {
81
+ throw bothHold(source.path, target.path, from, to);
82
+ }
83
+ for (const ext of SLIDE_SIDECARS) {
84
+ if (existsSync(paths.slide(from, ext)) && existsSync(paths.slide(to, ext))) {
85
+ throw bothHold(paths.slide(from, ext), paths.slide(to, ext), from, to);
86
+ }
87
+ }
88
+
89
+ const voice = planVoiceBeatKeys(deck.dir, from, to);
90
+ const steps: FileStep[] = [];
91
+ // The HTML that ends up at <to>: the author's, wherever it is; else whichever is there.
92
+ const toPath = paths.slide(to, ".html");
93
+ const kept = source && (source.authored || !target) ? source : target;
94
+ if (source && kept === source) {
95
+ steps.push(
96
+ target ? writeStep(toPath, target.html, source.html) : renameStep(source.path, toPath),
97
+ );
98
+ }
99
+ if (source && target) {
100
+ steps.push(removeStep(source.path, source.html));
101
+ }
102
+ if (kept) {
103
+ const next = relabel(kept.html, from, to);
104
+ if (next !== kept.html) {
105
+ steps.push(writeStep(toPath, kept.html, next));
106
+ }
107
+ }
108
+ for (const ext of SLIDE_SIDECARS) {
109
+ const sidecar = paths.slide(from, ext);
110
+ if (existsSync(sidecar)) {
111
+ steps.push(renameStep(sidecar, paths.slide(to, ext)));
112
+ }
113
+ }
114
+ if (voice) {
115
+ steps.push(writeStep(voice.path, voice.source, voice.next));
116
+ }
117
+ return steps;
118
+ }
119
+
120
+ /** A slide's HTML under one id, and whether the author wrote it rather than dek. */
121
+ type SlideFile = { path: string; html: string; authored: boolean };
122
+
123
+ /** A slide's own `data-slug`, pointed at its new id. */
124
+ function relabel(html: string, from: string, to: string): string {
125
+ return html.replaceAll(`data-slug="${from}"`, `data-slug="${to}"`);
126
+ }
127
+
128
+ function bothHold(fromPath: string, toPath: string, from: string, to: string): DekError {
129
+ const fromName = `slides/${basename(fromPath)}`;
130
+ const toName = `slides/${basename(toPath)}`;
131
+ const what = fromPath.endsWith(".html") ? "hold a slide you wrote" : "exist";
132
+ return new DekError(`${fromName} and ${toName} both ${what}`, {
133
+ path: fromPath,
134
+ hint: `merge them into ${toName} and remove ${fromName}, then run \`dekc mv ${from} ${to}\` again`,
135
+ });
136
+ }
137
+
138
+ /** One file change `dekc mv` makes, with how to take it back. */
139
+ type FileStep = { path: string; apply: () => void; undo: () => void };
140
+
141
+ function writeStep(path: string, before: string, after: string): FileStep {
142
+ return {
143
+ path,
144
+ apply: () => writeFileSync(path, after),
145
+ undo: () => writeFileSync(path, before),
146
+ };
147
+ }
148
+
149
+ function removeStep(path: string, before: string): FileStep {
150
+ return { path, apply: () => unlinkSync(path), undo: () => writeFileSync(path, before) };
151
+ }
152
+
153
+ function renameStep(from: string, to: string): FileStep {
154
+ return { path: to, apply: () => renameSync(from, to), undo: () => renameSync(to, from) };
155
+ }
156
+
157
+ /** Applies every step, or none: a failure takes back the steps already applied. */
158
+ function applySteps(steps: FileStep[]): void {
159
+ const done: FileStep[] = [];
160
+ try {
161
+ for (const step of steps) {
162
+ step.apply();
163
+ done.push(step);
164
+ }
165
+ } catch (error) {
166
+ const stuck: string[] = [];
167
+ for (const step of done.reverse()) {
168
+ try {
169
+ step.undo();
170
+ } catch {
171
+ stuck.push(step.path);
172
+ }
173
+ }
174
+ if (stuck.length > 0) {
175
+ throw new DekError(`dekc mv failed and could not restore ${stuck.join(", ")}`, {
176
+ cause: error,
177
+ hint: "check these files by hand",
178
+ });
179
+ }
180
+ throw error;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The voice.toml with its `[beats]` keys (`slug`, `"slug/beat"`) pointing at
186
+ * the renamed slide, or undefined when nothing changes. The rewrite is checked
187
+ * against the parsed file, so a layout it cannot rewrite stops the rename.
188
+ */
189
+ function planVoiceBeatKeys(
190
+ deckDir: string,
191
+ from: string,
192
+ to: string,
193
+ ): { path: string; source: string; next: string } | undefined {
194
+ const path = deckPaths(deckDir).voiceToml;
195
+ if (!existsSync(path)) {
196
+ return undefined;
197
+ }
198
+ const source = readFileSync(path, "utf8");
199
+ let parsed: Record<string, unknown>;
200
+ try {
201
+ parsed = Bun.TOML.parse(source) as Record<string, unknown>;
202
+ } catch {
203
+ // An unreadable voice.toml is the voice loader's to report, not mv's.
204
+ return undefined;
205
+ }
206
+ const beats = parsed.beats;
207
+ if (beats === null || typeof beats !== "object") {
208
+ return undefined;
209
+ }
210
+ const rename = (key: string): string | undefined => renameBeatKey(key, from, to);
211
+ const expected = {
212
+ ...parsed,
213
+ beats: Object.fromEntries(
214
+ Object.entries(beats).map(([key, value]) => [rename(key) ?? key, value]),
215
+ ),
216
+ };
217
+ const next = renameTableKeys(source, "beats", rename);
218
+ let actual: unknown;
219
+ try {
220
+ actual = Bun.TOML.parse(next);
221
+ } catch {
222
+ actual = undefined;
223
+ }
224
+ if (!Bun.deepEquals(actual, expected, true)) {
225
+ throw new DekError(`cannot rewrite the [beats] keys for "${from}" in voice.toml`, {
226
+ path,
227
+ hint: `write them as [beats."${from}/…"] tables or dotted keys, or rename them to "${to}" by hand`,
228
+ });
229
+ }
230
+ return next === source ? undefined : { path, source, next };
231
+ }
232
+
233
+ /** `slug` or `slug/beat` for the renamed slide, else undefined. */
234
+ function renameBeatKey(key: string, from: string, to: string): string | undefined {
235
+ const slash = key.indexOf("/");
236
+ const slug = slash < 0 ? key : key.slice(0, slash);
237
+ return slug === from ? `${to}${slash < 0 ? "" : key.slice(slash)}` : undefined;
238
+ }
239
+
240
+ /** Where a moved section lands: right before one section, or right after it. */
241
+ export type SectionPlace = { before: string } | { after: string };
242
+
243
+ export function reorderSection(dir: string, slug: string, place: SectionPlace): void;
244
+ export function reorderSection(source: ResolvedDeck, slug: string, place: SectionPlace): void;
245
+ export function reorderSection(
246
+ input: string | ResolvedDeck,
247
+ slug: string,
248
+ place: SectionPlace,
249
+ ): void {
250
+ const target = "before" in place ? place.before : place.after;
251
+ const { deck } = asResolvedDeck(input);
252
+ const sections = deck.deck.sections;
253
+ const fromIndex = sections.findIndex((entry) => entry.slug === slug);
254
+ if (fromIndex < 0) {
255
+ requireSection(deck, slug);
256
+ }
257
+ const targetIndex = sections.findIndex((entry) => entry.slug === target);
258
+ if (targetIndex < 0) {
259
+ requireSection(deck, target);
260
+ }
261
+
262
+ const source = readFileSync(deck.scriptPath, "utf8");
263
+ const lines = splitLines(source);
264
+ const firstLine = sections[0]?.line;
265
+ if (firstLine === undefined) {
266
+ return;
267
+ }
268
+ const head = lines.slice(0, firstLine - 1);
269
+ const chunks = sectionChunks(
270
+ lines,
271
+ sections.map((section) => section.line),
272
+ );
273
+
274
+ const moved = chunks.splice(fromIndex, 1)[0];
275
+ if (!moved) {
276
+ return;
277
+ }
278
+ const remainingTarget = chunks.findIndex((_, index) => {
279
+ const original = index < fromIndex ? index : index + 1;
280
+ return original === targetIndex;
281
+ });
282
+ const insertAt = "before" in place ? remainingTarget : remainingTarget + 1;
283
+ chunks.splice(insertAt, 0, moved);
284
+ writeFileSync(deck.scriptPath, joinLines(source, [...head, ...chunks.flat()]));
285
+ }
286
+
287
+ function rewriteHeadingId(heading: string, from: string, to: string): string {
288
+ const attr = new RegExp(`\\{#${escapeRegExp(from)}\\}`);
289
+ if (attr.test(heading)) {
290
+ return heading.replace(attr, `{#${to}}`);
291
+ }
292
+ return `${heading} {#${to}}`;
293
+ }
@@ -0,0 +1,99 @@
1
+ import { escapeAttr } from "./escape.ts";
2
+
3
+ export type OgpImage = {
4
+ url: string;
5
+ width: number;
6
+ height: number;
7
+ };
8
+
9
+ export type OgpInput = {
10
+ title: string;
11
+ description?: string;
12
+ event?: string;
13
+ date?: string;
14
+ lang: string;
15
+ /** The page's own absolute URL; left out, the page has no og:url. */
16
+ url?: string;
17
+ image?: OgpImage;
18
+ };
19
+
20
+ /** The region Open Graph pairs with a bare language, as `og:locale` wants `ll_CC`. */
21
+ const USUAL_REGION: Record<string, string> = {
22
+ ja: "JP",
23
+ en: "US",
24
+ zh: "CN",
25
+ ko: "KR",
26
+ de: "DE",
27
+ fr: "FR",
28
+ es: "ES",
29
+ it: "IT",
30
+ pt: "PT",
31
+ nl: "NL",
32
+ ru: "RU",
33
+ };
34
+
35
+ function ogLocale(lang: string): string | undefined {
36
+ const [language = "", ...rest] = lang.split("-");
37
+ const region = rest.find((part) => /^[A-Za-z]{2}$/.test(part));
38
+ const code = language.toLowerCase();
39
+ const country = region?.toUpperCase() ?? USUAL_REGION[code];
40
+ return country ? `${code}_${country}` : undefined;
41
+ }
42
+
43
+ /**
44
+ * What a link to the page shows when someone shares it: title, description, and, once the
45
+ * page knows where it is served from, its URL and a picture of the first slide. Crawlers only
46
+ * fetch an absolute og:image, so without a URL there is no image at all.
47
+ */
48
+ export function ogpHead(input: OgpInput): string {
49
+ const description =
50
+ input.description ?? ([input.event, input.date].filter(Boolean).join(" · ") || undefined);
51
+ const locale = ogLocale(input.lang);
52
+ const tags: Array<[kind: "name" | "property", key: string, value: string | number]> = [];
53
+ if (description) {
54
+ tags.push(["name", "description", description]);
55
+ }
56
+ tags.push(["property", "og:type", "website"], ["property", "og:title", input.title]);
57
+ if (description) {
58
+ tags.push(["property", "og:description", description]);
59
+ }
60
+ if (locale) {
61
+ tags.push(["property", "og:locale", locale]);
62
+ }
63
+ if (input.url) {
64
+ tags.push(["property", "og:url", input.url]);
65
+ }
66
+ if (input.image) {
67
+ tags.push(
68
+ ["property", "og:image", input.image.url],
69
+ ["property", "og:image:width", input.image.width],
70
+ ["property", "og:image:height", input.image.height],
71
+ ["property", "og:image:alt", input.title],
72
+ );
73
+ }
74
+ tags.push(["name", "twitter:card", input.image ? "summary_large_image" : "summary"]);
75
+ const lines = tags.map(
76
+ ([kind, key, value]) => `<meta ${kind}="${key}" content="${escapeAttr(String(value))}">`,
77
+ );
78
+ if (input.url) {
79
+ lines.push(`<link rel="canonical" href="${escapeAttr(input.url)}">`);
80
+ }
81
+ return lines.join("\n ");
82
+ }
83
+
84
+ /**
85
+ * The URL dist/ is served from, ending in a slash so a deck's file name joins onto it, or
86
+ * undefined when a crawler could not fetch it: not absolute http(s), or carrying a query.
87
+ */
88
+ export function parsePublicUrl(value: string): string | undefined {
89
+ let url: URL;
90
+ try {
91
+ url = new URL(value);
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ if ((url.protocol !== "https:" && url.protocol !== "http:") || url.search || url.hash) {
96
+ return undefined;
97
+ }
98
+ return url.href.endsWith("/") ? url.href : `${url.href}/`;
99
+ }
@@ -0,0 +1,24 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ export function walkUp<T>(startDir: string, visit: (dir: string) => T | undefined): T | undefined {
5
+ let dir = resolve(startDir);
6
+ while (true) {
7
+ const hit = visit(dir);
8
+ if (hit !== undefined) {
9
+ return hit;
10
+ }
11
+ const parent = dirname(dir);
12
+ if (parent === dir) {
13
+ return undefined;
14
+ }
15
+ dir = parent;
16
+ }
17
+ }
18
+
19
+ export function resolveBinFromAncestors(name: string, startDir: string): string | undefined {
20
+ return walkUp(startDir, (dir) => {
21
+ const bin = join(dir, "node_modules", ".bin", name);
22
+ return existsSync(bin) ? bin : undefined;
23
+ });
24
+ }
@@ -0,0 +1,220 @@
1
+ import type { Box, MeasuredElement } from "./slide-measure.ts";
2
+
3
+ export type Edge = "top" | "right" | "bottom" | "left";
4
+
5
+ export const EDGES: Edge[] = ["top", "right", "bottom", "left"];
6
+
7
+ export type Overflow = {
8
+ box: string;
9
+ text?: string;
10
+ /** Pixels past each edge it crosses. */
11
+ by: Partial<Record<Edge, number>>;
12
+ origin?: OverflowOrigin;
13
+ /** The ancestor that cuts the text off, when the edge is that ancestor's and not the frame's. */
14
+ clip?: string;
15
+ };
16
+
17
+ /** An overflow as measured: with the index of its element, which finds it again in a second measure. */
18
+ type LocatedOverflow = Overflow & { element: number };
19
+
20
+ /**
21
+ * What put an overflow past the edge, found by taking the page apart: the slide script's draw,
22
+ * the slide's own CSS, or neither, which leaves the content itself too big for the theme.
23
+ */
24
+ export type OverflowOrigin = "script" | "slide" | "content";
25
+
26
+ /** Whether `element` crosses an edge of the slide at all, whatever its parent does. */
27
+ export function crossesEdge(
28
+ slide: Box,
29
+ element: Pick<MeasuredElement, "rect"> & {
30
+ opacity?: number;
31
+ decorative?: boolean;
32
+ clip?: MeasuredElement["clip"];
33
+ },
34
+ ): boolean {
35
+ return (
36
+ (element.opacity ?? 1) >= 0.01 &&
37
+ element.decorative !== true &&
38
+ Object.keys(overflowAmounts(slide, visibleRect(element))).length > 0
39
+ );
40
+ }
41
+
42
+ /** An element's box, less what an ancestor inside the slide cuts off. */
43
+ function visibleRect({ rect, clip }: Pick<MeasuredElement, "rect" | "clip">): Box {
44
+ return clip
45
+ ? {
46
+ left: Math.max(rect.left, clip.rect.left),
47
+ top: Math.max(rect.top, clip.rect.top),
48
+ right: Math.min(rect.right, clip.rect.right),
49
+ bottom: Math.min(rect.bottom, clip.rect.bottom),
50
+ }
51
+ : rect;
52
+ }
53
+
54
+ function overflowAmounts(slide: Box, rect: Box): Partial<Record<Edge, number>> {
55
+ if (rect.right <= rect.left || rect.bottom <= rect.top) {
56
+ return {};
57
+ }
58
+ const past: Record<Edge, number> = {
59
+ top: slide.top - rect.top,
60
+ right: rect.right - slide.right,
61
+ bottom: rect.bottom - slide.bottom,
62
+ left: slide.left - rect.left,
63
+ };
64
+ const by: Partial<Record<Edge, number>> = {};
65
+ for (const edge of EDGES) {
66
+ // Sub-pixel layout rounding is not an overflow anyone can see.
67
+ if (past[edge] >= 1) {
68
+ by[edge] = Math.round(past[edge]);
69
+ }
70
+ }
71
+ return by;
72
+ }
73
+
74
+ /**
75
+ * The elements that cross an edge of the slide, each reported only for the
76
+ * edges its parent stays inside: a list that runs off the bottom is one
77
+ * finding, not one per item.
78
+ */
79
+ export function findOverflows(
80
+ slide: Box,
81
+ elements: Array<
82
+ Pick<MeasuredElement, "box" | "parent" | "rect" | "text"> & {
83
+ opacity?: number;
84
+ decorative?: boolean;
85
+ clip?: MeasuredElement["clip"];
86
+ }
87
+ >,
88
+ ): LocatedOverflow[] {
89
+ // An invisible element, such as a beat before it shows, overflows nothing anyone sees, and
90
+ // decoration, such as a glow, may bleed off the slide as it likes. What an ancestor inside the
91
+ // slide cuts off is not past the frame; findClippedText says when that is text.
92
+ const amounts = elements.map((element) =>
93
+ (element.opacity ?? 1) < 0.01 || element.decorative === true
94
+ ? {}
95
+ : overflowAmounts(slide, visibleRect(element)),
96
+ );
97
+ const found: LocatedOverflow[] = [];
98
+ elements.forEach((element, index) => {
99
+ const parent = amounts[element.parent] ?? {};
100
+ const by: Partial<Record<Edge, number>> = {};
101
+ for (const edge of EDGES) {
102
+ const amount = amounts[index]?.[edge];
103
+ if (amount !== undefined && parent[edge] === undefined) {
104
+ by[edge] = amount;
105
+ }
106
+ }
107
+ if (Object.keys(by).length > 0) {
108
+ found.push({
109
+ box: element.box,
110
+ ...(element.text ? { text: element.text } : {}),
111
+ by,
112
+ element: index,
113
+ });
114
+ }
115
+ });
116
+ return found;
117
+ }
118
+
119
+ /**
120
+ * The texts an ancestor inside the slide cuts off, with `overflow: hidden` or the like: each by how
121
+ * far its lines run past what the ancestor shows. A truncation the author asked for, an ellipsis
122
+ * or a line clamp, is left alone, and so are decoration and text nobody sees.
123
+ */
124
+ export function findClippedText(
125
+ elements: Array<
126
+ Pick<MeasuredElement, "box" | "text" | "textRects" | "opacity" | "decorative" | "clip">
127
+ >,
128
+ ): LocatedOverflow[] {
129
+ return elements.flatMap((element, index) => {
130
+ const { clip } = element;
131
+ if (!clip || clip.intended || element.decorative || element.opacity < 0.01) {
132
+ return [];
133
+ }
134
+ const lines = element.textRects.filter(
135
+ (rect) => rect.right > rect.left && rect.bottom > rect.top,
136
+ );
137
+ if (lines.length === 0) {
138
+ return [];
139
+ }
140
+ const text = {
141
+ left: Math.min(...lines.map((rect) => rect.left)),
142
+ top: Math.min(...lines.map((rect) => rect.top)),
143
+ right: Math.max(...lines.map((rect) => rect.right)),
144
+ bottom: Math.max(...lines.map((rect) => rect.bottom)),
145
+ };
146
+ const by = overflowAmounts(clip.rect, text);
147
+ return Object.keys(by).length === 0
148
+ ? []
149
+ : [
150
+ {
151
+ box: element.box,
152
+ ...(element.text ? { text: element.text } : {}),
153
+ by,
154
+ clip: clip.box,
155
+ element: index,
156
+ },
157
+ ];
158
+ });
159
+ }
160
+
161
+ /** One text as the page draws it, for finding which texts are drawn over which. */
162
+ type TextBox = { rects: Box[]; opacity: number };
163
+
164
+ /**
165
+ * The pairs of texts whose lines are drawn over each other, a folio or running head a
166
+ * pseudo-element draws included, first in the page first. Lines that only touch, as the words of
167
+ * one sentence set in two elements do, are not a collision: the lines must cross by a few pixels
168
+ * each way and share a fifth of the smaller one.
169
+ */
170
+ export function findCollisions<T extends TextBox>(texts: T[]): Array<[T, T]> {
171
+ const MIN_CROSS = 4;
172
+ const MIN_SHARE = 0.2;
173
+ const area = (rect: Box): number => (rect.right - rect.left) * (rect.bottom - rect.top);
174
+ const crosses = (a: Box, b: Box): boolean => {
175
+ const width = Math.min(a.right, b.right) - Math.max(a.left, b.left);
176
+ const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
177
+ return (
178
+ width >= MIN_CROSS &&
179
+ height >= MIN_CROSS &&
180
+ width * height >= MIN_SHARE * Math.min(area(a), area(b))
181
+ );
182
+ };
183
+ const shown = texts.filter((text) => text.opacity >= 0.01 && text.rects.length > 0);
184
+ const found: Array<[T, T]> = [];
185
+ for (const [at, one] of shown.entries()) {
186
+ for (const other of shown.slice(at + 1)) {
187
+ if (one.rects.some((a) => other.rects.some((b) => crosses(a, b)))) {
188
+ found.push([one, other]);
189
+ }
190
+ }
191
+ }
192
+ return found;
193
+ }
194
+
195
+ /**
196
+ * Whether two texts take one place, as the numbers a count-up stacks in one cell do: the box their
197
+ * lines span shares at least half of each. A callout laid over a label is far larger than the
198
+ * label, and takes no place of the label's.
199
+ */
200
+ export function samePlace(a: Pick<TextBox, "rects">, b: Pick<TextBox, "rects">): boolean {
201
+ const span = ({ rects }: Pick<TextBox, "rects">): Box => ({
202
+ left: Math.min(...rects.map((rect) => rect.left)),
203
+ top: Math.min(...rects.map((rect) => rect.top)),
204
+ right: Math.max(...rects.map((rect) => rect.right)),
205
+ bottom: Math.max(...rects.map((rect) => rect.bottom)),
206
+ });
207
+ const area = (box: Box): number =>
208
+ Math.max(0, box.right - box.left) * Math.max(0, box.bottom - box.top);
209
+ const [one, other] = [span(a), span(b)];
210
+ const shared = area({
211
+ left: Math.max(one.left, other.left),
212
+ top: Math.max(one.top, other.top),
213
+ right: Math.min(one.right, other.right),
214
+ bottom: Math.min(one.bottom, other.bottom),
215
+ });
216
+ return shared >= 0.5 * Math.max(area(one), area(other));
217
+ }
218
+
219
+ /** Two texts drawn over each other: the one first in the page, and the one it collides with. */
220
+ export type Collision = { box: string; text?: string; other: string; otherText?: string };
@@ -0,0 +1,53 @@
1
+ /** The part of a Playwright page the pool drives. */
2
+ type PoolPage = {
3
+ goto(url: string): Promise<unknown>;
4
+ close(): Promise<void>;
5
+ };
6
+
7
+ /**
8
+ * How many pages a worker renders at once. A new page costs Chromium about
9
+ * as much as rendering a slide on it, so pages are kept and reused; a few at
10
+ * a time keep the renderer busy without keeping a long deck alive at once.
11
+ */
12
+ const PAGE_LANES = 4;
13
+
14
+ /**
15
+ * Visits every item on a small set of reused pages and returns the results
16
+ * in item order. Each visit starts from about:blank, so what one slide's
17
+ * scripts left on `window` never reaches the next. After a visit fails, no
18
+ * lane takes another item; every page is closed and the first failure thrown.
19
+ */
20
+ export async function visitInPages<P extends PoolPage, T, R>(
21
+ items: readonly T[],
22
+ openPage: () => Promise<P>,
23
+ visit: (page: P, item: T) => Promise<R>,
24
+ { lanes = PAGE_LANES }: { lanes?: number } = {},
25
+ ): Promise<R[]> {
26
+ const results = new Array<R>(items.length);
27
+ let next = 0;
28
+ let failed = false;
29
+ let failure: unknown;
30
+ const lane = async (): Promise<void> => {
31
+ let page: P | undefined;
32
+ try {
33
+ page = await openPage();
34
+ while (!failed && next < items.length) {
35
+ const index = next++;
36
+ await page.goto("about:blank");
37
+ results[index] = await visit(page, items[index] as T);
38
+ }
39
+ } catch (error) {
40
+ if (!failed) {
41
+ failed = true;
42
+ failure = error;
43
+ }
44
+ } finally {
45
+ await page?.close();
46
+ }
47
+ };
48
+ await Promise.all(Array.from({ length: Math.min(lanes, items.length) }, lane));
49
+ if (failed) {
50
+ throw failure;
51
+ }
52
+ return results;
53
+ }