@curio-sd/e-module-builder 0.4.0 → 0.5.1

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/build.mjs CHANGED
@@ -24,6 +24,12 @@ const marked = new Marked(
24
24
  })
25
25
  )
26
26
 
27
+ const SECTION_RE = /^([a-zA-Z]+)(\d+)$/
28
+
29
+ function sectionLabel(prefix, num) {
30
+ return `${prefix.charAt(0).toUpperCase()}${prefix.slice(1)} ${num}`
31
+ }
32
+
27
33
  // ─── helpers ────────────────────────────────────────────────────────────────
28
34
 
29
35
  function rewriteAssetPaths(html, basePath) {
@@ -108,8 +114,8 @@ const mod = moduleMd.data
108
114
 
109
115
  const weekDirs = fs
110
116
  .readdirSync(CONTENT)
111
- .filter(d => /^week\d+$/.test(d) && fs.statSync(path.join(CONTENT, d)).isDirectory())
112
- .sort((a, b) => parseInt(a.slice(4)) - parseInt(b.slice(4)))
117
+ .filter(d => SECTION_RE.test(d) && fs.statSync(path.join(CONTENT, d)).isDirectory())
118
+ .sort((a, b) => parseInt(a.match(SECTION_RE)[2]) - parseInt(b.match(SECTION_RE)[2]))
113
119
 
114
120
  const weekCount = mod.weeks > 0 ? mod.weeks : weekDirs.length
115
121
  const activeWeeks = weekDirs.slice(0, weekCount)
@@ -119,7 +125,8 @@ const activeWeeks = weekDirs.slice(0, weekCount)
119
125
  const weeksData = []
120
126
 
121
127
  for (const weekDir of activeWeeks) {
122
- const weekNum = parseInt(weekDir.slice(4))
128
+ const [, sectionPrefix, sectionNumStr] = weekDir.match(SECTION_RE)
129
+ const weekNum = parseInt(sectionNumStr)
123
130
  const dir = path.join(CONTENT, weekDir)
124
131
 
125
132
  // theory.md → src/data/theory-weekN.json
@@ -133,14 +140,14 @@ for (const weekDir of activeWeeks) {
133
140
  }
134
141
  writeJson(SRC_DATA, `theory-week${weekNum}.json`, theoryOut)
135
142
 
136
- // quiz.md → src/data/tussentoets-weekN.json
143
+ // quiz.md → src/data/meetmoment-quiz-weekN.json
137
144
  const quizMd = readMd(path.join(dir, 'quiz.md'))
138
145
  const quizOut = {
139
146
  title: quizMd.data.title,
140
147
  passScore: quizMd.data.passScore ?? 70,
141
148
  questions: quizMd.data.questions ?? [],
142
149
  }
143
- writeJson(SRC_DATA, `tussentoets-week${weekNum}.json`, quizOut)
150
+ writeJson(SRC_DATA, `meetmoment-quiz-week${weekNum}.json`, quizOut)
144
151
 
145
152
  // exercises/ subfolder → src/data/exercises/weekN.json
146
153
  const exDir = path.join(dir, 'exercises')
@@ -149,9 +156,16 @@ for (const weekDir of activeWeeks) {
149
156
  .filter(f => f.endsWith('.md') && f !== '_meta.md')
150
157
  .sort((a, b) => parseInt(a) - parseInt(b))
151
158
  const exercises = exerciseFiles.map(f => {
152
- const ex = readMd(path.join(exDir, f)).data
159
+ const { data: ex, content } = readMd(path.join(exDir, f))
153
160
  if (!ex.type || ex.type === 'text') {
154
- ex.descriptionHtml = rewriteAssetPaths(marked.parse(ex.description ?? ''), `week${weekNum}/exercises`)
161
+ const src = content?.trim() ? content : (ex.description ?? '')
162
+ ex.descriptionHtml = rewriteAssetPaths(marked.parse(src), `week${weekNum}/exercises`)
163
+ }
164
+ ex.descriptionInlineHtml = marked.parseInline(ex.description ?? '')
165
+ if (ex.type === 'external') {
166
+ if (ex.task) ex.taskHtml = marked.parseInline(ex.task)
167
+ if (ex.hint) ex.hintHtml = marked.parse(ex.hint)
168
+ if (ex.solution) ex.solutionHtml = marked.parse(ex.solution)
155
169
  }
156
170
  return ex
157
171
  })
@@ -175,27 +189,73 @@ for (const weekDir of activeWeeks) {
175
189
  criteria: hwMd.data.criteria ?? [],
176
190
  maxPoints: hwMd.data.maxPoints ?? 0,
177
191
  tips: hwMd.data.tips ?? [],
192
+ ...(hwMd.data.linked_theory ? { linked_theory: hwMd.data.linked_theory } : {}),
178
193
  }
179
194
  writeJson(SRC_DATA, `inleveropdracht-week${weekNum}.json`, hwOut)
180
195
 
181
196
  weeksData.push({
182
197
  week: weekNum,
198
+ dirName: weekDir,
199
+ prefix: sectionPrefix,
183
200
  title: theoryMd.data.title,
184
- summary: theoryMd.data.summary ?? '',
201
+ summary: marked.parseInline(theoryMd.data.summary ?? ''),
185
202
  goal: theoryMd.data.goal,
186
203
  leeruitkomsten: theoryMd.data.leeruitkomsten ?? [],
187
204
  color: theoryMd.data.accent,
188
205
  pages: [
189
- { key: 'theorie', href: `/pages/week${weekNum}-theorie.html`, label: 'Theorie' },
190
- { key: 'oefeningen', href: `/pages/week${weekNum}-oefeningen.html`, label: 'Oefeningen' },
191
- { key: 'toets', href: `/pages/week${weekNum}-toets.html`, label: 'Tussentoets' },
192
- { key: 'oefening', href: `/pages/week${weekNum}-oefening.html`, label: 'Oefening' },
193
- { key: 'inleveropdracht', href: `/pages/week${weekNum}-inleveropdracht.html`, label: 'Inleveropdracht' },
206
+ { key: 'theorie', href: `/pages/${weekDir}-theorie.html`, label: 'Theorie' },
207
+ { key: 'oefeningen', href: `/pages/${weekDir}-oefeningen.html`, label: 'Oefeningen' },
208
+ { key: 'meetmoment', href: `/pages/${weekDir}-meetmoment.html`, label: 'Meetmoment' },
209
+ { key: 'oefening', href: `/pages/${weekDir}-oefening.html`, label: 'Oefening' },
210
+ { key: 'inleveropdracht', href: `/pages/${weekDir}-inleveropdracht.html`, label: 'Inleveropdracht' },
194
211
  ],
195
212
  })
196
213
  }
197
214
 
198
- // ─── 4. manifest.json ────────────────────────────────────────────────────────
215
+ // ─── 4. assessment data (parsed early so navLabel is available for manifest) ───────
216
+
217
+ const ASSESSMENTS_DIR = path.join(CONTENT, 'assessments')
218
+
219
+ function buildAssessmentData(filePath, fallbackTitle, fallbackNavLabel, fallbackDescription) {
220
+ if (fs.existsSync(filePath)) {
221
+ const md = readMd(filePath)
222
+ return {
223
+ title: md.data.title ?? fallbackTitle,
224
+ navLabel: md.data.navLabel ?? fallbackNavLabel,
225
+ description: marked.parseInline(md.data.description ?? fallbackDescription),
226
+ passScore: md.data.passScore ?? 70,
227
+ questions: md.data.questions ?? [],
228
+ }
229
+ }
230
+ return { title: fallbackTitle, navLabel: fallbackNavLabel, description: marked.parseInline(fallbackDescription), passScore: 70, questions: [] }
231
+ }
232
+
233
+ function buildPracticalAssessmentData(filePath, fallbackTitle, fallbackNavLabel) {
234
+ if (fs.existsSync(filePath)) {
235
+ const md = readMd(filePath)
236
+ return {
237
+ title: md.data.title ?? fallbackTitle,
238
+ navLabel: md.data.navLabel ?? fallbackNavLabel,
239
+ description: marked.parseInline(md.data.description ?? ''),
240
+ html: marked.parse(md.content ?? ''),
241
+ }
242
+ }
243
+ return { title: fallbackTitle, navLabel: fallbackNavLabel, description: '', html: '' }
244
+ }
245
+
246
+ const theoryAssessmentData = buildAssessmentData(
247
+ path.join(ASSESSMENTS_DIR, 'theory-assessment.md'),
248
+ `Meetmoment theorie — ${mod.name}`,
249
+ 'Meetmoment Theorie',
250
+ 'Meerkeuzevragen over de module. Minimaal 70% om te slagen.'
251
+ )
252
+ const practicalAssessmentData = buildPracticalAssessmentData(
253
+ path.join(ASSESSMENTS_DIR, 'practical-assessment.md'),
254
+ `Meetmoment praktijk — ${mod.name}`,
255
+ 'Meetmoment Praktijk'
256
+ )
257
+
258
+ // ─── 5. manifest.json ────────────────────────────────────────────────────────
199
259
 
200
260
  const manifest = {
201
261
  module: {
@@ -204,7 +264,7 @@ const manifest = {
204
264
  youtube: mod.youtube ?? null,
205
265
  weeks: weekCount,
206
266
  language: mod.language ?? 'nl',
207
- description: mod.description ?? '',
267
+ description: marked.parseInline(mod.description ?? ''),
208
268
  logoAlt: mod.logoAlt ?? mod.name,
209
269
  exerciseMode: mod.exerciseMode ?? 'external',
210
270
  },
@@ -212,34 +272,37 @@ const manifest = {
212
272
  nav: {
213
273
  home: { href: '/index.html', label: 'Home' },
214
274
  weeks: weeksData.map(wk => ({
215
- label: `Week ${wk.week}`,
275
+ label: sectionLabel(wk.prefix, wk.week),
216
276
  title: wk.title,
217
277
  children: [
218
- { href: `/pages/week${wk.week}-theorie.html`, label: 'Theorie' },
219
- { href: `/pages/week${wk.week}-oefeningen.html`, label: 'Oefeningen' },
220
- { href: `/pages/week${wk.week}-toets.html`, label: 'Tussentoets' },
221
- { href: `/pages/week${wk.week}-inleveropdracht.html`, label: 'Inleveropdracht' },
278
+ { href: `/pages/${wk.dirName}-theorie.html`, label: 'Theorie' },
279
+ { href: `/pages/${wk.dirName}-oefeningen.html`, label: 'Oefeningen' },
280
+ { href: `/pages/${wk.dirName}-meetmoment.html`, label: 'Quiz' },
281
+ { href: `/pages/${wk.dirName}-inleveropdracht.html`, label: 'Inleveropdracht' },
222
282
  ],
223
283
  })),
224
- examPages: [
225
- { href: '/pages/checklist.html', label: 'Checklist' },
226
- { href: '/pages/toets-theorie.html', label: 'Eindtoets theorie' },
227
- { href: '/pages/toets-praktijk.html', label: 'Eindtoets praktijk' },
228
- ],
284
+ assessmentSection: {
285
+ label: mod.assessmentSectionLabel ?? 'Afronding',
286
+ children: [
287
+ { href: '/pages/checklist.html', label: 'Checklist' },
288
+ { href: '/pages/meetmoment-theorie.html', label: theoryAssessmentData.navLabel },
289
+ { href: '/pages/meetmoment-praktijk.html', label: practicalAssessmentData.navLabel },
290
+ ],
291
+ },
229
292
  },
230
293
  pages: {
231
294
  static: [
232
295
  'index.html',
233
296
  'pages/checklist.html',
234
- 'pages/toets-theorie.html',
235
- 'pages/toets-praktijk.html',
297
+ 'pages/meetmoment-theorie.html',
298
+ 'pages/meetmoment-praktijk.html',
236
299
  ],
237
300
  week: weeksData.flatMap(wk => [
238
- `pages/week${wk.week}-theorie.html`,
239
- `pages/week${wk.week}-oefeningen.html`,
240
- `pages/week${wk.week}-toets.html`,
241
- `pages/week${wk.week}-oefening.html`,
242
- `pages/week${wk.week}-inleveropdracht.html`,
301
+ `pages/${wk.dirName}-theorie.html`,
302
+ `pages/${wk.dirName}-oefeningen.html`,
303
+ `pages/${wk.dirName}-meetmoment.html`,
304
+ `pages/${wk.dirName}-oefening.html`,
305
+ `pages/${wk.dirName}-inleveropdracht.html`,
243
306
  ]),
244
307
  },
245
308
  content: {
@@ -253,12 +316,13 @@ writeJson(SRC_DATA, 'manifest.json', manifest)
253
316
  // ─── 5. checklist.json ───────────────────────────────────────────────────────
254
317
 
255
318
  const checklistGroups = weeksData.map(wk => ({
256
- id: `week${wk.week}`,
257
- title: `Week ${wk.week} — ${wk.title}`,
319
+ id: wk.dirName,
320
+ title: `${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
258
321
  color: wk.color,
259
322
  items: (wk.leeruitkomsten ?? []).map((text, i) => ({
260
- id: `week${wk.week}-item-${i}`,
323
+ id: `${wk.dirName}-item-${i}`,
261
324
  text,
325
+ textHtml: marked.parseInline(text),
262
326
  })),
263
327
  }))
264
328
 
@@ -267,37 +331,16 @@ if (mod.algemeen?.length) {
267
331
  id: 'algemeen',
268
332
  title: 'Algemeen',
269
333
  color: 'slate',
270
- items: mod.algemeen.map((text, i) => ({ id: `algemeen-item-${i}`, text })),
334
+ items: mod.algemeen.map((text, i) => ({ id: `algemeen-item-${i}`, text, textHtml: marked.parseInline(text) })),
271
335
  })
272
336
  }
273
337
 
274
338
  writeJson(SRC_DATA, 'checklist.json', { groups: checklistGroups })
275
339
 
276
- // ─── 5b. exam data files (optional content/exams/*.md, else empty placeholder) ─
340
+ // ─── 5b. write assessment data JSON files ──────────────────────────────────────────
277
341
 
278
- function buildExamData(filePath, fallbackTitle) {
279
- if (fs.existsSync(filePath)) {
280
- const md = readMd(filePath)
281
- return {
282
- title: md.data.title ?? fallbackTitle,
283
- passScore: md.data.passScore ?? 70,
284
- questions: md.data.questions ?? [],
285
- }
286
- }
287
- return { title: fallbackTitle, passScore: 70, questions: [] }
288
- }
289
-
290
- const EXAMS_DIR = path.join(CONTENT, 'exams')
291
- writeJson(
292
- SRC_DATA,
293
- 'toets-theorie.json',
294
- buildExamData(path.join(EXAMS_DIR, 'theory-exam.md'), `Eindtoets theorie — ${mod.name}`)
295
- )
296
- writeJson(
297
- SRC_DATA,
298
- 'toets-praktijk.json',
299
- buildExamData(path.join(EXAMS_DIR, 'practical-exam.md'), `Eindtoets praktijk — ${mod.name}`)
300
- )
342
+ writeJson(SRC_DATA, 'meetmoment-theorie.json', theoryAssessmentData)
343
+ writeJson(SRC_DATA, 'meetmoment-praktijk.json', practicalAssessmentData)
301
344
 
302
345
  // ─── 6. generate per-week page stubs ─────────────────────────────────────────
303
346
 
@@ -305,27 +348,27 @@ const PAGE_TYPES = [
305
348
  {
306
349
  tplFile: 'theorie.html',
307
350
  suffix: 'theorie',
308
- pageTitle: wk => `Theorie Week ${wk.week} — ${wk.title}`,
351
+ pageTitle: wk => `Theorie ${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
309
352
  },
310
353
  {
311
- tplFile: 'toets.html',
312
- suffix: 'toets',
313
- pageTitle: wk => `Tussentoets Week ${wk.week} — ${wk.title}`,
354
+ tplFile: 'meetmoment.html',
355
+ suffix: 'meetmoment',
356
+ pageTitle: wk => `Meetmoment ${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
314
357
  },
315
358
  {
316
359
  tplFile: 'oefeningen.html',
317
360
  suffix: 'oefeningen',
318
- pageTitle: wk => `Oefeningen Week ${wk.week} — ${wk.title}`,
361
+ pageTitle: wk => `Oefeningen ${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
319
362
  },
320
363
  {
321
364
  tplFile: 'oefening.html',
322
365
  suffix: 'oefening',
323
- pageTitle: wk => `Oefening — Week ${wk.week}`,
366
+ pageTitle: wk => `Oefening — ${sectionLabel(wk.prefix, wk.week)}`,
324
367
  },
325
368
  {
326
369
  tplFile: 'inleveropdracht.html',
327
370
  suffix: 'inleveropdracht',
328
- pageTitle: wk => `Inleveropdracht Week ${wk.week} — ${wk.title}`,
371
+ pageTitle: wk => `Inleveropdracht ${sectionLabel(wk.prefix, wk.week)} — ${wk.title}`,
329
372
  },
330
373
  ]
331
374
 
@@ -340,21 +383,31 @@ for (const { tplFile, suffix, pageTitle } of PAGE_TYPES) {
340
383
  weekTitle: wk.title,
341
384
  pageTitle: pageTitle(wk),
342
385
  })
343
- fs.writeFileSync(path.join(PAGES, `week${wk.week}-${suffix}.html`), out)
386
+ fs.writeFileSync(path.join(PAGES, `${wk.dirName}-${suffix}.html`), out)
344
387
  }
345
388
  }
346
389
 
347
- // ─── 7. copy static pages (checklist, exams) with title substitution ─────────
390
+ // ─── 7. copy static pages (checklist, assessments) with title substitution ─────────
348
391
 
349
392
  const STATIC_PAGES = [
350
393
  { src: 'checklist.html', pageTitle: `Checklist — ${mod.name}` },
351
- { src: 'toets-theorie.html', pageTitle: `Eindtoets theorie — ${mod.name}` },
352
- { src: 'toets-praktijk.html', pageTitle: `Eindtoets praktijk — ${mod.name}` },
394
+ {
395
+ src: 'meetmoment-theorie.html',
396
+ pageTitle: `${theoryAssessmentData.navLabel} — ${mod.name}`,
397
+ assessmentTitle: theoryAssessmentData.navLabel,
398
+ assessmentDescription: theoryAssessmentData.description,
399
+ },
400
+ {
401
+ src: 'meetmoment-praktijk.html',
402
+ pageTitle: `${practicalAssessmentData.navLabel} — ${mod.name}`,
403
+ assessmentTitle: practicalAssessmentData.navLabel,
404
+ assessmentDescription: practicalAssessmentData.description,
405
+ },
353
406
  ]
354
407
 
355
- for (const { src, pageTitle } of STATIC_PAGES) {
408
+ for (const { src, pageTitle, assessmentTitle, assessmentDescription } of STATIC_PAGES) {
356
409
  const tpl = fs.readFileSync(path.join(TEMPLATES, src), 'utf8')
357
- const out = applyTemplate(tpl, { pageTitle })
410
+ const out = applyTemplate(tpl, { pageTitle, assessmentTitle, assessmentDescription })
358
411
  fs.writeFileSync(path.join(PAGES, src), out)
359
412
  }
360
413
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curio-sd/e-module-builder",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "A tool for building e-modules for Curio SD",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "templates",
12
12
  "public",
13
13
  "build.mjs",
14
+ "build-pdf.mjs",
14
15
  "vite.config.js",
15
16
  "vite-plugin-html-includes.js"
16
17
  ],
@@ -30,6 +31,7 @@
30
31
  "marked": "^18.0.5",
31
32
  "marked-highlight": "^2.2.4",
32
33
  "monaco-editor": "^0.53.0",
34
+ "pdfkit": "^0.19.1",
33
35
  "tailwindcss": "^4.1.8",
34
36
  "vite": "^8.0.16",
35
37
  "yaml": "^2.9.0"
package/src/css/main.css CHANGED
@@ -23,39 +23,6 @@
23
23
  body {
24
24
  @apply text-zinc-800;
25
25
  }
26
-
27
- /* Given that markdown is placed inside main, we want some basic styling for headings */
28
- main h2 {
29
- @apply mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400;
30
- }
31
-
32
- main h3 {
33
- @apply mt-4 text-base font-bold text-zinc-900
34
- }
35
-
36
- main h4 {
37
- @apply mt-3 text-sm font-semibold text-zinc-900
38
- }
39
-
40
- main h5 {
41
- @apply mt-2 text-sm font-normal uppercase text-zinc-900
42
- }
43
-
44
- main h6 {
45
- @apply mt-2 text-sm font-light uppercase text-zinc-900
46
- }
47
-
48
- main p {
49
- @apply mt-2 text-sm leading-relaxed text-zinc-700;
50
- }
51
-
52
- main ul {
53
- @apply mt-2 text-sm list-inside list-disc space-y-1.5 pl-1;
54
- }
55
-
56
- main ol {
57
- @apply mt-2 text-sm list-inside list-decimal space-y-1.5 pl-1;
58
- }
59
26
  }
60
27
 
61
28
  @layer components {
@@ -189,73 +156,103 @@
189
156
  @apply mx-3 mb-2 mt-1 flex items-center gap-2 border border-zinc-600 bg-zinc-900 px-3 py-2.5 text-[13px] font-medium text-zinc-100 transition hover:border-zinc-500 hover:bg-zinc-800 hover:text-white;
190
157
  }
191
158
 
159
+ .nav-group-toggle {
160
+ @apply flex w-full items-start justify-between px-3 pb-1 pt-4 text-left;
161
+ }
162
+
163
+ .nav-group-chevron {
164
+ @apply mt-0.5 h-3 w-3 shrink-0 text-zinc-500 transition-transform duration-200;
165
+ }
166
+
167
+ .nav-group-children {
168
+ display: grid;
169
+ grid-template-rows: 1fr;
170
+ transition: grid-template-rows 0.2s ease;
171
+ }
172
+
173
+ .nav-group-children.collapsed {
174
+ grid-template-rows: 0fr;
175
+ }
176
+
177
+ .nav-group-inner {
178
+ overflow: hidden;
179
+ }
180
+
192
181
  /* ── Theory prose typography ─────────────────────────────────────────────── */
193
182
 
194
- .prose-theory {
183
+ main {
195
184
  @apply space-y-6 text-zinc-700;
196
185
  }
197
186
 
198
- .prose-theory h2 {
187
+ main h2 {
199
188
  @apply mt-8 text-xl font-semibold text-zinc-900 first:mt-0;
200
189
  }
201
190
 
202
- .prose-theory h3 {
191
+ main h3 {
203
192
  @apply mt-4 text-base font-semibold text-zinc-900;
204
193
  }
205
194
 
206
- .prose-theory p {
195
+ main p {
207
196
  @apply leading-relaxed;
208
197
  }
209
198
 
210
- .prose-theory ul {
199
+ main ul {
211
200
  @apply list-inside list-disc space-y-1.5 pl-1;
212
201
  }
213
202
 
214
- .prose-theory ol {
203
+ main ol {
215
204
  @apply list-inside list-decimal space-y-1.5 pl-1;
216
205
  }
217
206
 
218
- .prose-theory li {
207
+ main li {
219
208
  @apply leading-relaxed;
220
209
  }
221
210
 
222
- .prose-theory a {
211
+ main a {
223
212
  @apply font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition hover:decoration-zinc-900;
224
213
  }
225
214
 
226
- .prose-theory code {
215
+ main code {
227
216
  @apply rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-sm text-zinc-800;
228
217
  }
229
218
 
230
- .prose-theory pre {
219
+ main pre {
231
220
  @apply overflow-x-auto bg-zinc-950 p-5 font-mono text-[13px] leading-relaxed text-zinc-300;
232
221
  }
233
222
 
234
- .prose-theory pre code {
223
+ main pre code {
235
224
  @apply bg-transparent p-0 text-inherit;
236
225
  }
237
226
 
238
- .prose-theory blockquote {
227
+ main blockquote {
239
228
  @apply border-l-4 border-zinc-300 pl-4 italic text-zinc-500;
240
229
  }
241
230
 
242
- .prose-theory table {
231
+ main table {
243
232
  @apply w-full text-left text-sm;
244
233
  }
245
234
 
246
- .prose-theory thead tr {
235
+ main thead tr {
247
236
  @apply border-b border-zinc-200 text-zinc-500;
248
237
  }
249
238
 
250
- .prose-theory tbody tr {
239
+ main tbody tr {
251
240
  @apply border-b border-zinc-100 last:border-0;
252
241
  }
253
242
 
254
- .prose-theory th,
255
- .prose-theory td {
243
+ main th,
244
+ main td {
256
245
  @apply py-2 pr-4;
257
246
  }
258
247
 
248
+ main details {
249
+ @apply rounded bg-zinc-100 p-4;
250
+ }
251
+
252
+ main details summary {
253
+ @apply cursor-pointer font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition hover:decoration-zinc-900;
254
+ }
255
+
259
256
  /* ── Custom components ───────────────────────────────────────────────────── */
260
257
 
261
258
  x-card {
@@ -313,4 +310,81 @@
313
310
  x-nav>p>a:last-child {
314
311
  @apply border-transparent bg-zinc-900 text-white hover:border-transparent hover:bg-zinc-800 focus-visible:ring-zinc-900;
315
312
  }
313
+
314
+ /* ── Theory side panel ───────────────────────────────────────────────────── */
315
+
316
+ .theory-panel {
317
+ @apply fixed inset-y-0 right-0 z-40 flex w-[480px] max-w-[90vw] flex-col border-l border-zinc-200 bg-white shadow-xl;
318
+ }
319
+
320
+ .theory-panel.panel-ready {
321
+ @apply transition-transform duration-300 ease-out;
322
+ }
323
+
324
+ .theory-panel.panel-hidden {
325
+ @apply translate-x-full;
326
+ }
327
+
328
+ .theory-panel-header {
329
+ @apply flex shrink-0 items-center border-b border-zinc-200 bg-zinc-50;
330
+ }
331
+
332
+ .theory-panel-tabs {
333
+ @apply flex flex-1 overflow-x-auto;
334
+ scrollbar-width: none;
335
+ }
336
+
337
+ .theory-panel-tabs::-webkit-scrollbar {
338
+ display: none;
339
+ }
340
+
341
+ .theory-tab {
342
+ @apply whitespace-nowrap border-b-2 border-transparent px-4 py-3 text-sm font-medium text-zinc-500 transition hover:text-zinc-900;
343
+ }
344
+
345
+ .theory-tab-active {
346
+ @apply border-zinc-900 text-zinc-900;
347
+ }
348
+
349
+ .theory-panel-close {
350
+ @apply shrink-0 p-3 text-zinc-400 transition hover:text-zinc-700;
351
+ }
352
+
353
+ .theory-panel-body {
354
+ @apply relative min-h-0 flex-1 flex flex-col;
355
+ }
356
+
357
+ .theory-panel-loader {
358
+ @apply absolute inset-0 z-10 flex items-center justify-center bg-white/80 backdrop-blur-sm;
359
+ }
360
+
361
+ .theory-panel-loader-hidden {
362
+ @apply hidden;
363
+ }
364
+
365
+ .theory-panel-spinner {
366
+ @apply h-8 w-8 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700;
367
+ }
368
+
369
+ .theory-panel-iframe {
370
+ @apply min-h-0 flex-1 w-full border-0;
371
+ }
372
+
373
+ .theory-panel-toggle {
374
+ @apply fixed bottom-6 right-6 shadow z-50 flex items-center gap-2 bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white shadow-lg transition hover:bg-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2;
375
+ }
376
+
377
+ /* ── Embedded theory mode (inside iframe) ────────────────────────────────── */
378
+
379
+ [data-embedded] {
380
+ @apply bg-white;
381
+ }
382
+
383
+ [data-embedded] [data-theory] {
384
+ @apply px-6 py-8;
385
+ }
386
+
387
+ [data-embedded] main {
388
+ @apply !p-0;
389
+ }
316
390
  }
@@ -20,7 +20,7 @@ export function initChecklist() {
20
20
  class="mt-0.5 h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-900"
21
21
  ${checked ? 'checked' : ''}
22
22
  />
23
- <span class="text-sm leading-relaxed ${checked ? 'text-zinc-400 line-through' : 'text-zinc-700'}">${item.text}</span>
23
+ <span class="text-sm leading-relaxed ${checked ? 'text-zinc-400 line-through' : 'text-zinc-700'}">${item.textHtml ?? item.text}</span>
24
24
  </label>
25
25
  `
26
26
  })
@@ -12,7 +12,7 @@ export function renderExerciseMeta(exercise) {
12
12
  diffEl.textContent = `Oefening ${String(exercise.id).padStart(2, '0')} · ${label}`
13
13
  }
14
14
  if (titleEl) titleEl.textContent = exercise.title
15
- if (descEl) descEl.textContent = exercise.description
15
+ if (descEl) descEl.innerHTML = exercise.descriptionInlineHtml ?? ''
16
16
  }
17
17
 
18
18
  export function markExerciseSolved(week, id) {
@@ -38,7 +38,7 @@ export function initExternalExercise(exercise, weekNum, { onSolved } = {}) {
38
38
  const deliverablesEl = document.querySelector('[data-external-deliverables]')
39
39
  const feedback = document.querySelector('[data-feedback]')
40
40
 
41
- if (taskEl) taskEl.innerHTML = exercise.task || exercise.description || ''
41
+ if (taskEl) taskEl.innerHTML = exercise.taskHtml || exercise.descriptionInlineHtml || ''
42
42
  if (stepsEl) stepsEl.innerHTML = renderSteps(exercise.steps)
43
43
  if (envEl) envEl.textContent = exercise.environment || 'Je eigen omgeving (editor, server of tool)'
44
44
  if (deliverablesEl) {
@@ -48,15 +48,14 @@ export function initExternalExercise(exercise, weekNum, { onSolved } = {}) {
48
48
  document.querySelector('[data-hint]')?.addEventListener('click', () => {
49
49
  showFeedback(
50
50
  feedback,
51
- `<p class="text-zinc-600">${(exercise.hint || 'Werk de stappen rustig door in je eigen omgeving.').replace(/\n/g, '<br>')}</p>`
51
+ exercise.hintHtml || '<p class="text-zinc-600">Werk de stappen rustig door in je eigen omgeving.</p>'
52
52
  )
53
53
  })
54
54
 
55
55
  document.querySelector('[data-solution]')?.addEventListener('click', () => {
56
- const solution = exercise.solution || 'Vergelijk je werk met de theorie en vraag feedback aan je docent.'
57
56
  showFeedback(
58
57
  feedback,
59
- `<p class="font-medium text-zinc-900">Voorbeelduitwerking</p><p class="mt-2 text-sm text-zinc-600">${solution.replace(/\n/g, '<br>')}</p>`
58
+ `<p class="font-medium text-zinc-900">Voorbeelduitwerking</p>${exercise.solutionHtml || '<p class="mt-2 text-sm text-zinc-600">Vergelijk je werk met de theorie en vraag feedback aan je docent.</p>'}`
60
59
  )
61
60
  })
62
61
 
@@ -3,6 +3,10 @@ import { sitePath } from '../site-path.js'
3
3
 
4
4
  const DIFFICULTY = ['', 'Beginner', 'Beginner', 'Gemiddeld', 'Gemiddeld', 'Gevorderd', 'Gevorderd', 'Expert', 'Expert']
5
5
 
6
+ function stripHtml(html) {
7
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
8
+ }
9
+
6
10
  export function initExerciseHub(weekData, weekNum) {
7
11
  const container = document.querySelector('[data-exercise-list]')
8
12
  if (!container) return
@@ -36,7 +40,7 @@ export function initExerciseHub(weekData, weekNum) {
36
40
  <h3 class="font-medium text-zinc-900">${ex.title}</h3>
37
41
  ${done ? '<span class="badge-done">Voltooid</span>' : ''}
38
42
  </div>
39
- <p class="mt-1.5 text-sm leading-relaxed text-zinc-500">${ex.description}</p>
43
+ <p class="mt-1.5 text-sm leading-relaxed text-zinc-500">${ex.descriptionInlineHtml ?? ex.description ?? ''}</p>
40
44
  ${modeBadge}
41
45
  </div>
42
46
  </a>