Eerste upload

This commit is contained in:
2026-05-09 08:33:11 +02:00
parent c755e10a77
commit 8435e62e9a
838 changed files with 174200 additions and 0 deletions
+564
View File
@@ -0,0 +1,564 @@
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>");
}
function markdownTextToHtml(lines) {
const html = [];
let paragraph = [];
let listType = null;
let listItems = [];
const flushParagraph = () => {
if (paragraph.length > 0) {
html.push(`<p>${paragraph.map(markdownInlineToHtml).join("<br>")}</p>`);
paragraph = [];
}
};
const flushList = () => {
if (listItems.length === 0) {
return;
}
const tag = listType === "ol" ? "ol" : "ul";
html.push(
`<${tag}>${listItems
.map((item) => `<li>${markdownInlineToHtml(item)}</li>`)
.join("")}</${tag}>`
);
listItems = [];
listType = null;
};
for (const rawLine of lines) {
const line = rawLine.trimEnd();
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
flushList();
continue;
}
const bulletMatch = trimmed.match(/^[-*]\s+(.*)$/);
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/);
if (bulletMatch) {
flushParagraph();
if (listType && listType !== "ul") {
flushList();
}
listType = "ul";
listItems.push(bulletMatch[1]);
continue;
}
if (orderedMatch) {
flushParagraph();
if (listType && listType !== "ol") {
flushList();
}
listType = "ol";
listItems.push(orderedMatch[1]);
continue;
}
flushList();
paragraph.push(trimmed);
}
flushParagraph();
flushList();
return html.join("");
}
function htmlToPlainText(html) {
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
function readSectionLines(lines, startIndex) {
const sectionLines = [];
let i = startIndex;
for (; i < lines.length; i += 1) {
if (/^(#|##|###)\s+/.test(lines[i])) {
break;
}
sectionLines.push(lines[i]);
}
return { sectionLines, nextIndex: i };
}
function parseTable(lines) {
const fields = Object.fromEntries(
lines
.map((line) => parseField(line.trim()))
.filter(Boolean)
.map((field) => [field.key, field.value])
);
const tableLines = lines.filter((line) => /^\|.*\|$/.test(line.trim()));
const rows = tableLines
.filter((line) => !/^\|(?:\s*:?-+:?\s*\|)+$/.test(line.trim()))
.map((line) =>
line
.trim()
.slice(1, -1)
.split("|")
.map((cell) => cell.trim())
);
return {
id: makeId(),
type: "table",
data: {
...(fields.titel ? { title: fields.titel } : {}),
topHeader:
fields["top header"]?.toLowerCase() === "aan" ||
fields["top header"]?.toLowerCase() === "true" ||
fields["bovenste header"]?.toLowerCase() === "aan" ||
fields["bovenste header"]?.toLowerCase() === "true",
leftHeader:
fields["left header"]?.toLowerCase() === "aan" ||
fields["left header"]?.toLowerCase() === "true" ||
fields["linker header"]?.toLowerCase() === "aan" ||
fields["linker header"]?.toLowerCase() === "true",
rows,
},
};
}
function parseImage(lines) {
const fields = Object.fromEntries(
lines
.map((line) => parseField(line.trim()))
.filter(Boolean)
.map((field) => [field.key, field.value])
);
return {
id: makeId(),
type: "image",
data: {
...(fields.titel ? { title: fields.titel } : {}),
...(fields.onderschrift ? { caption: fields.onderschrift } : {}),
...(fields.alt ? { alt: fields.alt } : {}),
...(fields.bestand ? { sourcePath: fields.bestand } : {}),
},
};
}
function parseQuestionBlock(lines, type) {
const answers = [];
const pairs = [];
let question = "";
let feedback = "";
for (const rawLine of lines) {
const trimmed = rawLine.trim();
if (!trimmed) {
continue;
}
const field = parseField(trimmed);
if (field) {
if (field.key === "vraag" || field.key === "instructie") {
question = field.value;
} else if (field.key === "toelichting") {
feedback = field.value;
}
continue;
}
const answerMatch = trimmed.match(/^-\s+\[([ xX])\]\s+(.*)$/);
if (answerMatch) {
answers.push({
id: makeId(),
text: answerMatch[2].trim(),
...(answerMatch[1].toLowerCase() === "x" ? { correct: true } : {}),
});
continue;
}
const pairMatch = trimmed.match(/^-\s+(.*?)\s*=\s*(.*)$/);
if (pairMatch) {
pairs.push({
id: makeId(),
left: pairMatch[1].trim(),
right: pairMatch[2].trim(),
});
}
}
if (type === "matching-pairs") {
return {
id: makeId(),
type,
data: {
question,
pairs,
...(feedback ? { feedback: `<p>${markdownInlineToHtml(feedback)}</p>` } : {}),
},
};
}
return {
id: makeId(),
type,
data: {
question,
...(answers.length > 0 ? { answers } : {}),
...(feedback ? { feedback: `<p>${markdownInlineToHtml(feedback)}</p>` } : {}),
},
};
}
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 <input.md> [output.json]"
);
}
const markdown = await fs.readFile(inputPath, "utf8");
const result = parseMarkdownToOnlineAcademy(markdown);
const resolvedOutput =
outputPath ?? path.join(path.dirname(inputPath), `${path.basename(inputPath, path.extname(inputPath))}.onlineacademy.json`);
await fs.writeFile(resolvedOutput, JSON.stringify(result, null, 2), "utf8");
console.log(`Dry-run JSON geschreven naar ${resolvedOutput}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}