@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.
@@ -0,0 +1,57 @@
1
+ import { getSolvedExercises } from './exercise-shared.js'
2
+ import { sitePath } from '../site-path.js'
3
+
4
+ const DIFFICULTY = ['', 'Beginner', 'Beginner', 'Gemiddeld', 'Gemiddeld', 'Gevorderd', 'Gevorderd', 'Expert', 'Expert']
5
+
6
+ export function initExerciseHub(weekData, weekNum) {
7
+ const container = document.querySelector('[data-exercise-list]')
8
+ if (!container) return
9
+
10
+ const solved = getSolvedExercises(weekNum)
11
+ const external = weekData.mode === 'external'
12
+
13
+ const subtitle = document.querySelector('[data-hub-subtitle]')
14
+ if (subtitle) {
15
+ subtitle.textContent = external
16
+ ? `${weekData.title || `Week ${weekNum}`} — 8 opdrachten voor je eigen omgeving`
17
+ : `${weekData.title || `Week ${weekNum}`} — 8 oefeningen met oplopende moeilijkheid`
18
+ }
19
+
20
+ const items = weekData.exercises
21
+ .map((ex) => {
22
+ const label = DIFFICULTY[ex.difficulty] || 'Beginner'
23
+ const done = solved.includes(ex.id)
24
+ const modeBadge = external
25
+ ? '<span class="badge mt-3">Eigen omgeving</span>'
26
+ : `<span class="badge mt-3">${label}</span>`
27
+
28
+ return `
29
+ <a
30
+ href="${sitePath(`/pages/week${weekNum}-oefening.html`)}?id=${ex.id}"
31
+ class="group flex items-start gap-5 px-6 py-5 transition hover:bg-zinc-50"
32
+ >
33
+ <span class="mt-0.5 font-mono text-sm text-zinc-300 transition group-hover:text-zinc-900">${String(ex.id).padStart(2, '0')}</span>
34
+ <div class="flex-1">
35
+ <div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
36
+ <h3 class="font-medium text-zinc-900">${ex.title}</h3>
37
+ ${done ? '<span class="badge-done">Voltooid</span>' : ''}
38
+ </div>
39
+ <p class="mt-1.5 text-sm leading-relaxed text-zinc-500">${ex.description}</p>
40
+ ${modeBadge}
41
+ </div>
42
+ </a>
43
+ `
44
+ })
45
+ .join('')
46
+
47
+ container.innerHTML = `<div class="card divide-y divide-zinc-100 p-0">${items}</div>`
48
+
49
+ const progressEl = document.querySelector('[data-hub-progress]')
50
+ const progressBar = document.querySelector('[data-hub-progress-bar]')
51
+ if (progressEl) {
52
+ const total = weekData.exercises.length
53
+ const percent = total ? Math.round((solved.length / total) * 100) : 0
54
+ progressEl.textContent = `${solved.length} / ${total}`
55
+ if (progressBar) progressBar.style.width = `${percent}%`
56
+ }
57
+ }
@@ -0,0 +1,309 @@
1
+ import { renderExerciseMeta, markExerciseSolved } from './exercise-shared.js'
2
+ import { sitePath } from '../site-path.js'
3
+
4
+ import { initExternalExercise } from './external-exercise.js'
5
+
6
+
7
+
8
+ async function loadWeekData(weekNum) {
9
+
10
+ return import(`../../data/exercises/week${weekNum}.json`).then((m) => m.default)
11
+
12
+ }
13
+
14
+
15
+
16
+ function isExternalMode(weekData, exercise) {
17
+
18
+ return weekData?.mode === 'external' || exercise?.type === 'external'
19
+
20
+ }
21
+
22
+
23
+
24
+ function showMissingContent(weekNum) {
25
+
26
+ const panel = document.querySelector('[data-exercise-content]')
27
+
28
+ panel?.querySelector('[data-exercise-interactive]')?.classList.add('hidden')
29
+
30
+ panel?.querySelector('[data-exercise-external]')?.classList.add('hidden')
31
+
32
+ panel?.querySelector('[data-hint]')?.closest('.mb-4')?.classList.add('hidden')
33
+
34
+ panel?.insertAdjacentHTML(
35
+
36
+ 'beforeend',
37
+
38
+ `<div class="card border-l-4 border-l-amber-500">
39
+
40
+ <p class="font-medium text-zinc-900">Content ontbreekt</p>
41
+
42
+ <p class="mt-2 text-sm text-zinc-600">De oefening staat in de navigatie, maar is nog niet gegenereerd. Run in een terminal:</p>
43
+
44
+ <pre class="code-block mt-3 text-sm"><code>npm run generate-content</code></pre>
45
+
46
+ <p class="mt-2 text-sm text-zinc-500">Herlaad daarna deze pagina.</p>
47
+
48
+ </div>`
49
+
50
+ )
51
+
52
+ }
53
+
54
+
55
+
56
+ export async function initExercisePage(weekNum) {
57
+
58
+ const params = new URLSearchParams(window.location.search)
59
+
60
+ const id = parseInt(params.get('id') || '1', 10)
61
+
62
+
63
+
64
+ let weekData
65
+
66
+ try {
67
+
68
+ weekData = await loadWeekData(weekNum)
69
+
70
+ } catch {
71
+
72
+ document.querySelector('[data-exercise-content]')?.insertAdjacentHTML(
73
+
74
+ 'beforeend',
75
+
76
+ `<p class="text-red-600">Oefeningdata voor week ${weekNum} niet gevonden.</p>`
77
+
78
+ )
79
+
80
+ return
81
+
82
+ }
83
+
84
+
85
+
86
+ const exercise = weekData?.exercises?.find((e) => e.id === id)
87
+
88
+
89
+
90
+ if (!exercise) {
91
+
92
+ document.querySelector('[data-exercise-content]')?.insertAdjacentHTML(
93
+
94
+ 'beforeend',
95
+
96
+ '<p class="text-red-600">Oefening niet gevonden.</p>'
97
+
98
+ )
99
+
100
+ return
101
+
102
+ }
103
+
104
+
105
+
106
+ if (!exercise.type || exercise.type === 'text') {
107
+ const panel = document.querySelector('[data-exercise-content]')
108
+ panel?.querySelector('[data-exercise-interactive]')?.classList.add('hidden')
109
+ panel?.querySelector('[data-exercise-external]')?.classList.add('hidden')
110
+ panel?.querySelector('[data-exercise-actions]')?.classList.add('hidden')
111
+
112
+ const descEl = document.querySelector('[data-exercise-description]')
113
+ if (descEl) {
114
+ descEl.classList.remove('hidden')
115
+ descEl.innerHTML = exercise.descriptionHtml ?? ''
116
+ }
117
+
118
+ renderExerciseMeta(exercise)
119
+ renderNavButtons(weekNum, id, weekData.exercises.length)
120
+ return
121
+ }
122
+
123
+ if (isExternalMode(weekData, exercise)) {
124
+
125
+ const missing = !exercise.task?.trim() && !exercise.description?.trim()
126
+
127
+ if (missing) {
128
+
129
+ showMissingContent(weekNum)
130
+
131
+ return
132
+
133
+ }
134
+
135
+ initExternalExercise(exercise, weekNum, {
136
+
137
+ onSolved: () => markExerciseSolved(weekNum, exercise.id),
138
+
139
+ })
140
+
141
+ renderNavButtons(weekNum, id, weekData.exercises.length)
142
+
143
+ return
144
+
145
+ }
146
+
147
+
148
+
149
+ renderExerciseMeta(exercise)
150
+
151
+
152
+
153
+ const missingContent =
154
+
155
+ !exercise.starterCss?.trim() ||
156
+
157
+ !exercise.previewHtml?.trim() ||
158
+
159
+ (exercise.type !== 'areas' && !exercise.checks?.length)
160
+
161
+
162
+
163
+ if (missingContent) {
164
+
165
+ showMissingContent(weekNum)
166
+
167
+ return
168
+
169
+ }
170
+
171
+
172
+
173
+ const {
174
+
175
+ initCssPlayground,
176
+
177
+ initAreasExercise,
178
+
179
+ initResponsiveExercise,
180
+
181
+ } = await import('./exercise-runner.js')
182
+
183
+
184
+
185
+ document.querySelector('[data-exercise-external]')?.classList.add('hidden')
186
+
187
+ document.querySelector('[data-exercise-interactive]')?.classList.remove('hidden')
188
+
189
+
190
+
191
+ const cssPanel = document.querySelector('[data-exercise-css-panel]')
192
+
193
+ const areasPanel = document.querySelector('[data-exercise-areas-panel]')
194
+
195
+ const responsiveBar = document.querySelector('[data-responsive-bar]')
196
+
197
+
198
+
199
+ const onSolved = () => markExerciseSolved(weekNum, exercise.id)
200
+
201
+
202
+
203
+ if (exercise.type === 'areas') {
204
+
205
+ cssPanel?.classList.add('hidden')
206
+
207
+ areasPanel?.classList.remove('hidden')
208
+
209
+ responsiveBar?.classList.add('hidden')
210
+
211
+ document.querySelector('[data-solution]')?.classList.add('hidden')
212
+
213
+
214
+
215
+ const containerInput = document.querySelector('[data-areas-container]')
216
+
217
+ if (containerInput) containerInput.value = exercise.starterAreas || ''
218
+
219
+
220
+
221
+ const selectsEl = document.querySelector('[data-area-selects]')
222
+
223
+ if (selectsEl) {
224
+
225
+ selectsEl.innerHTML = renderAreaSelects(exercise.areaItems, exercise.areaOptions)
226
+
227
+ }
228
+
229
+
230
+
231
+ initAreasExercise(exercise, { onSolved })
232
+
233
+ } else {
234
+
235
+ areasPanel?.classList.add('hidden')
236
+
237
+ cssPanel?.classList.remove('hidden')
238
+
239
+
240
+
241
+ if (exercise.type === 'responsive') {
242
+
243
+ responsiveBar?.classList.remove('hidden')
244
+
245
+ initResponsiveExercise(exercise, { onSolved })
246
+
247
+ } else {
248
+
249
+ responsiveBar?.classList.add('hidden')
250
+
251
+ initCssPlayground(exercise, { onSolved })
252
+
253
+ }
254
+
255
+ }
256
+
257
+
258
+
259
+ renderNavButtons(weekNum, id, weekData.exercises.length)
260
+
261
+ }
262
+
263
+
264
+
265
+ function renderNavButtons(week, currentId, total) {
266
+
267
+ const prev = document.querySelector('[data-prev-exercise]')
268
+
269
+ const next = document.querySelector('[data-next-exercise]')
270
+
271
+
272
+
273
+ if (prev) {
274
+
275
+ if (currentId > 1) {
276
+
277
+ prev.href = `${sitePath(`/pages/week${week}-oefening.html`)}?id=${currentId - 1}`
278
+
279
+ prev.classList.remove('hidden')
280
+
281
+ } else {
282
+
283
+ prev.classList.add('hidden')
284
+
285
+ }
286
+
287
+ }
288
+
289
+
290
+
291
+ if (next) {
292
+
293
+ if (currentId < total) {
294
+
295
+ next.href = `${sitePath(`/pages/week${week}-oefening.html`)}?id=${currentId + 1}`
296
+
297
+ next.classList.remove('hidden')
298
+
299
+ } else {
300
+
301
+ next.classList.add('hidden')
302
+
303
+ }
304
+
305
+ }
306
+
307
+ }
308
+
309
+
@@ -0,0 +1,53 @@
1
+ export function normalizeCss(css) {
2
+ return css.toLowerCase().replace(/\s+/g, ' ').trim()
3
+ }
4
+
5
+ /**
6
+ * @param {string} css
7
+ * @param {Array<{type: string, value?: string, values?: string[], pattern?: string, msg: string}>} checks
8
+ */
9
+ export function runChecks(css, checks) {
10
+ const n = normalizeCss(css)
11
+ return checks.map((check) => {
12
+ let ok = false
13
+ switch (check.type) {
14
+ case 'includes':
15
+ ok = n.includes(check.value.toLowerCase())
16
+ break
17
+ case 'includesAll':
18
+ ok = check.values.every((v) => n.includes(v.toLowerCase()))
19
+ break
20
+ case 'includesAny':
21
+ ok = check.values.some((v) => n.includes(v.toLowerCase()))
22
+ break
23
+ case 'regex':
24
+ ok = new RegExp(check.pattern, 'i').test(css)
25
+ break
26
+ case 'mediaQuery':
27
+ ok = n.includes('@media') && check.values.every((v) => n.includes(v.toLowerCase()))
28
+ break
29
+ default:
30
+ ok = false
31
+ }
32
+ return { ok, msg: check.msg }
33
+ })
34
+ }
35
+
36
+ export function validateAreas(containerValue, selects, expected) {
37
+ const errors = []
38
+ const normalized = containerValue.replace(/\s+/g, ' ').trim().toLowerCase()
39
+ const expectedNorm = expected.container.replace(/\s+/g, ' ').trim().toLowerCase()
40
+
41
+ if (normalized !== expectedNorm) {
42
+ errors.push('grid-template-areas op de container klopt niet')
43
+ }
44
+
45
+ selects.forEach((select) => {
46
+ const id = select.dataset.areaItem
47
+ if (select.value !== expected.items[id]) {
48
+ errors.push(`Item "${id.toUpperCase()}" heeft verkeerde grid-area`)
49
+ }
50
+ })
51
+
52
+ return errors
53
+ }
package/src/js/home.js ADDED
@@ -0,0 +1,86 @@
1
+ import manifest from '../data/manifest.json'
2
+ import { sitePath } from './site-path.js'
3
+
4
+
5
+
6
+ export function initHome() {
7
+
8
+ const mod = manifest.module
9
+
10
+ const contentStatus = manifest.content?.status || 'empty'
11
+
12
+ const isReady = contentStatus === 'generated'
13
+
14
+
15
+
16
+ const titleEl = document.querySelector('[data-home-title]')
17
+
18
+ const descEl = document.querySelector('[data-home-description]')
19
+
20
+ const labelEl = document.querySelector('[data-home-label]')
21
+
22
+ const setupEl = document.querySelector('[data-home-setup]')
23
+
24
+ const readyEl = document.querySelector('[data-home-ready]')
25
+
26
+ const curriculumEl = document.querySelector('[data-home-curriculum]')
27
+
28
+
29
+
30
+ if (labelEl) labelEl.textContent = mod.subtitle
31
+
32
+ if (titleEl) titleEl.textContent = mod.name
33
+
34
+ if (descEl) descEl.textContent = mod.description
35
+
36
+
37
+
38
+ if (setupEl) setupEl.classList.toggle('hidden', isReady)
39
+
40
+ if (readyEl) readyEl.classList.toggle('hidden', !isReady)
41
+
42
+
43
+
44
+ if (!isReady || !curriculumEl) return
45
+
46
+
47
+
48
+ const weekLinks = (week) => week.pages
49
+
50
+ .filter((p) => p.key !== 'oefening')
51
+
52
+ .map((p) => `<a href="${sitePath(p.href)}" class="text-link">${p.label}</a>`)
53
+
54
+ .join('\n ')
55
+
56
+
57
+
58
+ curriculumEl.innerHTML = manifest.weeks
59
+
60
+ .map(
61
+
62
+ (w) => `
63
+
64
+ <div class="card-interactive bg-white">
65
+
66
+ <span class="week-label">Week ${String(w.week).padStart(2, '0')}</span>
67
+
68
+ <h3 class="text-lg font-medium text-zinc-900">${w.title}</h3>
69
+
70
+ <p class="mt-2 text-sm leading-relaxed text-zinc-500">${w.summary}</p>
71
+
72
+ <div class="mt-6 flex flex-wrap gap-5">
73
+
74
+ ${weekLinks(w)}
75
+
76
+ </div>
77
+
78
+ </div>`
79
+
80
+ )
81
+
82
+ .join('\n')
83
+
84
+ }
85
+
86
+
@@ -0,0 +1,42 @@
1
+ import '../css/main.css'
2
+ import { sitePath } from './site-path.js'
3
+
4
+ const MENU_SVG = `<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16" /></svg>`
5
+
6
+ /**
7
+ * Wraps [data-page-content] in the shared app shell (sidebar + topbar).
8
+ * Pages only need their unique <main> content inside data-page-content.
9
+ */
10
+ export function mountLayout() {
11
+ const icon = document.querySelector('link[rel="icon"]')
12
+ if (icon) icon.href = sitePath('/favicon.svg')
13
+
14
+ const contentEl = document.querySelector('[data-page-content]')
15
+ if (!contentEl || document.querySelector('[data-app-shell]')) return
16
+
17
+ const content = contentEl.innerHTML.trim()
18
+ contentEl.remove()
19
+
20
+ const shell = document.createElement('div')
21
+ shell.className = 'app-shell'
22
+ shell.dataset.appShell = ''
23
+ shell.innerHTML = `
24
+ <div data-nav-overlay class="nav-overlay"></div>
25
+ <aside data-sidebar class="sidebar-panel">
26
+ <nav data-module-nav></nav>
27
+ </aside>
28
+ <div class="md:pl-60">
29
+ <header class="topbar">
30
+ <div class="flex items-center gap-4 px-4 py-3 md:px-8">
31
+ <button data-nav-toggle type="button" class="p-2 text-zinc-600 transition hover:text-zinc-900 md:hidden" aria-label="Menu openen">
32
+ ${MENU_SVG}
33
+ </button>
34
+ <div data-breadcrumbs></div>
35
+ </div>
36
+ </header>
37
+ ${content}
38
+ </div>
39
+ `
40
+
41
+ document.body.insertBefore(shell, document.body.firstChild)
42
+ }
@@ -0,0 +1,49 @@
1
+ import * as monaco from 'monaco-editor'
2
+ import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
3
+ import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
4
+
5
+ self.MonacoEnvironment = {
6
+ getWorker(_, label) {
7
+ if (label === 'css' || label === 'scss' || label === 'less') {
8
+ return new cssWorker()
9
+ }
10
+ return new editorWorker()
11
+ },
12
+ }
13
+
14
+ /**
15
+ * @param {HTMLElement} container
16
+ * @param {string} initialValue
17
+ * @param {(value: string) => void} [onChange]
18
+ */
19
+ export function createCssEditor(container, initialValue = '', onChange) {
20
+ const editor = monaco.editor.create(container, {
21
+ value: initialValue,
22
+ language: 'css',
23
+ theme: 'vs-dark',
24
+ fontSize: 14,
25
+ fontFamily: "'JetBrains Mono', ui-monospace, monospace",
26
+ minimap: { enabled: false },
27
+ lineNumbers: 'on',
28
+ scrollBeyondLastLine: false,
29
+ automaticLayout: true,
30
+ tabSize: 2,
31
+ wordWrap: 'on',
32
+ padding: { top: 12 },
33
+ })
34
+
35
+ if (onChange) {
36
+ editor.onDidChangeModelContent(() => onChange(editor.getValue()))
37
+ }
38
+
39
+ return editor
40
+ }
41
+
42
+ export function setEditorValue(editor, value) {
43
+ if (!editor) return
44
+ editor.setValue(value)
45
+ }
46
+
47
+ export function getEditorValue(editor) {
48
+ return editor?.getValue() ?? ''
49
+ }