┌─ TS ───────────────────────────────────────────────────────────────────────┐
│ // ```mermaid fences: linear chains render through the house chain │
│ // layout (flow.ts); anything beyond the parsed subset - branching, │
│ // subgraphs, RL/BT direction - shells out to mermaid-ascii from the │
│ // dev shell. Mermaid is the one authoring syntax because Obsidian and │
│ // GitHub preview it natively. │
│ import { execFileSync } from "node:child_process"; │
│ import { renderChain, type FlowGraph } from "./flow"; │
│ │
│ const HEADER_RE = /^(?:graph|flowchart)\s+(LR|RL|TD|TB|BT)\s*$/; │
│ const NODE_TOKEN = /^([A-Za-z0-9_.-]+)(?:[\[({]+([^\]})]+)[\])}]+)?/; │
│ const ARROW_TOKEN = /^-->(?:\|([^|]*)\|)?/; │
│ │
│ export interface ParsedMermaid { │
│ graph: FlowGraph; │
│ direction: string; │
│ } │
│ │
│ /** Parses the simple flowchart subset; null means "let the binary │
│ * handle it" - it supports far more syntax than this. */ │
│ export function parseMermaid(source: string): ParsedMermaid | null { │
│ const lines = source │
│ .split("\n") │
│ .map((l) => l.trim()) │
│ .filter((l) => l && !l.startsWith("%%")); │
│ if (!lines.length) return null; │
│ │
│ const header = lines[0].match(HEADER_RE); │
│ if (!header) return null; │
│ const direction = header[1]; │
│ if (direction === "RL" || direction === "BT") return null; │
│ │
│ const labels = new Map<string, string>(); │
│ const edges: FlowGraph["edges"] = []; │
│ for (const line of lines.slice(1)) { │
│ let rest = line; │
│ const first = rest.match(NODE_TOKEN); │
│ if (!first) return null; │
│ let prev = first[1]; │
│ if (first[2]) labels.set(prev, first[2].trim()); │
│ rest = rest.slice(first[0].length).trimStart(); │
│ while (rest) { │
│ const arrow = rest.match(ARROW_TOKEN); │
│ if (!arrow) return null; │
│ rest = rest.slice(arrow[0].length).trimStart(); │
│ const node = rest.match(NODE_TOKEN); │
│ if (!node || !node[1]) return null; │
│ if (node[2]) labels.set(node[1], node[2].trim()); │
│ edges.push({ from: prev, to: node[1], label: (arrow[1] ?? "").trim() }); │
│ prev = node[1]; │
│ rest = rest.slice(node[0].length).trimStart(); │
│ } │
│ } │
│ return edges.length ? { graph: { labels, edges }, direction } : null; │
│ } │
│ │
│ // Arrowhead glyphs the frame font doesn't cover, swapped for on-grid │
│ // equivalents. │
│ const ARROWS: Record<string, string> = { │
│ "?": "↓", "?": "↑", "?": ">", "?": ">", "?": "<", "?": "<", │
│ }; │
│ │
│ export function normalizeArt(art: string): string { │
│ return art │
│ .replace(/[??????]/gu, (c) => ARROWS[c]) │
│ .split("\n") │
│ .map((l) => l.replace(/\s+$/, "")) │
│ .join("\n") │
│ .replace(/^\n+|\n+$/g, ""); │
│ } │
│ │
│ function runMermaidAscii(source: string): string { │
│ try { │
│ return execFileSync( │
│ "mermaid-ascii", │
│ ["-f", "-", "--borderPadding", "0", "--paddingX", "5", "--paddingY", "2"], │
│ { input: source, encoding: "utf8" }, │
│ ); │
│ } catch (e) { │
│ const err = e as NodeJS.ErrnoException & { stderr?: string }; │
│ if (err.code === "ENOENT") { │
│ throw new Error("[mermaid] mermaid-ascii not found - is it in the dev shell? │
│ "); │
│ } │
│ throw new Error(`[mermaid] render failed: ${err.stderr || err.message}`); │
│ } │
│ } │
│ │
│ export function mermaidToAscii(source: string, width: number): string { │
│ const parsed = parseMermaid(source); │
│ if (parsed) { │
│ const art = renderChain(parsed.graph, width, parsed.direction === "LR"); │
│ if (art) return art; │
│ } │
│ return normalizeArt(runMermaidAscii(source)); │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ TS ─────────────────────────────────┐
│ // ```mermaid fences: linear chains render t │
│ hrough the house chain │
│ // layout (flow.ts); anything beyond the par │
│ sed subset - branching, │
│ // subgraphs, RL/BT direction - shells out t │
│ o mermaid-ascii from the │
│ // dev shell. Mermaid is the one authoring s │
│ yntax because Obsidian and │
│ // GitHub preview it natively. │
│ import { execFileSync } from "node:child_pro │
│ cess"; │
│ import { renderChain, type FlowGraph } from │
│ "./flow"; │
│ │
│ const HEADER_RE = /^(?:graph|flowchart)\s+(L │
│ R|RL|TD|TB|BT)\s*$/; │
│ const NODE_TOKEN = /^([A-Za-z0-9_.-]+)(?:[\[ │
│ ({]+([^\]})]+)[\])}]+)?/; │
│ const ARROW_TOKEN = /^-->(?:\|([^|]*)\|)?/; │
│ │
│ export interface ParsedMermaid { │
│ graph: FlowGraph; │
│ direction: string; │
│ } │
│ │
│ /** Parses the simple flowchart subset; null │
│ means "let the binary │
│ * handle it" - it supports far more syntax │
│ than this. */ │
│ export function parseMermaid(source: string) │
│ : ParsedMermaid | null { │
│ const lines = source │
│ .split("\n") │
│ .map((l) => l.trim()) │
│ .filter((l) => l && !l.startsWith("%%")) │
│ ; │
│ if (!lines.length) return null; │
│ │
│ const header = lines[0].match(HEADER_RE); │
│ if (!header) return null; │
│ const direction = header[1]; │
│ if (direction === "RL" || direction === "B │
│ T") return null; │
│ │
│ const labels = new Map<string, string>(); │
│ const edges: FlowGraph["edges"] = []; │
│ for (const line of lines.slice(1)) { │
│ let rest = line; │
│ const first = rest.match(NODE_TOKEN); │
│ if (!first) return null; │
│ let prev = first[1]; │
│ if (first[2]) labels.set(prev, first[2]. │
│ trim()); │
│ rest = rest.slice(first[0].length).trimS │
│ tart(); │
│ while (rest) { │
│ const arrow = rest.match(ARROW_TOKEN); │
│ if (!arrow) return null; │
│ rest = rest.slice(arrow[0].length).tri │
│ mStart(); │
│ const node = rest.match(NODE_TOKEN); │
│ if (!node || !node[1]) return null; │
│ if (node[2]) labels.set(node[1], node[ │
│ 2].trim()); │
│ edges.push({ from: prev, to: node[1], │
│ label: (arrow[1] ?? "").trim() }); │
│ prev = node[1]; │
│ rest = rest.slice(node[0].length).trim │
│ Start(); │
│ } │
│ } │
│ return edges.length ? { graph: { labels, e │
│ dges }, direction } : null; │
│ } │
│ │
│ // Arrowhead glyphs the frame font doesn't c │
│ over, swapped for on-grid │
│ // equivalents. │
│ const ARROWS: Record<string, string> = { │
│ "?": "↓", "?": "↑", "?": ">", "?": ">", "? │
│ ": "<", "?": "<", │
│ }; │
│ │
│ export function normalizeArt(art: string): s │
│ tring { │
│ return art │
│ .replace(/[??????]/gu, (c) => ARROWS[c]) │
│ .split("\n") │
│ .map((l) => l.replace(/\s+$/, "")) │
│ .join("\n") │
│ .replace(/^\n+|\n+$/g, ""); │
│ } │
│ │
│ function runMermaidAscii(source: string): st │
│ ring { │
│ try { │
│ return execFileSync( │
│ "mermaid-ascii", │
│ ["-f", "-", "--borderPadding", "0", "- │
│ -paddingX", "5", "--paddingY", "2"], │
│ { input: source, encoding: "utf8" }, │
│ ); │
│ } catch (e) { │
│ const err = e as NodeJS.ErrnoException & │
│ { stderr?: string }; │
│ if (err.code === "ENOENT") { │
│ throw new Error("[mermaid] mermaid-asc │
│ ii not found - is it in the dev shell?"); │
│ } │
│ throw new Error(`[mermaid] render failed │
│ : ${err.stderr || err.message}`); │
│ } │
│ } │
│ │
│ export function mermaidToAscii(source: strin │
│ g, width: number): string { │
│ const parsed = parseMermaid(source); │
│ if (parsed) { │
│ const art = renderChain(parsed.graph, wi │
│ dth, parsed.direction === "LR"); │
│ if (art) return art; │
│ } │
│ return normalizeArt(runMermaidAscii(source │
│ )); │
│ } │
└──────────────────────────────────────────────┘