Eerste upload
This commit is contained in:
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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();
|
||||
}
|
||||
Reference in New Issue
Block a user