mr-md 1.0.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.
- package/README.md +24 -0
- package/dist/builder.d.ts +169 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +545 -0
- package/dist/client/app.js +314 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/renderer/blocks.d.ts +15 -0
- package/dist/renderer/blocks.d.ts.map +1 -0
- package/dist/renderer/blocks.js +365 -0
- package/dist/renderer/html.d.ts +8 -0
- package/dist/renderer/html.d.ts.map +1 -0
- package/dist/renderer/html.js +141 -0
- package/dist/renderer/index.d.ts +4 -0
- package/dist/renderer/index.d.ts.map +1 -0
- package/dist/renderer/index.js +381 -0
- package/dist/renderer/markdown.d.ts +13 -0
- package/dist/renderer/markdown.d.ts.map +1 -0
- package/dist/renderer/markdown.js +143 -0
- package/dist/renderer/utils.d.ts +10 -0
- package/dist/renderer/utils.d.ts.map +1 -0
- package/dist/renderer/utils.js +59 -0
- package/dist/styles/theme.css +1258 -0
- package/dist/types.d.ts +207 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +62 -0
- package/src/builder.ts +708 -0
- package/src/client/app.js +314 -0
- package/src/index.ts +34 -0
- package/src/renderer/blocks.ts +452 -0
- package/src/renderer/html.ts +163 -0
- package/src/renderer/index.ts +401 -0
- package/src/renderer/markdown.ts +193 -0
- package/src/renderer/utils.ts +84 -0
- package/src/styles/theme.css +1258 -0
- package/src/types.ts +288 -0
package/src/builder.ts
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { render, renderChapter } from "./renderer/index.js";
|
|
4
|
+
import type {
|
|
5
|
+
AnimationBlock,
|
|
6
|
+
AnimationOptions,
|
|
7
|
+
Block,
|
|
8
|
+
BuildOptions,
|
|
9
|
+
CalloutBlock,
|
|
10
|
+
Chapter,
|
|
11
|
+
ChapterMeta,
|
|
12
|
+
CodeBlock,
|
|
13
|
+
ColumnItem,
|
|
14
|
+
ColumnsBlock,
|
|
15
|
+
ColumnsOptions,
|
|
16
|
+
DividerBlock,
|
|
17
|
+
HeadingBlock,
|
|
18
|
+
LatexBlock,
|
|
19
|
+
LatexOptions,
|
|
20
|
+
Lesson,
|
|
21
|
+
LessonMeta,
|
|
22
|
+
MarkdownBlock,
|
|
23
|
+
MediaBlock,
|
|
24
|
+
MediaOptions,
|
|
25
|
+
QuizBlock,
|
|
26
|
+
SectionBlock,
|
|
27
|
+
SimulationBlock,
|
|
28
|
+
SimulationConfig,
|
|
29
|
+
SimulationOptions,
|
|
30
|
+
YouTubeBlock,
|
|
31
|
+
YouTubeOptions,
|
|
32
|
+
} from "./types.js";
|
|
33
|
+
|
|
34
|
+
// ─── LessonBuilder ────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export class LessonBuilder {
|
|
37
|
+
private meta: LessonMeta;
|
|
38
|
+
private blocks: Block[] = [];
|
|
39
|
+
private options: BuildOptions;
|
|
40
|
+
private _rawOptions: BuildOptions;
|
|
41
|
+
|
|
42
|
+
constructor(title: string, options: BuildOptions = {}) {
|
|
43
|
+
this._rawOptions = options;
|
|
44
|
+
this.meta = {
|
|
45
|
+
title,
|
|
46
|
+
slug: title
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
49
|
+
.replace(/(^-|-$)/g, ""),
|
|
50
|
+
};
|
|
51
|
+
this.options = {
|
|
52
|
+
outDir: options.outDir ?? "./out",
|
|
53
|
+
contentBase: options.contentBase ?? ".",
|
|
54
|
+
theme: options.theme ?? "auto",
|
|
55
|
+
palette: options.palette ?? "ink",
|
|
56
|
+
strict: options.strict ?? true,
|
|
57
|
+
preset: {
|
|
58
|
+
layout: "lesson",
|
|
59
|
+
density: "comfortable",
|
|
60
|
+
tone: "scholarly",
|
|
61
|
+
...options.preset,
|
|
62
|
+
},
|
|
63
|
+
...options,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Meta setters ────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sets the URL slug for the generated HTML file.
|
|
71
|
+
* Automatically generated from the title by default.
|
|
72
|
+
*/
|
|
73
|
+
slug(slug: string): this {
|
|
74
|
+
this.meta.slug = slug;
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sets the SEO meta description and subheading for the lesson.
|
|
80
|
+
*/
|
|
81
|
+
description(text: string): this {
|
|
82
|
+
this.meta.description = text;
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Assigns taxonomy tags to the lesson. */
|
|
87
|
+
tags(...tags: string[]): this {
|
|
88
|
+
this.meta.tags = tags;
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Sets the author name for this lesson. */
|
|
93
|
+
author(name: string): this {
|
|
94
|
+
this.meta.author = name;
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sets the progression status of the lesson.
|
|
100
|
+
* Used to visually distinguish read/unread/locked lessons in Chapter timelines.
|
|
101
|
+
*/
|
|
102
|
+
status(status: "read" | "unread" | "locked"): this {
|
|
103
|
+
this.meta.status = status;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Curated production defaults for the generated lesson shell. */
|
|
108
|
+
preset(preset: NonNullable<BuildOptions["preset"]>): this {
|
|
109
|
+
this.options.preset = {
|
|
110
|
+
layout: preset.layout ?? this.options.preset?.layout ?? "lesson",
|
|
111
|
+
density: preset.density ?? this.options.preset?.density ?? "comfortable",
|
|
112
|
+
tone: preset.tone ?? this.options.preset?.tone ?? "scholarly",
|
|
113
|
+
};
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @internal Used by ChapterBuilder to push down shared config */
|
|
118
|
+
_inheritOptions(parentOpts: BuildOptions) {
|
|
119
|
+
this.options = {
|
|
120
|
+
outDir:
|
|
121
|
+
this._rawOptions.outDir ?? parentOpts.outDir ?? this.options.outDir,
|
|
122
|
+
contentBase:
|
|
123
|
+
this._rawOptions.contentBase ??
|
|
124
|
+
parentOpts.contentBase ??
|
|
125
|
+
this.options.contentBase,
|
|
126
|
+
theme: this._rawOptions.theme ?? parentOpts.theme ?? this.options.theme,
|
|
127
|
+
palette:
|
|
128
|
+
this._rawOptions.palette ?? parentOpts.palette ?? this.options.palette,
|
|
129
|
+
strict:
|
|
130
|
+
this._rawOptions.strict ?? parentOpts.strict ?? this.options.strict,
|
|
131
|
+
preset: {
|
|
132
|
+
...parentOpts.preset,
|
|
133
|
+
...this._rawOptions.preset,
|
|
134
|
+
...this.options.preset,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @internal Used by ChapterBuilder */
|
|
140
|
+
_setParentSlug(slug: string) {
|
|
141
|
+
this.meta.parentSlug = slug;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @internal Used by ChapterBuilder */
|
|
145
|
+
_setPrev(slug: string, title: string) {
|
|
146
|
+
this.meta.prevSlug = slug;
|
|
147
|
+
this.meta.prevTitle = title;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @internal Used by ChapterBuilder */
|
|
151
|
+
_setNext(slug: string, title: string) {
|
|
152
|
+
this.meta.nextSlug = slug;
|
|
153
|
+
this.meta.nextTitle = title;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @internal Used by ChapterBuilder */
|
|
157
|
+
_getMeta(): LessonMeta {
|
|
158
|
+
return this.meta;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Content blocks ───────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Smart helper that automatically infers the correct block type from the file extension or URL.
|
|
165
|
+
* Allows authors to quickly sequence a lesson without memorizing specific block methods.
|
|
166
|
+
*/
|
|
167
|
+
add(src: `${string}.md` | `${string}.mdx`): this;
|
|
168
|
+
add(src: `${string}.json`, opts?: Pick<QuizBlock, "label" | "caption">): this;
|
|
169
|
+
add(
|
|
170
|
+
src: `${string}.mp4` | `${string}.webm` | `${string}.mov`,
|
|
171
|
+
opts?: Omit<MediaOptions, "kind">,
|
|
172
|
+
): this;
|
|
173
|
+
add(
|
|
174
|
+
src: `${string}.mp3` | `${string}.wav` | `${string}.ogg` | `${string}.m4a`,
|
|
175
|
+
opts?: Omit<MediaOptions, "kind">,
|
|
176
|
+
): this;
|
|
177
|
+
add(
|
|
178
|
+
src:
|
|
179
|
+
| `${string}.png`
|
|
180
|
+
| `${string}.jpg`
|
|
181
|
+
| `${string}.jpeg`
|
|
182
|
+
| `${string}.gif`
|
|
183
|
+
| `${string}.svg`
|
|
184
|
+
| `${string}.webp`
|
|
185
|
+
| `${string}.avif`,
|
|
186
|
+
opts?: Omit<MediaOptions, "kind">,
|
|
187
|
+
): this;
|
|
188
|
+
// biome-ignore lint/suspicious/noExplicitAny: Overload signature
|
|
189
|
+
add(src: string, opts?: any): this;
|
|
190
|
+
// biome-ignore lint/suspicious/noExplicitAny: Overload implementation
|
|
191
|
+
add(src: string, opts: any = {}): this {
|
|
192
|
+
const lower = src.toLowerCase();
|
|
193
|
+
if (lower.endsWith(".md") || lower.endsWith(".mdx"))
|
|
194
|
+
return this.markdown(src);
|
|
195
|
+
if (lower.endsWith(".json")) return this.quiz(src, opts);
|
|
196
|
+
|
|
197
|
+
if (lower.endsWith(".js") || lower.endsWith(".ts")) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Ambiguous use of .add("${src}"). ` +
|
|
200
|
+
`Please use .code("${src}") to display the source code, ` +
|
|
201
|
+
`or .lab("${src}") to mount it as an interactive simulation.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (lower.match(/\.(png|jpg|jpeg|gif|webp|avif|svg)$/))
|
|
206
|
+
return this.image(src, opts);
|
|
207
|
+
if (lower.match(/\.(mp4|webm|mov)$/)) return this.video(src, opts);
|
|
208
|
+
if (lower.match(/\.(mp3|wav|ogg|m4a)$/)) return this.audio(src, opts);
|
|
209
|
+
if (lower.includes("youtube.com") || lower.includes("youtu.be"))
|
|
210
|
+
return this.youtube(src, opts);
|
|
211
|
+
|
|
212
|
+
// Fallback to text/markdown if unknown
|
|
213
|
+
return this.markdown(src);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Creates a major chapter heading (H2) and a top-level sidebar navigation entry.
|
|
218
|
+
* @param src Path to a markdown file or raw markdown string.
|
|
219
|
+
* @param title Optional title override for the sidebar and heading.
|
|
220
|
+
* @example lesson.heading("# Welcome to Physics")
|
|
221
|
+
*/
|
|
222
|
+
heading(src: string, title?: string): this {
|
|
223
|
+
this.blocks.push({ type: "heading", src, title } as HeadingBlock);
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Adds standard markdown prose into the current section.
|
|
229
|
+
* @param src Path to a markdown file or raw markdown string.
|
|
230
|
+
* @example lesson.markdown("This is a **bold** statement.")
|
|
231
|
+
*/
|
|
232
|
+
markdown(src: string): this {
|
|
233
|
+
this.blocks.push({ type: "markdown", src } as MarkdownBlock);
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Alias for `markdown()`. Keeps authoring readable in long lessons.
|
|
239
|
+
* @example lesson.content("content/intro.md")
|
|
240
|
+
*/
|
|
241
|
+
content(src: string): this {
|
|
242
|
+
return this.markdown(src);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Creates a new subsection heading (H3) and a sub-entry in the sidebar.
|
|
247
|
+
* @param src Path to a markdown file or raw markdown string.
|
|
248
|
+
* @param label Optional title override for the sidebar label.
|
|
249
|
+
* @example lesson.section("### 1. Kinematics")
|
|
250
|
+
*/
|
|
251
|
+
section(src: string, label?: string): this {
|
|
252
|
+
this.blocks.push({ type: "section", src, label } as SectionBlock);
|
|
253
|
+
return this;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Adds an 'Important' highlighted callout.
|
|
258
|
+
* @example lesson.important("Do not touch the exposed wire.")
|
|
259
|
+
*/
|
|
260
|
+
important(src: string): this {
|
|
261
|
+
this.blocks.push({ type: "important", src } as CalloutBlock);
|
|
262
|
+
return this;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Adds a 'Warning' highlighted callout.
|
|
267
|
+
* @example lesson.warning("This is deprecated.")
|
|
268
|
+
*/
|
|
269
|
+
warning(src: string): this {
|
|
270
|
+
this.blocks.push({ type: "warning", src } as CalloutBlock);
|
|
271
|
+
return this;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Adds a 'Tip' highlighted callout.
|
|
276
|
+
*/
|
|
277
|
+
tip(src: string): this {
|
|
278
|
+
this.blocks.push({ type: "tip", src } as CalloutBlock);
|
|
279
|
+
return this;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Adds a 'Note' highlighted callout.
|
|
284
|
+
*/
|
|
285
|
+
note(src: string): this {
|
|
286
|
+
this.blocks.push({ type: "note", src } as CalloutBlock);
|
|
287
|
+
return this;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
callout(type: CalloutBlock["type"], src: string): this {
|
|
291
|
+
this.blocks.push({ type, src } as CalloutBlock);
|
|
292
|
+
return this;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Adds a syntax-highlighted code block.
|
|
297
|
+
* @param src Path to a code file or raw code string.
|
|
298
|
+
* @param lang Explicit language for highlighting (e.g. "ts", "python").
|
|
299
|
+
* @param label Optional file name or label to display above the code block.
|
|
300
|
+
*/
|
|
301
|
+
code(src: string, lang?: string, label?: string): this {
|
|
302
|
+
this.blocks.push({ type: "code", src, lang, label } as CodeBlock);
|
|
303
|
+
return this;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
latex(tex: string, opts: LatexOptions = {}): this {
|
|
307
|
+
this.blocks.push({
|
|
308
|
+
type: "latex",
|
|
309
|
+
tex,
|
|
310
|
+
display: opts.display ?? true,
|
|
311
|
+
label: opts.label,
|
|
312
|
+
caption: opts.caption,
|
|
313
|
+
} as LatexBlock);
|
|
314
|
+
return this;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
columns(columns: ColumnItem[], opts: ColumnsOptions = {}): this {
|
|
318
|
+
this.blocks.push({ type: "columns", columns, ...opts } as ColumnsBlock);
|
|
319
|
+
return this;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Interactive blocks ───────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
private loadSimulationConfig(src: string): SimulationConfig | null {
|
|
325
|
+
try {
|
|
326
|
+
const base = this.options.contentBase ?? process.cwd();
|
|
327
|
+
const resolved = path.resolve(base, src);
|
|
328
|
+
const ext = path.extname(resolved);
|
|
329
|
+
const configPath = `${resolved.slice(0, -ext.length)}.config.json`;
|
|
330
|
+
if (fs.existsSync(configPath)) {
|
|
331
|
+
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// ignore
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Mounts a sandboxed JavaScript simulation or interactive applet.
|
|
341
|
+
* @param src Path to the JavaScript simulation code.
|
|
342
|
+
* @param opts Configuration options including tunables, height, and aspect ratio.
|
|
343
|
+
* @param height Optional fixed iframe height in pixels.
|
|
344
|
+
* @example lesson.simulation("sims/pendulum.js", { height: 500 })
|
|
345
|
+
*/
|
|
346
|
+
simulation(
|
|
347
|
+
src: string,
|
|
348
|
+
opts: SimulationOptions | Record<string, unknown> = {},
|
|
349
|
+
height = 420,
|
|
350
|
+
): this {
|
|
351
|
+
const fileConfig = this.loadSimulationConfig(src);
|
|
352
|
+
const normalized = normalizeSimulationOptions(opts, height, fileConfig);
|
|
353
|
+
this.blocks.push({
|
|
354
|
+
type: "simulation",
|
|
355
|
+
src,
|
|
356
|
+
dependencies: fileConfig?.dependencies,
|
|
357
|
+
...normalized,
|
|
358
|
+
} as SimulationBlock);
|
|
359
|
+
return this;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Opinionated alias for `simulation()`, configured specifically for interactive physics/math labs.
|
|
364
|
+
* Sets the default interaction mode to fully interactive.
|
|
365
|
+
* @param src Path to the JavaScript simulation code.
|
|
366
|
+
* @param opts Configuration options.
|
|
367
|
+
* @example lesson.lab("sims/optics-lab.js")
|
|
368
|
+
*/
|
|
369
|
+
lab(src: string, opts: SimulationOptions = {}): this {
|
|
370
|
+
return this.simulation(src, { controls: "interactive", ...opts });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Passive animation player */
|
|
374
|
+
animation(src: string, opts: AnimationOptions = {}): this {
|
|
375
|
+
this.blocks.push({
|
|
376
|
+
type: "animation",
|
|
377
|
+
src,
|
|
378
|
+
loop: opts.loop ?? true,
|
|
379
|
+
height: opts.height ?? 360,
|
|
380
|
+
label: opts.label,
|
|
381
|
+
caption: opts.caption,
|
|
382
|
+
accent: opts.accent ?? "neutral",
|
|
383
|
+
} as AnimationBlock);
|
|
384
|
+
return this;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
media(src: string, opts: MediaOptions = {}): this {
|
|
388
|
+
this.blocks.push({
|
|
389
|
+
type: "media",
|
|
390
|
+
src,
|
|
391
|
+
kind: opts.kind ?? inferMediaKind(src),
|
|
392
|
+
alt: opts.alt,
|
|
393
|
+
label: opts.label,
|
|
394
|
+
caption: opts.caption,
|
|
395
|
+
credit: opts.credit,
|
|
396
|
+
poster: opts.poster,
|
|
397
|
+
controls: opts.controls ?? true,
|
|
398
|
+
} as MediaBlock);
|
|
399
|
+
return this;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
image(src: string, opts: Omit<MediaOptions, "kind"> = {}): this {
|
|
403
|
+
return this.media(src, { ...opts, kind: "image" });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
video(src: string, opts: Omit<MediaOptions, "kind"> = {}): this {
|
|
407
|
+
return this.media(src, { ...opts, kind: "video" });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
youtube(idOrUrl: string, opts: YouTubeOptions = {}): this {
|
|
411
|
+
this.blocks.push({
|
|
412
|
+
type: "youtube",
|
|
413
|
+
id: extractYouTubeId(idOrUrl),
|
|
414
|
+
start: opts.start,
|
|
415
|
+
label: opts.label,
|
|
416
|
+
caption: opts.caption,
|
|
417
|
+
} as YouTubeBlock);
|
|
418
|
+
return this;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
audio(src: string, opts: Omit<MediaOptions, "kind"> = {}): this {
|
|
422
|
+
return this.media(src, { ...opts, kind: "audio" });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Injects an interactive multiple-choice quiz block.
|
|
427
|
+
* @param src Path to a .json file conforming to the `QuizFile` schema.
|
|
428
|
+
* @param opts Optional label and caption for the quiz block wrapper.
|
|
429
|
+
*/
|
|
430
|
+
quiz(src: string, opts: Pick<QuizBlock, "label" | "caption"> = {}): this {
|
|
431
|
+
this.blocks.push({ type: "quiz", src, ...opts } as QuizBlock);
|
|
432
|
+
return this;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Visual separator */
|
|
436
|
+
divider(): this {
|
|
437
|
+
this.blocks.push({ type: "divider" } as DividerBlock);
|
|
438
|
+
return this;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Output ───────────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
/** Returns the raw Lesson object (useful for testing or custom rendering) */
|
|
444
|
+
toJSON(): Lesson {
|
|
445
|
+
validateLesson(this.meta, this.blocks, this.options);
|
|
446
|
+
return { meta: this.meta, blocks: this.blocks };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Compiles everything and writes a single .html file to outDir */
|
|
450
|
+
build(): string {
|
|
451
|
+
validateLesson(this.meta, this.blocks, this.options);
|
|
452
|
+
const lesson: Lesson = { meta: this.meta, blocks: this.blocks };
|
|
453
|
+
const html = render(lesson, this.options);
|
|
454
|
+
|
|
455
|
+
const outDir = path.resolve(this.options.outDir as string);
|
|
456
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
457
|
+
|
|
458
|
+
const outPath = path.join(outDir, `${this.meta.slug}.html`);
|
|
459
|
+
fs.writeFileSync(outPath, html, "utf-8");
|
|
460
|
+
|
|
461
|
+
const relPath = path.relative(process.cwd(), outPath);
|
|
462
|
+
console.log(` ✓ Built lesson (${this.blocks.length} blocks) → ${relPath}`);
|
|
463
|
+
return outPath;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─── Factory function (the public API) ───────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Starts building a new interactive lesson.
|
|
471
|
+
* @param title The display title for the lesson.
|
|
472
|
+
* @param options Build configuration for this specific lesson.
|
|
473
|
+
* @example const l = lesson("Introduction to Kinematics").markdown("intro.md");
|
|
474
|
+
*/
|
|
475
|
+
export function lesson(
|
|
476
|
+
title: string,
|
|
477
|
+
options: BuildOptions = {},
|
|
478
|
+
): LessonBuilder {
|
|
479
|
+
return new LessonBuilder(title, options);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function normalizeSimulationOptions(
|
|
483
|
+
opts: SimulationOptions | Record<string, unknown>,
|
|
484
|
+
legacyHeight: number,
|
|
485
|
+
fileConfig: SimulationConfig | null = null,
|
|
486
|
+
): SimulationOptions {
|
|
487
|
+
let inline: SimulationOptions;
|
|
488
|
+
const optionKeys = [
|
|
489
|
+
"props",
|
|
490
|
+
"tunables",
|
|
491
|
+
"height",
|
|
492
|
+
"label",
|
|
493
|
+
"caption",
|
|
494
|
+
"controls",
|
|
495
|
+
"accent",
|
|
496
|
+
];
|
|
497
|
+
const looksLikeOptions = Object.keys(opts).every((key) =>
|
|
498
|
+
optionKeys.includes(key),
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
if (!looksLikeOptions) {
|
|
502
|
+
inline = { props: opts as Record<string, unknown> };
|
|
503
|
+
} else {
|
|
504
|
+
inline = opts as SimulationOptions;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
props: { ...(fileConfig?.props ?? {}), ...(inline.props ?? {}) },
|
|
509
|
+
tunables: inline.tunables ?? fileConfig?.tunables,
|
|
510
|
+
height: inline.height ?? fileConfig?.height ?? legacyHeight,
|
|
511
|
+
label: inline.label ?? fileConfig?.label,
|
|
512
|
+
caption: inline.caption ?? fileConfig?.caption,
|
|
513
|
+
controls: inline.controls ?? fileConfig?.controls ?? "interactive",
|
|
514
|
+
accent: inline.accent ?? fileConfig?.accent ?? "blue",
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function inferMediaKind(src: string): MediaBlock["kind"] {
|
|
519
|
+
const ext = path.extname(src).toLowerCase();
|
|
520
|
+
if ([".mp4", ".webm", ".mov"].includes(ext)) return "video";
|
|
521
|
+
if ([".mp3", ".wav", ".ogg", ".m4a"].includes(ext)) return "audio";
|
|
522
|
+
return "image"; // .gif, .svg, .webp, .avif, .jpg, .png
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function extractYouTubeId(idOrUrl: string): string {
|
|
526
|
+
const watch = idOrUrl.match(/[?&]v=([^&]+)/);
|
|
527
|
+
if (watch) return watch[1];
|
|
528
|
+
|
|
529
|
+
const short = idOrUrl.match(/youtu\.be\/([^?&/]+)/);
|
|
530
|
+
if (short) return short[1];
|
|
531
|
+
|
|
532
|
+
const embed = idOrUrl.match(/youtube\.com\/embed\/([^?&/]+)/);
|
|
533
|
+
if (embed) return embed[1];
|
|
534
|
+
|
|
535
|
+
const shorts = idOrUrl.match(/youtube\.com\/shorts\/([^?&/]+)/);
|
|
536
|
+
if (shorts) return shorts[1];
|
|
537
|
+
|
|
538
|
+
return idOrUrl;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function validateLesson(
|
|
542
|
+
meta: LessonMeta,
|
|
543
|
+
blocks: Block[],
|
|
544
|
+
options: BuildOptions,
|
|
545
|
+
): void {
|
|
546
|
+
if (options.strict === false) return;
|
|
547
|
+
|
|
548
|
+
const errors: string[] = [];
|
|
549
|
+
|
|
550
|
+
if (!meta.title.trim()) errors.push("Lesson title is required.");
|
|
551
|
+
if (!meta.slug.trim()) errors.push("Lesson slug is required.");
|
|
552
|
+
if (!blocks.length) errors.push("Lesson needs at least one block.");
|
|
553
|
+
|
|
554
|
+
blocks.forEach((block, index) => {
|
|
555
|
+
if (
|
|
556
|
+
"height" in block &&
|
|
557
|
+
typeof block.height === "number" &&
|
|
558
|
+
block.height < 240
|
|
559
|
+
) {
|
|
560
|
+
errors.push(
|
|
561
|
+
`${block.type} block ${index + 1} should be at least 240px tall.`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (
|
|
566
|
+
block.type === "media" &&
|
|
567
|
+
block.kind === "image" &&
|
|
568
|
+
!block.alt?.trim()
|
|
569
|
+
) {
|
|
570
|
+
errors.push(`Image media block ${index + 1} needs alt text.`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (
|
|
574
|
+
block.type === "simulation" &&
|
|
575
|
+
block.controls === "observe" &&
|
|
576
|
+
block.caption == null
|
|
577
|
+
) {
|
|
578
|
+
errors.push(
|
|
579
|
+
`Observe-only simulation block ${index + 1} needs a caption.`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
if (errors.length) {
|
|
585
|
+
throw new Error(
|
|
586
|
+
`Mr Markdown production checks failed:\n- ${errors.join("\n- ")}`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ─── ChapterBuilder ──────────────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
export class ChapterBuilder {
|
|
594
|
+
private meta: ChapterMeta;
|
|
595
|
+
private lessonBuilders: LessonBuilder[] = [];
|
|
596
|
+
private options: BuildOptions;
|
|
597
|
+
|
|
598
|
+
constructor(title: string, options: BuildOptions = {}) {
|
|
599
|
+
this.meta = {
|
|
600
|
+
title,
|
|
601
|
+
slug: title
|
|
602
|
+
.toLowerCase()
|
|
603
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
604
|
+
.replace(/(^-|-$)/g, ""),
|
|
605
|
+
};
|
|
606
|
+
this.options = {
|
|
607
|
+
outDir: options.outDir ?? "./out",
|
|
608
|
+
contentBase: options.contentBase ?? ".",
|
|
609
|
+
theme: options.theme ?? "auto",
|
|
610
|
+
palette: options.palette ?? "ink",
|
|
611
|
+
strict: options.strict ?? true,
|
|
612
|
+
preset: {
|
|
613
|
+
layout: "lesson",
|
|
614
|
+
density: "comfortable",
|
|
615
|
+
tone: "scholarly",
|
|
616
|
+
...options.preset,
|
|
617
|
+
},
|
|
618
|
+
...options,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
slug(slug: string): this {
|
|
623
|
+
this.meta.slug = slug;
|
|
624
|
+
return this;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
description(text: string): this {
|
|
628
|
+
this.meta.description = text;
|
|
629
|
+
return this;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
status(status: "completed" | "active" | "locked"): this {
|
|
633
|
+
this.meta.status = status;
|
|
634
|
+
return this;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
lesson(lessonBuilder: LessonBuilder): this {
|
|
638
|
+
lessonBuilder._inheritOptions(this.options);
|
|
639
|
+
lessonBuilder._setParentSlug(this.meta.slug);
|
|
640
|
+
this.lessonBuilders.push(lessonBuilder);
|
|
641
|
+
return this;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
build(): string {
|
|
645
|
+
for (let i = 0; i < this.lessonBuilders.length; i++) {
|
|
646
|
+
const lb = this.lessonBuilders[i];
|
|
647
|
+
const currentMeta = lb._getMeta();
|
|
648
|
+
if (i > 0 && !currentMeta.prevSlug) {
|
|
649
|
+
const prev = this.lessonBuilders[i - 1]._getMeta();
|
|
650
|
+
lb._setPrev(prev.slug, prev.title);
|
|
651
|
+
}
|
|
652
|
+
if (i < this.lessonBuilders.length - 1 && !currentMeta.nextSlug) {
|
|
653
|
+
const next = this.lessonBuilders[i + 1]._getMeta();
|
|
654
|
+
lb._setNext(next.slug, next.title);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Build all nested lessons first
|
|
659
|
+
const lessons: Lesson[] = [];
|
|
660
|
+
for (const lb of this.lessonBuilders) {
|
|
661
|
+
// We build it to write out the HTML file
|
|
662
|
+
lb.build();
|
|
663
|
+
// We also collect the JSON data to render the chapter index
|
|
664
|
+
lessons.push(lb.toJSON());
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const chapterData: Chapter = { meta: this.meta, lessons };
|
|
668
|
+
const html = renderChapter(chapterData, this.options);
|
|
669
|
+
|
|
670
|
+
const outDir = path.resolve(this.options.outDir as string);
|
|
671
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
672
|
+
|
|
673
|
+
const outPath = path.join(outDir, `${this.meta.slug}.html`);
|
|
674
|
+
fs.writeFileSync(outPath, html, "utf-8");
|
|
675
|
+
|
|
676
|
+
const relPath = path.relative(process.cwd(), outPath);
|
|
677
|
+
console.log(
|
|
678
|
+
` ✓ Built chapter (${this.lessonBuilders.length} lessons) → ${relPath}`,
|
|
679
|
+
);
|
|
680
|
+
return outPath;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Returns the raw Chapter object */
|
|
684
|
+
toJSON(): Chapter {
|
|
685
|
+
const lessons: Lesson[] = [];
|
|
686
|
+
for (const lb of this.lessonBuilders) {
|
|
687
|
+
lessons.push(lb.toJSON());
|
|
688
|
+
}
|
|
689
|
+
return { meta: this.meta, lessons };
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Starts building a new chapter that groups multiple lessons together.
|
|
695
|
+
* @param title The display title for the chapter.
|
|
696
|
+
* @param options Shared build configuration that cascades to all child lessons.
|
|
697
|
+
* @example
|
|
698
|
+
* chapter("Mechanics")
|
|
699
|
+
* .lesson(kinematicsLesson)
|
|
700
|
+
* .lesson(dynamicsLesson)
|
|
701
|
+
* .build();
|
|
702
|
+
*/
|
|
703
|
+
export function chapter(
|
|
704
|
+
title: string,
|
|
705
|
+
options: BuildOptions = {},
|
|
706
|
+
): ChapterBuilder {
|
|
707
|
+
return new ChapterBuilder(title, options);
|
|
708
|
+
}
|