184 lines
5.6 KiB
TypeScript
184 lines
5.6 KiB
TypeScript
|
|
/**
|
||
|
|
* ReactiveStatManager — mounts as a child of <Article> 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 <span> 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<string, StatDef>();
|
||
|
|
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<HTMLElement>(`[${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
|
||
|
|
* `<span data-reactive-stat="originalText">` 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(
|
||
|
|
`<span ${MARKER}="${escapeAttr(m[0])}">${escapeHtml(m[0])}</span>`,
|
||
|
|
);
|
||
|
|
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<HTMLElement>(`[${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, "<")
|
||
|
|
.replace(/>/g, ">");
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeAttr(s: string): string {
|
||
|
|
return s
|
||
|
|
.replace(/&/g, "&")
|
||
|
|
.replace(/"/g, """)
|
||
|
|
.replace(/</g, "<")
|
||
|
|
.replace(/>/g, ">");
|
||
|
|
}
|
||
|
|
|
||
|
|
export default ReactiveStatManager;
|