@curio-sd/e-module-builder 0.1.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/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Curio Software Developer
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/bin/cli.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath, pathToFileURL } from 'url'
3
+ import path from 'path'
4
+ import { cpSync, existsSync, watch } from 'fs'
5
+ import { spawn, spawnSync } from 'child_process'
6
+
7
+ const PKG_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
8
+ const PROJECT_DIR = process.cwd()
9
+ const command = process.argv[2]
10
+
11
+ if (!command || !['build', 'dev', 'preview'].includes(command)) {
12
+ console.error('Usage: e-module-builder <build|dev|preview>')
13
+ process.exit(1)
14
+ }
15
+
16
+ function copyAssets() {
17
+ for (const dir of ['js', 'css', 'partials']) {
18
+ const src = path.join(PKG_DIR, 'src', dir)
19
+ if (existsSync(src)) {
20
+ cpSync(src, path.join(PROJECT_DIR, 'src', dir), { recursive: true })
21
+ }
22
+ }
23
+ }
24
+
25
+ const PIPELINE_ENV = { ...process.env, E_MODULE_PROJECT_DIR: PROJECT_DIR, E_MODULE_PKG_DIR: PKG_DIR }
26
+ const PIPELINE_ARGS = [path.join(PKG_DIR, 'build.mjs')]
27
+
28
+ function runContentPipeline() {
29
+ const result = spawnSync(process.execPath, PIPELINE_ARGS, { cwd: PROJECT_DIR, stdio: 'inherit', env: PIPELINE_ENV })
30
+ if (result.status !== 0) process.exit(result.status ?? 1)
31
+ }
32
+
33
+ function runContentPipelineAsync() {
34
+ return new Promise((resolve, reject) => {
35
+ const child = spawn(process.execPath, PIPELINE_ARGS, { cwd: PROJECT_DIR, stdio: 'inherit', env: PIPELINE_ENV })
36
+ child.on('close', code => code === 0 ? resolve() : reject(new Error(`build.mjs exited with code ${code}`)))
37
+ })
38
+ }
39
+
40
+ async function main() {
41
+ if (command === 'preview') {
42
+ const { preview } = await import('vite')
43
+ const server = await preview({
44
+ root: PROJECT_DIR,
45
+ build: { outDir: path.join(PROJECT_DIR, 'dist') },
46
+ })
47
+ server.printUrls()
48
+ return
49
+ }
50
+
51
+ copyAssets()
52
+ runContentPipeline()
53
+
54
+ const { createConfig } = await import(pathToFileURL(path.join(PKG_DIR, 'vite.config.js')).href)
55
+ const cfg = createConfig({ projectDir: PROJECT_DIR, pkgDir: PKG_DIR })
56
+
57
+ if (command === 'build') {
58
+ const { build } = await import('vite')
59
+ await build(cfg)
60
+ } else {
61
+ const { createServer } = await import('vite')
62
+ const server = await createServer(cfg)
63
+ await server.listen()
64
+ server.printUrls()
65
+
66
+ const contentDir = path.join(PROJECT_DIR, 'content')
67
+ let rebuildTimer = null
68
+ watch(contentDir, { recursive: true }, () => {
69
+ clearTimeout(rebuildTimer)
70
+ rebuildTimer = setTimeout(async () => {
71
+ try {
72
+ await runContentPipelineAsync()
73
+ server.ws.send({ type: 'full-reload' })
74
+ } catch {
75
+ // build.mjs already printed the error
76
+ }
77
+ }, 80)
78
+ })
79
+ console.log(`\n watching content/`)
80
+ }
81
+ }
82
+
83
+ main().catch(e => { console.error(e); process.exit(1) })
package/build.mjs ADDED
@@ -0,0 +1,339 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import matter from 'gray-matter'
5
+ import { Marked } from 'marked'
6
+ import { markedHighlight } from "marked-highlight";
7
+ import hljs from 'highlight.js';
8
+
9
+ const PKG_DIR = path.dirname(fileURLToPath(import.meta.url))
10
+ const PROJECT_DIR = process.env.E_MODULE_PROJECT_DIR ?? process.cwd()
11
+ const CONTENT = path.join(PROJECT_DIR, 'content')
12
+ const SRC_DATA = path.join(PROJECT_DIR, 'src/data')
13
+ const PAGES = path.join(PROJECT_DIR, 'pages')
14
+ const TEMPLATES = path.join(PKG_DIR, 'templates/pages')
15
+
16
+ const marked = new Marked(
17
+ markedHighlight({
18
+ emptyLangClass: 'hljs',
19
+ langPrefix: 'hljs language-',
20
+ highlight(code, lang, info) {
21
+ const language = hljs.getLanguage(lang) ? lang : 'plaintext';
22
+ return hljs.highlight(code, { language }).value;
23
+ }
24
+ })
25
+ )
26
+
27
+ // ─── helpers ────────────────────────────────────────────────────────────────
28
+
29
+ function readMd(filePath) {
30
+ const raw = fs.readFileSync(filePath, 'utf8')
31
+ return matter(raw)
32
+ }
33
+
34
+ function writeJson(dir, filename, data) {
35
+ fs.mkdirSync(dir, { recursive: true })
36
+ fs.writeFileSync(path.join(dir, filename), JSON.stringify(data, null, 2))
37
+ }
38
+
39
+ function applyTemplate(tpl, vars) {
40
+ return Object.entries(vars).reduce(
41
+ (str, [k, v]) => str.replace(new RegExp(`\\{\\{\\s*${k}\\s*\\}\\}`, 'g'), v),
42
+ tpl
43
+ )
44
+ }
45
+
46
+ // Recognize <x-*> tags as block-level elements so marked doesn't wrap them in <p>.
47
+ marked.use({
48
+ extensions: [{
49
+ name: 'customElement',
50
+ level: 'block',
51
+ start(src) { return src.indexOf('<x-') },
52
+ tokenizer(src) {
53
+ const match = /^<(x-[a-z-]+)([^>]*)>([\s\S]*?)<\/\1>/.exec(src)
54
+ if (match) {
55
+ return { type: 'customElement', raw: match[0], tag: match[1], attrs: match[2].trim(), html: match[3] }
56
+ }
57
+ },
58
+ renderer(token) {
59
+ return `<${token.tag}${token.attrs ? ' ' + token.attrs : ''}>${marked.parse(token.html)}</${token.tag}>\n`
60
+ },
61
+ }],
62
+ })
63
+
64
+ // ─── 1. parse module.md ─────────────────────────────────────────────────────
65
+
66
+ const moduleMd = readMd(path.join(CONTENT, 'module.md'))
67
+ const mod = moduleMd.data
68
+
69
+ // ─── 2. discover week directories ────────────────────────────────────────────
70
+
71
+ const weekDirs = fs
72
+ .readdirSync(CONTENT)
73
+ .filter(d => /^week\d+$/.test(d) && fs.statSync(path.join(CONTENT, d)).isDirectory())
74
+ .sort((a, b) => parseInt(a.slice(4)) - parseInt(b.slice(4)))
75
+
76
+ const weekCount = mod.weeks > 0 ? mod.weeks : weekDirs.length
77
+ const activeWeeks = weekDirs.slice(0, weekCount)
78
+
79
+ // ─── 3. process each week ────────────────────────────────────────────────────
80
+
81
+ const weeksData = []
82
+
83
+ for (const weekDir of activeWeeks) {
84
+ const weekNum = parseInt(weekDir.slice(4))
85
+ const dir = path.join(CONTENT, weekDir)
86
+
87
+ // theory.md → src/data/theory-weekN.json
88
+ const theoryMd = readMd(path.join(dir, 'theory.md'))
89
+ const theoryOut = {
90
+ week: theoryMd.data.week ?? weekNum,
91
+ title: theoryMd.data.title,
92
+ goal: theoryMd.data.goal,
93
+ accent: theoryMd.data.accent,
94
+ html: marked.parse(theoryMd.content ?? ''),
95
+ }
96
+ writeJson(SRC_DATA, `theory-week${weekNum}.json`, theoryOut)
97
+
98
+ // quiz.md → src/data/tussentoets-weekN.json
99
+ const quizMd = readMd(path.join(dir, 'quiz.md'))
100
+ const quizOut = {
101
+ title: quizMd.data.title,
102
+ passScore: quizMd.data.passScore ?? 70,
103
+ questions: quizMd.data.questions ?? [],
104
+ }
105
+ writeJson(SRC_DATA, `tussentoets-week${weekNum}.json`, quizOut)
106
+
107
+ // exercises/ subfolder → src/data/exercises/weekN.json
108
+ const exDir = path.join(dir, 'exercises')
109
+ const metaMd = readMd(path.join(exDir, '_meta.md'))
110
+ const exerciseFiles = fs.readdirSync(exDir)
111
+ .filter(f => f.endsWith('.md') && f !== '_meta.md')
112
+ .sort((a, b) => parseInt(a) - parseInt(b))
113
+ const exercises = exerciseFiles.map(f => {
114
+ const ex = readMd(path.join(exDir, f)).data
115
+ if (!ex.type || ex.type === 'text') {
116
+ ex.descriptionHtml = marked.parse(ex.description ?? '')
117
+ }
118
+ return ex
119
+ })
120
+ const exOut = {
121
+ week: metaMd.data.week ?? weekNum,
122
+ title: metaMd.data.title,
123
+ color: metaMd.data.color,
124
+ ...(metaMd.data.mode ? { mode: metaMd.data.mode } : {}),
125
+ exercises,
126
+ }
127
+ writeJson(path.join(SRC_DATA, 'exercises'), `week${weekNum}.json`, exOut)
128
+
129
+ // assignment.md → src/data/inleveropdracht-weekN.json
130
+ const hwMd = readMd(path.join(dir, 'assignment.md'))
131
+ const hwBody = hwMd.content.trim()
132
+ const hwParas = hwBody.split(/\n\n+/)
133
+ const hwOut = {
134
+ week: hwMd.data.week ?? weekNum,
135
+ title: hwMd.data.title,
136
+ subtitle: hwMd.data.subtitle ?? '',
137
+ client: hwMd.data.client ?? '',
138
+ case: hwParas[0] ?? '',
139
+ assignment: hwParas.slice(1).join('\n\n'),
140
+ deliverables: hwMd.data.deliverables ?? [],
141
+ criteria: hwMd.data.criteria ?? [],
142
+ maxPoints: hwMd.data.maxPoints ?? 0,
143
+ tips: hwMd.data.tips ?? [],
144
+ }
145
+ writeJson(SRC_DATA, `inleveropdracht-week${weekNum}.json`, hwOut)
146
+
147
+ weeksData.push({
148
+ week: weekNum,
149
+ title: theoryMd.data.title,
150
+ summary: theoryMd.data.summary ?? '',
151
+ goal: theoryMd.data.goal,
152
+ leeruitkomsten: theoryMd.data.leeruitkomsten ?? [],
153
+ color: theoryMd.data.accent,
154
+ pages: [
155
+ { key: 'theorie', href: `/pages/week${weekNum}-theorie.html`, label: 'Theorie' },
156
+ { key: 'oefeningen', href: `/pages/week${weekNum}-oefeningen.html`, label: 'Oefeningen' },
157
+ { key: 'toets', href: `/pages/week${weekNum}-toets.html`, label: 'Tussentoets' },
158
+ { key: 'oefening', href: `/pages/week${weekNum}-oefening.html`, label: 'Oefening' },
159
+ { key: 'inleveropdracht', href: `/pages/week${weekNum}-inleveropdracht.html`, label: 'Inleveropdracht' },
160
+ ],
161
+ })
162
+ }
163
+
164
+ // ─── 4. manifest.json ────────────────────────────────────────────────────────
165
+
166
+ const manifest = {
167
+ module: {
168
+ name: mod.name,
169
+ subtitle: mod.subtitle ?? 'E-module',
170
+ youtube: mod.youtube ?? null,
171
+ weeks: weekCount,
172
+ language: mod.language ?? 'nl',
173
+ description: mod.description ?? '',
174
+ logoAlt: mod.logoAlt ?? mod.name,
175
+ exerciseMode: mod.exerciseMode ?? 'external',
176
+ },
177
+ weeks: weeksData,
178
+ nav: {
179
+ home: { href: '/index.html', label: 'Home' },
180
+ weeks: weeksData.map(wk => ({
181
+ label: `Week ${wk.week}`,
182
+ title: wk.title,
183
+ children: [
184
+ { href: `/pages/week${wk.week}-theorie.html`, label: 'Theorie' },
185
+ { href: `/pages/week${wk.week}-oefeningen.html`, label: 'Oefeningen' },
186
+ { href: `/pages/week${wk.week}-toets.html`, label: 'Tussentoets' },
187
+ { href: `/pages/week${wk.week}-inleveropdracht.html`, label: 'Inleveropdracht' },
188
+ ],
189
+ })),
190
+ examPages: [
191
+ { href: '/pages/checklist.html', label: 'Checklist' },
192
+ { href: '/pages/toets-theorie.html', label: 'Eindtoets theorie' },
193
+ { href: '/pages/toets-praktijk.html', label: 'Eindtoets praktijk' },
194
+ ],
195
+ },
196
+ pages: {
197
+ static: [
198
+ 'index.html',
199
+ 'pages/checklist.html',
200
+ 'pages/toets-theorie.html',
201
+ 'pages/toets-praktijk.html',
202
+ ],
203
+ week: weeksData.flatMap(wk => [
204
+ `pages/week${wk.week}-theorie.html`,
205
+ `pages/week${wk.week}-oefeningen.html`,
206
+ `pages/week${wk.week}-toets.html`,
207
+ `pages/week${wk.week}-oefening.html`,
208
+ `pages/week${wk.week}-inleveropdracht.html`,
209
+ ]),
210
+ },
211
+ content: {
212
+ status: 'generated',
213
+ aiInstructions: mod.aiInstructions ?? '',
214
+ },
215
+ }
216
+
217
+ writeJson(SRC_DATA, 'manifest.json', manifest)
218
+
219
+ // ─── 5. checklist.json ───────────────────────────────────────────────────────
220
+
221
+ const checklistGroups = weeksData.map(wk => ({
222
+ id: `week${wk.week}`,
223
+ title: `Week ${wk.week} — ${wk.title}`,
224
+ color: wk.color,
225
+ items: (wk.leeruitkomsten ?? []).map((text, i) => ({
226
+ id: `week${wk.week}-item-${i}`,
227
+ text,
228
+ })),
229
+ }))
230
+
231
+ if (mod.algemeen?.length) {
232
+ checklistGroups.push({
233
+ id: 'algemeen',
234
+ title: 'Algemeen',
235
+ color: 'slate',
236
+ items: mod.algemeen.map((text, i) => ({ id: `algemeen-item-${i}`, text })),
237
+ })
238
+ }
239
+
240
+ writeJson(SRC_DATA, 'checklist.json', { groups: checklistGroups })
241
+
242
+ // ─── 5b. exam data files (optional content/exams/*.md, else empty placeholder) ─
243
+
244
+ function buildExamData(filePath, fallbackTitle) {
245
+ if (fs.existsSync(filePath)) {
246
+ const md = readMd(filePath)
247
+ return {
248
+ title: md.data.title ?? fallbackTitle,
249
+ passScore: md.data.passScore ?? 70,
250
+ questions: md.data.questions ?? [],
251
+ }
252
+ }
253
+ return { title: fallbackTitle, passScore: 70, questions: [] }
254
+ }
255
+
256
+ const EXAMS_DIR = path.join(CONTENT, 'exams')
257
+ writeJson(
258
+ SRC_DATA,
259
+ 'toets-theorie.json',
260
+ buildExamData(path.join(EXAMS_DIR, 'theory-exam.md'), `Eindtoets theorie — ${mod.name}`)
261
+ )
262
+ writeJson(
263
+ SRC_DATA,
264
+ 'toets-praktijk.json',
265
+ buildExamData(path.join(EXAMS_DIR, 'practical-exam.md'), `Eindtoets praktijk — ${mod.name}`)
266
+ )
267
+
268
+ // ─── 6. generate per-week page stubs ─────────────────────────────────────────
269
+
270
+ const PAGE_TYPES = [
271
+ {
272
+ tplFile: 'theorie.html',
273
+ suffix: 'theorie',
274
+ pageTitle: wk => `Theorie Week ${wk.week} — ${wk.title}`,
275
+ },
276
+ {
277
+ tplFile: 'toets.html',
278
+ suffix: 'toets',
279
+ pageTitle: wk => `Tussentoets Week ${wk.week} — ${wk.title}`,
280
+ },
281
+ {
282
+ tplFile: 'oefeningen.html',
283
+ suffix: 'oefeningen',
284
+ pageTitle: wk => `Oefeningen Week ${wk.week} — ${wk.title}`,
285
+ },
286
+ {
287
+ tplFile: 'oefening.html',
288
+ suffix: 'oefening',
289
+ pageTitle: wk => `Oefening — Week ${wk.week}`,
290
+ },
291
+ {
292
+ tplFile: 'inleveropdracht.html',
293
+ suffix: 'inleveropdracht',
294
+ pageTitle: wk => `Inleveropdracht Week ${wk.week} — ${wk.title}`,
295
+ },
296
+ ]
297
+
298
+ fs.mkdirSync(PAGES, { recursive: true })
299
+
300
+ for (const { tplFile, suffix, pageTitle } of PAGE_TYPES) {
301
+ const tpl = fs.readFileSync(path.join(TEMPLATES, tplFile), 'utf8')
302
+ for (const wk of weeksData) {
303
+ const out = applyTemplate(tpl, {
304
+ week: String(wk.week),
305
+ weekPadded: String(wk.week).padStart(2, '0'),
306
+ weekTitle: wk.title,
307
+ pageTitle: pageTitle(wk),
308
+ })
309
+ fs.writeFileSync(path.join(PAGES, `week${wk.week}-${suffix}.html`), out)
310
+ }
311
+ }
312
+
313
+ // ─── 7. copy static pages (checklist, exams) with title substitution ─────────
314
+
315
+ const STATIC_PAGES = [
316
+ { src: 'checklist.html', pageTitle: `Checklist — ${mod.name}` },
317
+ { src: 'toets-theorie.html', pageTitle: `Eindtoets theorie — ${mod.name}` },
318
+ { src: 'toets-praktijk.html', pageTitle: `Eindtoets praktijk — ${mod.name}` },
319
+ ]
320
+
321
+ for (const { src, pageTitle } of STATIC_PAGES) {
322
+ const tpl = fs.readFileSync(path.join(TEMPLATES, src), 'utf8')
323
+ const out = applyTemplate(tpl, { pageTitle })
324
+ fs.writeFileSync(path.join(PAGES, src), out)
325
+ }
326
+
327
+ // ─── 8. copy content/*.html files verbatim to pages/ ─────────────────────────
328
+
329
+ const htmlFiles = fs.readdirSync(CONTENT).filter(f => f.endsWith('.html'))
330
+ for (const f of htmlFiles) {
331
+ fs.copyFileSync(path.join(CONTENT, f), path.join(PAGES, f))
332
+ }
333
+
334
+ // ─── 8b. generate index.html from template ───────────────────────────────────
335
+
336
+ const indexTpl = fs.readFileSync(path.join(PKG_DIR, 'templates/index.html'), 'utf8')
337
+ fs.writeFileSync(path.join(PROJECT_DIR, 'index.html'), applyTemplate(indexTpl, { pageTitle: mod.name }))
338
+
339
+ console.log(`Build complete: ${weekCount} weeks → src/data/ and pages/`)
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@curio-sd/e-module-builder",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A tool for building e-modules for Curio SD",
6
+ "license": "MIT",
7
+ "main": "./bin/cli.js",
8
+ "files": [
9
+ "bin",
10
+ "src",
11
+ "templates",
12
+ "public",
13
+ "build.mjs",
14
+ "vite.config.js",
15
+ "vite-plugin-html-includes.js"
16
+ ],
17
+ "bin": {
18
+ "e-module-builder": "./bin/cli.js"
19
+ },
20
+ "dependencies": {
21
+ "@tailwindcss/vite": "^4.1.8",
22
+ "gray-matter": "^4.0.3",
23
+ "marked": "^18.0.5",
24
+ "monaco-editor": "^0.55.1",
25
+ "tailwindcss": "^4.1.8",
26
+ "vite": "^6.3.5",
27
+ "yaml": "^2.9.0"
28
+ }
29
+ }