@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,100 @@
1
+ import type { Box, MeasuredElement } from "./slide-measure.ts";
2
+
3
+ /**
4
+ * How much of the frame a slide fills, and where: what an agent otherwise opens a screenshot to
5
+ * see, as numbers. What fills a slide is what the audience reads or looks at: each text's lines,
6
+ * each picture's box, and each painted box that holds nothing, as a chart's bar or a swatch is. A
7
+ * painted box that holds something, a card or a window, is as full as what it holds, so one whose
8
+ * words sit in its top half leaves the rest empty. Decoration counts like the rest: aria-hidden
9
+ * tells lint and screen readers to pass it by, and the audience still sees it take its place.
10
+ * Text a pseudo-element draws fills nothing (a folio sits in a corner of every slide), nor does
11
+ * anything the beat has not shown yet. Every share is of the frame, from 0 to 1, to two places.
12
+ */
13
+ export type Fill = {
14
+ /** The share of the frame the content covers, overlaps counted once. */
15
+ coverage: number;
16
+ /** The smallest box holding all of it, as shares of the frame's width and height. */
17
+ box?: Box;
18
+ /** The share of each tenth of the frame, top to bottom, the content covers. */
19
+ rows: number[];
20
+ /** The same, left to right. */
21
+ columns: number[];
22
+ };
23
+
24
+ const BANDS = 10;
25
+
26
+ const share = (part: number, whole: number): number =>
27
+ whole > 0 ? Math.round((part / whole) * 100) / 100 : 0;
28
+
29
+ export function measureFill(frame: Box, elements: MeasuredElement[]): Fill {
30
+ const width = Math.max(0, Math.round(frame.right - frame.left));
31
+ const height = Math.max(0, Math.round(frame.bottom - frame.top));
32
+ // One cell a pixel, so what overlaps is counted once.
33
+ const covered = new Uint8Array(width * height);
34
+ let extent: Box | undefined;
35
+ const holding = new Set(elements.map((element) => element.parent));
36
+ for (const [index, element] of elements.entries()) {
37
+ if (element.opacity <= 0) {
38
+ continue;
39
+ }
40
+ const seen = element.clip?.rect;
41
+ const mark = element.picture || (element.paints && !holding.has(index));
42
+ for (const rect of [...(mark ? [element.rect] : []), ...element.textRects]) {
43
+ const left = Math.max(rect.left, frame.left, seen?.left ?? -Infinity);
44
+ const top = Math.max(rect.top, frame.top, seen?.top ?? -Infinity);
45
+ const right = Math.min(rect.right, frame.right, seen?.right ?? Infinity);
46
+ const bottom = Math.min(rect.bottom, frame.bottom, seen?.bottom ?? Infinity);
47
+ if (right <= left || bottom <= top) {
48
+ continue;
49
+ }
50
+ extent = extent
51
+ ? {
52
+ left: Math.min(extent.left, left),
53
+ top: Math.min(extent.top, top),
54
+ right: Math.max(extent.right, right),
55
+ bottom: Math.max(extent.bottom, bottom),
56
+ }
57
+ : { left, top, right, bottom };
58
+ const [x0, x1] = [Math.round(left - frame.left), Math.round(right - frame.left)];
59
+ const [y0, y1] = [Math.round(top - frame.top), Math.round(bottom - frame.top)];
60
+ for (let y = y0; y < y1; y++) {
61
+ covered.fill(1, y * width + x0, y * width + x1);
62
+ }
63
+ }
64
+ }
65
+ const rows = new Array<number>(height).fill(0);
66
+ const columns = new Array<number>(width).fill(0);
67
+ let total = 0;
68
+ for (let y = 0; y < height; y++) {
69
+ for (let x = 0; x < width; x++) {
70
+ if (covered[y * width + x]) {
71
+ rows[y] = (rows[y] ?? 0) + 1;
72
+ columns[x] = (columns[x] ?? 0) + 1;
73
+ total++;
74
+ }
75
+ }
76
+ }
77
+ /** Each tenth of `lines`, as the share of it `across` cells deep that is covered. */
78
+ const bands = (lines: number[], across: number): number[] =>
79
+ Array.from({ length: BANDS }, (_, band) => {
80
+ const from = Math.round((band * lines.length) / BANDS);
81
+ const to = Math.round(((band + 1) * lines.length) / BANDS);
82
+ const sum = lines.slice(from, to).reduce((a, b) => a + b, 0);
83
+ return share(sum, (to - from) * across);
84
+ });
85
+ return {
86
+ coverage: share(total, width * height),
87
+ ...(extent
88
+ ? {
89
+ box: {
90
+ left: share(extent.left - frame.left, width),
91
+ top: share(extent.top - frame.top, height),
92
+ right: share(extent.right - frame.left, width),
93
+ bottom: share(extent.bottom - frame.top, height),
94
+ },
95
+ }
96
+ : {}),
97
+ rows: bands(rows, width),
98
+ columns: bands(columns, height),
99
+ };
100
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Brings the page to the end of the beat it shows, the way a still of that beat
3
+ * looks: every animation run to its end, the view transition over, and the slide
4
+ * script drawn at the end of its motion. An animation that repeats forever has no
5
+ * end, so it is held at its first frame instead, and every still of it agrees.
6
+ *
7
+ * Runs in the page, shipped by `page.evaluate` or embedded as source in still
8
+ * pages, so it references nothing outside itself.
9
+ */
10
+ export function finishBeat(): void {
11
+ // A paused animation seeked past its own end has no effect, so `getAnimations()` leaves it
12
+ // out, yet it never finishes: the go that started it would wait on it forever.
13
+ const held = (window.__dekStarted ?? []).filter((animation) => animation.playState === "paused");
14
+ for (const animation of new Set([...document.getAnimations(), ...held])) {
15
+ const end = animation.effect?.getComputedTiming().endTime;
16
+ if (typeof end === "number" && Number.isFinite(end)) {
17
+ animation.finish();
18
+ } else {
19
+ animation.pause();
20
+ animation.currentTime = 0;
21
+ }
22
+ }
23
+ // Chromium never reports the end of a transition whose animations were paused and
24
+ // seeked, even once they finish; its animations are over, so the transition is too.
25
+ document.activeViewTransition?.skipTransition();
26
+ const motion = window.dekMotion;
27
+ motion?.seek(motion.duration());
28
+ // What the next go finds already here is this beat's, and seeking that go leaves it alone.
29
+ window.__dekSettled = new Set(document.getAnimations());
30
+ }
@@ -0,0 +1,234 @@
1
+ import { DekError } from "./error.ts";
2
+
3
+ /**
4
+ * The two GitHub calls dekc ref needs: pin a rev to a commit, and download
5
+ * that commit. DEK_GITHUB_API points them elsewhere, the way DEK_VOICE_URL
6
+ * does for the voice engine.
7
+ */
8
+ const DEFAULT_API = "https://api.github.com";
9
+
10
+ /** A repository tarball larger than this is refused rather than held in memory. */
11
+ const MAX_TARBALL_BYTES = 200 * 1024 * 1024;
12
+
13
+ /** A commit lookup is one small answer; a stall this long is a dead connection. */
14
+ const COMMIT_TIMEOUT_MS = 30 * 1000;
15
+
16
+ /** A tarball near the size cap on a slow line still fits well inside this. */
17
+ const TARBALL_TIMEOUT_MS = 10 * 60 * 1000;
18
+
19
+ const SIGN_IN_HINT =
20
+ "if the repository is private, set GITHUB_TOKEN or run `gh auth login`, then run the command again";
21
+
22
+ export type GithubOptions = {
23
+ /** Covers the whole call, from connecting to the last byte of the body. */
24
+ timeoutMs?: number;
25
+ };
26
+
27
+ export type TarballOptions = GithubOptions & {
28
+ maxBytes?: number;
29
+ };
30
+
31
+ function apiBase(): string {
32
+ return (process.env.DEK_GITHUB_API ?? DEFAULT_API).replace(/\/+$/, "");
33
+ }
34
+
35
+ /** GITHUB_TOKEN, else what `gh auth token` prints, else nothing: public repositories need no token. */
36
+ function token(): string | undefined {
37
+ const fromEnv = process.env.GITHUB_TOKEN?.trim();
38
+ if (fromEnv) {
39
+ return fromEnv;
40
+ }
41
+ try {
42
+ const result = Bun.spawnSync([process.env.DEK_GH ?? "gh", "auth", "token"], {
43
+ stdout: "pipe",
44
+ stderr: "ignore",
45
+ });
46
+ const printed = result.exitCode === 0 ? result.stdout.toString().trim() : "";
47
+ return printed || undefined;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * One call's deadline. `signal` goes to fetch and fires on the timeout or on
55
+ * `abort`, which is how a download is dropped: cancelling the body reader
56
+ * alone leaves the connection open. `timeout` tells the two apart.
57
+ */
58
+ type Deadline = {
59
+ signal: AbortSignal;
60
+ timeout: AbortSignal;
61
+ abort: () => void;
62
+ expired: () => DekError;
63
+ };
64
+
65
+ function deadline(what: string, timeoutMs: number): Deadline {
66
+ const controller = new AbortController();
67
+ const timeout = AbortSignal.timeout(timeoutMs);
68
+ return {
69
+ signal: AbortSignal.any([controller.signal, timeout]),
70
+ timeout,
71
+ abort: () => controller.abort(),
72
+ expired: () =>
73
+ new DekError(`GitHub timed out after ${timeoutMs / 1000}s while fetching ${what}`, {
74
+ hint: "check the network and run the command again; GitHub may also be slow, see https://www.githubstatus.com",
75
+ }),
76
+ };
77
+ }
78
+
79
+ function rateLimitHint(retryAfter: string | null): string {
80
+ const wait = retryAfter ? `wait ${retryAfter}s, and ` : "";
81
+ return `${wait}set GITHUB_TOKEN or run \`gh auth login\` to raise the limit, then run the command again`;
82
+ }
83
+
84
+ async function request(
85
+ path: string,
86
+ what: string,
87
+ accept: string,
88
+ { signal, timeout, expired }: Deadline,
89
+ ): Promise<Response> {
90
+ const auth = token();
91
+ let response: Response;
92
+ try {
93
+ response = await fetch(`${apiBase()}${path}`, {
94
+ headers: {
95
+ accept,
96
+ "user-agent": "dek",
97
+ ...(auth ? { authorization: `Bearer ${auth}` } : {}),
98
+ },
99
+ redirect: "follow",
100
+ signal,
101
+ });
102
+ } catch (error) {
103
+ if (timeout.aborted) {
104
+ throw expired();
105
+ }
106
+ throw new DekError(`could not reach GitHub for ${what}`, {
107
+ cause: error,
108
+ hint: "check the network; an agent sandbox may block it, so run the command yourself",
109
+ });
110
+ }
111
+ if (response.ok) {
112
+ return response;
113
+ }
114
+ if (response.status === 404) {
115
+ throw new DekError(`${what} not found on GitHub`, { hint: SIGN_IN_HINT });
116
+ }
117
+ const retryAfter = response.headers.get("retry-after");
118
+ if (
119
+ (response.status === 403 || response.status === 429) &&
120
+ (retryAfter !== null || response.headers.get("x-ratelimit-remaining") === "0")
121
+ ) {
122
+ throw new DekError(`GitHub rate limit reached while fetching ${what}`, {
123
+ hint: rateLimitHint(retryAfter),
124
+ });
125
+ }
126
+ if (response.status === 401 || response.status === 403) {
127
+ throw new DekError(`GitHub refused access to ${what} (${response.status})`, {
128
+ hint: SIGN_IN_HINT,
129
+ });
130
+ }
131
+ throw new DekError(`GitHub answered ${response.status} for ${what}`, {
132
+ hint: "run the command again; if it keeps failing, check https://www.githubstatus.com",
133
+ });
134
+ }
135
+
136
+ /** The full sha a rev names; the default branch when `rev` is omitted. */
137
+ export async function resolveRev(
138
+ owner: string,
139
+ repo: string,
140
+ rev?: string,
141
+ options: GithubOptions = {},
142
+ ): Promise<string> {
143
+ const what = `${owner}/${repo}${rev ? `@${rev}` : ""}`;
144
+ const response = await request(
145
+ `/repos/${owner}/${repo}/commits/${encodeURIComponent(rev ?? "HEAD")}`,
146
+ what,
147
+ "application/vnd.github.sha",
148
+ deadline(what, options.timeoutMs ?? COMMIT_TIMEOUT_MS),
149
+ );
150
+ const sha = (await response.text()).trim();
151
+ if (!/^[0-9a-f]{40}$/.test(sha)) {
152
+ throw new DekError(`GitHub did not return a commit for ${what}`, {
153
+ hint: "run the command again",
154
+ });
155
+ }
156
+ return sha;
157
+ }
158
+
159
+ /** The repository at one commit, as a gzipped tarball. */
160
+ export async function downloadTarball(
161
+ owner: string,
162
+ repo: string,
163
+ sha: string,
164
+ options: TarballOptions = {},
165
+ ): Promise<Uint8Array> {
166
+ const what = `${owner}/${repo}@${sha.slice(0, 7)}`;
167
+ const maxBytes = options.maxBytes ?? MAX_TARBALL_BYTES;
168
+ const limit = deadline(what, options.timeoutMs ?? TARBALL_TIMEOUT_MS);
169
+ const response = await request(`/repos/${owner}/${repo}/tarball/${sha}`, what, "*/*", limit);
170
+ const tooLarge = new DekError(`${what} is larger than ${maxBytes / 1024 / 1024} MB`, {
171
+ hint: "dekc ref downloads the whole repository; keep decks in a smaller repository",
172
+ });
173
+ if (Number(response.headers.get("content-length") ?? 0) > maxBytes) {
174
+ throw tooLarge;
175
+ }
176
+ return readBody(response, maxBytes, tooLarge, limit);
177
+ }
178
+
179
+ /**
180
+ * The body, chunk by chunk, giving up as soon as it passes `maxBytes`:
181
+ * codeload answers chunked, so the content-length check above rarely applies.
182
+ * The fetch signal does not wake a read in progress, so the timeout also
183
+ * cancels the reader.
184
+ */
185
+ async function readBody(
186
+ response: Response,
187
+ maxBytes: number,
188
+ tooLarge: DekError,
189
+ { timeout, abort, expired }: Deadline,
190
+ ): Promise<Uint8Array> {
191
+ if (!response.body) {
192
+ return new Uint8Array(0);
193
+ }
194
+ const reader = response.body.getReader();
195
+ const cancel = () => void reader.cancel().catch(() => {});
196
+ timeout.addEventListener("abort", cancel, { once: true });
197
+ const chunks: Uint8Array[] = [];
198
+ let total = 0;
199
+ try {
200
+ while (true) {
201
+ const { done, value } = await reader.read();
202
+ if (done) {
203
+ break;
204
+ }
205
+ total += value.byteLength;
206
+ if (total > maxBytes) {
207
+ abort();
208
+ throw tooLarge;
209
+ }
210
+ chunks.push(value);
211
+ }
212
+ } catch (error) {
213
+ if (timeout.aborted) {
214
+ throw expired();
215
+ }
216
+ throw error;
217
+ } finally {
218
+ timeout.removeEventListener("abort", cancel);
219
+ }
220
+ if (timeout.aborted) {
221
+ throw expired();
222
+ }
223
+ return concat(chunks, total);
224
+ }
225
+
226
+ function concat(chunks: Uint8Array[], total: number): Uint8Array {
227
+ const bytes = new Uint8Array(total);
228
+ let offset = 0;
229
+ for (const chunk of chunks) {
230
+ bytes.set(chunk, offset);
231
+ offset += chunk.byteLength;
232
+ }
233
+ return bytes;
234
+ }
@@ -0,0 +1,253 @@
1
+ import { hasSlideClass, uniqueMark } from "./html.ts";
2
+ import { lineLocator } from "./lines.ts";
3
+ import { attributeUrls, type UrlUse } from "./url-attributes.ts";
4
+
5
+ /** Where something is written: 1-based line, and 1-based column in UTF-16 units, as editors count. */
6
+ export type SourceSpot = { line: number; column: number };
7
+
8
+ /** One attribute as the parser read it, located at its name. */
9
+ export type HtmlAttribute = SourceSpot & { name: string; value: string };
10
+
11
+ /**
12
+ * One start tag, located at its `<`, with its attributes in source order, and whether it is in a
13
+ * slide: the `<section class="slide">` itself or inside one, rather than a full document's head.
14
+ */
15
+ type HtmlElement = SourceSpot & {
16
+ tag: string;
17
+ attributes: HtmlAttribute[];
18
+ inSlide: boolean;
19
+ };
20
+
21
+ /** One URL an attribute names, located where the URL itself is written. */
22
+ export type HtmlRef = SourceSpot & { tag: string; attr: string; value: string; use: UrlUse };
23
+
24
+ export type HtmlScan = {
25
+ /** Every start tag in the file, in document order. */
26
+ elements: HtmlElement[];
27
+ /** Every `<section class="slide">`; only the first is ever shown. */
28
+ slides: HtmlElement[];
29
+ slug?: string;
30
+ /** The slide section's `data-layout`. */
31
+ layout?: string;
32
+ classes: string[];
33
+ /** Every URL the markup names, a `srcset` candidate apiece. */
34
+ refs: HtmlRef[];
35
+ /** Headings with nothing to read: no text, no image, no `aria-label`. */
36
+ emptyHeadings: HtmlElement[];
37
+ /**
38
+ * Each outermost `aria-hidden="true"` element that holds text, with that text, its whitespace
39
+ * collapsed. An SVG's `<title>` and `<desc>` describe it and are never drawn, so they hold none.
40
+ */
41
+ hiddenTexts: Array<{ element: HtmlElement; text: string }>;
42
+ };
43
+
44
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
45
+ /** Elements that give a heading something to show without any text. */
46
+ const CONTENT_TAGS = new Set(["img", "svg", "picture", "video", "canvas", "object", "math"]);
47
+ /** Elements whose text describes a picture rather than being drawn in it. */
48
+ const UNDRAWN_TAGS = new Set(["title", "desc"]);
49
+
50
+ /**
51
+ * What a slide's markup uses and references, read in one pass of the real parser and located in
52
+ * the source. The parser has no positions, so each start tag gets a marker in front of it; where
53
+ * the markers land, less their own length, is where the tags were written.
54
+ */
55
+ export function scanSlideHtml(html: string): HtmlScan {
56
+ const mark = uniqueMark(html);
57
+ const parsed: Array<{ tag: string; attributes: Array<[string, string]>; inSlide: boolean }> = [];
58
+ let slidesOpen = 0;
59
+ const headings: Array<{ index: number; content: boolean }> = [];
60
+ const open: Array<{ index: number; content: boolean }> = [];
61
+ const hidden: Array<{ index: number; text: string }> = [];
62
+ // The outermost aria-hidden element still open, and how many undrawn elements are open in it.
63
+ let hiding: { index: number; text: string } | undefined;
64
+ let undrawn = 0;
65
+
66
+ const transformed = new HTMLRewriter()
67
+ .on("*", {
68
+ element(el) {
69
+ el.before(mark, { html: true });
70
+ const tag = el.tagName.toLowerCase();
71
+ const attributes = [...el.attributes].map(([name, value]): [string, string] => [
72
+ name.toLowerCase(),
73
+ value,
74
+ ]);
75
+ if (CONTENT_TAGS.has(tag)) {
76
+ for (const heading of open) {
77
+ heading.content = true;
78
+ }
79
+ }
80
+ if (hiding) {
81
+ // Two elements' texts are two words, whatever whitespace the source puts between them.
82
+ hiding.text += " ";
83
+ if (UNDRAWN_TAGS.has(tag) && el.canHaveContent) {
84
+ undrawn++;
85
+ el.onEndTag(() => {
86
+ undrawn--;
87
+ });
88
+ }
89
+ } else if (el.getAttribute("aria-hidden")?.trim() === "true" && el.canHaveContent) {
90
+ const entry = { index: parsed.length, text: "" };
91
+ hidden.push(entry);
92
+ hiding = entry;
93
+ el.onEndTag(() => {
94
+ hiding = undefined;
95
+ });
96
+ }
97
+ if (HEADING_TAGS.has(tag) && el.canHaveContent) {
98
+ const heading = {
99
+ index: parsed.length,
100
+ content: (el.getAttribute("aria-label") ?? "").trim() !== "",
101
+ };
102
+ headings.push(heading);
103
+ open.push(heading);
104
+ el.onEndTag(() => {
105
+ open.splice(open.indexOf(heading), 1);
106
+ });
107
+ }
108
+ if (tag === "section" && hasSlideClass(el.getAttribute("class")) && el.canHaveContent) {
109
+ slidesOpen++;
110
+ el.onEndTag(() => {
111
+ slidesOpen--;
112
+ });
113
+ }
114
+ parsed.push({ tag, attributes, inSlide: slidesOpen > 0 });
115
+ },
116
+ })
117
+ .onDocument({
118
+ text(chunk) {
119
+ if (hiding && undrawn === 0) {
120
+ hiding.text += chunk.text;
121
+ }
122
+ if (open.length > 0 && chunk.text.trim() !== "") {
123
+ for (const heading of open) {
124
+ heading.content = true;
125
+ }
126
+ }
127
+ },
128
+ })
129
+ .transform(html);
130
+ if (typeof transformed !== "string") {
131
+ throw new Error("HTMLRewriter.transform expected a string");
132
+ }
133
+
134
+ const spot = lineLocator(html);
135
+ const elements: HtmlElement[] = [];
136
+ const refs: HtmlRef[] = [];
137
+ let start = 0;
138
+ for (const [index, before] of transformed.split(mark).slice(0, -1).entries()) {
139
+ start += before.length;
140
+ const { tag, attributes, inSlide } = parsed[index] ?? {
141
+ tag: "",
142
+ attributes: [],
143
+ inSlide: false,
144
+ };
145
+ const offsets = attributeOffsets(html, start);
146
+ const element: HtmlElement = {
147
+ tag,
148
+ inSlide,
149
+ ...spot(start),
150
+ attributes: attributes.map(([name, value]) => ({
151
+ name,
152
+ value,
153
+ ...spot(offsets.get(name)?.name ?? start),
154
+ })),
155
+ };
156
+ elements.push(element);
157
+ for (const attribute of element.attributes) {
158
+ const valueAt = offsets.get(attribute.name)?.value;
159
+ refs.push(
160
+ ...attributeRefs(element.tag, attribute).map(({ ref, offset }) => ({
161
+ ...ref,
162
+ ...(valueAt === undefined ? spot(start) : spot(valueAt + offset)),
163
+ })),
164
+ );
165
+ }
166
+ }
167
+
168
+ const slides = elements.filter(
169
+ (element) => element.tag === "section" && hasSlideClass(attributeValue(element, "class")),
170
+ );
171
+ const slug = slides.map((element) => attributeValue(element, "data-slug")).find(isDefined);
172
+ const layout = slides[0] && attributeValue(slides[0], "data-layout");
173
+ return {
174
+ elements,
175
+ slides,
176
+ ...(slug === undefined ? {} : { slug }),
177
+ ...(layout === undefined ? {} : { layout }),
178
+ classes: elements.flatMap((element) =>
179
+ (attributeValue(element, "class") ?? "").split(/\s+/).filter(Boolean),
180
+ ),
181
+ refs,
182
+ emptyHeadings: headings
183
+ .filter((heading) => !heading.content)
184
+ .flatMap((heading) => elements[heading.index] ?? []),
185
+ hiddenTexts: hidden.flatMap(({ index, text }) => {
186
+ const element = elements[index];
187
+ const squashed = text.replace(/\s+/g, " ").trim();
188
+ return element && squashed !== "" ? [{ element, text: squashed }] : [];
189
+ }),
190
+ };
191
+ }
192
+
193
+ /** The value of `name` on `element`, as the parser read it. */
194
+ function attributeValue(element: HtmlElement, name: string): string | undefined {
195
+ return element.attributes.find((attribute) => attribute.name === name)?.value;
196
+ }
197
+
198
+ function isDefined<T>(value: T | undefined): value is T {
199
+ return value !== undefined;
200
+ }
201
+
202
+ /** The URLs one attribute names, each with its offset into the value. */
203
+ function attributeRefs(
204
+ tag: string,
205
+ attribute: HtmlAttribute,
206
+ ): Array<{ ref: Omit<HtmlRef, keyof SourceSpot>; offset: number }> {
207
+ const named = attributeUrls(tag, attribute.name, attribute.value);
208
+ if (!named) {
209
+ return [];
210
+ }
211
+ return named.urls.map(({ url, offset }) => ({
212
+ ref: { tag, attr: attribute.name, value: url, use: named.use },
213
+ offset,
214
+ }));
215
+ }
216
+
217
+ /**
218
+ * Where each attribute of the start tag at `start` is written: its name, and its value when it
219
+ * has one. Only positions come from here; names and values are the parser's.
220
+ */
221
+ function attributeOffsets(
222
+ html: string,
223
+ start: number,
224
+ ): Map<string, { name: number; value?: number }> {
225
+ const offsets = new Map<string, { name: number; value?: number }>();
226
+ const re = /\s*(?:([^\s"'>/=]+)(?:(\s*=\s*)(["']?))?|\/)/y;
227
+ re.lastIndex = start + 1 + (html.slice(start + 1).match(/^[^\s/>]*/)?.[0].length ?? 0);
228
+ while (re.lastIndex < html.length && html[re.lastIndex] !== ">") {
229
+ const at = re.lastIndex;
230
+ const match = re.exec(html);
231
+ if (!match || match[0].length === 0) {
232
+ break;
233
+ }
234
+ const [whole, attr, equals, quote] = match;
235
+ if (attr === undefined) {
236
+ continue;
237
+ }
238
+ const nameAt = at + whole.indexOf(attr);
239
+ let valueAt: number | undefined;
240
+ if (equals !== undefined) {
241
+ valueAt = nameAt + attr.length + equals.length + (quote ? 1 : 0);
242
+ const end = quote
243
+ ? html.indexOf(quote, valueAt)
244
+ : valueAt + Math.max(0, html.slice(valueAt).search(/[\s>]/));
245
+ re.lastIndex = end === -1 ? html.length : quote ? end + 1 : end;
246
+ }
247
+ const key = attr.toLowerCase();
248
+ if (!offsets.has(key)) {
249
+ offsets.set(key, valueAt === undefined ? { name: nameAt } : { name: nameAt, value: valueAt });
250
+ }
251
+ }
252
+ return offsets;
253
+ }