@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,72 @@
1
+ import type { Page } from "playwright";
2
+ import { finishBeat } from "./finish-beat.ts";
3
+ import {
4
+ type EvaluatingPage,
5
+ freezeAt,
6
+ holdStarted,
7
+ seekStarted,
8
+ startGoPaused,
9
+ } from "./in-page-go.ts";
10
+ import type { MorphSpec } from "./playwright.ts";
11
+ import type { Position } from "./step.ts";
12
+
13
+ /** Loads the video document with motion on, the way `dekc video` plays it. */
14
+ export async function loadVideoDoc(
15
+ page: Pick<Page, "emulateMedia" | "setContent">,
16
+ html: string,
17
+ ): Promise<void> {
18
+ await page.emulateMedia({ reducedMotion: "no-preference" });
19
+ await page.setContent(html, { waitUntil: "load" });
20
+ }
21
+
22
+ /** Goes to `position` and ends that beat, as the page stands when the talk moves on. */
23
+ export async function settleAt(page: EvaluatingPage, position: Position): Promise<void> {
24
+ await page.evaluate((to) => window.dekGo?.(to), position);
25
+ await page.evaluate(finishBeat);
26
+ }
27
+
28
+ /**
29
+ * Plays `go(position)` held still: shoots it at each of `stopsFor(span)` ms into everything the
30
+ * go started, then ends it as a still of that beat ends it and shoots that. The span is the
31
+ * longest of the go's animations and the slide script's motion. Returns the span.
32
+ */
33
+ export async function captureGo(
34
+ page: EvaluatingPage,
35
+ position: Position,
36
+ stopsFor: (span: number) => number[],
37
+ shoot: (ms: number, end: boolean) => Promise<void>,
38
+ ): Promise<number> {
39
+ await page.evaluate(startGoPaused, position);
40
+ const span = await page.evaluate(holdStarted);
41
+ for (const ms of stopsFor(span)) {
42
+ await page.evaluate(seekStarted, ms);
43
+ await shoot(ms, false);
44
+ }
45
+ await endGo(page);
46
+ await shoot(span, true);
47
+ return span;
48
+ }
49
+
50
+ /**
51
+ * Settles `from`, then starts `go(to)` and stops every animation (view-transition pseudo-elements
52
+ * included) at `at` of the view transition's span. The slide script is held at the morph's moment
53
+ * in ms, not at `at` of its own motion, the way the video recorder seeks both.
54
+ *
55
+ * Unlike `captureGo`, the span here is the view transition's alone: `--at` names a moment of the
56
+ * move between slides, however long the slide's own entrance runs.
57
+ */
58
+ export async function freezeTransition(page: EvaluatingPage, morph: MorphSpec): Promise<void> {
59
+ await settleAt(page, morph.from);
60
+ if (!(await page.evaluate(startGoPaused, morph.to))) {
61
+ // No transition to hold at a moment of; the frame is the go at its end, as a still shows it.
62
+ await endGo(page);
63
+ return;
64
+ }
65
+ await page.evaluate(freezeAt, morph.at);
66
+ }
67
+
68
+ /** Ends the go `startGoPaused` began, as a still of its beat ends it, and waits for it to return. */
69
+ async function endGo(page: EvaluatingPage): Promise<void> {
70
+ await page.evaluate(finishBeat);
71
+ await page.evaluate(() => window.__dekPendingGo);
72
+ }
@@ -0,0 +1,128 @@
1
+ export function playerChromeCss(
2
+ options: { presenter?: boolean; width?: number; height?: number } = {},
3
+ ): string {
4
+ const width = options.width ?? 1280;
5
+ const height = options.height ?? 720;
6
+ const base = `html, body { margin: 0; height: 100%; background: #000; color: #fff; }
7
+ #dek-shell { height: 100%; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "current"; }
8
+ body:not(.is-presenter):not(.is-rail-hidden) #dek-shell:has(> #dek-rail) { grid-template-columns: var(--dek-rail-w, 188px) minmax(0, 1fr); grid-template-areas: "rail current"; }
9
+ #dek-current { grid-area: current; height: 100%; display: flex; flex-direction: column; min-height: 0; }
10
+ #dek-current-stage { flex: 1; min-height: 0; position: relative; overflow: hidden; touch-action: pan-y pinch-zoom; }
11
+ #dek-current-stage #deck { position: absolute; top: 0; left: 0; margin: 0; transform-origin: top left; }
12
+ #deck { position: relative; width: ${width}px; height: ${height}px; margin: 0 auto; transform-origin: top center; }
13
+ @media not print { #deck > .slide:not(.is-current) { display: none; } }
14
+ #deck { view-transition-name: slide; }
15
+ ::view-transition-group(slide) { overflow: clip; }
16
+ ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
17
+ #dek-rail { grid-area: rail; min-height: 0; background: #141414; border-right: 1px solid rgba(255,255,255,0.08); overflow: auto; padding: 12px 10px; display: flex; flex-direction: column; gap: 12px; box-sizing: border-box; scrollbar-width: thin; }
18
+ .dek-thumb { display: flex; align-items: flex-start; gap: 8px; color: inherit; text-decoration: none; outline: none; }
19
+ .dek-thumb-num { width: 16px; flex: none; font: 500 11px/1.2 system-ui, sans-serif; text-align: right; color: rgba(255,255,255,0.55); padding-top: 2px; font-variant-numeric: tabular-nums; }
20
+ .dek-thumb-frame { position: relative; flex: 1; min-width: 0; aspect-ratio: ${width}/${height}; background: #111; border-radius: 4px; outline: 2px solid transparent; overflow: hidden; }
21
+ .dek-thumb-stage { position: absolute; top: 0; left: 0; transform-origin: top left; pointer-events: none; }
22
+ .dek-thumb-stage > .slide { width: 100%; height: 100%; }
23
+ #dek-rail .slide [data-step] { opacity: 1 !important; transform: none !important; transition: none !important; }
24
+ .dek-thumb.is-current .dek-thumb-num { color: #fff; }
25
+ .dek-thumb.is-current .dek-thumb-frame { outline-color: #3ab9d5; }
26
+ .dek-thumb:hover .dek-thumb-frame, .dek-thumb:focus-visible .dek-thumb-frame { outline-color: rgba(255,255,255,0.25); }
27
+ .dek-thumb.is-current:hover .dek-thumb-frame, .dek-thumb.is-current:focus-visible .dek-thumb-frame { outline-color: #3ab9d5; }
28
+ #dek-rail-resize { position: fixed; left: calc(var(--dek-rail-w, 188px) - 3px); top: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 5; touch-action: none; }
29
+ #dek-rail-resize:hover, #dek-rail-resize[data-dragging] { background: rgba(255,255,255,0.12); }
30
+ body.is-rail-hidden #dek-rail, body.is-rail-hidden #dek-rail-resize, body[data-mode="video"] #dek-rail, body[data-mode="video"] #dek-rail-resize { display: none; }
31
+ @media (max-width: 640px) {
32
+ body:not(.is-presenter):not(.is-rail-hidden) #dek-shell:has(> #dek-rail) { grid-template-columns: minmax(0, 1fr); grid-template-areas: "current"; }
33
+ #dek-rail, #dek-rail-resize { display: none; }
34
+ body:not(.is-rail-hidden):has(> #dek-shell > #dek-rail) #dek-hint { left: 50%; }
35
+ }
36
+ .dek-panel-label { display: none; }
37
+ #dek-announce { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
38
+ #dek-hint { position: fixed; left: 50%; bottom: 28px; z-index: 20; display: flex; gap: 18px; padding: 10px 18px; background: rgba(18,18,18,0.86); color: #fff; border: 1px solid rgba(255,255,255,0.14); border-radius: 999px; box-shadow: 0 8px 28px rgba(0,0,0,0.35); font: 500 13px/1.4 system-ui, sans-serif; white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 12px); animation: dek-hint 5s cubic-bezier(0.22, 1, 0.36, 1) 0.5s forwards; }
39
+ body:not(.is-rail-hidden):has(> #dek-shell > #dek-rail) #dek-hint { left: calc(50% + var(--dek-rail-w, 188px) / 2); }
40
+ #dek-hint > span { display: inline-flex; align-items: center; gap: 8px; }
41
+ #dek-hint kbd { min-width: 1.6em; padding: 1px 6px; box-sizing: border-box; border: 1px solid rgba(255,255,255,0.35); border-bottom-width: 2px; border-radius: 5px; font: 600 12px/1.4 ui-monospace, monospace; text-align: center; }
42
+ @keyframes dek-hint { 12% { opacity: 1; transform: translate(-50%, 0); } 80% { opacity: 1; transform: translate(-50%, 0); } 100% { opacity: 0; transform: translate(-50%, 0); } }
43
+ @media (prefers-reduced-motion: reduce) { #dek-hint { transform: translate(-50%, 0); animation-name: dek-hint-fade; } @keyframes dek-hint-fade { 12%, 80% { opacity: 1; } } }
44
+ @media (pointer: coarse) { #dek-hint { display: none; } }
45
+ #dek-laser { position: absolute; z-index: 30; width: 24px; height: 24px; margin: -12px 0 0 -12px; border-radius: 50%; background: radial-gradient(circle, #fff 0 12%, #ff3b30 22% 38%, rgba(255,59,48,0.45) 52%, rgba(255,59,48,0) 70%); pointer-events: none; }
46
+ #dek-laser[hidden] { display: none; }
47
+ body.is-laser #dek-current-stage { cursor: crosshair; touch-action: none; }
48
+ .dek-diagnostics { position: fixed; left: 0; right: 0; bottom: 0; padding: 8px 12px; background: #900; color: #fff; font: 12px/1.4 monospace; white-space: pre-wrap; z-index: 10; }
49
+ body[data-mode="video"] .dek-diagnostics { display: none !important; }
50
+ @media print {
51
+ ${indent(printPageCss({ width, height }))}
52
+ body, #dek-shell, #dek-current, #dek-current-stage { display: block; height: auto; overflow: visible; position: static; padding: 0; }
53
+ #deck [data-step] { opacity: 1 !important; transform: none !important; transition: none !important; }
54
+ #dek-rail, #dek-rail-resize, #dek-hint, #dek-laser, .dek-panel-label, .dek-diagnostics { display: none !important; }
55
+ }`;
56
+ if (options.presenter === false) {
57
+ return base;
58
+ }
59
+ return `${base}
60
+ @media print { #dek-presenter, #dek-progress { display: none !important; } }
61
+ body[data-mode="video"] #dek-presenter, body[data-mode="video"] #dek-progress { display: none !important; }
62
+ #dek-presenter[hidden], #dek-progress[hidden], #dek-next-end[hidden] { display: none; }
63
+ #dek-mark-toggle { flex: none; width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; background: transparent; color: rgba(255,255,255,0.45); cursor: pointer; font: 600 1rem/1 system-ui, sans-serif; }
64
+ #dek-mark-toggle::before { content: "✎"; }
65
+ #dek-mark-toggle[aria-pressed="true"] { border-color: #eab308; color: #eab308; }
66
+ #dek-mark-toggle[data-error] { border-color: #ef4444; }
67
+ #dek-beats li.is-marked::after { content: " ✎"; color: #eab308; }
68
+ #dek-laser-toggle { flex: none; width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; background: transparent; cursor: pointer; }
69
+ #dek-laser-toggle::before { content: ""; display: block; width: 10px; height: 10px; margin: auto; border-radius: 50%; background: rgba(255,255,255,0.45); }
70
+ #dek-laser-toggle[aria-pressed="true"] { border-color: #ff3b30; }
71
+ #dek-laser-toggle[aria-pressed="true"]::before { background: #ff3b30; box-shadow: 0 0 8px #ff3b30; }
72
+ body.is-presenter { background: #121212; color: #ddd; font-family: system-ui, sans-serif; display: flex; flex-direction: column; }
73
+ body.is-presenter .dek-panel-label { display: block; font: 12px/1.4 system-ui, sans-serif; opacity: 0.65; padding: 4px 8px; }
74
+ body.is-presenter #dek-progress:not([hidden]) { display: flex; height: 4px; flex: none; background: #121212; gap: 1px; }
75
+ #dek-progress > span { flex: 1; background: rgba(255,255,255,0.12); position: relative; }
76
+ #dek-progress > span.is-done { background: #3ab9d5; }
77
+ #dek-progress > span.is-current::after { content: ""; position: absolute; inset: 0 auto 0 0; width: var(--dek-fill, 0%); background: #3ab9d5; }
78
+ body.is-presenter #dek-rail, body.is-presenter #dek-rail-resize { display: none; }
79
+ body.is-presenter #dek-shell { flex: 1; min-height: 0; height: auto; display: grid; grid-template-columns: minmax(0, 1fr) minmax(16rem, 26%); grid-template-rows: minmax(0, 1.15fr) minmax(0, 1fr) auto; grid-template-areas: "current next" "current notes" "bar bar"; gap: 1px; background: rgba(255,255,255,0.12); }
80
+ body.is-presenter #dek-current { grid-area: current; height: auto; background: #121212; }
81
+ body.is-presenter #dek-current-stage { padding: 8px 16px 16px; box-sizing: border-box; }
82
+ body.is-presenter #dek-presenter:not([hidden]) { display: contents; }
83
+ #dek-next-panel { grid-area: next; display: flex; flex-direction: column; min-width: 0; min-height: 0; background: #121212; }
84
+ .dek-next-body { flex: 1; min-height: 0; position: relative; }
85
+ #dek-next-stage { height: 100%; position: relative; overflow: hidden; }
86
+ .dek-preview-frame { position: absolute; top: 0; left: 0; transform-origin: top left; }
87
+ .dek-preview-frame > .slide { width: 100%; height: 100%; }
88
+ #dek-next-end { margin: 0; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; opacity: 0.5; font: 14px/1.4 system-ui, sans-serif; pointer-events: none; }
89
+ #dek-next { margin: 0; padding: 4px 8px; font: 12px/1.4 system-ui, sans-serif; opacity: 0.7; }
90
+ #dek-next:empty { display: none; }
91
+ #dek-notes-panel { grid-area: notes; display: flex; flex-direction: column; min-width: 0; min-height: 0; background: #121212; overflow: hidden; }
92
+ #dek-beats { margin: 0; padding: 8px 12px; list-style: none; font: 13px/1.4 system-ui, sans-serif; border-bottom: 1px solid rgba(255,255,255,0.12); overflow: auto; max-height: 40%; }
93
+ #dek-beats li { padding: 2px 6px; border-radius: 2px; }
94
+ .is-current-beat { background: rgba(255,255,255,0.12); }
95
+ #dek-script { flex: 1; margin: 0; padding: 12px; overflow: auto; white-space: pre-wrap; font: 14px/1.5 system-ui, sans-serif; }
96
+ #dek-presenter-bar { grid-area: bar; display: flex; align-items: baseline; gap: 12px; padding: 8px 12px; background: #121212; font: 14px/1.4 system-ui, sans-serif; }
97
+ #dek-page { opacity: 0.85; }
98
+ #dek-page .dek-page-total { opacity: 0.5; font-size: 0.85em; }
99
+ #dek-elapsed { margin: 0 0 0 auto; font: 1.5rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; }
100
+ #dek-elapsed.is-warn { color: #eab308; }
101
+ #dek-elapsed.is-over { color: #ef4444; }
102
+ #dek-budget { margin: 0; opacity: 0.5; font: 0.85rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; }
103
+ #dek-budget:empty { display: none; }
104
+ @media (max-aspect-ratio: 1/1) {
105
+ body.is-presenter #dek-shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1.4fr) minmax(7rem, 0.55fr) minmax(0, 1fr) auto; grid-template-areas: "current" "next" "notes" "bar"; }
106
+ }`;
107
+ }
108
+
109
+ /**
110
+ * One slide per page at the deck's logical size. A slide's display is its layout, which the
111
+ * theme owns, so neither this nor the player's chrome ever sets it: the player hides the
112
+ * slides it is not showing on screen only. `dekc pdf` prints with it, and the player wraps
113
+ * it in `@media print` so the browser's own Print gives the same pages.
114
+ */
115
+ export function printPageCss(size: { width: number; height: number }): string {
116
+ return `@page { size: ${size.width}px ${size.height}px; margin: 0 }
117
+ html, body { margin: 0; padding: 0; background: #000; }
118
+ #deck { position: static; width: ${size.width}px; height: auto; margin: 0; transform: none !important; }
119
+ #deck > .slide { width: ${size.width}px; height: ${size.height}px; break-inside: avoid; }
120
+ #deck > .slide:not(:last-child) { break-after: page; }`;
121
+ }
122
+
123
+ function indent(css: string): string {
124
+ return css
125
+ .split("\n")
126
+ .map((line) => ` ${line}`)
127
+ .join("\n");
128
+ }
@@ -0,0 +1,82 @@
1
+ import { DekToml, Voice } from "./config.ts";
2
+ import { splitLines } from "./lines.ts";
3
+ import { readFrontmatterYaml } from "./parse.ts";
4
+ import { Frontmatter } from "./schema.ts";
5
+ import { suggest } from "./suggest.ts";
6
+ import { scanTomlKeys } from "./toml-keys.ts";
7
+ import { unrecognizedKeys } from "./zod.ts";
8
+
9
+ /** A key dek does not read, where it is, and the known key it most likely meant. */
10
+ export type UnknownKey = { key: string; line: number; suggestion?: string };
11
+
12
+ /** dek.toml's schema, but a key it does not read is an issue; `refs` still takes any key. */
13
+ const StrictDekToml = DekToml.extend({ voice: Voice.strict().optional() }).strict();
14
+
15
+ /** Every key dek.toml may hold, as dotted paths. */
16
+ const DEK_TOML_KEYS: readonly string[] = [
17
+ ...Object.keys(DekToml.shape),
18
+ ...Object.keys(Voice.shape).map((key) => `voice.${key}`),
19
+ ];
20
+
21
+ export const FRONTMATTER_KEYS: readonly string[] = Object.keys(Frontmatter.shape);
22
+
23
+ /**
24
+ * Keys in dek.toml that dek ignores. Parsing drops unknown keys without a word, so a typo such
25
+ * as `latin_per_minut` would silently leave the default in place. The schema decides what is
26
+ * unknown, inline tables such as `voice = { sped = 1 }` included; the scan only says where. A
27
+ * file that does not parse has none: loading it reports that.
28
+ */
29
+ export function unknownTomlKeys(source: string): UnknownKey[] {
30
+ let parsed: unknown;
31
+ try {
32
+ parsed = Bun.TOML.parse(source);
33
+ } catch {
34
+ return [];
35
+ }
36
+ const unread = unrecognizedKeys(StrictDekToml, parsed ?? {});
37
+ if (unread.length === 0) {
38
+ return [];
39
+ }
40
+ const scanned = scanTomlKeys(source);
41
+ return unread
42
+ .map((path) => {
43
+ const key = path.join(".");
44
+ const written =
45
+ scanned.find((entry) => entry.path.join(".") === key) ??
46
+ scanned.find((entry) => entry.path.join(".").startsWith(`${key}.`));
47
+ return withSuggestion({ key, line: written?.line ?? 1 }, DEK_TOML_KEYS);
48
+ })
49
+ .sort((a, b) => a.line - b.line);
50
+ }
51
+
52
+ /**
53
+ * Top-level frontmatter keys dek ignores, with 1-based lines in script.md; `firstLine` is the line
54
+ * the YAML starts on, right after the opening `---`. The schema decides what is unknown, as it
55
+ * does for dek.toml; YAML that does not parse has none, since the deck then fails to load.
56
+ */
57
+ export function unknownFrontmatterKeys(yaml: string, firstLine: number): UnknownKey[] {
58
+ let parsed: unknown;
59
+ try {
60
+ parsed = readFrontmatterYaml(yaml);
61
+ } catch {
62
+ return [];
63
+ }
64
+ const lines = splitLines(yaml);
65
+ return unrecognizedKeys(Frontmatter.strict(), parsed ?? {})
66
+ .map(([key = ""]) => {
67
+ const index = lines.findIndex((text) => topLevelKey(text) === key);
68
+ return withSuggestion({ key, line: firstLine + Math.max(0, index) }, FRONTMATTER_KEYS);
69
+ })
70
+ .sort((a, b) => a.line - b.line);
71
+ }
72
+
73
+ /** The key a top-level YAML line writes, quoted or not. */
74
+ function topLevelKey(text: string): string | undefined {
75
+ const match = text.match(/^(?:"([^"]*)"|'([^']*)'|([A-Za-z_][\w-]*))\s*:/);
76
+ return match?.[1] ?? match?.[2] ?? match?.[3];
77
+ }
78
+
79
+ function withSuggestion(entry: UnknownKey, known: readonly string[]): UnknownKey {
80
+ const suggestion = suggest(entry.key, [...known]);
81
+ return suggestion ? { ...entry, suggestion } : entry;
82
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { parsePublicUrl } from "./ogp.ts";
4
+ import { isPinnedRev, isPlainRefName } from "./ref-name.ts";
5
+ import { parseTomlWith } from "./zod.ts";
6
+
7
+ export const Voice = z.object({
8
+ engine: z.string().optional(),
9
+ speaker: z.string(),
10
+ speed: z.number().optional(),
11
+ });
12
+
13
+ export const DekToml = z.object({
14
+ url: z
15
+ .string()
16
+ .refine((value) => parsePublicUrl(value) !== undefined, {
17
+ message:
18
+ "write the absolute http(s) URL dist/ is served from, like https://example.com/talks/",
19
+ })
20
+ .optional(),
21
+ max_classes: z.number().optional(),
22
+ cjk_per_minute: z.number().optional(),
23
+ latin_per_minute: z.number().optional(),
24
+ voice: Voice.optional(),
25
+ refs: z
26
+ .record(z.string(), z.string())
27
+ .superRefine((refs, ctx) => {
28
+ for (const [name, rev] of Object.entries(refs)) {
29
+ if (!isPlainRefName(name)) {
30
+ ctx.addIssue({ code: "custom", path: [name], message: "a ref is named owner/repo/deck" });
31
+ } else if (!isPinnedRev(rev)) {
32
+ ctx.addIssue({
33
+ code: "custom",
34
+ path: [name],
35
+ message: `pin a ref to a 40-character commit sha; run \`dekc ref ${name}\` to pin one`,
36
+ });
37
+ }
38
+ }
39
+ })
40
+ .optional(),
41
+ });
42
+
43
+ type VoiceDefaults = {
44
+ engine: string;
45
+ speaker: string;
46
+ speed: number;
47
+ };
48
+
49
+ export type DekConfig = {
50
+ /** Where dist/ is served from, ending in a slash; link previews need it for og:image. */
51
+ url?: string;
52
+ maxClasses: number;
53
+ cjkPerMinute: number;
54
+ latinPerMinute: number;
55
+ voice?: VoiceDefaults;
56
+ /** `[refs]`: each ref name and the commit it is pinned to. */
57
+ refs?: Record<string, string>;
58
+ };
59
+
60
+ export const DEFAULT_CONFIG: DekConfig = {
61
+ maxClasses: 40,
62
+ cjkPerMinute: 300,
63
+ latinPerMinute: 130,
64
+ };
65
+
66
+ export function parseDekToml(source: string, path?: string): DekConfig {
67
+ const data = parseTomlWith(DekToml, source, {
68
+ label: "dek.toml",
69
+ anchor: "dek-toml",
70
+ ...(path === undefined ? {} : { path }),
71
+ });
72
+ const voice = data.voice
73
+ ? {
74
+ engine: data.voice.engine ?? "voicevox",
75
+ speaker: data.voice.speaker,
76
+ speed: data.voice.speed ?? 1,
77
+ }
78
+ : undefined;
79
+ const url = data.url === undefined ? undefined : parsePublicUrl(data.url);
80
+ return {
81
+ ...(url ? { url } : {}),
82
+ maxClasses: data.max_classes ?? DEFAULT_CONFIG.maxClasses,
83
+ cjkPerMinute: data.cjk_per_minute ?? DEFAULT_CONFIG.cjkPerMinute,
84
+ latinPerMinute: data.latin_per_minute ?? DEFAULT_CONFIG.latinPerMinute,
85
+ ...(voice ? { voice } : {}),
86
+ ...(data.refs && Object.keys(data.refs).length > 0 ? { refs: data.refs } : {}),
87
+ };
88
+ }
89
+
90
+ export function loadConfig(configPath: string): DekConfig {
91
+ if (!existsSync(configPath)) {
92
+ return { ...DEFAULT_CONFIG };
93
+ }
94
+ return parseDekToml(readFileSync(configPath, "utf8"), configPath);
95
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The one way dek steps through CSS text: strings and comments are opaque, and a newline ends an
3
+ * unclosed string, as the CSS tokenizer reads it. Every scanner in css.ts and lint/tokens.ts is built on
4
+ * these, so none of them can disagree about where a string or a block ends.
5
+ */
6
+
7
+ /** The index just past the string that opens at `start`, or the newline that cuts it short. */
8
+ export function consumeString(source: string, start: number, end = source.length): number {
9
+ const quote = source[start];
10
+ let i = start + 1;
11
+ while (i < end) {
12
+ const ch = source[i];
13
+ if (ch === "\\") {
14
+ i += 2;
15
+ continue;
16
+ }
17
+ if (ch === quote) {
18
+ return i + 1;
19
+ }
20
+ if (ch === "\n") {
21
+ return i;
22
+ }
23
+ i++;
24
+ }
25
+ return end;
26
+ }
27
+
28
+ /** The index just past the comment that opens at `start`; an unclosed one runs to the end. */
29
+ export function consumeComment(source: string, start: number, end = source.length): number {
30
+ const close = source.indexOf("*/", start + 2);
31
+ return close === -1 || close + 2 > end ? end : close + 2;
32
+ }
33
+
34
+ function isCommentStart(source: string, i: number): boolean {
35
+ return source[i] === "/" && source[i + 1] === "*";
36
+ }
37
+
38
+ /**
39
+ * The first index from `start` whose character is one of `stops` outside strings, comments, and
40
+ * `()`/`[]`; `end` when there is none. An opening brace counts only when `{` is a stop.
41
+ */
42
+ export function scanTopLevel(source: string, start: number, end: number, stops: string): number {
43
+ let depth = 0;
44
+ let i = start;
45
+ while (i < end) {
46
+ const ch = source[i] ?? "";
47
+ if (ch === '"' || ch === "'") {
48
+ i = consumeString(source, i, end);
49
+ continue;
50
+ }
51
+ if (isCommentStart(source, i)) {
52
+ i = consumeComment(source, i, end);
53
+ continue;
54
+ }
55
+ if (ch === "\\") {
56
+ i += 2;
57
+ continue;
58
+ }
59
+ if (depth === 0 && stops.includes(ch)) {
60
+ return i;
61
+ }
62
+ if (ch === "(" || ch === "[") {
63
+ depth++;
64
+ } else if ((ch === ")" || ch === "]") && depth > 0) {
65
+ depth--;
66
+ }
67
+ i++;
68
+ }
69
+ return end;
70
+ }
71
+
72
+ /** The index of the `}` that closes the block opening at `open`, or `end` when it never closes. */
73
+ export function matchBrace(source: string, open: number, end = source.length): number {
74
+ let depth = 0;
75
+ let i = open;
76
+ while (i < end) {
77
+ i = scanTopLevel(source, i, end, "{}");
78
+ if (source[i] === "{") {
79
+ depth++;
80
+ } else if (source[i] === "}") {
81
+ depth--;
82
+ if (depth === 0) {
83
+ return i;
84
+ }
85
+ }
86
+ i++;
87
+ }
88
+ return end;
89
+ }
90
+
91
+ /** The text with each comment removed, strings kept byte for byte. */
92
+ export function stripComments(source: string): string {
93
+ return replaceOpaque(source, { comment: () => "" });
94
+ }
95
+
96
+ /**
97
+ * The text with each string and comment blanked to spaces, newlines kept, so only CSS syntax is
98
+ * left and every offset still points where it did.
99
+ */
100
+ export function blankStringsAndComments(source: string): string {
101
+ const blank = (text: string) => text.replace(/[^\n]/g, " ");
102
+ return replaceOpaque(source, { comment: blank, string: blank });
103
+ }
104
+
105
+ function replaceOpaque(
106
+ source: string,
107
+ replace: { comment?: (text: string) => string; string?: (text: string) => string },
108
+ ): string {
109
+ let out = "";
110
+ let from = 0;
111
+ let i = 0;
112
+ while (i < source.length) {
113
+ const ch = source[i];
114
+ if (ch === '"' || ch === "'") {
115
+ const end = consumeString(source, i);
116
+ if (replace.string) {
117
+ out += source.slice(from, i) + replace.string(source.slice(i, end));
118
+ from = end;
119
+ }
120
+ i = end;
121
+ continue;
122
+ }
123
+ if (isCommentStart(source, i)) {
124
+ const end = consumeComment(source, i);
125
+ if (replace.comment) {
126
+ out += source.slice(from, i) + replace.comment(source.slice(i, end));
127
+ from = end;
128
+ }
129
+ i = end;
130
+ continue;
131
+ }
132
+ i += ch === "\\" ? 2 : 1;
133
+ }
134
+ return out + source.slice(from);
135
+ }
136
+
137
+ /** Splits on top-level commas only, leaving `:is(.a, .b)` or `var(--a, b)` whole. */
138
+ export function splitTopLevel(text: string, separator = ","): string[] {
139
+ const parts: string[] = [];
140
+ let start = 0;
141
+ for (;;) {
142
+ const at = scanTopLevel(text, start, text.length, separator);
143
+ parts.push(text.slice(start, at));
144
+ if (at >= text.length) {
145
+ return parts;
146
+ }
147
+ start = at + 1;
148
+ }
149
+ }
150
+
151
+ /** Each whole identifier in a value outside strings and comments, with where it starts. */
152
+ export function cssIdents(value: string): Array<{ ident: string; start: number }> {
153
+ const bare = blankStringsAndComments(value);
154
+ return [...bare.matchAll(/-?[_a-zA-Z\u0080-\uffff][-\w\u0080-\uffff]*/g)].map((match) => ({
155
+ ident: match[0],
156
+ start: match.index,
157
+ }));
158
+ }
@@ -0,0 +1,140 @@
1
+ import { outermostStyleRules, type Stylesheet, splitSelectorList, startsAtSlide } from "./css.ts";
2
+ import { consumeComment, consumeString, cssIdents } from "./css-scan.ts";
3
+
4
+ /** Text to put in place of `[start, end)` of the stylesheet as it was read. */
5
+ type CssEdit = { start: number; end: number; text: string };
6
+
7
+ /** One rewrite, as the edits it makes to a stylesheet. */
8
+ export type CssEditor = (sheet: Stylesheet) => CssEdit[];
9
+
10
+ /**
11
+ * The stylesheet with every editor's edits applied at once. Each editor reads the same sheet, so
12
+ * a pipeline of rewrites parses the stylesheet once; their edits must not overlap.
13
+ */
14
+ export function rewriteCss(sheet: Stylesheet, ...editors: CssEditor[]): string {
15
+ const edits = editors.flatMap((editor) => editor(sheet)).sort((a, b) => a.start - b.start);
16
+ let out = "";
17
+ let from = 0;
18
+ for (const edit of edits) {
19
+ out += sheet.source.slice(from, edit.start) + edit.text;
20
+ from = edit.end;
21
+ }
22
+ return out + sheet.source.slice(from);
23
+ }
24
+
25
+ /**
26
+ * Scopes a slide's own stylesheet to that slide. A leading `.slide` compound
27
+ * gains `:where([data-slug])`; any other selector is nested under the scoped
28
+ * slide. The scope weighs exactly one `.slide`, so a rule behaves as if it were
29
+ * written at the end of theme.css: it beats the theme's `.slide .x`, and the
30
+ * theme's state rules (`.slide.is-current [data-step]`) still beat it.
31
+ * Local keyframes are renamed so two slides can both define `pop`.
32
+ */
33
+ export function scopeToSlide(slug: string): CssEditor {
34
+ const scope = `.slide:where([data-slug="${slug}"])`;
35
+ const rename = (name: string) => `${slug}--${name}`;
36
+ return (sheet) => {
37
+ const edits: CssEdit[] = outermostStyleRules(sheet).map((rule) => ({
38
+ start: rule.preludeStart,
39
+ end: rule.preludeEnd,
40
+ text: splitSelectorList(rule.prelude)
41
+ .map((part) => {
42
+ const item = part.trim();
43
+ return startsAtSlide(item)
44
+ ? `${scope}${item.slice(".slide".length)}`
45
+ : `${scope} ${item}`;
46
+ })
47
+ .join(", "),
48
+ }));
49
+ const local = new Set(sheet.keyframes.map((keyframes) => keyframes.name));
50
+ for (const keyframes of sheet.keyframes) {
51
+ edits.push({ start: keyframes.start, end: keyframes.end, text: rename(keyframes.name) });
52
+ }
53
+ for (const decl of sheet.decls) {
54
+ if (local.size === 0 || !ANIMATION_PROPERTY_RE.test(decl.property)) {
55
+ continue;
56
+ }
57
+ for (const { ident, start } of cssIdents(
58
+ sheet.source.slice(decl.valueStart, decl.valueEnd),
59
+ )) {
60
+ if (local.has(ident)) {
61
+ const at = decl.valueStart + start;
62
+ edits.push({ start: at, end: at + ident.length, text: rename(ident) });
63
+ }
64
+ }
65
+ }
66
+ return edits;
67
+ };
68
+ }
69
+
70
+ const ANIMATION_PROPERTY_RE = /^(-\w+-)?animation(-name)?$/i;
71
+
72
+ /**
73
+ * Gives the view-transition pseudo-elements the theme's tokens. They hang off the root, not
74
+ * `.slide`, so `var(--step-transition)` in `::view-transition-new(slide)` would find nothing.
75
+ * Each rule that sets tokens on every slide gains a twin on `::view-transition` holding those
76
+ * tokens alone, inside the same at-rule, so the transition sees the values the slides see.
77
+ */
78
+ export const shareTokensWithTransitions: CssEditor = (sheet) =>
79
+ outermostStyleRules(sheet).flatMap((rule) => {
80
+ if (!splitSelectorList(rule.prelude).some((part) => part.trim() === ".slide")) {
81
+ return [];
82
+ }
83
+ const tokens = rule.decls
84
+ // A `url()` token is an image the transition never draws; copying it would inline it twice.
85
+ .filter(
86
+ (decl) =>
87
+ decl.property.startsWith("--") &&
88
+ !sheet.urls.some((url) => url.start >= decl.valueStart && url.start < decl.valueEnd),
89
+ )
90
+ .map((decl) => `${decl.property}: ${decl.value};`);
91
+ const at = rule.bodyEnd + 1;
92
+ return tokens.length === 0
93
+ ? []
94
+ : [{ start: at, end: at, text: `\n::view-transition { ${tokens.join(" ")} }` }];
95
+ });
96
+
97
+ /**
98
+ * Each `url()` that `cssUrls` reports rewritten to `url("<replacement>")`, or left as written
99
+ * when `replace` has none for it.
100
+ */
101
+ export function replaceUrls(replace: (value: string) => string | undefined): CssEditor {
102
+ return (sheet) =>
103
+ sheet.urls.flatMap((url) => {
104
+ const next = replace(url.value);
105
+ return next === undefined ? [] : [{ start: url.start, end: url.end, text: `url("${next}")` }];
106
+ });
107
+ }
108
+
109
+ /** Whitespace as CSS defines it. U+3000 and NBSP are content, not whitespace. */
110
+ const CSS_WHITESPACE = new Set([" ", "\t", "\n", "\r", "\f"]);
111
+
112
+ /**
113
+ * Drops comments and collapses whitespace to one space, leaving strings byte for byte, so
114
+ * `content: "a b"` or `" "` renders in the build as it does on the dev server.
115
+ */
116
+ export function minifyCss(css: string): string {
117
+ let out = "";
118
+ let space = false;
119
+ let i = 0;
120
+ while (i < css.length) {
121
+ const ch = css[i] ?? "";
122
+ if (ch === "/" && css[i + 1] === "*") {
123
+ i = consumeComment(css, i);
124
+ continue;
125
+ }
126
+ if (CSS_WHITESPACE.has(ch)) {
127
+ space = true;
128
+ i++;
129
+ continue;
130
+ }
131
+ if (space && out !== "") {
132
+ out += " ";
133
+ }
134
+ space = false;
135
+ const end = ch === '"' || ch === "'" ? consumeString(css, i) : ch === "\\" ? i + 2 : i + 1;
136
+ out += css.slice(i, end);
137
+ i = end;
138
+ }
139
+ return out;
140
+ }