/** * ReactiveVariableManager — mounts as a child of
and reactively * replaces {{$key}} template patterns in the rendered markdown DOM with * live variable values from the journal stream. * * On mount, walks all text nodes in the article content looking for * {{$key}} placeholders. Each match is wrapped in a with a * data-reactive-var attribute so that subsequent updates (driven by * a Solid effect) only touch those spans. */ import { Component, createEffect, onCleanup } from "solid-js"; import { useArticleDom } from "../Article"; import { useJournalStream } from "../stores/journalStream"; const TEMPLATE_RE = /\{\{(\$[a-zA-Z_][a-zA-Z0-9_]*)\}\}/g; /** Tag name used for marker spans — must stay in sync with the scan pass. */ const MARKER = "data-reactive-var"; export const ReactiveVariableManager: Component = () => { const contentDom = useArticleDom(); const stream = useJournalStream(); const values = () => stream.variables; // ---- Initial scan: wrap {{$key}} text in marker spans ---- createEffect(() => { const dom = contentDom(); if (!dom) return; // Only scan once — after the first scan, the spans exist and we // switch to the reactive update effect below. if (dom.querySelector(`[${MARKER}]`)) return; scanAndWrap(dom); }); onCleanup(() => { const dom = contentDom(); if (dom) unwrapAll(dom); }); // ---- Reactive updates: whenever variable values change, update all spans ---- createEffect(() => { const dom = contentDom(); if (!dom) return; const v = values(); const spans = dom.querySelectorAll(`[${MARKER}]`); for (const span of spans) { const template = span.getAttribute(MARKER) ?? ""; let result = template; TEMPLATE_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = TEMPLATE_RE.exec(template)) !== null) { const key = m[1]; // includes $ prefix const resolved = v[key] ?? ""; result = result.replace(`{{${key}}}`, resolved); } if (span.textContent !== result) { span.textContent = result; } } }); return null; // renders nothing — works purely via DOM manipulation }; // --------------------------------------------------------------------------- // DOM helpers // --------------------------------------------------------------------------- /** * Walk all text nodes in a subtree and wrap `{{$key}}` patterns in * `` elements. */ function scanAndWrap(root: Element): void { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const replacements: { node: Text; html: string }[] = []; let textNode: Text | null; while ((textNode = walker.nextNode() as Text | null)) { const text = textNode.textContent; if (!text) continue; TEMPLATE_RE.lastIndex = 0; if (!TEMPLATE_RE.test(text)) continue; // Build replacement HTML: split on {{$key}} and wrap each key span TEMPLATE_RE.lastIndex = 0; const parts: string[] = []; let lastIndex = 0; let m: RegExpExecArray | null; while ((m = TEMPLATE_RE.exec(text)) !== null) { if (m.index > lastIndex) { parts.push(escapeHtml(text.slice(lastIndex, m.index))); } parts.push( `${escapeHtml(m[0])}`, ); lastIndex = TEMPLATE_RE.lastIndex; } if (lastIndex < text.length) { parts.push(escapeHtml(text.slice(lastIndex))); } replacements.push({ node: textNode, html: parts.join("") }); } for (const { node, html } of replacements) { const wrapper = document.createElement("span"); wrapper.innerHTML = html; node.replaceWith(wrapper); } } /** * Reverse scanAndWrap — remove marker spans, restoring plain text. * Called on cleanup so subsequent remounts get a clean slate. */ function unwrapAll(root: Element): void { const spans = root.querySelectorAll(`[${MARKER}]`); // Process in reverse to avoid DOM mutation issues for (let i = spans.length - 1; i >= 0; i--) { const span = spans[i]; const parent = span.parentNode; if (!parent) continue; const text = span.getAttribute(MARKER) ?? span.textContent ?? ""; span.replaceWith(document.createTextNode(text)); } // Normalize to merge adjacent text nodes root.normalize(); } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">"); } function escapeAttr(s: string): string { return s .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } export default ReactiveVariableManager;