@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/src/js/nav.js ADDED
@@ -0,0 +1,165 @@
1
+ import { mountLayout } from './layout.js'
2
+ import manifest from '../data/manifest.json'
3
+ import { sitePath } from './site-path.js'
4
+
5
+ function buildNavItems() {
6
+ const items = [{ href: manifest.nav.home.href, label: manifest.nav.home.label }]
7
+
8
+ for (const week of manifest.nav.weeks) {
9
+ items.push({
10
+ label: week.label,
11
+ title: week.title,
12
+ children: week.children,
13
+ })
14
+ }
15
+
16
+ for (const page of manifest.nav.examPages) {
17
+ items.push(page)
18
+ }
19
+
20
+ return items
21
+ }
22
+
23
+ const NAV_ITEMS = buildNavItems()
24
+
25
+ function isActive(href) {
26
+ const path = window.location.pathname.replace(/\/$/, '')
27
+ const target = href.replace(/\/$/, '')
28
+ if (target.endsWith('index.html') || target === '/index.html') {
29
+ return path.endsWith('/') || path.endsWith('/index.html') || path === ''
30
+ }
31
+ return path.endsWith(target) || path.includes(target)
32
+ }
33
+
34
+ function renderNavLink(item, nested = false) {
35
+ const active = isActive(item.href)
36
+ const base = nested
37
+ ? 'block px-3 py-2 text-[13px] transition'
38
+ : 'block px-3 py-2.5 text-[13px] font-medium transition'
39
+ const classes = active
40
+ ? `${base} text-white`
41
+ : `${base} text-zinc-300 hover:text-white`
42
+
43
+ const external = item.external ? ' target="_blank" rel="noopener noreferrer"' : ''
44
+ return `<a href="${sitePath(item.href)}" class="${classes}"${external}>${item.label}</a>`
45
+ }
46
+
47
+ function renderCrashCourseLink() {
48
+ const url = manifest.module.youtube
49
+ if (!url) return ''
50
+
51
+ return `
52
+ <a
53
+ href="${url}"
54
+ class="nav-cta"
55
+ target="_blank"
56
+ rel="noopener noreferrer"
57
+ >
58
+ <svg class="h-4 w-4 shrink-0 text-zinc-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
59
+ <path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.75 15.02V8.98L15.5 12l-5.75 3.02z"/>
60
+ </svg>
61
+ Crash Course
62
+ </a>
63
+ `
64
+ }
65
+
66
+ function renderNavGroup(group) {
67
+ const childLinks = group.children.map((c) => renderNavLink(c, true)).join('')
68
+ const title = group.title
69
+ ? `<p class="mt-0.5 text-[10px] font-normal leading-snug text-zinc-500">${group.title}</p>`
70
+ : ''
71
+ return `
72
+ <div class="space-y-0.5">
73
+ <div class="px-3 pb-1 pt-4">
74
+ <p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-zinc-400">${group.label}</p>
75
+ ${title}
76
+ </div>
77
+ ${childLinks}
78
+ </div>
79
+ `
80
+ }
81
+
82
+ export function getManifest() {
83
+ return manifest
84
+ }
85
+
86
+ export function initNav() {
87
+ const navEl = document.querySelector('[data-module-nav]')
88
+ if (!navEl) return
89
+
90
+ const mod = manifest.module
91
+ const parts = []
92
+ for (const item of NAV_ITEMS) {
93
+ if (item.label === 'Week 1' && mod.youtube) {
94
+ parts.push(renderCrashCourseLink())
95
+ }
96
+ parts.push(item.children ? renderNavGroup(item) : renderNavLink(item))
97
+ }
98
+ const links = parts.join('')
99
+
100
+ navEl.innerHTML = `
101
+ <div class="flex min-h-0 flex-1 flex-col">
102
+ <div class="shrink-0 border-b border-zinc-800 px-5 py-5">
103
+ <a href="${sitePath('/index.html')}" class="flex flex-col items-center text-center">
104
+ <img src="${sitePath('/logo.svg')}" alt="${mod.logoAlt}" class="sidebar-logo" width="155" height="91" />
105
+ <p class="mt-2.5 text-[11px] uppercase tracking-[0.15em] text-zinc-400">${mod.name} — ${mod.subtitle}</p>
106
+ </a>
107
+ </div>
108
+ <div class="sidebar-scroll space-y-1">${links}</div>
109
+ </div>
110
+ `
111
+ }
112
+
113
+ export function initMobileNav() {
114
+ const toggle = document.querySelector('[data-nav-toggle]')
115
+ const sidebar = document.querySelector('[data-sidebar]')
116
+ const overlay = document.querySelector('[data-nav-overlay]')
117
+
118
+ if (!toggle || !sidebar) return
119
+
120
+ const isMobile = () => !window.matchMedia('(min-width: 768px)').matches
121
+
122
+ const close = () => {
123
+ if (!isMobile()) return
124
+ sidebar.classList.add('-translate-x-full')
125
+ overlay?.classList.add('hidden')
126
+ document.body.classList.remove('overflow-hidden')
127
+ }
128
+
129
+ const open = () => {
130
+ sidebar.classList.remove('-translate-x-full')
131
+ overlay?.classList.remove('hidden')
132
+ document.body.classList.add('overflow-hidden')
133
+ }
134
+
135
+ toggle.addEventListener('click', () => {
136
+ if (sidebar.classList.contains('-translate-x-full')) open()
137
+ else close()
138
+ })
139
+
140
+ overlay?.addEventListener('click', close)
141
+ sidebar.querySelectorAll('a').forEach((a) => a.addEventListener('click', close))
142
+ }
143
+
144
+ export function renderBreadcrumbs(crumbs) {
145
+ const el = document.querySelector('[data-breadcrumbs]')
146
+ if (!el || !crumbs?.length) return
147
+
148
+ el.innerHTML = crumbs
149
+ .map((c, i) => {
150
+ const isLast = i === crumbs.length - 1
151
+ const content = c.href && !isLast
152
+ ? `<a href="${sitePath(c.href)}" class="text-zinc-500 transition hover:text-zinc-900">${c.label}</a>`
153
+ : `<span class="text-zinc-900">${c.label}</span>`
154
+ const sep = i < crumbs.length - 1 ? '<span class="text-zinc-300">/</span>' : ''
155
+ return `${content}${sep}`
156
+ })
157
+ .join(' ')
158
+ }
159
+
160
+ export function initPage({ breadcrumbs } = {}) {
161
+ mountLayout()
162
+ initNav()
163
+ initMobileNav()
164
+ renderBreadcrumbs(breadcrumbs)
165
+ }
package/src/js/quiz.js ADDED
@@ -0,0 +1,111 @@
1
+ import { getQuizScore, setQuizScore, removeQuizScore } from './storage.js'
2
+
3
+ function renderPreview(preview) {
4
+ if (!preview) return ''
5
+ return `
6
+ <div class="my-4 overflow-hidden border border-zinc-100 bg-zinc-50 p-4">
7
+ <style>${preview.css}</style>
8
+ ${preview.html}
9
+ </div>
10
+ `
11
+ }
12
+
13
+ export function initQuiz(quizData, containerSelector = '[data-quiz]') {
14
+ const container = document.querySelector(containerSelector)
15
+ if (!container) return
16
+
17
+ const previous = getQuizScore(quizData.title)
18
+ if (previous) {
19
+ showResults(container, quizData, previous, true)
20
+ return
21
+ }
22
+
23
+ container.innerHTML = `
24
+ <form data-quiz-form class="space-y-6">
25
+ ${quizData.questions
26
+ .map(
27
+ (q, qi) => `
28
+ <fieldset class="card" data-question="${q.id}">
29
+ <legend class="mb-4 text-base font-medium text-zinc-900">
30
+ <span class="mr-3 font-mono text-sm text-zinc-400">${String(qi + 1).padStart(2, '0')}</span>
31
+ ${q.question}
32
+ </legend>
33
+ ${renderPreview(q.preview)}
34
+ <div class="space-y-2">
35
+ ${q.options
36
+ .map(
37
+ (opt, oi) => `
38
+ <label class="flex cursor-pointer items-center gap-3 border border-zinc-200 p-3 transition hover:bg-zinc-50 has-checked:border-zinc-900 has-checked:bg-zinc-50">
39
+ <input type="radio" name="${q.id}" value="${oi}" class="h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-900" required />
40
+ <span class="text-sm text-zinc-700">${opt}</span>
41
+ </label>
42
+ `
43
+ )
44
+ .join('')}
45
+ </div>
46
+ <p data-explanation="${q.id}" class="mt-3 hidden text-sm"></p>
47
+ </fieldset>
48
+ `
49
+ )
50
+ .join('')}
51
+ <button type="submit" class="btn-primary">Indienen</button>
52
+ </form>
53
+ `
54
+
55
+ container.querySelector('[data-quiz-form]').addEventListener('submit', (e) => {
56
+ e.preventDefault()
57
+ const form = e.target
58
+ const answers = {}
59
+
60
+ quizData.questions.forEach((q) => {
61
+ const selected = form.querySelector(`input[name="${q.id}"]:checked`)
62
+ answers[q.id] = selected ? parseInt(selected.value, 10) : -1
63
+ })
64
+
65
+ const correct = quizData.questions.filter((q) => answers[q.id] === q.correct).length
66
+ const total = quizData.questions.length
67
+ const percent = Math.round((correct / total) * 100)
68
+ const passed = percent >= quizData.passScore
69
+
70
+ const result = { correct, total, percent, passed, answers, date: new Date().toISOString() }
71
+ setQuizScore(quizData.title, result)
72
+
73
+ quizData.questions.forEach((q) => {
74
+ const isCorrect = answers[q.id] === q.correct
75
+ const el = container.querySelector(`[data-explanation="${q.id}"]`)
76
+ el.classList.remove('hidden')
77
+ el.classList.add(isCorrect ? 'text-zinc-700' : 'text-zinc-500')
78
+ el.textContent = isCorrect ? `✓ Correct. ${q.explanation}` : `✗ Fout. ${q.explanation}`
79
+ })
80
+
81
+ showResults(container, quizData, result, false, form)
82
+ })
83
+ }
84
+
85
+ function showResults(container, quizData, result, fromStorage, form = null) {
86
+ const passed = result.passed
87
+ const resultHtml = `
88
+ <div class="card mb-6 ${passed ? 'bg-zinc-50' : ''}">
89
+ <h2 class="text-xl font-medium text-zinc-900">
90
+ ${passed ? 'Geslaagd' : 'Nog niet geslaagd'}
91
+ </h2>
92
+ <p class="mt-2 text-zinc-600">
93
+ Score: ${result.correct} / ${result.total} (${result.percent}%)
94
+ — minimaal ${quizData.passScore}% nodig
95
+ </p>
96
+ ${fromStorage ? '<p class="mt-2 text-sm text-zinc-500">Eerder resultaat (opgeslagen in browser). <button type="button" data-reset-quiz class="text-link">Opnieuw maken</button></p>' : ''}
97
+ </div>
98
+ `
99
+
100
+ if (fromStorage) {
101
+ container.innerHTML = resultHtml
102
+ container.querySelector('[data-reset-quiz]')?.addEventListener('click', () => {
103
+ removeQuizScore(quizData.title)
104
+ location.reload()
105
+ })
106
+ return
107
+ }
108
+
109
+ form.querySelector('button[type="submit"]').disabled = true
110
+ form.insertAdjacentHTML('beforebegin', resultHtml)
111
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Resolve root-absolute paths (/pages/…) to URLs that work in dev, preview, and static dist.
3
+ */
4
+ export function sitePath(href) {
5
+ if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#')) {
6
+ return href
7
+ }
8
+
9
+ const path = href.startsWith('/') ? href.slice(1) : href
10
+ const loc = decodeURIComponent(window.location.pathname).replace(/\\/g, '/')
11
+ const inPages = /\/pages\/[^/]+\.html$/i.test(loc)
12
+
13
+ if (!inPages) return path
14
+
15
+ if (path === 'index.html') return '../index.html'
16
+ if (path.startsWith('pages/')) return path.slice('pages/'.length)
17
+ return `../${path}`
18
+ }
@@ -0,0 +1,51 @@
1
+ const PREFIX = 'grid-module:'
2
+
3
+ export function getItem(key, fallback = null) {
4
+ try {
5
+ const raw = localStorage.getItem(PREFIX + key)
6
+ return raw ? JSON.parse(raw) : fallback
7
+ } catch {
8
+ return fallback
9
+ }
10
+ }
11
+
12
+ export function setItem(key, value) {
13
+ localStorage.setItem(PREFIX + key, JSON.stringify(value))
14
+ }
15
+
16
+ export function getChecklistState() {
17
+ return getItem('checklist', {})
18
+ }
19
+
20
+ export function setChecklistItem(id, checked) {
21
+ const state = getChecklistState()
22
+ state[id] = checked
23
+ setItem('checklist', state)
24
+ }
25
+
26
+ export function getQuizScore(quizId) {
27
+ return getItem(`quiz:${quizId}`, null)
28
+ }
29
+
30
+ export function setQuizScore(quizId, score) {
31
+ setItem(`quiz:${quizId}`, score)
32
+ }
33
+
34
+ export function removeQuizScore(quizId) {
35
+ localStorage.removeItem(PREFIX + `quiz:${quizId}`)
36
+ }
37
+
38
+ export function getChecklistProgress(checklistData) {
39
+ const state = getChecklistState()
40
+ let total = 0
41
+ let checked = 0
42
+
43
+ for (const group of checklistData.groups) {
44
+ for (const item of group.items) {
45
+ total++
46
+ if (state[item.id]) checked++
47
+ }
48
+ }
49
+
50
+ return { total, checked, percent: total ? Math.round((checked / total) * 100) : 0 }
51
+ }
@@ -0,0 +1,41 @@
1
+ import './components.js'
2
+
3
+ function esc(s) {
4
+ return String(s)
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ }
9
+
10
+ export async function initTheory(week) {
11
+ const container = document.querySelector(`[data-theory][data-week="${week}"]`)
12
+ if (!container) return
13
+
14
+ let data
15
+ try {
16
+ data = await import(`../data/theory-week${week}.json`).then((m) => m.default)
17
+ } catch {
18
+ container.innerHTML = `
19
+ <p class="text-red-600">Theorie voor week ${week} niet gevonden.</p>
20
+ <p class="mt-2 text-sm text-zinc-500">Run <code class="font-mono">npm run generate-content</code> om content te genereren.</p>
21
+ `
22
+ return
23
+ }
24
+
25
+ if (!data?.html) {
26
+ container.innerHTML = `
27
+ <p class="text-amber-700">Theorie voor week ${week} is nog leeg.</p>
28
+ <p class="mt-2 text-sm text-zinc-500">Run <code class="font-mono">npm run generate-content</code> om content te genereren.</p>
29
+ `
30
+ return
31
+ }
32
+
33
+ container.innerHTML = `
34
+ <span class="week-label">Week ${data.week}</span>
35
+ <h1 class="text-3xl font-semibold tracking-tight text-zinc-900">${esc(data.title)}</h1>
36
+ <p class="mt-2 text-lg text-zinc-600">Leerdoel: ${data.goal}</p>
37
+ <div class="prose-theory mt-8">
38
+ ${data.html}
39
+ </div>
40
+ `
41
+ }
@@ -0,0 +1,161 @@
1
+ import { getItem, setItem } from './storage.js'
2
+
3
+ function storageKey(week) {
4
+ return `thuiswerk:week${week}`
5
+ }
6
+
7
+ export function initThuiswerk(data) {
8
+ const container = document.querySelector('[data-thuiswerk]')
9
+ if (!container) return
10
+
11
+ const state = getItem(storageKey(data.week), { criteria: {}, notes: '', submitted: false })
12
+
13
+ const criteriaHtml = data.criteria
14
+ .map((c) => {
15
+ const checked = !!state.criteria[c.id]
16
+ const optional = c.optional ? ' <span class="text-zinc-400">(bonus)</span>' : ''
17
+ return `
18
+ <label class="flex cursor-pointer items-start gap-3 border-b border-zinc-100 py-3 last:border-0">
19
+ <input
20
+ type="checkbox"
21
+ data-criterion-id="${c.id}"
22
+ class="mt-0.5 h-4 w-4 border-zinc-300 text-zinc-900 focus:ring-zinc-900"
23
+ ${checked ? 'checked' : ''}
24
+ />
25
+ <span class="flex-1 text-sm text-zinc-700">
26
+ ${c.text}${optional}
27
+ <span class="ml-2 font-mono text-xs text-zinc-400">${c.points}p</span>
28
+ </span>
29
+ </label>
30
+ `
31
+ })
32
+ .join('')
33
+
34
+ const deliverablesHtml = data.deliverables
35
+ .map((d) => `<li class="text-sm text-zinc-600">${d}</li>`)
36
+ .join('')
37
+
38
+ const tipsHtml = data.tips.map((t) => `<li class="text-sm text-zinc-600">${t}</li>`).join('')
39
+
40
+ container.innerHTML = `
41
+ <section class="card mb-6">
42
+ <p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">Casus</p>
43
+ <h2 class="mt-2 text-xl font-medium text-zinc-900">${data.client}</h2>
44
+ <p class="mt-4 leading-relaxed text-zinc-600">${data.case}</p>
45
+ </section>
46
+
47
+ <section class="card mb-6">
48
+ <p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">Opdracht</p>
49
+ <p class="mt-3 leading-relaxed text-zinc-600">${data.assignment}</p>
50
+ <div class="mt-6">
51
+ <p class="text-sm font-medium text-zinc-900">Inleveren</p>
52
+ <ul class="mt-2 list-inside list-disc space-y-1">${deliverablesHtml}</ul>
53
+ </div>
54
+ </section>
55
+
56
+ <section class="card mb-6">
57
+ <div class="mb-4 flex items-end justify-between">
58
+ <div>
59
+ <p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">Beoordelingscriteria</p>
60
+ <p class="mt-1 text-sm text-zinc-500">Maximaal ${data.maxPoints} punten — gebruik als checklist vóór je inlevert</p>
61
+ </div>
62
+ <span data-criteria-score class="font-mono text-sm text-zinc-900">0 / ${data.maxPoints}</span>
63
+ </div>
64
+ <div data-criteria-list>${criteriaHtml}</div>
65
+ </section>
66
+
67
+ <section class="card mb-6">
68
+ <label class="block">
69
+ <p class="text-sm font-medium text-zinc-900">Notities / link naar je bestanden</p>
70
+ <p class="mt-1 text-sm text-zinc-500">Bijv. GitHub-link, Google Drive, of een korte toelichting voor je docent.</p>
71
+ <textarea
72
+ data-thuiswerk-notes
73
+ class="mt-3 min-h-[120px] w-full border border-zinc-200 bg-white p-4 text-sm text-zinc-700 focus:border-zinc-900 focus:outline-none"
74
+ placeholder="Plak hier je inlever-link of notities..."
75
+ >${state.notes || ''}</textarea>
76
+ </label>
77
+ </section>
78
+
79
+ <section class="card-muted mb-6">
80
+ <p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">Tips</p>
81
+ <ul class="mt-3 list-inside list-disc space-y-1">${tipsHtml}</ul>
82
+ </section>
83
+
84
+ <div class="flex flex-wrap gap-3">
85
+ <button type="button" data-export-thuiswerk class="btn-secondary">Exporteer checklist</button>
86
+ <button type="button" data-mark-submitted class="btn-primary">${state.submitted ? 'Ingeleverd ✓' : 'Markeer als ingeleverd'}</button>
87
+ </div>
88
+ <p data-submitted-hint class="mt-3 text-sm text-zinc-500 ${state.submitted ? '' : 'hidden'}">Je hebt deze opdracht gemarkeerd als ingeleverd. Lever ook in via het kanaal van je docent.</p>
89
+ `
90
+
91
+ const saveState = (updates) => {
92
+ const current = getItem(storageKey(data.week), { criteria: {}, notes: '', submitted: false })
93
+ setItem(storageKey(data.week), { ...current, ...updates })
94
+ }
95
+
96
+ const updateScore = () => {
97
+ const checked = container.querySelectorAll('[data-criterion-id]:checked')
98
+ let points = 0
99
+ checked.forEach((input) => {
100
+ const c = data.criteria.find((x) => x.id === input.dataset.criterionId)
101
+ if (c) points += c.points
102
+ })
103
+ const el = container.querySelector('[data-criteria-score]')
104
+ if (el) el.textContent = `${points} / ${data.maxPoints}`
105
+ }
106
+
107
+ container.querySelectorAll('[data-criterion-id]').forEach((input) => {
108
+ input.addEventListener('change', () => {
109
+ const criteria = {}
110
+ container.querySelectorAll('[data-criterion-id]').forEach((cb) => {
111
+ criteria[cb.dataset.criterionId] = cb.checked
112
+ })
113
+ saveState({ criteria })
114
+ updateScore()
115
+ })
116
+ })
117
+
118
+ const notesEl = container.querySelector('[data-thuiswerk-notes]')
119
+ notesEl?.addEventListener('input', () => {
120
+ saveState({ notes: notesEl.value })
121
+ })
122
+
123
+ container.querySelector('[data-export-thuiswerk]')?.addEventListener('click', async () => {
124
+ const criteria = getItem(storageKey(data.week), {}).criteria || {}
125
+ const notes = notesEl?.value || ''
126
+ const lines = [
127
+ data.subtitle,
128
+ data.title,
129
+ '='.repeat(40),
130
+ '',
131
+ `Klant: ${data.client}`,
132
+ '',
133
+ 'Criteria:',
134
+ ]
135
+ for (const c of data.criteria) {
136
+ const mark = criteria[c.id] ? '[x]' : '[ ]'
137
+ lines.push(` ${mark} (${c.points}p) ${c.text}`)
138
+ }
139
+ if (notes) {
140
+ lines.push('', 'Notities:', notes)
141
+ }
142
+ try {
143
+ await navigator.clipboard.writeText(lines.join('\n'))
144
+ const btn = container.querySelector('[data-export-thuiswerk]')
145
+ btn.textContent = 'Gekopieerd!'
146
+ setTimeout(() => { btn.textContent = 'Exporteer checklist' }, 2000)
147
+ } catch { /* ignore */ }
148
+ })
149
+
150
+ container.querySelector('[data-mark-submitted]')?.addEventListener('click', () => {
151
+ const current = getItem(storageKey(data.week), { submitted: false })
152
+ const submitted = !current.submitted
153
+ saveState({ submitted })
154
+ const btn = container.querySelector('[data-mark-submitted]')
155
+ const hint = container.querySelector('[data-submitted-hint]')
156
+ btn.textContent = submitted ? 'Ingeleverd ✓' : 'Markeer als ingeleverd'
157
+ hint?.classList.toggle('hidden', !submitted)
158
+ })
159
+
160
+ updateScore()
161
+ }
@@ -0,0 +1,6 @@
1
+ <meta charset="UTF-8" />
2
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
3
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
4
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
5
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
6
+ <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />