diff --git a/Makefile b/Makefile
index d5ad1f3..22392f5 100644
--- a/Makefile
+++ b/Makefile
@@ -107,6 +107,7 @@ price-check:
site-check:
python3 site/build.py --check
node site/pow.test.js
+ node site/md.test.js
node site/playground.test.js
node site/playground.dom.test.js
node site/waves.test.js
diff --git a/site/build.py b/site/build.py
index 69ca544..f8647b2 100644
--- a/site/build.py
+++ b/site/build.py
@@ -1775,6 +1775,7 @@ def jstr(s):
if TURNSTILE_SITEKEY
else ""
)
+ + '\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) + '' + 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 = '
' + inline(pbuf.join('\n')).replace(/\n/g, '
') + '
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('
+