UNPKG

protoml-parser

Version:

ProtoML is a lightweight, declarative markup language designed for writing and structuring meeting protocols, notes and task lists in a human-readable and machine-parseable format.

215 lines (189 loc) 5.89 kB
const fs = require("fs"); const path = require("path"); const { getVisibleMetaEntries } = require("./renderUtils"); function loadTheme(themeName, skip = 0) { if (skip) { return ""; } const themePath = path.join( __dirname, "./themes", `${themeName || "default"}.css` ); try { return fs.readFileSync(themePath, "utf8"); } catch (err) { console.warn(`Theme "${themeName}" not found. Using default.`); return fs.readFileSync( path.join(__dirname, "./themes/default.css"), "utf8" ); } } function escape(text) { return String(text).replace( /[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;", }[c]) ); } function renderTemplate(template, context = {}) { return String(template).replace(/\{\{(.*?)\}\}/g, (_, key) => { const normalizedKey = String(key).trim(); return context[normalizedKey] ?? ""; }); } function toCssIdentifier(value) { return String(value) .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-") .replace(/^-+|-+$/g, ""); } function renderHTML(ast, options = {}) { const effectiveTheme = options.theme || ast.meta?.theme || "default"; const css = loadTheme(effectiveTheme, options.skipTheme); const protocolTitle = renderTemplate( ast.meta?.protocol || "Protocol - {{date}}", { ...(ast.meta || {}), date: ast.meta?.date || "Untitled", } ); const meetingTitle = renderTemplate( ast.meta?.meeting_title || "Rendered Meeting", ast.meta || {} ); const html = []; html.push(`<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>${escape(protocolTitle)}</title> <style>${css}</style> </head> <body>`); html.push(`<h1>${escape(protocolTitle)}</h1>`); if (ast.participants) { html.push(`<section><h2>Participants</h2><ul>`); for (const id in ast.participants) { const p = ast.participants[id]; html.push(`<li>${escape(p.name)} (${escape(p.alias || id)})</li>`); } html.push(`</ul></section>`); } if (ast.subjects) { html.push(`<section><h2>Subjects</h2><ul>`); for (const id in ast.subjects) { html.push(`<li><b>${id}:</b> ${ast.subjects[id]}</li>`); } html.push(`</ul></section>`); } if (ast.tag_stats && Object.keys(ast.tag_stats).length) { html.push(`<section><h2>Tags</h2><div class="tag-summary">`); for (const id in ast.tag_stats) { const stat = ast.tag_stats[id]; const tagClass = toCssIdentifier(id); html.push( `<article class="tag-card tag-${tagClass}">` + `<div class="tag-card-head"><span class="tag">${escape(stat.label)}</span><code>@tag=${escape(id)}</code></div>` + `<div class="tag-card-stats">` + `<span>Total: ${stat.total}</span>` + `<span>Open: ${stat.open}</span>` + `<span>Done: ${stat.done}</span>` + `</div>` + `</article>` ); } html.push(`</div></section>`); } if (ast.tasks?.length) { html.push(`<section><h2>Tasks</h2><ul>`); for (const task of ast.tasks) { const cls = [ task.done ? "done" : "", task.meta?.tag ? `task-tag-${toCssIdentifier(task.meta.tag)}` : "", ].filter(Boolean).join(" "); let extra = []; if (task.meta?.ptp && ast.participants?.[task.meta.ptp]) { const p = ast.participants[task.meta.ptp]; extra.push(`Assigned to: ${escape(p.name)}`); } if (task.meta?.subject && ast.subjects?.[task.meta.subject]) { extra.push( `Subject: ${escape(ast.subjects[task.meta.subject])}` ); } if (task.meta?.tag && ast.tags?.[task.meta.tag]) { extra.push( `Tag: <span class="tag">${escape(ast.tags[task.meta.tag])}</span>` ); } const metaInfo = extra.length ? `<div class="meta">${extra.join(" • ")}</div>` : ""; html.push(`<li class="${cls}">${task.text}${metaInfo}</li>`); } html.push(`</ul></section>`); } const metaEntries = getVisibleMetaEntries(ast, options); if (metaEntries.length) { html.push(`<section><h2>Meta</h2><ul>`); for (const [key, value] of metaEntries) { html.push(`<li><b>${escape(key)}:</b> ${escape(value)}</li>`); } html.push(`</ul></section>`); } if (ast.notes?.length) { html.push(`<section><h2>Notes</h2><ul>`); for (const note of ast.notes) { html.push(`<li>${note}</li>`); } html.push(`</ul></section>`); } if (ast.references?.length) { html.push(`<section><h2>References</h2><ul>`); for (const entry of ast.references) { const match = String(entry).match(/^(.+?)\|(.+)$/); if (match) { html.push(`<li><a href="${escape(match[2].trim())}" target="_blank">${escape(match[1].trim())}</a></li>`); } else { html.push(`<li>${escape(entry)}</li>`); } } html.push(`</ul></section>`); } if (ast.attachments?.length) { html.push(`<section><h2>Attachments</h2><ul>`); for (const entry of ast.attachments) { const match = String(entry).match(/^(.+?)\|(.+)$/); if (match) { html.push(`<li><a href="${escape(match[2].trim())}" target="_blank">${escape(match[1].trim())}</a></li>`); } else { html.push(`<li>${escape(entry)}</li>`); } } html.push(`</ul></section>`); } if (ast.meeting?.length) { html.push(`<section><h2>${escape(meetingTitle)}</h2>`); for (const line of ast.meeting) { const trimmed = String(line).trim(); if (trimmed.startsWith("<") && trimmed.endsWith(">")) { html.push(line); } else { html.push(`<p>${line}</p>`); } } html.push(`</section>`); } html.push(`</body></html>`); return html.join("\n"); } module.exports = renderHTML; module.exports.loadTheme = loadTheme;