/** * ReactiveStatManager — mounts as a child of
and reactively * replaces ${key} template patterns in the rendered markdown DOM with * live stat 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-stat attribute so that subsequent updates (driven by * a Solid effect) only touch those spans. */ import { Component, createEffect, onCleanup, createMemo } from "solid-js"; import { useArticleDom } from "../Article"; import { useJournalStream } from "../stores/journalStream"; import { useJournalCompletions } from "../journal/completions"; import { fullKey } from "../journal/stat-helpers"; import type { StatDef } from "../journal/completions"; const TEMPLATE_RE = /\$\{(\w+)\}/g; /** Tag name used for marker spans — must stay in sync with the scan pass. */ const MARKER = "data-reactive-stat"; export const ReactiveStatManager: Component = () => { const contentDom = useArticleDom(); const stream = useJournalStream(); const comp = useJournalCompletions(); const statDefs = createMemo(() => comp.data.stats); const values = createMemo(() => stream.stats); /** Build a lookup: bare key -> StatDef (for scope-aware resolution) */ const bareDefMap = createMemo(() => { const map = new Map(); for (const def of statDefs()) { if (!map.has(def.key)) { map.set(def.key, def); } } return map; }); /** * Resolve a bare key from markdown template to its runtime value. * Uses StatDef scoping when available, falls back to player-first, global-second. */ function resolveBareKey(bareKey: string): string { const bareDef = bareDefMap().get(bareKey); if (bareDef) { const fk = fullKey(bareDef, stream.myName); return values()[fk] ?? bareDef.default ?? ""; } const pk = `${stream.myName}:${bareKey}`; if (values()[pk] !== undefined) return values()[pk]; return values()[bareKey] ?? ""; } // ---- 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 stat 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) { result = result.replace(`\${${m[1]}}`, resolveBareKey(m[1])); } 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 ReactiveStatManager;