// moat.mjs — shared base for the AC "HTML moat sites". // These are fast, dependency-free HTML front-doors that wrap the AC runtime: // • laklok.com/html — the Laer Klokken chat client // • prompt.ac — the HTML prompt shell (AC runtime in an iframe) // Both load instantly in plain DOM and talk to AC over its existing wire // protocol, instead of waiting on the full runtime boot. This module holds the // pieces they have in common: in-message token parsing, handle coloring, // relative time, and the iframe bridge that drives a hosted AC runtime. // ── Token parsing ────────────────────────────────────────────────────────── // Mirrors lib/chat-highlighting.mjs: @handles, urls, 'prompts', #paintings, // $kidlisp, *clock. Order matters — longest / most-specific alternative first. const TOKEN = /(@[a-z0-9][a-z0-9._]*|https?:\/\/[^\s]+|'[^']+'|#[a-z0-9]{3,}|\$[a-z0-9]+|\*[a-z0-9]+)/gi; const KIND = { "@": "handle", h: "url", "'": "prompt", "#": "painting", $: "kidlisp", "*": "clock", }; // Split `text` into typed segments. Plain runs come back as { type: "text" }. export function parseTokens(text = "") { const out = []; let last = 0, m; TOKEN.lastIndex = 0; while ((m = TOKEN.exec(text))) { if (m.index > last) out.push({ type: "text", value: text.slice(last, m.index) }); const tok = m[0]; const key = tok[0] === "h" || /^https?:/i.test(tok) ? "h" : tok[0]; out.push({ type: KIND[key] || "text", value: tok }); last = m.index + tok.length; } if (last < text.length) out.push({ type: "text", value: text.slice(last) }); return out; } // Render `text` into a DocumentFragment with colored, optionally-clickable // token spans (classes: t-handle / t-url / t-prompt / t-painting / t-kidlisp / // t-clock — each moat site styles these). `open` defaults to opening the AC // page for handles + kidlisp; pass your own to intercept. export function renderBody(text, open = defaultOpen) { const frag = document.createDocumentFragment(); for (const seg of parseTokens(text)) { if (seg.type === "text") { frag.append(document.createTextNode(seg.value)); continue; } let node; if (seg.type === "url") { node = document.createElement("a"); node.className = "t-url"; node.href = seg.value; node.target = "_blank"; node.rel = "noopener nofollow"; node.textContent = seg.value; } else { node = document.createElement("span"); node.className = "t-" + seg.type; node.textContent = seg.value; if (seg.type === "handle") { node.style.color = handleColor(seg.value.slice(1).toLowerCase()); node.title = "open " + seg.value; node.style.cursor = "pointer"; node.onclick = () => open(seg.value); } else if (seg.type === "kidlisp") { node.style.cursor = "pointer"; node.title = "kidlisp " + seg.value; node.onclick = () => open(seg.value); } } frag.append(node); } return frag; } function defaultOpen(slug) { window.open("https://aesthetic.computer/" + slug.replace(/^[@$#*']+/, (p) => p), "_blank"); } // ── Handle coloring ──────────────────────────────────────────────────────── // Deterministic hue per handle — a stable social-graph color cue without a // round-trip. (Real per-character /api/handle-colors is a later upgrade.) export function handleColor(name) { let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; return `hsl(${h % 360} 75% 72%)`; } // ── Relative time ────────────────────────────────────────────────────────── export function timeAgo(when) { const t = new Date(when).getTime(); const s = Math.max(0, (Date.now() - t) / 1000); if (s < 45) return "now"; if (s < 3600) return Math.round(s / 60) + "m"; if (s < 86400) return Math.round(s / 3600) + "h"; return Math.round(s / 86400) + "d"; } // ── Tiny DOM helper ──────────────────────────────────────────────────────── export function el(tag, props = {}, ...kids) { const node = Object.assign(document.createElement(tag), props); for (const k of kids) node.append(k); return node; } // ── ACFrame — the iframe bridge ──────────────────────────────────────────── // Wraps a hosted AC runtime (