import fs from "node:fs/promises"; import path from "node:path"; import crypto from "node:crypto"; const SUPPORTED_BLOCK_TYPES = new Set([ "text", "heading", "quote", "table", "image", "multiple-choice", "multiple-response", "open-question", "matching-pairs", ]); const MAX_QUESTION_LENGTH = 350; const MAX_FEEDBACK_LENGTH = 999; const MAX_MULTI_RESPONSE_ANSWERS = 10; const MAX_TABLE_COLUMNS = 5; const MAX_TABLE_ROWS = 20; function makeId() { return crypto.randomUUID(); } function stripQuotes(value) { return value.replace(/^['"]|['"]$/g, "").trim(); } function parseFrontmatter(lines) { const meta = {}; if (lines[0]?.trim() !== "---") { return { meta, nextIndex: 0 }; } let i = 1; for (; i < lines.length; i += 1) { const line = lines[i].trim(); if (line === "---") { return { meta, nextIndex: i + 1 }; } if (!line) { continue; } const separator = line.indexOf(":"); if (separator === -1) { continue; } const key = line.slice(0, separator).trim(); const value = stripQuotes(line.slice(separator + 1).trim()); meta[key] = value; } return { meta, nextIndex: i }; } function parseField(line) { const match = line.match(/^\*\*(.+?):\*\*\s*(.*)$/); if (!match) { return null; } return { key: match[1].trim().toLowerCase(), value: match[2].trim(), }; } function markdownInlineToHtml(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\*(.+?)\*/g, "$1"); } function markdownTextToHtml(lines) { const html = []; let paragraph = []; let listType = null; let listItems = []; const flushParagraph = () => { if (paragraph.length > 0) { html.push(`
${paragraph.map(markdownInlineToHtml).join("
")}
${markdownInlineToHtml(feedback)}
` } : {}), }, }; } return { id: makeId(), type, data: { question, ...(answers.length > 0 ? { answers } : {}), ...(feedback ? { feedback: `${markdownInlineToHtml(feedback)}
` } : {}), }, }; } function parseBlock(blockType, sectionLines) { const trimmedLines = sectionLines.map((line) => line.trimEnd()); switch (blockType) { case "Tekst": { const titleField = trimmedLines .map((line) => parseField(line.trim())) .find((field) => field?.key === "titel"); const contentLines = titleField ? trimmedLines.filter((line) => parseField(line.trim()) === null) : trimmedLines; return { id: makeId(), type: "text", data: { ...(titleField?.value ? { title: titleField.value } : {}), content: markdownTextToHtml(contentLines), }, }; } case "Kop": return { id: makeId(), type: "heading", data: { title: trimmedLines.filter(Boolean).join(" ").trim(), }, }; case "Quote": { const fields = Object.fromEntries( trimmedLines .map((line) => parseField(line.trim())) .filter(Boolean) .map((field) => [field.key, field.value]) ); return { id: makeId(), type: "quote", data: { quote: fields.tekst ?? "", ...(fields.auteur ? { author: fields.auteur } : {}), }, }; } case "Tabel": return parseTable(trimmedLines); case "Afbeelding": return parseImage(trimmedLines); case "Meerkeuze": return parseQuestionBlock(trimmedLines, "multiple-choice"); case "Multi antwoord": return parseQuestionBlock(trimmedLines, "multiple-response"); case "Open vraag": return parseQuestionBlock(trimmedLines, "open-question"); case "Koppelvraag": return parseQuestionBlock(trimmedLines, "matching-pairs"); default: throw new Error(`Onbekend bloktype: ${blockType}`); } } export function parseMarkdownToOnlineAcademy(markdown) { const lines = markdown.replace(/\r\n/g, "\n").split("\n"); const { meta, nextIndex } = parseFrontmatter(lines); const documentModel = { training: meta.training ?? "", chapters: [], examPage: { enabled: false, blocks: [], examSettings: { showFeedbackAfterExam: false, questionPool: false, numberOfQuestions: 0, minimumPassingScore: 60, }, }, }; let currentChapter = null; let currentPage = null; let i = nextIndex; while (i < lines.length) { const line = lines[i].trim(); if (!line) { i += 1; continue; } if (line.startsWith("# Module:")) { currentChapter = { id: makeId(), title: line.replace("# Module:", "").trim(), pages: [], }; documentModel.chapters.push(currentChapter); currentPage = null; i += 1; continue; } if (line.startsWith("## Pagina:")) { if (!currentChapter) { throw new Error("Pagina gevonden voordat een module/hoofdstuk is gestart."); } currentPage = { id: makeId(), pageType: "number", title: line.replace("## Pagina:", "").trim(), blocks: [], }; currentChapter.pages.push(currentPage); i += 1; continue; } if (line.startsWith("### ")) { if (!currentPage) { throw new Error("Blok gevonden voordat een pagina is gestart."); } const blockType = line.replace("### ", "").trim(); const { sectionLines, nextIndex: afterSection } = readSectionLines(lines, i + 1); currentPage.blocks.push(parseBlock(blockType, sectionLines)); i = afterSection; continue; } i += 1; } return { title: documentModel.training, jsonContent: JSON.stringify({ chapters: documentModel.chapters, examPage: documentModel.examPage, }), }; } export function parseMarkdownToCourseModel(markdown) { const result = parseMarkdownToOnlineAcademy(markdown); return { title: result.title, ...JSON.parse(result.jsonContent), }; } export function validateCourseModel(courseModel, supportedTypes = SUPPORTED_BLOCK_TYPES) { const issues = []; for (const chapter of courseModel.chapters) { for (const page of chapter.pages) { for (const block of page.blocks) { if (!supportedTypes.has(block.type)) { issues.push( `Bloktype "${block.type}" op pagina "${page.title}" wordt nog niet ondersteund.` ); } if (block.type === "image" && !block.data.sourcePath?.trim()) { issues.push(`Afbeeldingsblok op pagina "${page.title}" mist een Bestand-pad.`); } if (block.type === "table") { const rows = block.data.rows ?? []; const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0); if (rows.length === 0) { issues.push(`Tabel op pagina "${page.title}" bevat geen rijen.`); } if (rows.length > MAX_TABLE_ROWS) { issues.push( `Tabel op pagina "${page.title}" mag maximaal ${MAX_TABLE_ROWS} rijen hebben.` ); } if (columnCount === 0) { issues.push(`Tabel op pagina "${page.title}" bevat geen kolommen.`); } if (columnCount > MAX_TABLE_COLUMNS) { issues.push( `Tabel op pagina "${page.title}" mag maximaal ${MAX_TABLE_COLUMNS} kolommen hebben.` ); } } if ( block.type === "multiple-choice" || block.type === "multiple-response" || block.type === "open-question" ) { if (!block.data.question?.trim()) { issues.push(`Vraag ontbreekt voor bloktype "${block.type}" op pagina "${page.title}".`); } if ((block.data.question?.trim().length ?? 0) > MAX_QUESTION_LENGTH) { issues.push( `Vraag voor bloktype "${block.type}" op pagina "${page.title}" is langer dan ${MAX_QUESTION_LENGTH} karakters.` ); } if ( block.data.feedback && htmlToPlainText(block.data.feedback).length > MAX_FEEDBACK_LENGTH ) { issues.push( `Toelichting voor bloktype "${block.type}" op pagina "${page.title}" is langer dan ${MAX_FEEDBACK_LENGTH} karakters.` ); } } if (block.type === "multiple-choice") { const answers = block.data.answers ?? []; const correctCount = answers.filter((answer) => answer.correct).length; if (answers.length < 2) { issues.push( `Meerkeuzevraag op pagina "${page.title}" moet minimaal 2 antwoorden hebben.` ); } if (correctCount !== 1) { issues.push( `Meerkeuzevraag op pagina "${page.title}" moet exact 1 correct antwoord hebben.` ); } } if (block.type === "multiple-response") { const answers = block.data.answers ?? []; const correctCount = answers.filter((answer) => answer.correct).length; if (answers.length < 2) { issues.push( `Multi-antwoordvraag op pagina "${page.title}" moet minimaal 2 antwoorden hebben.` ); } if (answers.length > MAX_MULTI_RESPONSE_ANSWERS) { issues.push( `Multi-antwoordvraag op pagina "${page.title}" mag maximaal ${MAX_MULTI_RESPONSE_ANSWERS} antwoorden hebben.` ); } if (correctCount < 1) { issues.push( `Multi-antwoordvraag op pagina "${page.title}" moet minimaal 1 correct antwoord hebben.` ); } } } } } return issues; } async function main() { const inputPath = process.argv[2]; const outputPath = process.argv[3]; if (!inputPath) { throw new Error( "Gebruik: node markdown-to-onlineacademy-json.mjs