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.
313 lines (255 loc) • 8.51 kB
JavaScript
const { collectMeetingHeadings } = require("./headingUtils");
function resolveReferences(ast, options = {}) {
const get = (group, id) => {
if (!ast[group] || !ast[group][id]) {
if (options.strict)
throw new Error(`Unresolved reference @${group}=${id}`);
return { id, unresolved: true };
}
const value = ast[group][id];
if (value && typeof value === "object" && !Array.isArray(value)) {
return { id, ...value };
}
return { id, label: value };
};
const groupAliases = {
participant: "participants",
participants: "participants",
subject: "subjects",
subjects: "subjects",
tag: "tags",
tags: "tags",
meta: "meta",
signature: "signatures",
signatures: "signatures",
approval: "approvals",
approvals: "approvals",
};
const resolveMetaValue = (key) => {
if (ast.meta?.[key] != null) {
return ast.meta[key];
}
if (options.strict) {
throw new Error(`@=meta:${key} not found`);
}
return key;
};
const resolveStructuredReference = (expression) => {
const parts = String(expression || "").split(":").map((part) => part.trim()).filter(Boolean);
if (!parts.length) {
return expression;
}
const group = groupAliases[parts[0]] || parts[0];
if (group === "meta") {
return resolveMetaValue(parts[1]);
}
const id = parts[1];
const field = parts[2];
if (!id) {
return expression;
}
const target = ast[group]?.[id];
if (target == null) {
if (options.strict) {
throw new Error(`@=${expression} not found`);
}
return expression;
}
if (field) {
if (target && typeof target === "object" && target[field] != null) {
return target[field];
}
if (field === "id") {
return id;
}
if (!field && typeof target !== "object") {
return target;
}
if (options.strict) {
throw new Error(`@=${expression} field not found`);
}
return expression;
}
if (typeof target === "object") {
return target.label || target.name || target.title || target.status || target.role || id;
}
return target;
};
const replaceInlineReferences = (line) => {
let resolved = String(line);
resolved = resolved.replace(/@=([A-Za-z0-9_:-]+)/g, (_, expression) => {
return resolveStructuredReference(expression);
});
resolved = resolved.replace(/@=([A-Za-z0-9_-]+)/g, (_, id) => {
if (ast.subjects?.[id]) {
return ast.subjects[id];
}
if (ast.participants?.[id]) {
return ast.participants[id].name;
}
if (ast.tags?.[id]) {
return ast.tags[id];
}
if (options.strict) {
throw new Error(`@=${id} not found`);
}
return id;
});
return resolved;
};
const renderSignature = (id) => {
const signature = ast.signatures?.[id];
if (!signature) {
if (options.strict) {
throw new Error(`@=${id} not found`);
}
return `@=${id}`;
}
const parts = [
signature.name ? `<strong>${signature.name}</strong>` : "",
signature.role ? `<span class="signature-role">${signature.role}</span>` : "",
signature.date ? `<span class="signature-date">${signature.date}</span>` : "",
signature.note ? `<span class="signature-note">${signature.note}</span>` : "",
].filter(Boolean).join("<br>");
return `<div class="signature-block"><h3>Signature</h3>${parts}</div>`;
};
const renderApproval = (id) => {
const approval = ast.approvals?.[id];
if (!approval) {
if (options.strict) {
throw new Error(`@=${id} not found`);
}
return `@=${id}`;
}
const parts = [
approval.label ? `<strong>${approval.label}</strong>` : "",
approval.status ? `<span class="approval-status">${approval.status}</span>` : "",
approval.by ? `<span class="approval-by">${approval.by}</span>` : "",
approval.date ? `<span class="approval-date">${approval.date}</span>` : "",
approval.notes ? `<span class="approval-notes">${approval.notes}</span>` : "",
].filter(Boolean).join("<br>");
return `<div class="approval-block"><h3>Approval</h3>${parts}</div>`;
};
const renderToc = (block, maxLevel = 3) => {
const headings = collectMeetingHeadings(block, maxLevel);
if (!headings.length) {
return `<nav class="toc"><strong>Table of Contents</strong><p class="toc-empty">(no headings)</p></nav>`;
}
const items = headings
.map((heading) => `<li class="toc-level-${heading.level}"><a href="#${heading.id}">${heading.text}</a></li>`)
.join("");
return `<nav class="toc"><strong>Table of Contents</strong><ul>${items}</ul></nav>`;
};
const resolveImportedContent = (name) => {
const entry = ast._importCache?.[name];
if (!entry) {
if (options.strict) {
throw new Error(`@=${name} not found`);
}
return null;
}
return entry.content;
};
const expandMacros = (block) => {
return block.flatMap((line) => {
const match = line.match(/@=([\w-]+):(.*)/);
if (!match || !ast._macroCache) return [line];
const [_, macroName, rawParams] = match;
const template = ast._macroCache[macroName];
if (!template) return [line];
const params = {};
rawParams.split(";").forEach((p) => {
const [k, v] = p.split("=");
params[k.trim()] = v.trim();
});
const rendered = template.replace(/\{\{(.*?)\}\}/g, (_, key) => {
const val = params[key] || "";
if (val.startsWith("@@e=") || val.startsWith("@@ref=")) {
return replaceInlineReferences(val);
}
return val;
});
return [rendered];
});
};
const expandImports = (block) => {
return block.flatMap((line) => {
const match = line.match(/^@@(?:import|output)=([^\s]+)$/);
if (!match) return [line];
const content = resolveImportedContent(match[1]);
if (content == null) return [line];
if (Array.isArray(content)) return content;
return content.split(/\r?\n/);
});
};
const expandMeetingCommands = (block) => {
return block.map((line) => {
const trimmed = String(line).trim();
const tocMatch = trimmed.match(/^@?$/);
if (tocMatch) {
return renderToc(block, Number(tocMatch[1] || 3));
}
const signatureMatch = trimmed.match(/^@=([A-Za-z0-9_-]+)$/);
if (signatureMatch) {
return renderSignature(signatureMatch[1]);
}
const approvalMatch = trimmed.match(/^@=([A-Za-z0-9_-]+)$/);
if (approvalMatch) {
return renderApproval(approvalMatch[1]);
}
return replaceInlineReferences(line);
});
};
const computeTagStats = () => {
if (!ast.tags) return;
const stats = {};
for (const id in ast.tags) {
stats[id] = {
id,
label: ast.tags[id],
total: 0,
open: 0,
done: 0,
};
}
if (Array.isArray(ast.tasks)) {
for (const task of ast.tasks) {
const tagId = task.meta?.tag;
if (!tagId) continue;
if (!stats[tagId]) {
stats[tagId] = {
id: tagId,
label: ast.tags?.[tagId] || tagId,
total: 0,
open: 0,
done: 0,
};
}
stats[tagId].total += 1;
if (task.done) stats[tagId].done += 1;
else stats[tagId].open += 1;
}
}
ast.tag_stats = stats;
};
if (Array.isArray(ast.tasks)) {
ast.tasks = ast.tasks.map((task) => {
const out = { ...task };
const ptpMatch = task.raw.match(/=([^\s]+)/);
const subjMatch = [...task.raw.matchAll(/(?:\s|^)=([^\s]+)/g)].pop();
const tagMatch = task.raw.match(/=([^\s]+)/);
if (ptpMatch) out.assigned_to = get("participants", ptpMatch[1]);
if (subjMatch) out.subject = get("subjects", subjMatch[1]);
if (tagMatch) out.tag = get("tags", tagMatch[1]);
return out;
});
}
if (Array.isArray(ast.meeting)) {
ast.meeting = expandImports(ast.meeting);
ast.meeting = expandMeetingCommands(ast.meeting);
ast.meeting = expandMacros(ast.meeting);
}
computeTagStats();
return ast;
}
module.exports = {resolveReferences};