From db86285444aab921663eb1dfce5c84c554204c94 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 11 Jun 2026 16:06:48 +0900 Subject: feat(cache-warming,surfaces,metrics,markdown): conversation-scoped surfaces, cache warming + retention, markdown Consumes the backend cache-warming + cache-rate handoffs end-to-end and adds supporting infra: - protocol/transport: conversation-scoped surfaces (conversationId on subscribe/invoke/surface + staleness routing); store auto-subscribes the catalog with the focused conversation and re-scopes on switch. - surface-host: generic Number field renderer + custom rendererId dispatch (graceful skip on unknown). - cache-warming feature: enabled toggle, min+sec interval, AUTHORITATIVE countdown from the surface's cache-warming-timer nextWarmAt, manual Warm now (POST /chat/warm), lastWarmAt-keyed history, cache-retention stat, expectedCacheRate headline. - metrics: cross-turn expected-cache (retention) derivation + bubble badge; cache-rate fix needs no code change (inputTokens now total). - markdown feature: marked + marked-highlight + highlight.js + dompurify, rendered in ChatView. - fixes (gemini review): {#key activeConversationId} remount of CacheWarmingView to stop history/feedback leaking across tabs; guard NaN interval inputs from committing 0. - docs/contracts: regenerated transport/ui-contract mirrors; backend-handoff updated (CR-3 resolved). Verified: svelte-check 0 errors, biome clean, 494 tests pass, vite build OK. --- src/features/markdown/logic/markdown.ts | 165 ++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/features/markdown/logic/markdown.ts (limited to 'src/features/markdown/logic/markdown.ts') diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts new file mode 100644 index 0000000..3a6e5a6 --- /dev/null +++ b/src/features/markdown/logic/markdown.ts @@ -0,0 +1,165 @@ +/** + * Pure Markdown → sanitized-HTML renderer for assistant messages. + * + * Mirrors old Dispatch's stack (marked + marked-highlight + highlight.js + + * DOMPurify; GFM + line breaks; streaming delimiter-closing), but kept fully + * SYNCHRONOUS and pure: `input → output`, no effects, no `$effect`. Languages + * are a fixed "hot set" registered at module load (no lazy dynamic import), so a + * single `renderMarkdown(text)` call is deterministic and unit-testable. + * + * The only ambient dependency is DOMPurify, which sanitizes against the DOM — + * present in the browser and in the jsdom test env. + */ + +import DOMPurify from "dompurify"; +import type { LanguageFn } from "highlight.js"; +import hljs from "highlight.js/lib/core"; +import bash from "highlight.js/lib/languages/bash"; +import c from "highlight.js/lib/languages/c"; +import cpp from "highlight.js/lib/languages/cpp"; +import csharp from "highlight.js/lib/languages/csharp"; +import css from "highlight.js/lib/languages/css"; +import go from "highlight.js/lib/languages/go"; +import java from "highlight.js/lib/languages/java"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import markdownLang from "highlight.js/lib/languages/markdown"; +import php from "highlight.js/lib/languages/php"; +import plaintext from "highlight.js/lib/languages/plaintext"; +import python from "highlight.js/lib/languages/python"; +import ruby from "highlight.js/lib/languages/ruby"; +import rust from "highlight.js/lib/languages/rust"; +import shell from "highlight.js/lib/languages/shell"; +import sql from "highlight.js/lib/languages/sql"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import yaml from "highlight.js/lib/languages/yaml"; +import { Marked } from "marked"; +import { markedHighlight } from "marked-highlight"; + +// Hot set: registered eagerly so common code blocks highlight on first paint. +const HOT_LANGUAGES: Record = { + bash, + c, + cpp, + csharp, + css, + go, + java, + javascript, + json, + markdown: markdownLang, + php, + plaintext, + python, + ruby, + rust, + shell, + sql, + typescript, + xml, + yaml, +}; +for (const [name, lang] of Object.entries(HOT_LANGUAGES)) { + hljs.registerLanguage(name, lang); +} + +// Normalize common fence aliases to canonical highlight.js names. +const ALIASES: Record = { + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + tsx: "typescript", + py: "python", + py3: "python", + rb: "ruby", + sh: "bash", + zsh: "bash", + yml: "yaml", + "c++": "cpp", + cxx: "cpp", + "c#": "csharp", + cs: "csharp", + htm: "xml", + html: "xml", + svg: "xml", + md: "markdown", + mdx: "markdown", + golang: "go", + rs: "rust", +}; + +function normalizeLang(lang: string): string { + const lower = lang.toLowerCase().trim(); + return ALIASES[lower] ?? lower; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const md = new Marked( + markedHighlight({ + emptyLangClass: "hljs", + langPrefix: "hljs language-", + highlight(code: string, lang: string): string { + if (!lang) return escapeHtml(code); + const name = normalizeLang(lang); + if (!hljs.getLanguage(name)) return escapeHtml(code); + try { + return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; + } catch { + return escapeHtml(code); + } + }, + }), + { gfm: true, breaks: true }, +); + +/** + * While a message is still streaming, balance dangling fences / emphasis so the + * partial text renders cleanly instead of flashing raw markers. + */ +function closeOpenDelimiters(src: string): string { + let out = src; + const fenceCount = (out.match(/^```/gm) ?? []).length; + if (fenceCount % 2 !== 0) out += "\n```"; + const boldCount = (out.match(/\*\*/g) ?? []).length; + if (boldCount % 2 !== 0) out += "**"; + const inlineCode = (out.match(/(?…`) in a positioned container with a +// copy button. marked emits exactly one `
`/`
` pair per block and +// escapes `<`/`>` inside code, so these literal tags only ever delimit blocks. +// `data-copy` is the delegation hook the component listens for; DOMPurify keeps +// `'; + +function addCopyButtons(html: string): string { + return html + .replace(/
/g, `
${COPY_BUTTON}
`)
+		.replace(/<\/pre>/g, "
"); +} + +/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ +export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { + const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; + try { + const raw = md.parse(src) as string; + return DOMPurify.sanitize(addCopyButtons(raw)); + } catch { + return ""; + } +} -- cgit v1.2.3