@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,187 @@
1
+ import type { Deck } from "./schema.ts";
2
+ import type { Position } from "./step.ts";
3
+
4
+ export type VoiceDict = Record<string, { kana: string; accent?: number }>;
5
+
6
+ export type Cue = {
7
+ position: Position;
8
+ slug: string;
9
+ line: number;
10
+ paragraphs: string[];
11
+ };
12
+
13
+ const FENCE_RE = /^\s*```/;
14
+ const BLOCKQUOTE_RE = /^\s*>/;
15
+ const LIST_RE = /^\s*(?:[-*+]|\d+\.)\s/;
16
+ const TABLE_RE = /^\s*\|/;
17
+ const ASCII_WORD_RE = /[A-Za-z][A-Za-z0-9+.#-]*/g;
18
+
19
+ export function spokenParagraphs(markdown: string): string[] {
20
+ const paragraphs: string[] = [];
21
+ let current: string[] = [];
22
+ let inFence = false;
23
+
24
+ const flush = (): void => {
25
+ if (current.length === 0) {
26
+ return;
27
+ }
28
+ const text = normalizeSpoken(unwrapInline(current.join("\n")));
29
+ if (text) {
30
+ paragraphs.push(text);
31
+ }
32
+ current = [];
33
+ };
34
+
35
+ for (const line of markdown.split(/\r?\n/)) {
36
+ if (FENCE_RE.test(line)) {
37
+ if (!inFence) {
38
+ flush();
39
+ }
40
+ inFence = !inFence;
41
+ continue;
42
+ }
43
+ if (inFence) {
44
+ continue;
45
+ }
46
+ if (
47
+ BLOCKQUOTE_RE.test(line) ||
48
+ LIST_RE.test(line) ||
49
+ TABLE_RE.test(line) ||
50
+ line.trim() === ""
51
+ ) {
52
+ flush();
53
+ continue;
54
+ }
55
+ current.push(line);
56
+ }
57
+ flush();
58
+ return paragraphs;
59
+ }
60
+
61
+ export function applyDict(text: string, dict: VoiceDict): string {
62
+ const keys = Object.keys(dict).sort((a, b) => b.length - a.length);
63
+ if (keys.length === 0) {
64
+ return text;
65
+ }
66
+ const pattern = keys.map(dictKeyPattern).join("|");
67
+ return text.replace(new RegExp(pattern, "g"), (match) => dict[match]?.kana ?? match);
68
+ }
69
+
70
+ export function unknownAsciiWords(text: string, dict: VoiceDict): string[] {
71
+ const found: string[] = [];
72
+ const seen = new Set<string>();
73
+ for (const match of text.matchAll(ASCII_WORD_RE)) {
74
+ const word = match[0];
75
+ if (word.length < 2 || Object.hasOwn(dict, word) || seen.has(word)) {
76
+ continue;
77
+ }
78
+ seen.add(word);
79
+ found.push(word);
80
+ }
81
+ return found;
82
+ }
83
+
84
+ type CueSource = {
85
+ position: Position;
86
+ slug: string;
87
+ line: number;
88
+ markdown: string;
89
+ };
90
+
91
+ /**
92
+ * One entry per cue: the slide's arrival, spoken from the paragraphs before its first `###`,
93
+ * then each beat.
94
+ */
95
+ function cueSources(deck: Deck): CueSource[] {
96
+ const sources: CueSource[] = [];
97
+ for (const [slideIndex, section] of deck.sections.entries()) {
98
+ sources.push({
99
+ position: { slideIndex, beatIndex: 0 },
100
+ slug: section.slug,
101
+ line: section.line,
102
+ markdown: section.body,
103
+ });
104
+ for (const [index, beat] of section.beats.entries()) {
105
+ sources.push({
106
+ position: { slideIndex, beatIndex: index + 1 },
107
+ slug: section.slug,
108
+ line: beat.line,
109
+ markdown: beat.body,
110
+ });
111
+ }
112
+ }
113
+ return sources;
114
+ }
115
+
116
+ export function cuesFromDeck(deck: Deck, dict: VoiceDict = {}): Cue[] {
117
+ return cueSources(deck).map((source) => ({
118
+ position: source.position,
119
+ slug: source.slug,
120
+ line: source.line,
121
+ paragraphs: spokenParagraphs(source.markdown).map((paragraph) => applyDict(paragraph, dict)),
122
+ }));
123
+ }
124
+
125
+ export type SilentCue = Pick<CueSource, "position" | "slug" | "line">;
126
+
127
+ /**
128
+ * Cues that show something (list, code, table) but say nothing. An empty or
129
+ * blockquote-only beat is a deliberate pause and is not reported.
130
+ */
131
+ export function silentCues(deck: Deck): SilentCue[] {
132
+ return cueSources(deck)
133
+ .filter(
134
+ (source) => spokenParagraphs(source.markdown).length === 0 && hasVisibleBody(source.markdown),
135
+ )
136
+ .map(({ position, slug, line }) => ({ position, slug, line }));
137
+ }
138
+
139
+ function hasVisibleBody(markdown: string): boolean {
140
+ return markdown.split(/\r?\n/).some((line) => line.trim() !== "" && !BLOCKQUOTE_RE.test(line));
141
+ }
142
+
143
+ function unwrapInline(text: string): string {
144
+ return text
145
+ .replace(/!\[[^\]]*]\([^)]*\)/g, "")
146
+ .replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
147
+ .replace(/`([^`]+)`/g, "$1")
148
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
149
+ .replace(/__([^_]+)__/g, "$1")
150
+ .replace(/(?<![A-Za-z0-9_])\*([^*]+)\*(?![A-Za-z0-9_])/g, "$1")
151
+ .replace(/(?<![A-Za-z0-9_])_([^_]+)_(?![A-Za-z0-9_])/g, "$1");
152
+ }
153
+
154
+ function normalizeSpoken(text: string): string {
155
+ return text
156
+ .replace(/[ \t]+/g, " ")
157
+ .replace(/ *\n */g, "\n")
158
+ .trim();
159
+ }
160
+
161
+ function dictKeyPattern(key: string): string {
162
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
+ if (/^[A-Za-z]/.test(key)) {
164
+ return `(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`;
165
+ }
166
+ return escaped;
167
+ }
168
+
169
+ const SENTENCE_RE = /(?<=[。!?!?.])\s*|(?<=\.)(?=\s|$)\s*|\n+/;
170
+ const ABBREV_END = /(?:^| )[A-Za-z]{1,3}\.$/;
171
+
172
+ export function splitSentences(paragraph: string): string[] {
173
+ const parts = paragraph
174
+ .split(SENTENCE_RE)
175
+ .map((part) => part.trim())
176
+ .filter(Boolean);
177
+ const merged: string[] = [];
178
+ for (const part of parts) {
179
+ const prev = merged.at(-1);
180
+ if (prev && ABBREV_END.test(prev)) {
181
+ merged[merged.length - 1] = `${prev} ${part}`;
182
+ } else {
183
+ merged.push(part);
184
+ }
185
+ }
186
+ return merged;
187
+ }
@@ -0,0 +1,34 @@
1
+ import { join } from "node:path";
2
+
3
+ /** The extensions a file under `slides/` can have: the slide, its sidecars, and its build. */
4
+ type SlideExt = ".html" | ".css" | ".ts" | ".js";
5
+
6
+ /** Where each file a deck is made of lives, named once instead of joined at every read. */
7
+ export type DeckPaths = {
8
+ dir: string;
9
+ script: string;
10
+ theme: string;
11
+ slides: string;
12
+ assets: string;
13
+ voice: string;
14
+ voiceToml: string;
15
+ voiceDict: string;
16
+ /** `slides/<slug><ext>`. */
17
+ slide(slug: string, ext: SlideExt): string;
18
+ };
19
+
20
+ export function deckPaths(deckDir: string): DeckPaths {
21
+ const slides = join(deckDir, "slides");
22
+ const voice = join(deckDir, "voice");
23
+ return {
24
+ dir: deckDir,
25
+ script: join(deckDir, "script.md"),
26
+ theme: join(deckDir, "theme.css"),
27
+ slides,
28
+ assets: join(deckDir, "assets"),
29
+ voice,
30
+ voiceToml: join(voice, "voice.toml"),
31
+ voiceDict: join(voice, "dict.toml"),
32
+ slide: (slug, ext) => join(slides, `${slug}${ext}`),
33
+ };
34
+ }
@@ -0,0 +1,114 @@
1
+ /** An error means the deck is not done; a warning is worth a look but does not fail lint. */
2
+ export type Severity = "error" | "warning";
3
+
4
+ /**
5
+ * What a finding is about. A slide finding names its slide (`slug`), and `dekc check <slug>`
6
+ * reports exactly those; a deck finding is about a file the slides share, such as theme.css
7
+ * or the frontmatter; a project finding is about dek.toml, one per project however many decks.
8
+ * `dekc lint` reports all three.
9
+ */
10
+ export type Scope = "project" | "deck" | "slide";
11
+
12
+ /**
13
+ * The rule table: every diagnostic dek itself emits, with its severity and the scopes it is
14
+ * found in. A rule that reads a shared file and a slide's own, like DEK014 on theme.css and on
15
+ * slides/<slug>.css, has both. Voice and timing rules are warnings: a live-only deck is done
16
+ * without them. So are the findings about input dek ignores (DEK008) or reads another way
17
+ * (DEK044), an empty heading (DEK024), which a slide script may fill, and a step bound by
18
+ * position where the beat has an id (DEK025), which is right until a beat is inserted, and a
19
+ * style several slides repeat (DEK026), which works but belongs in the theme. Text under
20
+ * aria-hidden (DEK029) is right on a sample the talk shows as unreadable.
21
+ */
22
+ export const RULES = {
23
+ DEK001: { severity: "error", scopes: ["slide"] },
24
+ DEK002: { severity: "error", scopes: ["slide"] },
25
+ DEK003: { severity: "error", scopes: ["slide"] },
26
+ DEK004: { severity: "error", scopes: ["slide"] },
27
+ DEK005: { severity: "error", scopes: ["slide"] },
28
+ DEK006: { severity: "error", scopes: ["slide"] },
29
+ DEK007: { severity: "error", scopes: ["slide"] },
30
+ DEK008: { severity: "warning", scopes: ["project", "deck"] },
31
+ DEK009: { severity: "error", scopes: ["slide"] },
32
+ DEK010: { severity: "error", scopes: ["slide"] },
33
+ DEK011: { severity: "error", scopes: ["slide"] },
34
+ DEK012: { severity: "error", scopes: ["deck", "slide"] },
35
+ DEK013: { severity: "error", scopes: ["deck"] },
36
+ DEK014: { severity: "error", scopes: ["deck", "slide"] },
37
+ DEK015: { severity: "error", scopes: ["deck"] },
38
+ DEK016: { severity: "error", scopes: ["slide"] },
39
+ DEK017: { severity: "error", scopes: ["slide"] },
40
+ DEK018: { severity: "error", scopes: ["deck"] },
41
+ DEK019: { severity: "error", scopes: ["slide"] },
42
+ DEK020: { severity: "error", scopes: ["deck", "slide"] },
43
+ DEK021: { severity: "error", scopes: ["deck", "slide"] },
44
+ DEK022: { severity: "error", scopes: ["deck", "slide"] },
45
+ DEK023: { severity: "error", scopes: ["deck", "slide"] },
46
+ DEK024: { severity: "warning", scopes: ["slide"] },
47
+ DEK025: { severity: "warning", scopes: ["slide"] },
48
+ DEK026: { severity: "warning", scopes: ["slide"] },
49
+ DEK027: { severity: "error", scopes: ["deck"] },
50
+ DEK028: { severity: "warning", scopes: ["slide"] },
51
+ DEK029: { severity: "warning", scopes: ["slide"] },
52
+ DEK030: { severity: "error", scopes: ["slide"] },
53
+ DEK031: { severity: "error", scopes: ["slide"] },
54
+ DEK032: { severity: "error", scopes: ["slide"] },
55
+ DEK033: { severity: "error", scopes: ["slide"] },
56
+ DEK040: { severity: "warning", scopes: ["slide"] },
57
+ DEK041: { severity: "warning", scopes: ["deck"] },
58
+ DEK042: { severity: "warning", scopes: ["slide"] },
59
+ DEK043: { severity: "warning", scopes: ["deck"] },
60
+ DEK044: { severity: "warning", scopes: ["deck", "slide"] },
61
+ DEK045: { severity: "warning", scopes: ["deck"] },
62
+ } as const satisfies Record<string, { severity: Severity; scopes: readonly Scope[] }>;
63
+
64
+ export type RuleId = keyof typeof RULES;
65
+
66
+ export type Diagnostic = {
67
+ id: string;
68
+ severity: Severity;
69
+ message: string;
70
+ path?: string;
71
+ line?: number;
72
+ /** 1-based, in UTF-16 units as editors count; set when the rule knows where on the line. */
73
+ column?: number;
74
+ slug?: string;
75
+ /** The fix, phrased as what to do next. */
76
+ hint?: string;
77
+ /** The values the message names, such as the offending class or the overflow in px, as fields. */
78
+ data?: { [key: string]: DiagnosticValue };
79
+ };
80
+
81
+ export type DiagnosticValue =
82
+ | string
83
+ | number
84
+ | boolean
85
+ | DiagnosticValue[]
86
+ | { [key: string]: DiagnosticValue };
87
+
88
+ export type DiagnosticFields = Omit<Diagnostic, "id" | "severity">;
89
+
90
+ /** A diagnostic of one dek rule; its severity comes from the rule table. */
91
+ export function diag(id: RuleId, fields: DiagnosticFields): Diagnostic {
92
+ // Severity right after the id, so it reads first in --json.
93
+ return { id, severity: RULES[id].severity, ...fields };
94
+ }
95
+
96
+ /** A diagnostic from outside the rule table, such as rumdl's or a crash: always an error. */
97
+ export function errorDiagnostic(id: string, fields: DiagnosticFields): Diagnostic {
98
+ return { id, severity: "error", ...fields };
99
+ }
100
+
101
+ export function hasErrors(diagnostics: Diagnostic[]): boolean {
102
+ return diagnostics.some((diagnostic) => diagnostic.severity === "error");
103
+ }
104
+
105
+ /**
106
+ * A check a command did not run, so its empty diagnostics are not a pass for it: why it did not
107
+ * run, and what to do so that it does.
108
+ */
109
+ export type SkippedCheck = {
110
+ /** `preview` is a build's link preview image, which needs a URL and Playwright. */
111
+ check: "lint" | "preview" | "rumdl" | "visual" | "voice";
112
+ reason: string;
113
+ hint?: string;
114
+ };
@@ -0,0 +1,218 @@
1
+ import { readTheme } from "./assets.ts";
2
+ import { playerChromeCss } from "./chrome.ts";
3
+ import { type DekConfig, loadConfig } from "./config.ts";
4
+ import { escapeAttr, escapeHtml } from "./escape.ts";
5
+ import { collectSlidesHtml, htmlShell } from "./html.ts";
6
+ import { type OgpImage, ogpHead } from "./ogp.ts";
7
+ import { PAGE_ID, type PageMode, pageConfigAttrs } from "./page.ts";
8
+ import {
9
+ nextPresenterTitle,
10
+ presenterSlides,
11
+ presenterState,
12
+ type ScriptReader,
13
+ } from "./presenter.ts";
14
+ import { RAIL_WIDTH_DEFAULT, RAIL_WIDTH_MAX, RAIL_WIDTH_MIN } from "./rail-width.ts";
15
+ import { type ProjectDeck, resolveDeck } from "./resolve.ts";
16
+ import { logicalSize } from "./size.ts";
17
+ import { readSlideScripts, slideScriptTags } from "./slide-script.ts";
18
+ import { formatClock } from "./timing.ts";
19
+
20
+ /** Who the page is for; each decides what else the page carries. */
21
+ export type PageTarget =
22
+ /** The dev server's page: assets by URL, live reload, and the presenter's notes when allowed. */
23
+ | {
24
+ kind: "dev";
25
+ mode: "player" | "presenter";
26
+ includeNotes: boolean;
27
+ liveReloadScript: string;
28
+ /**
29
+ * What a presenter page sends as `?token=` on its live channels at `/decks/<name>/events`
30
+ * and `/decks/<name>/ws`: neither an EventSource nor a WebSocket can set a header of its
31
+ * own. An audience page never carries one.
32
+ */
33
+ liveToken?: string;
34
+ }
35
+ /** `dekc build`: one file that stands alone, shared as a link. */
36
+ | {
37
+ kind: "build";
38
+ /** Built with `--public`, for anyone who has the link rather than the speaker. */
39
+ public?: boolean;
40
+ /** The built page's absolute URL, for og:url. */
41
+ publicUrl?: string;
42
+ /** The first slide's picture, for og:image. */
43
+ previewImage?: OgpImage;
44
+ }
45
+ /** `dekc video`: one file that stands alone, every slide and nothing around them. */
46
+ | { kind: "video" };
47
+
48
+ /** The deck at `dir`, rendered for `target`. */
49
+ export function renderDeckHtml(
50
+ dir: string,
51
+ options: { playerScript: string; target: PageTarget },
52
+ ): string {
53
+ const { project, deck } = resolveDeck(dir);
54
+ return renderDeckDocument(deck, { ...options, config: loadConfig(project.configPath) });
55
+ }
56
+
57
+ export function renderDeckDocument(
58
+ deck: ProjectDeck,
59
+ options: { config: DekConfig; playerScript: string; target: PageTarget },
60
+ ): string {
61
+ const { target } = options;
62
+ const mode: PageMode =
63
+ target.kind === "dev" ? target.mode : target.kind === "build" ? "player" : "video";
64
+ const live = target.kind === "dev";
65
+ // A file that stands alone carries its assets and a minified theme; the dev server serves
66
+ // assets by URL. A build skips a broken slide script, as the dev server does, since lint never
67
+ // stops a build and its findings name the script. A video has no findings to carry the news, and
68
+ // hours of rendering would show slides without their motion, so a broken script stops it.
69
+ const standalone = !live;
70
+ const strictScripts = target.kind === "video";
71
+ const includeNotes = target.kind === "dev" ? target.includeNotes : target.kind === "build";
72
+ const data = presenterSlides(deck, options.config, scriptReader(target)).map((slide) =>
73
+ includeNotes ? slide : { ...slide, script: "" },
74
+ );
75
+ const slidesHtml = collectSlidesHtml(deck, { inline: standalone });
76
+ const themeCss = readTheme(deck.dir, standalone);
77
+ const slideScripts = readSlideScripts(deck.dir, { strict: strictScripts });
78
+ const state = data[0] ? presenterState(data, { slideIndex: 0, beatIndex: 0 }) : undefined;
79
+ const budget = data[0]?.budgetSeconds !== undefined ? formatClock(data[0].budgetSeconds) : "";
80
+ const size = logicalSize(deck.deck.ratio);
81
+ const presenterOpen = mode === "presenter";
82
+ const hidden = presenterOpen ? "" : " hidden";
83
+ const nextTitle = state ? nextPresenterTitle(state) : "";
84
+ const atEnd = Boolean(state) && nextTitle === "" && !state?.next;
85
+ const page = data.length > 0 ? `1 <span class="dek-page-total">/ ${data.length}</span>` : "";
86
+ const progress = includeNotes ? `<div id="${PAGE_ID.progress}"${hidden}></div>` : "";
87
+ const presenter = includeNotes
88
+ ? `<aside id="${PAGE_ID.presenter}"${hidden}>
89
+ <section id="${PAGE_ID.nextPanel}">
90
+ <div class="dek-panel-label">Next</div>
91
+ <div class="dek-next-body">
92
+ <div id="${PAGE_ID.nextStage}"></div>
93
+ <p id="${PAGE_ID.nextEnd}"${atEnd ? "" : " hidden"}>End</p>
94
+ </div>
95
+ <p id="${PAGE_ID.next}">${escapeHtml(nextTitle)}</p>
96
+ </section>
97
+ <section id="${PAGE_ID.notesPanel}">
98
+ <ol id="${PAGE_ID.beats}">
99
+ ${(state?.current.beats ?? [])
100
+ .map((beat, index) => `<li data-beat-index="${index + 1}">${escapeHtml(beat.title)}</li>`)
101
+ .join("")}
102
+ </ol>
103
+ <pre id="${PAGE_ID.script}">${escapeHtml(state?.script ?? "")}</pre>
104
+ </section>
105
+ <footer id="${PAGE_ID.presenterBar}">
106
+ <span id="${PAGE_ID.page}">${page}</span>
107
+ <p id="${PAGE_ID.elapsed}">0:00</p>
108
+ <p id="${PAGE_ID.budget}">${budget}</p>
109
+ ${target.kind === "dev" ? markButton(deck.deck.lang) : ""}
110
+ <button id="${PAGE_ID.laserToggle}" type="button" aria-pressed="false" aria-label="${escapeAttr(laserLabel(deck.deck.lang))}" title="${escapeAttr(laserLabel(deck.deck.lang))}"></button>
111
+ </footer>
112
+ </aside>`
113
+ : "";
114
+ const currentLabel = includeNotes ? `<div class="dek-panel-label">Current</div>` : "";
115
+ const rail = target.kind === "video" ? "" : renderRailHtml(data);
116
+ const hint = target.kind === "build" ? renderKeyHintHtml(deck.deck.lang) : "";
117
+ const railResize = rail
118
+ ? `<div id="${PAGE_ID.railResize}" role="separator" aria-orientation="vertical" aria-label="Resize slide list" aria-valuemin="${RAIL_WIDTH_MIN}" aria-valuemax="${RAIL_WIDTH_MAX}" aria-valuenow="${RAIL_WIDTH_DEFAULT}" tabindex="0"></div>`
119
+ : "";
120
+
121
+ const ogp =
122
+ target.kind === "build"
123
+ ? `${ogpHead({
124
+ title: deck.deck.title,
125
+ description: deck.deck.description,
126
+ event: deck.deck.event,
127
+ date: deck.deck.date,
128
+ lang: deck.deck.lang,
129
+ url: target.publicUrl,
130
+ image: target.previewImage,
131
+ })}
132
+ `
133
+ : "";
134
+ const liveToken = target.kind === "dev" ? target.liveToken : undefined;
135
+
136
+ return htmlShell({
137
+ lang: deck.deck.lang,
138
+ title: deck.deck.title,
139
+ head: `${ogp}<style>${playerChromeCss({ presenter: includeNotes, ...size })}</style>
140
+ <style data-dek-theme>${themeCss}</style>`,
141
+ bodyAttrs: `${presenterOpen ? ' class="is-presenter"' : ""}${pageConfigAttrs({
142
+ mode,
143
+ deck: deck.name,
144
+ live,
145
+ ...(liveToken ? { liveToken } : {}),
146
+ })}`,
147
+ body: `${progress}
148
+ <div id="${PAGE_ID.shell}">
149
+ ${rail}
150
+ <main id="${PAGE_ID.current}">
151
+ ${currentLabel}
152
+ <div id="${PAGE_ID.currentStage}"><div id="${PAGE_ID.deck}">${slidesHtml}</div></div>
153
+ </main>
154
+ ${presenter}
155
+ </div>
156
+ ${railResize}
157
+ ${hint}
158
+ <div id="${PAGE_ID.announce}" aria-live="polite"></div>
159
+ <script type="application/json" id="${PAGE_ID.data}">${jsonForScript(data)}</script>
160
+ ${slideScriptTags(slideScripts)}
161
+ <script>${options.playerScript}</script>
162
+ ${target.kind === "dev" ? `<script>${target.liveReloadScript}</script>` : ""}`,
163
+ });
164
+ }
165
+
166
+ /**
167
+ * A page built with `--public` is for anyone who has the link, so it carries the script as the
168
+ * audience may read it; any other page is the speaker's own. A URL alone says where the page is
169
+ * served, not who reads it: the speaker may present from that very page.
170
+ */
171
+ function scriptReader(target: PageTarget): ScriptReader {
172
+ return target.kind === "build" && target.public === true ? "audience" : "speaker";
173
+ }
174
+
175
+ export function renderRailHtml(slides: Array<{ slug: string; title: string }>): string {
176
+ const items = slides
177
+ .map((slide, index) => {
178
+ const n = index + 1;
179
+ const label = `${n}. ${slide.title}`;
180
+ return `<a class="dek-thumb" href="#${escapeAttr(slide.slug)}" data-slide-index="${index}" aria-label="${escapeAttr(label)}"><span class="dek-thumb-num">${n}</span><span class="dek-thumb-frame"></span></a>`;
181
+ })
182
+ .join("");
183
+ return `<nav id="${PAGE_ID.rail}" aria-label="Slides">${items}</nav>`;
184
+ }
185
+
186
+ const KEY_HINT_LABELS = {
187
+ ja: { rail: "スライド一覧", presenter: "発表者ビュー" },
188
+ en: { rail: "Slide rail", presenter: "Presenter view" },
189
+ };
190
+
191
+ /**
192
+ * The button that marks the beat on screen to rewrite. Only the dev server's page has it: the
193
+ * marks go to a file of the project's, and a built file has no server to keep them.
194
+ */
195
+ function markButton(lang: string): string {
196
+ const label = lang.toLowerCase().startsWith("ja")
197
+ ? "このビートに直す印を付ける (m)"
198
+ : "Mark this beat to rewrite (m)";
199
+ return `<button id="${PAGE_ID.markToggle}" type="button" aria-pressed="false" aria-label="${escapeAttr(label)}" title="${escapeAttr(label)}"></button>`;
200
+ }
201
+
202
+ /** The laser button's name, with the key that does the same. */
203
+ function laserLabel(lang: string): string {
204
+ return lang.toLowerCase().startsWith("ja") ? "レーザーポインター (l)" : "Laser pointer (l)";
205
+ }
206
+
207
+ /**
208
+ * The keys a viewer of a built file cannot discover by looking: `s` and `p`. Shown once as
209
+ * the file opens, it fades on its own and goes away at the first key or move.
210
+ */
211
+ function renderKeyHintHtml(lang: string): string {
212
+ const labels = lang.toLowerCase().startsWith("ja") ? KEY_HINT_LABELS.ja : KEY_HINT_LABELS.en;
213
+ return `<div id="${PAGE_ID.hint}" role="status"><span><kbd>s</kbd>${labels.rail}</span><span><kbd>p</kbd>${labels.presenter}</span></div>`;
214
+ }
215
+
216
+ function jsonForScript(value: unknown): string {
217
+ return JSON.stringify(value).replace(/</g, "\\u003c");
218
+ }
@@ -0,0 +1,31 @@
1
+ export class DekError extends Error {
2
+ readonly line?: number;
3
+ readonly path?: string;
4
+ readonly hint?: string;
5
+
6
+ constructor(
7
+ message: string,
8
+ options?: { line?: number; path?: string; hint?: string; cause?: unknown },
9
+ ) {
10
+ super(message, { cause: options?.cause });
11
+ this.name = "DekError";
12
+ this.line = options?.line;
13
+ this.path = options?.path;
14
+ this.hint = options?.hint;
15
+ }
16
+ }
17
+
18
+ /** What a thrown value says, as plain fields: a DekError keeps its location and hint. */
19
+ export type ErrorFields = { message: string; path?: string; line?: number; hint?: string };
20
+
21
+ export function errorFields(error: unknown): ErrorFields {
22
+ if (error instanceof DekError) {
23
+ return {
24
+ message: error.message,
25
+ ...(error.path !== undefined ? { path: error.path } : {}),
26
+ ...(error.line !== undefined ? { line: error.line } : {}),
27
+ ...(error.hint !== undefined ? { hint: error.hint } : {}),
28
+ };
29
+ }
30
+ return { message: error instanceof Error ? error.message : String(error) };
31
+ }
@@ -0,0 +1,16 @@
1
+ export function escapeHtml(text: string): string {
2
+ return text
3
+ .replaceAll("&", "&amp;")
4
+ .replaceAll("<", "&lt;")
5
+ .replaceAll(">", "&gt;")
6
+ .replaceAll('"', "&quot;");
7
+ }
8
+
9
+ export function escapeAttr(text: string): string {
10
+ return escapeHtml(text);
11
+ }
12
+
13
+ /** `text` matched literally inside a regular expression. */
14
+ export function escapeRegExp(text: string): string {
15
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
+ }