From 200120ebd06c1fc91170450f927cf8ad9cd53d20 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Thu, 13 Aug 2026 00:01:43 -0700 Subject: [PATCH] feat(site): the playground renders the answer Model answers are markdown; render them. A small hand-rolled renderer (site/md.js) does headings, bold/italic, inline code, fenced code blocks, lists, links, blockquotes and tables in the codebase's vanilla, stdlib-only style - no vendored library, per DESIGN.md/TASTE.md. Model output is treated as hostile: every byte is HTML-escaped before it is emitted, and link hrefs are dropped unless they are http(s) or mailto, so a javascript: or data: URL never becomes an href. The streamed buffer is re-rendered per chunk and the renderer tolerates half-written markdown mid-stream without throwing. UX, in-contract with the terminal palette: a copy button on each answer and on every code block, a raw/rendered toggle so the exact markdown is one click away, the reasoning stream still shown dim, and the command panel kept prominent and in sync - it still shows the equivalent deepseek invocation for whatever is configured. Tests: md.test.js unit-tests the renderer including the two security cases (\n' + '\n' ), )) diff --git a/site/md.js b/site/md.js new file mode 100644 index 0000000..58d2cfd --- /dev/null +++ b/site/md.js @@ -0,0 +1,220 @@ +// A small, hostile-input-safe markdown renderer for the playground. +// +// Model output is markdown and it is untrusted, so this file has one +// non-negotiable job: never let a byte the model sent reach the page as +// HTML. Every tag emitted is written here; every run of model text is +// escaped exactly once before it is emitted, and link targets are dropped +// unless they are http(s) or mailto. There is no path that passes raw +// model HTML through. +// +// It is deliberately small. The playground streams tokens and re-renders +// the whole buffer per chunk, so the renderer also has to tolerate +// half-written markdown mid-stream without throwing: an unclosed fence is +// a code block to end of input, an unmatched `*` is a literal asterisk. +// +// Same dual export shape as pow.js: a browser global, or module.exports +// under node so md.test.js can require it. +(function (global) { + 'use strict'; + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // Only http(s) and mailto survive. A javascript:, data:, vbscript: or + // file: URL is dropped and the link renders as plain text. A target with + // no scheme (a relative path or #anchor) is harmless and kept. Control + // characters are stripped before the scheme test so a smuggled scheme can + // not slip past it; the value is escaped on the way out regardless, which + // neutralises entity-encoded schemes too. + function safeHref(href) { + var raw = String(href).trim(); + var probe = raw.replace(/[\u0000-\u0020]+/g, '').toLowerCase(); + if (/^(https?:\/\/|mailto:)/.test(probe)) return raw; + if (/^[a-z][a-z0-9+.\-]*:/.test(probe)) return null; + return raw; + } + + // Placeholders for lifted-out spans. Control characters that can not + // appear in the escaped text and survive escapeHtml untouched. + var CODE = '\u0000'; + var LINK = '\u0001'; + + // Inline spans. Code spans are lifted out first (they suppress every + // other construct inside them), then links (captured before escaping so + // the href is clean), then the remaining text is escaped once and + // emphasis is applied to it. + function inline(src) { + var codes = []; + var text = String(src).replace(/(`+)([\s\S]*?)\1/g, function (m, ticks, code) { + codes.push('' + escapeHtml(code.replace(/^ | $/g, '')) + ''); + return CODE + (codes.length - 1) + CODE; + }); + + var links = []; + text = text.replace(/\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g, function (m, label, href) { + links.push({ label: label, href: safeHref(href) }); + return LINK + (links.length - 1) + LINK; + }); + + text = escapeHtml(text); + + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + text = text.replace(/__([^_]+)__/g, '$1'); + text = text.replace(/\*([^*\s][^*]*?)\*/g, '$1'); + text = text.replace(/(^|[^a-zA-Z0-9_])_([^_]+)_(?![a-zA-Z0-9_])/g, '$1$2'); + + text = text.replace(new RegExp(LINK + '(\\d+)' + LINK, 'g'), function (m, n) { + var l = links[+n]; + var lbl = escapeHtml(l.label); + if (l.href === null) return lbl; + return '' + lbl + ''; + }); + + text = text.replace(new RegExp(CODE + '(\\d+)' + CODE, 'g'), function (m, n) { + return codes[+n]; + }); + + return text; + } + + function codeBlock(code, lang) { + return '
' + + '' + + '
' +
+      escapeHtml(code) + '
'; + } + + function splitRow(row) { + return row.replace(/^\s*\|?/, '').replace(/\|?\s*$/, '').split('|').map(function (c) { + return c.trim(); + }); + } + + var FENCE_CLOSE = /^\s*(```+|~~~+)\s*$/; + + function render(src) { + var lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n'); + var out = []; + var i = 0; + + while (i < lines.length) { + var line = lines[i]; + + // Fenced code. An unclosed fence runs to the end of the buffer, which + // is the common mid-stream case and must not throw. + var fence = line.match(/^\s*(```+|~~~+)(.*)$/); + if (fence) { + var marker = fence[1].charAt(0); + var buf = []; + i++; + while (i < lines.length && + !(FENCE_CLOSE.test(lines[i]) && lines[i].replace(/^\s*/, '').charAt(0) === marker)) { + buf.push(lines[i]); + i++; + } + i++; // consume the closing fence when there is one + var lang = fence[2].trim().replace(/[^a-zA-Z0-9_+\-]/g, ''); + out.push(codeBlock(buf.join('\n'), lang)); + continue; + } + + if (/^\s*$/.test(line)) { i++; continue; } + + var h = line.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/); + if (h) { + var level = h[1].length; + out.push('' + inline(h[2]) + ''); + i++; + continue; + } + + if (/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(line)) { + out.push('
'); + i++; + continue; + } + + if (/^\s*>/.test(line)) { + var qbuf = []; + while (i < lines.length && /^\s*>/.test(lines[i])) { + qbuf.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + out.push('
' + render(qbuf.join('\n')) + '
'); + continue; + } + + // Pipe table: a header row, a separator row of dashes, then body rows. + if (line.indexOf('|') >= 0 && i + 1 < lines.length && + /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1]) && + lines[i + 1].indexOf('|') >= 0) { + var header = splitRow(line); + var aligns = splitRow(lines[i + 1]).map(function (c) { + var l = c.charAt(0) === ':'; + var r = c.charAt(c.length - 1) === ':'; + return r && l ? 'center' : r ? 'right' : l ? 'left' : ''; + }); + i += 2; + var rows = []; + while (i < lines.length && lines[i].indexOf('|') >= 0 && !/^\s*$/.test(lines[i])) { + rows.push(splitRow(lines[i])); + i++; + } + var thead = '' + header.map(function (c, k) { + return '' + inline(c) + ''; + }).join('') + ''; + var tbody = '' + rows.map(function (r) { + return '' + header.map(function (_, k) { + return '' + inline(r[k] || '') + ''; + }).join('') + ''; + }).join('') + ''; + out.push('' + thead + tbody + '
'); + continue; + } + + // List. Flat only: nested items render as one level, which is enough + // for chat output and never throws on ragged indentation. + var lm = line.match(/^\s*([-*+]|\d+[.)])\s+/); + if (lm) { + var ordered = /\d/.test(lm[1]); + var items = []; + while (i < lines.length) { + var im = lines[i].match(/^\s*([-*+]|\d+[.)])\s+(.*)$/); + if (!im) break; + items.push('
  • ' + inline(im[2]) + '
  • '); + i++; + } + var tag = ordered ? 'ol' : 'ul'; + out.push('<' + tag + '>' + items.join('') + ''); + continue; + } + + // Paragraph: run to the next blank line or block starter. + var pbuf = []; + while (i < lines.length && !/^\s*$/.test(lines[i]) && + !/^\s*(```+|~~~+)/.test(lines[i]) && + !/^\s{0,3}#{1,6}\s/.test(lines[i]) && + !/^\s*>/.test(lines[i]) && + !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i])) { + pbuf.push(lines[i]); + i++; + } + out.push('

    ' + inline(pbuf.join('\n')).replace(/\n/g, '
    ') + '

    '); + } + + // No separator between blocks: they are display:block and stack on + // their own, and a stray '\n' would show through the composer's + // white-space: pre-wrap as an extra blank line. + return out.join(''); + } + + var api = { render: render, escapeHtml: escapeHtml, safeHref: safeHref }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + global.dsmd = api; +})(typeof self !== 'undefined' ? self : this); diff --git a/site/md.test.js b/site/md.test.js new file mode 100644 index 0000000..77c92ed --- /dev/null +++ b/site/md.test.js @@ -0,0 +1,93 @@ +// Unit tests for the playground's markdown renderer. +// +// The renderer turns untrusted model output into HTML, so most of what is +// protected here is what it must NOT emit: no live '); +check('a )'); +check('a data: link is dropped', !dataLink.includes('data:') && !dataLink.includes(' and "}}]}', + '{"choices":[{"delta":{"content":"[x](javascript:alert(3))"}}]}', + '{"model":"deepseek-v4-flash","choices":[{"delta":{},"finish_reason":"stop"}],' + + '"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}', + '[DONE]', + ]); + mdrun.byId['pg-enrolBtn'].fire('click'); + for (let i = 0; i < 60 && !mdrun.storage['dsplay.token']; i++) await tick(); + mdrun.byId['pg-prompt'].value = 'render markdown'; + mdrun.byId['pg-send'].fire('click'); + for (let i = 0; i < 200 && mdrun.byId['pg-send'].disabled; i++) await tick(); + + const mlog = mdrun.byId['pg-log']; + const answer = mlog.children[mlog.children.length - 1]; + const bodyEl = answer.children.find((c) => c.className === 'pg-body'); + const rendered = bodyEl ? bodyEl.innerHTML : ''; + check('renders a heading', rendered.includes('

    Title

    '), rendered); + check('renders bold', rendered.includes('bold'), rendered); + check('renders inline code', rendered.includes('inline'), rendered); + check('renders a fenced code block with a copy button', + rendered.includes('class="pg-codecopy"') && rendered.includes('
     in the answer is inert',
    +    rendered.includes('<script>') && rendered.indexOf('
     
    +
     
     
     
    diff --git a/site/style.css b/site/style.css
    index f25f2ad..edbee41 100644
    --- a/site/style.css
    +++ b/site/style.css
    @@ -1688,3 +1688,143 @@ figure.shot figcaption {
       margin-bottom: 4px;
     }
     .pg-check input { margin: 0; }
    +
    +/* Rendered markdown inside an answer. The .pg-body sits inside .pg-log,
    + * which carries the .term palette, so these read off the term tokens and
    + * stay dark in both themes like the rest of the transcript. Everything
    + * that could be wider than the column scrolls inside itself; the document
    + * never moves sideways. */
    +.pg-body > :first-child { margin-top: 0; }
    +.pg-body > :last-child { margin-bottom: 0; }
    +.pg-body h1,
    +.pg-body h2,
    +.pg-body h3,
    +.pg-body h4,
    +.pg-body h5,
    +.pg-body h6 {
    +    color: var(--term-text);
    +    font-weight: 700;
    +    font-size: 0.95rem;
    +    margin: 0.9rem 0 0.35rem;
    +}
    +.pg-body h1 { font-size: 1.05rem; }
    +/* The page dresses its own h2/h3 with a '## ' / '### ' prefix as a design
    + * flourish. In a rendered answer that reads as markdown that failed to
    + * render, so drop it here. */
    +.pg-body h2::before,
    +.pg-body h3::before { content: none; }
    +.pg-body p { margin: 0 0 0.6rem; }
    +.pg-body ul,
    +.pg-body ol {
    +    margin: 0 0 0.6rem;
    +    padding-left: 1.4rem;
    +}
    +.pg-body li { margin: 0.1rem 0; }
    +.pg-body a { color: var(--term-cyan); }
    +.pg-body strong { color: var(--term-text); font-weight: 700; }
    +.pg-body em { font-style: italic; }
    +.pg-body code {
    +    background: var(--term-bg-2);
    +    border: 1px solid var(--term-line);
    +    border-radius: 3px;
    +    padding: 0.05em 0.3em;
    +    font-size: 0.92em;
    +    color: var(--term-yellow);
    +}
    +.pg-body blockquote {
    +    margin: 0 0 0.6rem;
    +    padding-left: 0.8rem;
    +    border-left: 2px solid var(--term-line);
    +    color: var(--term-muted);
    +}
    +.pg-body hr {
    +    border: 0;
    +    border-top: 1px solid var(--term-line);
    +    margin: 0.9rem 0;
    +}
    +.pg-body table {
    +    display: block;
    +    width: max-content;
    +    max-width: 100%;
    +    overflow-x: auto;
    +    border-collapse: collapse;
    +    margin: 0 0 0.6rem;
    +    font-size: 0.92em;
    +}
    +.pg-body th,
    +.pg-body td {
    +    border: 1px solid var(--term-line);
    +    padding: 0.25rem 0.5rem;
    +    text-align: left;
    +}
    +.pg-body th { color: var(--term-text); }
    +
    +/* A fenced code block and its copy button. */
    +.pg-code {
    +    position: relative;
    +    margin: 0 0 0.6rem;
    +}
    +.pg-code pre {
    +    background: var(--term-bg-2);
    +    border: 1px solid var(--term-line);
    +    border-left: 2px solid var(--term-cyan);
    +    margin: 0;
    +    padding: 0.7rem 0.8rem;
    +    overflow-x: auto;
    +    font-size: 0.82rem;
    +    line-height: 1.5;
    +}
    +.pg-code pre code {
    +    background: none;
    +    border: 0;
    +    padding: 0;
    +    color: var(--term-text);
    +    font-size: inherit;
    +}
    +.pg-code .pg-codecopy {
    +    position: absolute;
    +    top: 0.3rem;
    +    right: 0.3rem;
    +    font: inherit;
    +    font-size: 0.7rem;
    +    color: var(--term-muted);
    +    background: var(--term-bg);
    +    border: 1px solid var(--term-line);
    +    border-radius: 3px;
    +    padding: 0.1rem 0.45rem;
    +    cursor: pointer;
    +    transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
    +}
    +.pg-code .pg-codecopy:hover {
    +    color: var(--term-cyan);
    +    border-color: var(--term-cyan);
    +}
    +
    +/* Per-answer tools: copy the raw markdown, or flip the whole answer to it. */
    +.pg-raw {
    +    white-space: pre-wrap;
    +    word-wrap: break-word;
    +    margin: 0;
    +    color: var(--term-text);
    +    font-size: 0.85rem;
    +}
    +.pg-turn-tools {
    +    display: flex;
    +    gap: 0.4rem;
    +    margin-top: 0.4rem;
    +}
    +.pg-turn-tools button {
    +    font: inherit;
    +    font-size: 0.7rem;
    +    color: var(--term-muted);
    +    background: transparent;
    +    border: 1px solid var(--term-line);
    +    border-radius: 3px;
    +    padding: 0.1rem 0.5rem;
    +    cursor: pointer;
    +    transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
    +}
    +.pg-turn-tools button:hover {
    +    color: var(--term-cyan);
    +    border-color: var(--term-cyan);
    +}