function parse(md) {
const nodes = [];
const labels = [];
const errors = [];
const langs = [];
const dims = {};
const marks = [];
const preamble = { end: md.length, ruled: false }; const lines = [];
for (let start = 0; start < md.length; ) {
const nl = md.indexOf('\n', start);
const end = nl === -1 ? md.length : nl + 1;
lines.push({ start, end, text: md.slice(start, end).replace(/\r?\n$/, '') });
start = end;
}
let fence = null;
let node = null;
const scopes = []; const scope = block => Object.fromEntries(scopes.filter(s => s.block === block).map(s => [s.key, s.value]));
const inCard = () => scopes.some(s => !s.block);
lines.forEach((line, i) => {
const lineNo = i + 1;
const f = /^ {0,3}(`{3,}|~{3,})/.exec(line.text);
if (fence) {
if (f && f[1][0] === fence[0] && f[1].length >= fence.length && line.text.trim() === f[1]) fence = null;
} else if (f) {
fence = f[1];
} else {
const h = /^ {0,3}#{1,6}(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/d.exec(line.text);
const rule = /^ {0,3}-{3,}[ \t]*$/.test(line.text);
const d = /^ {0,3}<div ([\w-]+)="([\w-]+)">[ \t]*$/.exec(line.text);
const block = d !== null && d[1] !== 'lang' && !inCard() && (!node || /^ {0,3}#{1,6}(?:[ \t]|$)/.test(lines.find((l, j) => j > i && l.text.trim() !== '')?.text ?? ''));
if (preamble.end === md.length && (h || block)) preamble.end = line.start;
else if (preamble.end === md.length && line.text.trim() !== '') preamble.ruled = rule;
const noop = h?.[1]?.startsWith('-'); if (noop && inCard()) return;
if (noop) {
if (node) node.end = line.start;
node = null;
return;
}
if (h && inCard()) {
const t = heading(h[1] ?? '', 0, line.start, lineNo, errors);
const lang = scope(false).lang;
if (scopes.some(s => !s.block && s.key !== 'lang')) return errors.push({ line: lineNo, msg: 'a heading inside a variant scope' });
if (t.labels.length) errors.push({ line: lineNo, msg: 'labels go on the main heading' });
if (lang in node.titles) errors.push({ line: lineNo, msg: `a second \`${lang}\` title` });
else node.titles[lang] = t.title;
return;
}
if (h) {
if (node) node.end = line.start;
node = heading(h[1] ?? '', line.start + (h.indices[1]?.[0] ?? line.text.length), line.start, lineNo, errors);
node.scope = scope(true);
nodes.push(node);
return;
}
const r = /^ {0,3}(?:[-*+]|\d+[.)])[ \t]+\[([^\]]*)\]\(#([^)\s]+)\)[ \t]*$/.exec(line.text);
if (r && node) {
node.responses.push({ label: r[1], slot: decodeURIComponent(r[2]), scope: scope(false), line: lineNo, start: line.start, end: line.end });
return;
}
if (d && (node || block)) {
const [, key, value] = d;
if (block && node) node.end = line.start;
if (block) node = null;
if (scopes.some(s => s.key === key)) errors.push({ line: lineNo, msg: `\`<div ${key}>\` inside another` });
scopes.push({ key, value, line: lineNo, block });
const values = key === 'lang' ? langs : (dims[key] ??= []);
if (!values.includes(value)) values.push(value);
return;
}
if (scopes.length && /^ {0,3}<\/div>[ \t]*$/.test(line.text)) {
if (scopes.pop().block && node) {
node.end = line.start;
node = null;
}
return;
}
const l = /^\[([\w-]+)\]:[ \t]+<(oklch\(\d*\.?\d+ \d*\.?\d+ \d*\.?\d+\))>(?:[ \t]+"((?:[^"\\]|\\.)*)")?[ \t]*$/.exec(line.text);
if (l && !node) {
if (labels.some(d => d.name === l[1])) errors.push({ line: lineNo, msg: `label \`${l[1]}\` defined twice` });
else labels.push({ name: l[1], color: l[2], description: (l[3] ?? '').replace(/\\(.)/g, '$1'), line: lineNo, start: line.start, end: line.end });
return;
}
const m = /^\[(?:([\w-]+)=([\w-]+)|lang="([\w-]+)")\]:[ \t]+#[ \t]*$/.exec(line.text);
if (m && !node) {
const dim = m[1] ?? null;
if (marks.some(k => k.dim === dim)) errors.push({ line: lineNo, msg: `a second mark for \`${dim ?? 'lang'}\`` });
else marks.push({ dim, value: m[2] ?? m[3], line: lineNo, start: line.start, end: line.end });
return;
}
if (rule && !node) return;
}
if (node) node.body.push({ text: line.text, scope: scope(false) });
else if (line.text.trim() !== '') errors.push({ line: lineNo, msg: 'text outside any card' });
});
for (const s of scopes) errors.push({ line: s.line, msg: `\`<div ${s.key}="${s.value}">\` not closed` });
if (node) node.end = md.length;
if (nodes.length === 0 && errors.length === 0) errors.push({ line: 1, msg: 'no headings' });
const byId = new Map();
for (const n of nodes) {
for (const l of n.labels) if (!labels.some(d => d.name === l)) errors.push({ line: n.line, msg: `unknown label \`.${l}\`` });
if (byId.has(n.id)) errors.push({ line: n.line, msg: `duplicate of line ${byId.get(n.id).line}: same slot` });
else byId.set(n.id, n);
}
const edges = [];
for (const n of nodes) {
for (const r of n.responses) {
if (r.label.trim() === '' && n.responses.some(o => o !== r && Object.entries(o.scope).every(([k, v]) => (r.scope[k] ?? v) === v))) errors.push({ line: r.line, msg: "an unlabeled response must be its card's only one" });
if (!byId.has(r.slot)) {
errors.push({ line: r.line, msg: `response links to unknown slot #${r.slot}` });
continue;
}
edges.push({ from: n.id, to: r.slot, label: r.label, scope: r.scope, start: r.start, end: r.end });
}
delete n.responses;
}
for (const vs of Object.values(dims)) vs.sort();
const graph = { nodes, edges, labels, marks, preamble, langs, dims, errors, warnings: [] };
if (errors.length === 0) {
const reached = visible(graph, {});
for (const n of nodes.filter(n => !reached.has(n.id))) {
n.stray = true;
graph.warnings.push({ line: n.line, msg: `"${n.title}" is unreachable from the first card` });
}
}
for (const m of marks) if (!(m.dim === null ? langs : (dims[m.dim] ?? [])).includes(m.value)) errors.push({ line: m.line, msg: `no \`<div ${m.dim ?? 'lang'}="${m.value}">\`` });
return graph;
}
function heading(raw, at, start, line, errors) {
const labels = [];
let title = raw.trim();
const attrs = { start: at + title.length, end: at + title.length };
const t = /\s*\{\s*((?:[\w-]+=[\w-]+|\.[\w-]+)(?:\s+(?:[\w-]+=[\w-]+|\.[\w-]+))*)\s*\}$/.exec(title);
if (t) {
title = title.slice(0, t.index);
attrs.start = at + t.index;
for (const token of t[1].split(/\s+/)) {
if (token.startsWith('.')) {
if (labels.includes(token.slice(1))) errors.push({ line, msg: `label \`${token}\` given twice` });
else labels.push(token.slice(1));
continue;
}
const [k, v] = token.split('=');
errors.push({ line, msg: `\`${token}\` on a heading: variants are \`<div ${k}="${v}">\` scopes` });
}
}
if (title === '') errors.push({ line, msg: 'empty heading' });
const id = title.toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');
return { id, title, titles: {}, labels, attrs, line, start, end: null, body: [], responses: [], stray: false };
}
function visible(graph, selection) {
const out = new Map();
for (const e of graph.edges.filter(e => inScope(e, selection))) {
if (!out.has(e.from)) out.set(e.from, []);
out.get(e.from).push(e.to);
}
const admitted = new Set(graph.nodes.filter(n => inScope(n, selection)).map(n => n.id));
const seen = new Set();
const stack = graph.nodes.filter((n, i) => i === 0 || n.stray).map(n => n.id); while (stack.length) {
const id = stack.pop();
if (seen.has(id) || !admitted.has(id)) continue;
seen.add(id);
stack.push(...(out.get(id) ?? []));
}
return seen;
}
function inScope(item, selection) {
return Object.entries(item.scope).every(([k, v]) => ((k === 'lang' ? selection.lang : selection.variants?.[k]) ?? v) === v);
}
if (typeof module !== 'undefined') module.exports = { parse, visible, inScope };