@curio-sd/e-module-builder 0.3.1 → 0.5.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 CHANGED
@@ -45,9 +45,9 @@ content/
45
45
  2.md ← exercise 2
46
46
 
47
47
  week2/ … weekN/ ← same structure
48
- exams/
49
- theory-exam.md ← final theory exam (optional)
50
- practical-exam.md ← final practical exam (optional)
48
+ assessments/
49
+ theory-assessment.md ← final theory assessment (optional)
50
+ practical-assessment.md ← final practical assessment (optional)
51
51
  ```
52
52
 
53
53
  ## Content file formats
@@ -162,19 +162,61 @@ mode: interactive # optional, overrides module-level exerciseMode for this set
162
162
 
163
163
  ### `content/weekN/exercises/N.md`
164
164
 
165
- Each file is a single exercise defined entirely in YAML frontmatter.
165
+ Each file is a single exercise. The YAML frontmatter holds metadata; for text exercises the markdown **body** (content below the frontmatter) is rendered as the exercise content.
166
166
 
167
- **Text/instructions exercise:**
167
+ **Text exercise with markdown body (recommended for rich content):**
168
+
169
+ ```markdown
170
+ ---
171
+ id: 1
172
+ difficulty: 1
173
+ title: Columns
174
+ type: text
175
+ ---
176
+
177
+ Create a grid with **two equal columns** using `grid-template-columns`.
178
+
179
+ ## Tips
180
+
181
+ - Use `repeat(2, 1fr)` for equal columns.
182
+ - `fr` stands for _fractional unit_.
183
+ ```
184
+
185
+ The body can contain any markdown: headings, bold/italic, lists, images, code blocks, and [custom elements](#custom-elements-in-markdown). No CSS playground or external link is needed — teachers can write the full exercise content as plain markdown.
186
+
187
+ **Shorthand (inline description in YAML, for very short exercises):**
168
188
 
169
189
  ```yaml
170
190
  ---
171
191
  type: text
172
192
  title: Columns
173
- description: |
174
- Create a grid with two equal columns.
193
+ description: Create a grid with two equal columns.
175
194
  ---
176
195
  ```
177
196
 
197
+ When both a body and a `description` field are present the body takes precedence.
198
+
199
+ **Linking theory pages (optional):**
200
+
201
+ Use `linked_theory` to attach one or more theory pages to an exercise. A collapsible panel slides in from the right with a tab per week, embedding the theory page so students can look up content without leaving the exercise.
202
+
203
+ ```yaml
204
+ ---
205
+ id: 3
206
+ type: text
207
+ title: Columns
208
+ linked_theory:
209
+ - week1
210
+ - week2
211
+ ---
212
+ ```
213
+
214
+ | Field | Required | Description |
215
+ | ----- | -------- | ----------- |
216
+ | `linked_theory` | no | List of week identifiers (e.g. `week1`). Renders a collapsible right-side panel with tabbed iframes — one per linked week. When absent, no panel or toggle button is shown. Theory pages load without their own navbar inside the panel. |
217
+
218
+ ---
219
+
178
220
  **Interactive (Monaco editor) exercise:**
179
221
 
180
222
  ```yaml
@@ -199,9 +241,9 @@ url: https://cssgridgarden.com
199
241
 
200
242
  ---
201
243
 
202
- ### `content/exams/theory-exam.md` and `practical-exam.md`
244
+ ### `content/assessments/theory-assessment.md` and `practical-assessment.md`
203
245
 
204
- Same structure as `quiz.md`. Practical exam questions may include a `preview` field with `css` and `html` for a live CSS preview alongside the question.
246
+ Same structure as `quiz.md`. Practical assessment questions may include a `preview` field with `css` and `html` for a live CSS preview alongside the question.
205
247
 
206
248
  ---
207
249
 
@@ -247,20 +289,20 @@ The build pipeline runs before Vite and produces:
247
289
  | ------ | ------ |
248
290
  | `src/data/manifest.json` | `module.md` + all week frontmatter |
249
291
  | `src/data/theory-weekN.json` | `weekN/theory.md` |
250
- | `src/data/tussentoets-weekN.json` | `weekN/quiz.md` |
292
+ | `src/data/meetmoment-quiz-weekN.json` | `weekN/quiz.md` |
251
293
  | `src/data/exercises/weekN.json` | `weekN/exercises/` |
252
294
  | `src/data/inleveropdracht-weekN.json` | `weekN/assignment.md` |
253
295
  | `src/data/checklist.json` | `leeruitkomsten` from all weeks |
254
- | `src/data/toets-theorie.json` | `exams/theory-exam.md` |
255
- | `src/data/toets-praktijk.json` | `exams/practical-exam.md` |
296
+ | `src/data/meetmoment-theorie.json` | `assessments/theory-assessment.md` |
297
+ | `src/data/meetmoment-praktijk.json` | `assessments/practical-assessment.md` |
256
298
  | `pages/weekN-theorie.html` | generated from template |
257
299
  | `pages/weekN-oefeningen.html` | generated from template |
258
- | `pages/weekN-toets.html` | generated from template |
300
+ | `pages/weekN-meetmoment.html` | generated from template |
259
301
  | `pages/weekN-oefening.html` | generated from template |
260
302
  | `pages/weekN-inleveropdracht.html` | generated from template |
261
303
  | `pages/checklist.html` | generated from template |
262
- | `pages/toets-theorie.html` | generated from template |
263
- | `pages/toets-praktijk.html` | generated from template |
304
+ | `pages/meetmoment-theorie.html` | generated from template |
305
+ | `pages/meetmoment-praktijk.html` | generated from template |
264
306
  | `index.html` | generated from template |
265
307
 
266
308
  In `dev` mode, changes to `content/` trigger an automatic rebuild and browser reload.
package/bin/cli.js CHANGED
@@ -57,6 +57,8 @@ async function main() {
57
57
  if (command === 'build') {
58
58
  const { build } = await import('vite')
59
59
  await build(cfg)
60
+ const { generatePdf } = await import(pathToFileURL(path.join(PKG_DIR, 'build-pdf.mjs')).href)
61
+ await generatePdf({ projectDir: PROJECT_DIR })
60
62
  } else {
61
63
  const { createServer } = await import('vite')
62
64
  const server = await createServer(cfg)
package/build-pdf.mjs ADDED
@@ -0,0 +1,452 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import matter from 'gray-matter'
4
+ import { Marked } from 'marked'
5
+ import PDFDocument from 'pdfkit'
6
+
7
+ const marked = new Marked()
8
+
9
+ const SECTION_RE = /^([a-zA-Z]+)(\d+)$/
10
+ const CUSTOM_EL_RE = /^<(x-[a-z-]+|details)([^>]*)>([\s\S]*?)<\/\1>/i
11
+ const SUMMARY_RE = /<summary>([\s\S]*?)<\/summary>/i
12
+
13
+ // ─── Inline token → span list ─────────────────────────────────────────────────
14
+
15
+ function boldOf(font) {
16
+ return font === 'Helvetica-Oblique' ? 'Helvetica-BoldOblique' : 'Helvetica-Bold'
17
+ }
18
+ function italicOf(font) {
19
+ return font === 'Helvetica-Bold' ? 'Helvetica-BoldOblique' : 'Helvetica-Oblique'
20
+ }
21
+
22
+ function collectSpans(tokens, spans, ctx) {
23
+ for (const token of tokens) {
24
+ switch (token.type) {
25
+ case 'text':
26
+ if (token.tokens?.length) collectSpans(token.tokens, spans, ctx)
27
+ else if (token.text) spans.push({ ...ctx, text: token.text })
28
+ break
29
+ case 'strong':
30
+ collectSpans(token.tokens ?? [], spans, { ...ctx, font: boldOf(ctx.font) })
31
+ break
32
+ case 'em':
33
+ collectSpans(token.tokens ?? [], spans, { ...ctx, font: italicOf(ctx.font) })
34
+ break
35
+ case 'codespan':
36
+ spans.push({ ...ctx, font: 'Courier', text: token.text })
37
+ break
38
+ case 'link':
39
+ collectSpans(
40
+ token.tokens ?? [{ type: 'text', text: token.text || token.href }],
41
+ spans,
42
+ { ...ctx, color: '#0057B8' }
43
+ )
44
+ break
45
+ case 'image':
46
+ if (token.text) spans.push({ ...ctx, text: `[${token.text}]` })
47
+ break
48
+ case 'html': {
49
+ const stripped = token.text.replace(/<[^>]+>/g, '').trim()
50
+ if (stripped) spans.push({ ...ctx, text: stripped })
51
+ break
52
+ }
53
+ case 'br':
54
+ case 'softbreak':
55
+ spans.push({ ...ctx, text: '\n' })
56
+ break
57
+ default:
58
+ if (token.text) spans.push({ ...ctx, text: token.text })
59
+ }
60
+ }
61
+ }
62
+
63
+ function flushSpans(doc, spans) {
64
+ const items = spans.filter(s => s.text)
65
+ if (!items.length) return
66
+ for (let i = 0; i < items.length; i++) {
67
+ const { text, font, fontSize, color } = items[i]
68
+ doc.font(font).fontSize(fontSize).fillColor(color)
69
+ doc.text(text, { continued: i < items.length - 1, lineGap: 2 })
70
+ }
71
+ }
72
+
73
+ // Renders paragraph inline tokens; handles inline images by flushing pending spans first
74
+ function renderParagraphTokens(doc, tokens, contentDir, ctx) {
75
+ let pending = []
76
+
77
+ function flush() {
78
+ flushSpans(doc, pending)
79
+ pending = []
80
+ }
81
+
82
+ for (const token of tokens) {
83
+ if (token.type === 'image') {
84
+ flush()
85
+ renderLocalImage(doc, token.href, contentDir)
86
+ } else {
87
+ collectSpans([token], pending, ctx)
88
+ }
89
+ }
90
+ flush()
91
+ }
92
+
93
+ function renderLocalImage(doc, href, contentDir) {
94
+ if (!href || /^https?:\/\/|^data:/.test(href)) return
95
+ const imgPath = path.resolve(contentDir, href)
96
+ if (!fs.existsSync(imgPath)) return
97
+ const ext = path.extname(imgPath).toLowerCase()
98
+ if (!['.jpg', '.jpeg', '.png'].includes(ext)) return // pdfkit only supports jpg/png natively
99
+ try {
100
+ const maxW = doc.page.width - doc.page.margins.left - doc.page.margins.right
101
+ doc.image(imgPath, { fit: [maxW, 280], align: 'left' })
102
+ doc.moveDown(0.3)
103
+ } catch {
104
+ // unsupported or corrupt — skip silently
105
+ }
106
+ }
107
+
108
+ // ─── Block token renderer ─────────────────────────────────────────────────────
109
+
110
+ function renderTokens(doc, tokens, contentDir, opts = {}) {
111
+ for (const token of tokens) {
112
+ renderToken(doc, token, contentDir, opts)
113
+ }
114
+ }
115
+
116
+ function renderToken(doc, token, contentDir, opts = {}) {
117
+ const base = { font: 'Helvetica', fontSize: 11, ...opts }
118
+ const ctx = { font: base.font, fontSize: base.fontSize, color: '#000' }
119
+
120
+ switch (token.type) {
121
+ case 'space':
122
+ doc.moveDown(0.2)
123
+ break
124
+
125
+ case 'heading': {
126
+ const sizes = [0, 18, 15, 13, 12, 11, 11]
127
+ const sz = sizes[Math.min(token.depth, 6)] ?? 11
128
+ doc.moveDown(0.6)
129
+ const spans = []
130
+ collectSpans(token.tokens ?? [], spans, { font: 'Helvetica-Bold', fontSize: sz, color: '#111' })
131
+ flushSpans(doc, spans)
132
+ doc.moveDown(0.2)
133
+ doc.font(base.font).fontSize(base.fontSize).fillColor('#000')
134
+ break
135
+ }
136
+
137
+ case 'paragraph': {
138
+ doc.font(base.font).fontSize(base.fontSize).fillColor('#000')
139
+ renderParagraphTokens(doc, token.tokens ?? [], contentDir, ctx)
140
+ doc.moveDown(0.5)
141
+ break
142
+ }
143
+
144
+ case 'code': {
145
+ doc.moveDown(0.3)
146
+ const lineH = 13
147
+ const estimatedH = token.text.split('\n').length * lineH + 18
148
+
149
+ if (doc.y + estimatedH > doc.page.height - doc.page.margins.bottom - 20) {
150
+ doc.addPage()
151
+ }
152
+
153
+ const rx = doc.page.margins.left
154
+ const ry = doc.y
155
+ const rw = doc.page.width - doc.page.margins.left - doc.page.margins.right
156
+
157
+ doc.save().rect(rx, ry, rw, estimatedH).fill('#F4F4F4').restore()
158
+ doc.font('Courier').fontSize(9).fillColor('#333')
159
+ .text(token.text, rx + 8, ry + 9, { width: rw - 16, lineGap: 2, paragraphGap: 0 })
160
+
161
+ doc.font(base.font).fontSize(base.fontSize).fillColor('#000')
162
+ doc.moveDown(0.5)
163
+ break
164
+ }
165
+
166
+ case 'blockquote': {
167
+ doc.moveDown(0.3)
168
+ doc.font('Helvetica-Oblique').fontSize(base.fontSize).fillColor('#555')
169
+ renderTokens(doc, token.tokens ?? [], contentDir, { ...base, font: 'Helvetica-Oblique' })
170
+ doc.font(base.font).fontSize(base.fontSize).fillColor('#000')
171
+ doc.moveDown(0.3)
172
+ break
173
+ }
174
+
175
+ case 'list': {
176
+ doc.moveDown(0.2)
177
+ token.items?.forEach((item, idx) => {
178
+ const bullet = token.ordered ? `${idx + 1}.` : '•'
179
+ const spans = []
180
+ for (const t of (item.tokens ?? [])) {
181
+ if (t.type === 'text' || t.type === 'paragraph') {
182
+ collectSpans(t.tokens ?? [t], spans, ctx)
183
+ }
184
+ }
185
+ const allSpans = [{ ...ctx, color: '#444', text: `${bullet} ` }, ...spans.filter(s => s.text)]
186
+ if (allSpans.length) flushSpans(doc, allSpans)
187
+ doc.moveDown(0.15)
188
+ })
189
+ doc.moveDown(0.3)
190
+ break
191
+ }
192
+
193
+ case 'hr': {
194
+ doc.moveDown(0.3)
195
+ const hrX = doc.page.margins.left
196
+ const hrW = doc.page.width - doc.page.margins.left - doc.page.margins.right
197
+ doc.save().moveTo(hrX, doc.y).lineTo(hrX + hrW, doc.y)
198
+ .strokeColor('#DDD').lineWidth(1).stroke().restore()
199
+ doc.moveDown(0.5)
200
+ break
201
+ }
202
+
203
+ case 'html': {
204
+ const raw = (token.raw ?? token.text ?? '').trim()
205
+ const m = CUSTOM_EL_RE.exec(raw)
206
+ if (m) renderCustomElement(doc, m[1].toLowerCase(), parseAttrs(m[2]), m[3], contentDir, base)
207
+ break
208
+ }
209
+
210
+ default:
211
+ if (token.text) {
212
+ doc.font(base.font).fontSize(base.fontSize).fillColor('#888').text(token.text)
213
+ doc.moveDown(0.2)
214
+ }
215
+ }
216
+ }
217
+
218
+ // ─── Custom elements ──────────────────────────────────────────────────────────
219
+
220
+ function parseAttrs(attrStr) {
221
+ const attrs = {}
222
+ const re = /([\w-]+)="([^"]*)"/g
223
+ let m
224
+ while ((m = re.exec(attrStr)) !== null) attrs[m[1]] = m[2]
225
+ return attrs
226
+ }
227
+
228
+ function renderCustomElement(doc, tagName, attrs, inner, contentDir, base) {
229
+ switch (tagName) {
230
+ case 'x-callout': {
231
+ doc.moveDown(0.3)
232
+ const origLeft = doc.page.margins.left
233
+ const startY = doc.y
234
+ doc.page.margins.left = origLeft + 14
235
+ doc.font('Helvetica').fontSize(base.fontSize).fillColor('#333')
236
+ renderTokens(doc, marked.lexer(inner.trim()), contentDir, { ...base, font: 'Helvetica' })
237
+ const endY = doc.y
238
+ doc.page.margins.left = origLeft
239
+ const accentColor = attrs.type === 'warning' ? '#F59E0B' : '#6366F1'
240
+ doc.save().rect(origLeft, startY, 3, Math.max(endY - startY, 8)).fill(accentColor).restore()
241
+ doc.font(base.font).fontSize(base.fontSize).fillColor('#000').moveDown(0.3)
242
+ break
243
+ }
244
+
245
+ case 'x-compare': {
246
+ doc.moveDown(0.3)
247
+ const itemRe = /<x-compare-item\s+title="([^"]*)">([\s\S]*?)<\/x-compare-item>/gi
248
+ let m
249
+ while ((m = itemRe.exec(inner)) !== null) {
250
+ doc.font('Helvetica-Bold').fontSize(base.fontSize - 0.5).fillColor('#555').text(m[1])
251
+ doc.moveDown(0.2)
252
+ doc.font(base.font).fontSize(base.fontSize).fillColor('#000')
253
+ renderTokens(doc, marked.lexer(m[2].trim()), contentDir, base)
254
+ doc.moveDown(0.3)
255
+ }
256
+ break
257
+ }
258
+
259
+ case 'x-card':
260
+ doc.moveDown(0.2)
261
+ renderTokens(doc, marked.lexer(inner.trim()), contentDir, base)
262
+ doc.moveDown(0.2)
263
+ break
264
+
265
+ case 'x-nav':
266
+ break // Navigation links have no place in a PDF
267
+
268
+ case 'details': {
269
+ const summaryMatch = SUMMARY_RE.exec(inner)
270
+ const summaryText = summaryMatch?.[1].trim()
271
+ const body = inner.replace(SUMMARY_RE, '').trim()
272
+ doc.moveDown(0.3)
273
+ if (summaryText) {
274
+ doc.font('Helvetica-Bold').fontSize(base.fontSize).fillColor('#444').text(summaryText)
275
+ doc.moveDown(0.2)
276
+ }
277
+ if (body) {
278
+ doc.font(base.font).fontSize(base.fontSize).fillColor('#000')
279
+ renderTokens(doc, marked.lexer(body), contentDir, base)
280
+ }
281
+ doc.moveDown(0.3)
282
+ break
283
+ }
284
+
285
+ default:
286
+ renderTokens(doc, marked.lexer(inner.trim()), contentDir, base)
287
+ }
288
+ }
289
+
290
+ // ─── Sections ─────────────────────────────────────────────────────────────────
291
+
292
+ function renderCover(doc, mod) {
293
+ doc.y = doc.page.height * 0.28
294
+
295
+ doc.font('Helvetica').fontSize(10).fillColor('#999')
296
+ .text((mod.subtitle ?? 'E-module').toUpperCase(), { characterSpacing: 1.5 })
297
+ doc.moveDown(0.5)
298
+ doc.font('Helvetica-Bold').fontSize(34).fillColor('#111').text(mod.name ?? 'E-module')
299
+
300
+ if (mod.description) {
301
+ doc.moveDown(1)
302
+ doc.font('Helvetica').fontSize(12).fillColor('#555').text(mod.description, { lineGap: 4 })
303
+ }
304
+ }
305
+
306
+ function renderExercises(doc, exercisesDir) {
307
+ const INTERACTIVE = new Set(['css-playground', 'areas', 'responsive'])
308
+
309
+ const files = fs.readdirSync(exercisesDir)
310
+ .filter(f => /^\d+\.md$/.test(f))
311
+ .sort((a, b) => parseInt(a) - parseInt(b))
312
+
313
+ for (const file of files) {
314
+ const { data: fm, content: body } = matter(
315
+ fs.readFileSync(path.join(exercisesDir, file), 'utf8')
316
+ )
317
+ if (fm.type === 'external') continue
318
+
319
+ doc.moveDown(0.4)
320
+ doc.font('Helvetica-Bold').fontSize(11).fillColor('#111').text(fm.title ?? file)
321
+ doc.moveDown(0.2)
322
+
323
+ const descMd = body.trim() || (fm.description ? String(fm.description) : '')
324
+ if (descMd) {
325
+ doc.font('Helvetica').fontSize(10).fillColor('#333')
326
+ renderTokens(doc, marked.lexer(descMd), exercisesDir, { font: 'Helvetica', fontSize: 10 })
327
+ } else if (INTERACTIVE.has(fm.type)) {
328
+ doc.font('Helvetica-Oblique').fontSize(10).fillColor('#999')
329
+ .text('(Interactieve oefening — zie de online module)')
330
+ }
331
+ doc.font('Helvetica').fontSize(11).fillColor('#000')
332
+ }
333
+ }
334
+
335
+ function renderAssignment(doc, assignmentPath) {
336
+ const { data: fm, content: body } = matter(fs.readFileSync(assignmentPath, 'utf8'))
337
+ const contentDir = path.dirname(assignmentPath)
338
+
339
+ doc.font('Helvetica-Bold').fontSize(15).fillColor('#111').text(fm.title ?? 'Inleveropdracht')
340
+ if (fm.subtitle) {
341
+ doc.moveDown(0.2)
342
+ doc.font('Helvetica').fontSize(10).fillColor('#666').text(fm.subtitle)
343
+ }
344
+ doc.moveDown(0.5).fillColor('#000')
345
+
346
+ if (body.trim()) {
347
+ renderTokens(doc, marked.lexer(body.trim()), contentDir)
348
+ }
349
+
350
+ if (fm.deliverables?.length) {
351
+ doc.moveDown(0.4)
352
+ doc.font('Helvetica-Bold').fontSize(11).fillColor('#111').text('In te leveren')
353
+ doc.moveDown(0.2)
354
+ doc.font('Helvetica').fontSize(10).fillColor('#333')
355
+ doc.list(fm.deliverables, { bulletRadius: 2, lineGap: 2 })
356
+ }
357
+
358
+ if (fm.criteria?.length) {
359
+ doc.moveDown(0.4)
360
+ doc.font('Helvetica-Bold').fontSize(11).fillColor('#111').text('Beoordelingscriteria')
361
+ doc.moveDown(0.2)
362
+ for (const c of fm.criteria) {
363
+ const pts = c.points ? ` (${c.points} pt)` : ''
364
+ doc.font('Helvetica').fontSize(10).fillColor('#333').text(`• ${c.text}${pts}`, { lineGap: 2 })
365
+ }
366
+ }
367
+
368
+ if (fm.tips?.length) {
369
+ doc.moveDown(0.4)
370
+ doc.font('Helvetica-Bold').fontSize(11).fillColor('#111').text('Tips')
371
+ doc.moveDown(0.2)
372
+ doc.font('Helvetica').fontSize(10).fillColor('#333')
373
+ doc.list(fm.tips, { bulletRadius: 2, lineGap: 2 })
374
+ }
375
+ }
376
+
377
+ function renderWeek(doc, weekDir, sectionLabel) {
378
+ const theoryPath = path.join(weekDir, 'theory.md')
379
+ if (!fs.existsSync(theoryPath)) return
380
+
381
+ const { data: fm, content: theoryBody } = matter(fs.readFileSync(theoryPath, 'utf8'))
382
+
383
+ doc.font('Helvetica-Bold').fontSize(22).fillColor('#111').text(sectionLabel)
384
+ if (fm.title) {
385
+ doc.moveDown(0.2)
386
+ doc.font('Helvetica-Bold').fontSize(14).fillColor('#333').text(fm.title)
387
+ }
388
+ if (fm.goal) {
389
+ doc.moveDown(0.3)
390
+ doc.font('Helvetica-Oblique').fontSize(10).fillColor('#666').text(`Leerdoel: ${fm.goal}`)
391
+ }
392
+ doc.fillColor('#000').moveDown(0.8)
393
+
394
+ if (theoryBody.trim()) {
395
+ renderTokens(doc, marked.lexer(theoryBody.trim()), weekDir)
396
+ }
397
+
398
+ const exercisesDir = path.join(weekDir, 'exercises')
399
+ if (fs.existsSync(exercisesDir)) {
400
+ doc.addPage()
401
+ doc.font('Helvetica-Bold').fontSize(15).fillColor('#111').text('Oefeningen')
402
+ doc.moveDown(0.5).fillColor('#000')
403
+ renderExercises(doc, exercisesDir)
404
+ }
405
+
406
+ const assignmentPath = path.join(weekDir, 'assignment.md')
407
+ if (fs.existsSync(assignmentPath)) {
408
+ doc.addPage()
409
+ renderAssignment(doc, assignmentPath)
410
+ }
411
+ }
412
+
413
+ // ─── Entry point ──────────────────────────────────────────────────────────────
414
+
415
+ export async function generatePdf({ projectDir }) {
416
+ const CONTENT = path.join(projectDir, 'content')
417
+ const DIST = path.join(projectDir, 'dist')
418
+
419
+ if (!fs.existsSync(path.join(CONTENT, 'module.md'))) {
420
+ console.log(' PDF skipped: no content/module.md')
421
+ return
422
+ }
423
+
424
+ const { data: mod } = matter(fs.readFileSync(path.join(CONTENT, 'module.md'), 'utf8'))
425
+
426
+ const weekDirs = fs.readdirSync(CONTENT)
427
+ .filter(d => SECTION_RE.test(d) && fs.statSync(path.join(CONTENT, d)).isDirectory())
428
+ .sort((a, b) => Number(SECTION_RE.exec(a)[2]) - Number(SECTION_RE.exec(b)[2]))
429
+ .slice(0, mod.weeks ?? 99)
430
+
431
+ const doc = new PDFDocument({ margin: 72, size: 'A4', autoFirstPage: true })
432
+ const outPath = path.join(DIST, 'e-module.pdf')
433
+ const writeStream = fs.createWriteStream(outPath)
434
+ doc.pipe(writeStream)
435
+
436
+ renderCover(doc, mod)
437
+
438
+ for (const dirName of weekDirs) {
439
+ const [, prefix, num] = SECTION_RE.exec(dirName)
440
+ const label = `${prefix.charAt(0).toUpperCase()}${prefix.slice(1)} ${num}`
441
+ doc.addPage()
442
+ renderWeek(doc, path.join(CONTENT, dirName), label)
443
+ }
444
+
445
+ doc.end()
446
+ await new Promise((resolve, reject) => {
447
+ writeStream.on('finish', resolve)
448
+ writeStream.on('error', reject)
449
+ })
450
+
451
+ console.log(' PDF → dist/e-module.pdf')
452
+ }