@velumo/runtime 0.1.0-beta.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 (63) hide show
  1. package/dist/action-dispatcher.d.ts +27 -0
  2. package/dist/action-dispatcher.d.ts.map +1 -0
  3. package/dist/action-dispatcher.js +34 -0
  4. package/dist/action-dispatcher.js.map +1 -0
  5. package/dist/camera.d.ts +51 -0
  6. package/dist/camera.d.ts.map +1 -0
  7. package/dist/camera.js +327 -0
  8. package/dist/camera.js.map +1 -0
  9. package/dist/controller.d.ts +33 -0
  10. package/dist/controller.d.ts.map +1 -0
  11. package/dist/controller.js +109 -0
  12. package/dist/controller.js.map +1 -0
  13. package/dist/cue-dispatcher.d.ts +76 -0
  14. package/dist/cue-dispatcher.d.ts.map +1 -0
  15. package/dist/cue-dispatcher.js +164 -0
  16. package/dist/cue-dispatcher.js.map +1 -0
  17. package/dist/hash.d.ts +8 -0
  18. package/dist/hash.d.ts.map +1 -0
  19. package/dist/hash.js +27 -0
  20. package/dist/hash.js.map +1 -0
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +13 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/notes.d.ts +32 -0
  26. package/dist/notes.d.ts.map +1 -0
  27. package/dist/notes.js +132 -0
  28. package/dist/notes.js.map +1 -0
  29. package/dist/registry.d.ts +32 -0
  30. package/dist/registry.d.ts.map +1 -0
  31. package/dist/registry.js +76 -0
  32. package/dist/registry.js.map +1 -0
  33. package/dist/scene-player.d.ts +40 -0
  34. package/dist/scene-player.d.ts.map +1 -0
  35. package/dist/scene-player.js +177 -0
  36. package/dist/scene-player.js.map +1 -0
  37. package/dist/spatial.d.ts +11 -0
  38. package/dist/spatial.d.ts.map +1 -0
  39. package/dist/spatial.js +28 -0
  40. package/dist/spatial.js.map +1 -0
  41. package/dist/theme-bundle.d.ts +32 -0
  42. package/dist/theme-bundle.d.ts.map +1 -0
  43. package/dist/theme-bundle.js +8 -0
  44. package/dist/theme-bundle.js.map +1 -0
  45. package/dist/timeline.d.ts +46 -0
  46. package/dist/timeline.d.ts.map +1 -0
  47. package/dist/timeline.js +217 -0
  48. package/dist/timeline.js.map +1 -0
  49. package/dist/tsconfig.tsbuildinfo +1 -0
  50. package/package.json +19 -0
  51. package/src/action-dispatcher.ts +81 -0
  52. package/src/camera.ts +513 -0
  53. package/src/controller.ts +171 -0
  54. package/src/cue-dispatcher.ts +299 -0
  55. package/src/hash.ts +39 -0
  56. package/src/index.ts +12 -0
  57. package/src/notes.ts +194 -0
  58. package/src/registry.ts +194 -0
  59. package/src/scene-player.ts +244 -0
  60. package/src/spatial.ts +40 -0
  61. package/src/theme-bundle.ts +35 -0
  62. package/src/timeline.ts +323 -0
  63. package/tsconfig.json +10 -0
@@ -0,0 +1,171 @@
1
+ import type { Manifest, SlideEntry, SlideKind } from "@velumo/core";
2
+
3
+ export interface RuntimeState {
4
+ readonly deckTitle: string;
5
+ readonly slideIndex: number;
6
+ readonly slideId: string;
7
+ readonly slideKind: SlideKind;
8
+ readonly fragmentIndex: number;
9
+ readonly fragmentCount: number;
10
+ readonly isFirstSlide: boolean;
11
+ readonly isLastSlide: boolean;
12
+ }
13
+
14
+ export interface RuntimeControllerOptions {
15
+ readonly initialSlideId?: string;
16
+ readonly initialFragmentIndex?: number;
17
+ }
18
+
19
+ export type RuntimeEventName = "slide:enter" | "slide:leave" | "state:change";
20
+
21
+ export interface RuntimeEvent {
22
+ readonly slideId: string;
23
+ readonly slideIndex: number;
24
+ readonly state: RuntimeState;
25
+ }
26
+
27
+ type RuntimeEventHandler = (event: RuntimeEvent) => void;
28
+
29
+ function fragmentCountFor(slide: SlideEntry): number {
30
+ return slide.fragments?.length ?? 0;
31
+ }
32
+
33
+ function clampFragmentIndex(slide: SlideEntry, index: number): number {
34
+ return Math.max(0, Math.min(index, fragmentCountFor(slide)));
35
+ }
36
+
37
+ export class RuntimeController {
38
+ readonly #manifest: Manifest;
39
+ #slideIndex: number;
40
+ #fragmentIndex: number;
41
+ readonly #handlers = new Map<RuntimeEventName, Set<RuntimeEventHandler>>();
42
+
43
+ constructor(manifest: Manifest, options: RuntimeControllerOptions = {}) {
44
+ this.#manifest = manifest;
45
+ this.#slideIndex = this.#initialSlideIndex(options.initialSlideId);
46
+ this.#fragmentIndex = clampFragmentIndex(
47
+ this.#currentSlide(),
48
+ options.initialFragmentIndex ?? 0,
49
+ );
50
+ }
51
+
52
+ getState(): RuntimeState {
53
+ const slide = this.#currentSlide();
54
+
55
+ return {
56
+ deckTitle: this.#manifest.deck.title,
57
+ slideIndex: this.#slideIndex,
58
+ slideId: slide.id,
59
+ slideKind: slide.kind,
60
+ fragmentIndex: this.#fragmentIndex,
61
+ fragmentCount: fragmentCountFor(slide),
62
+ isFirstSlide: this.#slideIndex === 0,
63
+ isLastSlide: this.#slideIndex === this.#manifest.slides.length - 1,
64
+ };
65
+ }
66
+
67
+ next(): boolean {
68
+ const slide = this.#currentSlide();
69
+ const fragmentCount = fragmentCountFor(slide);
70
+
71
+ if (this.#fragmentIndex < fragmentCount) {
72
+ this.#fragmentIndex += 1;
73
+ this.#emit("state:change", slide);
74
+ return true;
75
+ }
76
+
77
+ if (this.#slideIndex >= this.#manifest.slides.length - 1) {
78
+ return false;
79
+ }
80
+
81
+ this.#moveToSlide(this.#slideIndex + 1, 0);
82
+ return true;
83
+ }
84
+
85
+ previous(): boolean {
86
+ const slide = this.#currentSlide();
87
+
88
+ if (this.#fragmentIndex > 0) {
89
+ this.#fragmentIndex -= 1;
90
+ this.#emit("state:change", slide);
91
+ return true;
92
+ }
93
+
94
+ if (this.#slideIndex <= 0) {
95
+ return false;
96
+ }
97
+
98
+ const targetIndex = this.#slideIndex - 1;
99
+ const targetSlide = this.#manifest.slides[targetIndex];
100
+ this.#moveToSlide(targetIndex, fragmentCountFor(targetSlide));
101
+ return true;
102
+ }
103
+
104
+ jumpToSlide(id: string): boolean {
105
+ const targetIndex = this.#manifest.slides.findIndex(
106
+ (slide) => slide.id === id,
107
+ );
108
+
109
+ if (targetIndex === -1 || targetIndex === this.#slideIndex) {
110
+ return false;
111
+ }
112
+
113
+ this.#moveToSlide(targetIndex, 0);
114
+ return true;
115
+ }
116
+
117
+ on(eventName: RuntimeEventName, handler: RuntimeEventHandler): () => void {
118
+ const handlers = this.#handlers.get(eventName) ?? new Set();
119
+ handlers.add(handler);
120
+ this.#handlers.set(eventName, handlers);
121
+
122
+ return () => {
123
+ handlers.delete(handler);
124
+ };
125
+ }
126
+
127
+ #initialSlideIndex(initialSlideId: string | undefined): number {
128
+ if (initialSlideId === undefined) {
129
+ return 0;
130
+ }
131
+
132
+ const index = this.#manifest.slides.findIndex(
133
+ (slide) => slide.id === initialSlideId,
134
+ );
135
+
136
+ return index === -1 ? 0 : index;
137
+ }
138
+
139
+ #currentSlide(): SlideEntry {
140
+ return this.#manifest.slides[this.#slideIndex];
141
+ }
142
+
143
+ #moveToSlide(targetIndex: number, fragmentIndex: number): void {
144
+ const previousSlide = this.#currentSlide();
145
+ const targetSlide = this.#manifest.slides[targetIndex];
146
+
147
+ this.#emit("slide:leave", previousSlide);
148
+ this.#slideIndex = targetIndex;
149
+ this.#fragmentIndex = clampFragmentIndex(targetSlide, fragmentIndex);
150
+ this.#emit("slide:enter", targetSlide);
151
+ this.#emit("state:change", targetSlide);
152
+ }
153
+
154
+ #emit(eventName: RuntimeEventName, slide: SlideEntry): void {
155
+ const handlers = this.#handlers.get(eventName);
156
+
157
+ if (handlers === undefined) {
158
+ return;
159
+ }
160
+
161
+ const event: RuntimeEvent = {
162
+ slideId: slide.id,
163
+ slideIndex: this.#slideIndex,
164
+ state: this.getState(),
165
+ };
166
+
167
+ for (const handler of handlers) {
168
+ handler(event);
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,299 @@
1
+ import type { Cue, JsonValue } from "@velumo/core";
2
+ import { ActionDispatcher, type ActionContext } from "./action-dispatcher.js";
3
+ import type { ActionHandler, RuntimeRegistry } from "./registry.js";
4
+ import type { TimelineScheduler } from "./timeline.js";
5
+
6
+ const ERROR_PREFIX = "CueDispatcher: ";
7
+
8
+ // The cue path's handler context. Extends the generalized ActionContext,
9
+ // narrowing source to "cue" and making the cue-only fields required (they are
10
+ // always populated when a timeline cue fires). Built-in action factories read
11
+ // context.cue / cueIndex / timeMs, so they keep working unchanged.
12
+ export interface CueDispatchEvent extends ActionContext {
13
+ readonly source: "cue";
14
+ readonly cue: Cue;
15
+ readonly cueIndex: number;
16
+ readonly timeMs: number;
17
+ }
18
+
19
+ export interface CueDispatcherOptions {
20
+ readonly scheduler: TimelineScheduler;
21
+ readonly registry: RuntimeRegistry<unknown, unknown, CueDispatchEvent>;
22
+ }
23
+
24
+ export interface CueDispatcherResult {
25
+ dispose(): void;
26
+ }
27
+
28
+ export class CueDispatcher implements CueDispatcherResult {
29
+ readonly #disposeFromScheduler: () => void;
30
+ #disposed = false;
31
+
32
+ constructor(options: CueDispatcherOptions) {
33
+ // Both the cue path (here) and the control path route lookup+invoke through
34
+ // the same ActionDispatcher. The dispatcher never throws for a missing
35
+ // handler; the cue path re-imposes its throw-on-unknown contract on false.
36
+ const dispatcher = new ActionDispatcher<CueDispatchEvent>({
37
+ registry: options.registry,
38
+ });
39
+ this.#disposeFromScheduler = options.scheduler.on("cue:fired", (event) => {
40
+ if (event.type !== "cue:fired") {
41
+ return;
42
+ }
43
+ const actionType = event.cue.action.type;
44
+ const context: CueDispatchEvent = {
45
+ action: event.cue.action,
46
+ payload: event.cue.action.payload,
47
+ source: "cue",
48
+ cue: event.cue,
49
+ cueIndex: event.cueIndex,
50
+ timeMs: event.timeMs,
51
+ // Thread the action's top-level target on the cue path too (the control
52
+ // path already does), so a handler branching on context.target sees the
53
+ // same value regardless of which path fired it.
54
+ ...(event.cue.action.target === undefined
55
+ ? {}
56
+ : { target: event.cue.action.target }),
57
+ };
58
+ const handled = dispatcher.dispatch(actionType, context);
59
+ if (!handled) {
60
+ throw new Error(
61
+ `${ERROR_PREFIX}no action handler registered for type '${actionType}'`,
62
+ );
63
+ }
64
+ });
65
+ }
66
+
67
+ dispose(): void {
68
+ if (this.#disposed) {
69
+ return;
70
+ }
71
+ this.#disposed = true;
72
+ this.#disposeFromScheduler();
73
+ }
74
+ }
75
+
76
+ export function createCueDispatcher(
77
+ options: CueDispatcherOptions,
78
+ ): CueDispatcher {
79
+ return new CueDispatcher(options);
80
+ }
81
+
82
+ export type BuiltInActionType =
83
+ | "show"
84
+ | "hide"
85
+ | "setClass"
86
+ | "emit"
87
+ | "camera"
88
+ | "fx";
89
+
90
+ const _builtInActionTypeCheck: BuiltInActionType = "show";
91
+ void _builtInActionTypeCheck;
92
+
93
+ export interface ShowActionEvent {
94
+ readonly type: "show";
95
+ readonly target: string;
96
+ readonly cue: Cue;
97
+ readonly cueIndex: number;
98
+ readonly timeMs: number;
99
+ }
100
+
101
+ export interface HideActionEvent {
102
+ readonly type: "hide";
103
+ readonly target: string;
104
+ readonly cue: Cue;
105
+ readonly cueIndex: number;
106
+ readonly timeMs: number;
107
+ }
108
+
109
+ export interface SetClassActionEvent {
110
+ readonly type: "setClass";
111
+ readonly target: string;
112
+ readonly className: string;
113
+ readonly mode: "add" | "remove" | "toggle";
114
+ readonly cue: Cue;
115
+ readonly cueIndex: number;
116
+ readonly timeMs: number;
117
+ }
118
+
119
+ export interface EmitActionEvent {
120
+ readonly type: "emit";
121
+ readonly name: string;
122
+ readonly data: JsonValue | undefined;
123
+ readonly cue: Cue;
124
+ readonly cueIndex: number;
125
+ readonly timeMs: number;
126
+ }
127
+
128
+ export interface CameraActionEvent {
129
+ readonly type: "camera";
130
+ readonly payload: JsonValue | undefined;
131
+ readonly cue: Cue;
132
+ readonly cueIndex: number;
133
+ readonly timeMs: number;
134
+ }
135
+
136
+ export interface FxActionEvent {
137
+ readonly type: "fx";
138
+ readonly payload: JsonValue | undefined;
139
+ readonly cue: Cue;
140
+ readonly cueIndex: number;
141
+ readonly timeMs: number;
142
+ }
143
+
144
+ function requirePayloadObject(
145
+ payload: JsonValue | undefined,
146
+ actionType: BuiltInActionType,
147
+ ): Record<string, JsonValue> {
148
+ if (
149
+ payload === undefined ||
150
+ payload === null ||
151
+ typeof payload !== "object" ||
152
+ Array.isArray(payload)
153
+ ) {
154
+ const describe =
155
+ payload === undefined
156
+ ? "undefined"
157
+ : payload === null
158
+ ? "null"
159
+ : Array.isArray(payload)
160
+ ? "array"
161
+ : typeof payload;
162
+ throw new Error(
163
+ `${ERROR_PREFIX}${actionType} action requires payload object; got ${describe}`,
164
+ );
165
+ }
166
+ return payload as Record<string, JsonValue>;
167
+ }
168
+
169
+ function requireNonEmptyString(
170
+ payload: Record<string, JsonValue>,
171
+ field: string,
172
+ actionType: BuiltInActionType,
173
+ ): string {
174
+ const value = payload[field];
175
+ if (typeof value !== "string" || value.length === 0) {
176
+ throw new Error(
177
+ `${ERROR_PREFIX}${actionType} action payload.${field} must be a non-empty string; got ${
178
+ typeof value === "string" ? "''" : String(value)
179
+ }`,
180
+ );
181
+ }
182
+ return value;
183
+ }
184
+
185
+ function readSetClassMode(
186
+ payload: Record<string, JsonValue>,
187
+ ): "add" | "remove" | "toggle" {
188
+ const mode = payload.mode;
189
+ if (mode === undefined) {
190
+ return "add";
191
+ }
192
+ if (mode === "add" || mode === "remove" || mode === "toggle") {
193
+ return mode;
194
+ }
195
+ throw new Error(
196
+ `${ERROR_PREFIX}setClass action payload.mode must be 'add' | 'remove' | 'toggle' (or omitted); got ${String(mode)}`,
197
+ );
198
+ }
199
+
200
+ export function createShowAction(
201
+ callback: (event: ShowActionEvent) => void,
202
+ ): ActionHandler<CueDispatchEvent> {
203
+ return (context) => {
204
+ const payload = requirePayloadObject(context.cue.action.payload, "show");
205
+ const target = requireNonEmptyString(payload, "target", "show");
206
+ callback({
207
+ type: "show",
208
+ target,
209
+ cue: context.cue,
210
+ cueIndex: context.cueIndex,
211
+ timeMs: context.timeMs,
212
+ });
213
+ };
214
+ }
215
+
216
+ export function createHideAction(
217
+ callback: (event: HideActionEvent) => void,
218
+ ): ActionHandler<CueDispatchEvent> {
219
+ return (context) => {
220
+ const payload = requirePayloadObject(context.cue.action.payload, "hide");
221
+ const target = requireNonEmptyString(payload, "target", "hide");
222
+ callback({
223
+ type: "hide",
224
+ target,
225
+ cue: context.cue,
226
+ cueIndex: context.cueIndex,
227
+ timeMs: context.timeMs,
228
+ });
229
+ };
230
+ }
231
+
232
+ export function createSetClassAction(
233
+ callback: (event: SetClassActionEvent) => void,
234
+ ): ActionHandler<CueDispatchEvent> {
235
+ return (context) => {
236
+ const payload = requirePayloadObject(
237
+ context.cue.action.payload,
238
+ "setClass",
239
+ );
240
+ const target = requireNonEmptyString(payload, "target", "setClass");
241
+ const className = requireNonEmptyString(payload, "className", "setClass");
242
+ const mode = readSetClassMode(payload);
243
+ callback({
244
+ type: "setClass",
245
+ target,
246
+ className,
247
+ mode,
248
+ cue: context.cue,
249
+ cueIndex: context.cueIndex,
250
+ timeMs: context.timeMs,
251
+ });
252
+ };
253
+ }
254
+
255
+ export function createEmitAction(
256
+ callback: (event: EmitActionEvent) => void,
257
+ ): ActionHandler<CueDispatchEvent> {
258
+ return (context) => {
259
+ const payload = requirePayloadObject(context.cue.action.payload, "emit");
260
+ const name = requireNonEmptyString(payload, "name", "emit");
261
+ const data = payload.data;
262
+ callback({
263
+ type: "emit",
264
+ name,
265
+ data,
266
+ cue: context.cue,
267
+ cueIndex: context.cueIndex,
268
+ timeMs: context.timeMs,
269
+ });
270
+ };
271
+ }
272
+
273
+ export function createCameraAction(
274
+ callback: (event: CameraActionEvent) => void,
275
+ ): ActionHandler<CueDispatchEvent> {
276
+ return (context) => {
277
+ callback({
278
+ type: "camera",
279
+ payload: context.cue.action.payload,
280
+ cue: context.cue,
281
+ cueIndex: context.cueIndex,
282
+ timeMs: context.timeMs,
283
+ });
284
+ };
285
+ }
286
+
287
+ export function createFxAction(
288
+ callback: (event: FxActionEvent) => void,
289
+ ): ActionHandler<CueDispatchEvent> {
290
+ return (context) => {
291
+ callback({
292
+ type: "fx",
293
+ payload: context.cue.action.payload,
294
+ cue: context.cue,
295
+ cueIndex: context.cueIndex,
296
+ timeMs: context.timeMs,
297
+ });
298
+ };
299
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { RuntimeState } from "./controller.js";
2
+
3
+ export interface RuntimeHashOptions {
4
+ readonly initialSlideId?: string;
5
+ readonly initialFragmentIndex?: number;
6
+ }
7
+
8
+ export function serializeRuntimeHash(state: RuntimeState): string {
9
+ const params = new URLSearchParams({
10
+ slide: state.slideId,
11
+ fragment: String(state.fragmentIndex),
12
+ });
13
+
14
+ return `#${params.toString()}`;
15
+ }
16
+
17
+ export function parseRuntimeHash(hash: string): RuntimeHashOptions {
18
+ const source = hash.startsWith("#") ? hash.slice(1) : hash;
19
+ const params = new URLSearchParams(source);
20
+ const initialSlideId = params.get("slide") ?? undefined;
21
+ const initialFragmentIndex = parseFragmentIndex(params.get("fragment"));
22
+
23
+ return {
24
+ ...(initialSlideId === undefined ? {} : { initialSlideId }),
25
+ ...(initialFragmentIndex === undefined ? {} : { initialFragmentIndex }),
26
+ };
27
+ }
28
+
29
+ function parseFragmentIndex(fragment: string | null): number | undefined {
30
+ if (fragment === null) {
31
+ return undefined;
32
+ }
33
+
34
+ const parsedFragment = Number(fragment);
35
+
36
+ return Number.isInteger(parsedFragment) && parsedFragment >= 0
37
+ ? parsedFragment
38
+ : undefined;
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export const runtimePackageName = "@velumo/runtime";
2
+ export * from "./controller.js";
3
+ export * from "./hash.js";
4
+ export * from "./notes.js";
5
+ export * from "./registry.js";
6
+ export * from "./timeline.js";
7
+ export * from "./cue-dispatcher.js";
8
+ export * from "./action-dispatcher.js";
9
+ export * from "./camera.js";
10
+ export * from "./spatial.js";
11
+ export * from "./scene-player.js";
12
+ export * from "./theme-bundle.js";
package/src/notes.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type { Manifest, SlideKind } from "@velumo/core";
2
+ import type { RuntimeController, RuntimeState } from "./controller.js";
3
+
4
+ export type SpeakerNoteBlock =
5
+ | {
6
+ readonly type: "heading";
7
+ readonly level: 1 | 2 | 3;
8
+ readonly text: string;
9
+ }
10
+ | { readonly type: "paragraph"; readonly text: string }
11
+ | { readonly type: "list"; readonly items: readonly string[] }
12
+ | { readonly type: "code"; readonly text: string };
13
+
14
+ export interface SpeakerNotesEntry {
15
+ readonly slideId: string;
16
+ readonly slideIndex: number;
17
+ readonly slideKind: SlideKind;
18
+ readonly raw: string;
19
+ readonly plainText: string;
20
+ readonly hasNotes: boolean;
21
+ readonly blocks: readonly SpeakerNoteBlock[];
22
+ }
23
+
24
+ export interface SpeakerNotesModel {
25
+ getBySlideId(slideId: string): SpeakerNotesEntry;
26
+ getForState(state: RuntimeState): SpeakerNotesEntry;
27
+ getForRuntime(runtime: RuntimeController): SpeakerNotesEntry;
28
+ }
29
+
30
+ const speakerNoteCodeFence = "```";
31
+ const speakerNoteHeadingPattern = /^(#{1,3})\s+(.+)$/;
32
+ const speakerNoteListItemPattern = /^[-*]\s+/;
33
+
34
+ export function createSpeakerNotesModel(manifest: Manifest): SpeakerNotesModel {
35
+ return {
36
+ getBySlideId(slideId) {
37
+ return notesForSlide(manifest, slideId);
38
+ },
39
+ getForState(state) {
40
+ return notesForSlide(manifest, state.slideId);
41
+ },
42
+ getForRuntime(runtime) {
43
+ return notesForSlide(manifest, runtime.getState().slideId);
44
+ },
45
+ };
46
+ }
47
+
48
+ function notesForSlide(manifest: Manifest, slideId: string): SpeakerNotesEntry {
49
+ const slideIndex = manifest.slides.findIndex((slide) => slide.id === slideId);
50
+ const slide = manifest.slides[slideIndex];
51
+
52
+ if (slide === undefined) {
53
+ return emptyNotes(slideId);
54
+ }
55
+
56
+ const raw = slide.notes ?? "";
57
+ const blocks = raw.trim().length === 0 ? [] : parseSpeakerNoteBlocks(raw);
58
+
59
+ return {
60
+ slideId: slide.id,
61
+ slideIndex,
62
+ slideKind: slide.kind,
63
+ raw,
64
+ plainText: blocksToPlainText(blocks),
65
+ hasNotes: raw.trim().length > 0,
66
+ blocks,
67
+ };
68
+ }
69
+
70
+ function emptyNotes(slideId: string): SpeakerNotesEntry {
71
+ return {
72
+ slideId,
73
+ slideIndex: -1,
74
+ slideKind: "slide",
75
+ raw: "",
76
+ plainText: "",
77
+ hasNotes: false,
78
+ blocks: [],
79
+ };
80
+ }
81
+
82
+ function parseSpeakerNoteBlocks(source: string): SpeakerNoteBlock[] {
83
+ const blocks: SpeakerNoteBlock[] = [];
84
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
85
+ let index = 0;
86
+
87
+ while (index < lines.length) {
88
+ const trimmedLine = lines[index].trim();
89
+
90
+ if (trimmedLine.length === 0) {
91
+ index += 1;
92
+ continue;
93
+ }
94
+
95
+ if (trimmedLine === speakerNoteCodeFence) {
96
+ const codeLines: string[] = [];
97
+ index += 1;
98
+
99
+ while (
100
+ index < lines.length &&
101
+ lines[index].trim() !== speakerNoteCodeFence
102
+ ) {
103
+ codeLines.push(lines[index]);
104
+ index += 1;
105
+ }
106
+
107
+ blocks.push({ type: "code", text: codeLines.join("\n") });
108
+ if (index < lines.length) {
109
+ index += 1;
110
+ }
111
+ continue;
112
+ }
113
+
114
+ const headingMatch = trimmedLine.match(speakerNoteHeadingPattern);
115
+ if (headingMatch !== null) {
116
+ blocks.push({
117
+ type: "heading",
118
+ level: headingMatch[1].length as 1 | 2 | 3,
119
+ text: cleanInlineText(headingMatch[2]),
120
+ });
121
+ index += 1;
122
+ continue;
123
+ }
124
+
125
+ if (speakerNoteListItemPattern.test(trimmedLine)) {
126
+ const items: string[] = [];
127
+
128
+ while (
129
+ index < lines.length &&
130
+ speakerNoteListItemPattern.test(lines[index].trim())
131
+ ) {
132
+ items.push(
133
+ cleanInlineText(
134
+ lines[index].trim().replace(speakerNoteListItemPattern, ""),
135
+ ),
136
+ );
137
+ index += 1;
138
+ }
139
+
140
+ blocks.push({ type: "list", items });
141
+ continue;
142
+ }
143
+
144
+ const paragraphLines: string[] = [];
145
+
146
+ while (index < lines.length && isSpeakerNoteParagraphLine(lines[index])) {
147
+ paragraphLines.push(lines[index].trim());
148
+ index += 1;
149
+ }
150
+
151
+ blocks.push({
152
+ type: "paragraph",
153
+ text: cleanInlineText(paragraphLines.join(" ")),
154
+ });
155
+ }
156
+
157
+ return blocks;
158
+ }
159
+
160
+ function isSpeakerNoteParagraphLine(line: string): boolean {
161
+ const trimmedLine = line.trim();
162
+
163
+ return (
164
+ trimmedLine.length > 0 &&
165
+ trimmedLine !== speakerNoteCodeFence &&
166
+ trimmedLine.match(speakerNoteHeadingPattern) === null &&
167
+ !speakerNoteListItemPattern.test(trimmedLine)
168
+ );
169
+ }
170
+
171
+ function cleanInlineText(source: string): string {
172
+ return source
173
+ .replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, "$1")
174
+ .replace(/<\/?[^>]+>/g, "")
175
+ .replace(/`([^`]+)`/g, "$1")
176
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
177
+ .replace(/\*([^*]+)\*/g, "$1")
178
+ .trim();
179
+ }
180
+
181
+ function blocksToPlainText(blocks: readonly SpeakerNoteBlock[]): string {
182
+ return blocks
183
+ .map((block) => {
184
+ switch (block.type) {
185
+ case "heading":
186
+ case "paragraph":
187
+ case "code":
188
+ return block.text;
189
+ case "list":
190
+ return block.items.join("\n");
191
+ }
192
+ })
193
+ .join("\n\n");
194
+ }