@curio-sd/e-module-builder 0.4.0 → 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 +57 -15
- package/bin/cli.js +2 -0
- package/build-pdf.mjs +452 -0
- package/build.mjs +126 -73
- package/package.json +3 -1
- package/src/css/main.css +124 -50
- package/src/js/checklist.js +1 -1
- package/src/js/exercises/exercise-shared.js +1 -1
- package/src/js/exercises/external-exercise.js +3 -4
- package/src/js/exercises/hub.js +5 -1
- package/src/js/exercises/load-exercise.js +3 -0
- package/src/js/exercises/theory-panel.js +95 -0
- package/src/js/home.js +1 -1
- package/src/js/nav.js +63 -8
- package/templates/index.html +17 -9
- package/templates/pages/inleveropdracht.html +32 -0
- package/templates/pages/meetmoment-praktijk.html +35 -0
- package/templates/pages/meetmoment-theorie.html +29 -0
- package/templates/pages/{toets.html → meetmoment.html} +11 -4
- package/templates/pages/oefening.html +29 -0
- package/templates/pages/oefeningen.html +46 -38
- package/templates/pages/theorie.html +6 -1
- package/templates/pages/toets-praktijk.html +0 -25
- package/templates/pages/toets-theorie.html +0 -25
package/README.md
CHANGED
|
@@ -45,9 +45,9 @@ content/
|
|
|
45
45
|
2.md ← exercise 2
|
|
46
46
|
…
|
|
47
47
|
week2/ … weekN/ ← same structure
|
|
48
|
-
|
|
49
|
-
theory-
|
|
50
|
-
practical-
|
|
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
|
|
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
|
|
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/
|
|
244
|
+
### `content/assessments/theory-assessment.md` and `practical-assessment.md`
|
|
203
245
|
|
|
204
|
-
Same structure as `quiz.md`. Practical
|
|
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/
|
|
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/
|
|
255
|
-
| `src/data/
|
|
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-
|
|
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/
|
|
263
|
-
| `pages/
|
|
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
|
+
}
|