oxedyne/daimond/www/js/render.js
31.7 KiB, 1 run
created by r2519314175:1431, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* ============================================================ |
| 2 | Daimond — rich markdown rendering + lightweight code highlighting |
| 3 | ------------------------------------------------------------ |
| 4 | Self-contained, dependency-free (relies only on the already |
| 5 | global `marked`). Exposes: |
| 6 | |
| 7 | window.DaimondRender = { md, escapeHtml, sanitize, |
| 8 | foldScan, foldSegments, summaryText, |
| 9 | seamText, seamLine } |
| 10 | |
| 11 | `DaimondRender.md(text)` renders markdown to an HTML string with: |
| 12 | - single-newline line breaks (marked `breaks: true`), |
| 13 | - a small built-in syntax highlighter for common languages, |
| 14 | - a "Copy" button on every fenced code block. |
| 15 | |
| 16 | Tables (and all other non-code markup) pass through untouched. |
| 17 | |
| 18 | `foldScan` / `foldSegments` do not render anything. They read a |
| 19 | model's `<details>` folds straight out of the raw text, so the chat |
| 20 | can keep one control across a whole stream and so the key a fold is |
| 21 | known by is derived the same way here as it is in Rust. See |
| 22 | dev/CONTRACT_FOLD.md §2. |
| 23 | ============================================================ */ |
| 24 | (function () { |
| 25 | 'use strict'; |
| 26 | |
| 27 | /// What the app says. Only the copy button on a code block: everything else |
| 28 | /// in this file is markup or a syntax-highlighter keyword. |
| 29 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 30 | |
| 31 | // ── HTML entity helpers ──────────────────────────────────── |
| 32 | |
| 33 | /// Escape the five significant HTML characters. |
| 34 | function escapeHtml(s) { |
| 35 | return String(s) |
| 36 | .replace(/&/g, '&') |
| 37 | .replace(/</g, '<') |
| 38 | .replace(/>/g, '>') |
| 39 | .replace(/"/g, '"') |
| 40 | .replace(/'/g, '''); |
| 41 | } |
| 42 | |
| 43 | /// Reverse the entity encoding that `marked` applies to fenced |
| 44 | /// code bodies, recovering the raw source text. |
| 45 | function unescapeHtml(s) { |
| 46 | return String(s) |
| 47 | .replace(/</g, '<') |
| 48 | .replace(/>/g, '>') |
| 49 | .replace(/"/g, '"') |
| 50 | .replace(/�?39;/g, "'") |
| 51 | .replace(/'/gi, "'") |
| 52 | .replace(/&/g, '&'); |
| 53 | } |
| 54 | |
| 55 | // ── Language specifications ──────────────────────────────── |
| 56 | |
| 57 | /// Build a lookup object from an array of words. |
| 58 | function wordSet(arr) { |
| 59 | var o = Object.create(null); |
| 60 | for (var i = 0; i < arr.length; i++) o[arr[i]] = true; |
| 61 | return o; |
| 62 | } |
| 63 | |
| 64 | // Shared token regexes (sticky, matched at an explicit offset). |
| 65 | var RE = { |
| 66 | lineSlash: /\/\/[^\n]*/y, |
| 67 | blockC: /\/\*[\s\S]*?\*\//y, |
| 68 | lineHash: /#[^\n]*/y, |
| 69 | dStr: /"(?:\\.|[^"\\\n])*"?/y, |
| 70 | sStr: /'(?:\\.|[^'\\\n])*'?/y, |
| 71 | tplStr: /`(?:\\.|[^`\\])*`?/y, |
| 72 | pyTripleD: /"""[\s\S]*?(?:"""|$)/y, |
| 73 | pyTripleS: /'''[\s\S]*?(?:'''|$)/y, |
| 74 | num: /0[xXbBoO][0-9a-fA-F_]+|(?:\d[\d_]*)(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?/y, |
| 75 | ident: /[A-Za-z_$][A-Za-z0-9_$]*/y, |
| 76 | bashVar: /\$\{[^}]*\}|\$[A-Za-z0-9_]+|\$[@*#?$!0-9]/y, |
| 77 | }; |
| 78 | |
| 79 | // Each rule is { re, cls }. cls "ident" is classified further. |
| 80 | var LANGS = { |
| 81 | javascript: { |
| 82 | upperType: true, |
| 83 | keywords: wordSet([ |
| 84 | 'var', 'let', 'const', 'function', 'return', 'if', 'else', |
| 85 | 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', |
| 86 | 'new', 'typeof', 'instanceof', 'in', 'of', 'this', 'class', |
| 87 | 'extends', 'super', 'import', 'export', 'from', 'default', |
| 88 | 'try', 'catch', 'finally', 'throw', 'async', 'await', 'yield', |
| 89 | 'delete', 'void', 'static', 'get', 'set', |
| 90 | ]), |
| 91 | types: wordSet([]), |
| 92 | literals: wordSet(['true', 'false', 'null', 'undefined', 'NaN', 'Infinity']), |
| 93 | rules: [ |
| 94 | { re: RE.blockC, cls: 'comment' }, |
| 95 | { re: RE.lineSlash, cls: 'comment' }, |
| 96 | { re: RE.tplStr, cls: 'string' }, |
| 97 | { re: RE.dStr, cls: 'string' }, |
| 98 | { re: RE.sStr, cls: 'string' }, |
| 99 | { re: RE.num, cls: 'number' }, |
| 100 | { re: RE.ident, cls: 'ident' }, |
| 101 | ], |
| 102 | }, |
| 103 | rust: { |
| 104 | upperType: true, |
| 105 | keywords: wordSet([ |
| 106 | 'fn', 'let', 'mut', 'const', 'static', 'struct', 'enum', |
| 107 | 'trait', 'impl', 'for', 'while', 'loop', 'if', 'else', |
| 108 | 'match', 'return', 'break', 'continue', 'use', 'mod', 'pub', |
| 109 | 'crate', 'self', 'Self', 'super', 'as', 'where', 'ref', |
| 110 | 'move', 'dyn', 'async', 'await', 'unsafe', 'extern', 'type', |
| 111 | 'in', 'box', |
| 112 | ]), |
| 113 | types: wordSet([ |
| 114 | 'i8', 'i16', 'i32', 'i64', 'i128', 'isize', |
| 115 | 'u8', 'u16', 'u32', 'u64', 'u128', 'usize', |
| 116 | 'f32', 'f64', 'bool', 'char', 'str', 'String', |
| 117 | 'Vec', 'Option', 'Result', 'Box', 'Rc', 'Arc', 'HashMap', |
| 118 | 'BTreeMap', 'Outcome', |
| 119 | ]), |
| 120 | literals: wordSet(['true', 'false', 'Some', 'None', 'Ok', 'Err']), |
| 121 | rules: [ |
| 122 | { re: RE.blockC, cls: 'comment' }, |
| 123 | { re: RE.lineSlash, cls: 'comment' }, |
| 124 | { re: RE.dStr, cls: 'string' }, |
| 125 | { re: RE.sStr, cls: 'string' }, |
| 126 | { re: RE.num, cls: 'number' }, |
| 127 | { re: RE.ident, cls: 'ident' }, |
| 128 | ], |
| 129 | }, |
| 130 | python: { |
| 131 | upperType: true, |
| 132 | keywords: wordSet([ |
| 133 | 'def', 'return', 'if', 'elif', 'else', 'for', 'while', |
| 134 | 'break', 'continue', 'pass', 'import', 'from', 'as', 'class', |
| 135 | 'try', 'except', 'finally', 'raise', 'with', 'lambda', |
| 136 | 'global', 'nonlocal', 'yield', 'del', 'in', 'is', 'not', |
| 137 | 'and', 'or', 'assert', 'async', 'await', |
| 138 | ]), |
| 139 | types: wordSet([ |
| 140 | 'int', 'float', 'str', 'bool', 'list', 'dict', 'tuple', |
| 141 | 'set', 'bytes', 'object', |
| 142 | ]), |
| 143 | literals: wordSet(['True', 'False', 'None', 'self', 'cls']), |
| 144 | rules: [ |
| 145 | { re: RE.lineHash, cls: 'comment' }, |
| 146 | { re: RE.pyTripleD, cls: 'string' }, |
| 147 | { re: RE.pyTripleS, cls: 'string' }, |
| 148 | { re: RE.dStr, cls: 'string' }, |
| 149 | { re: RE.sStr, cls: 'string' }, |
| 150 | { re: RE.num, cls: 'number' }, |
| 151 | { re: RE.ident, cls: 'ident' }, |
| 152 | ], |
| 153 | }, |
| 154 | bash: { |
| 155 | upperType: false, |
| 156 | keywords: wordSet([ |
| 157 | 'if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', |
| 158 | 'do', 'done', 'case', 'esac', 'function', 'in', 'select', |
| 159 | 'return', 'break', 'continue', 'local', 'export', 'declare', |
| 160 | 'readonly', 'source', 'alias', 'unset', 'set', 'echo', 'cd', |
| 161 | 'exit', 'trap', |
| 162 | ]), |
| 163 | types: wordSet([]), |
| 164 | literals: wordSet(['true', 'false']), |
| 165 | rules: [ |
| 166 | { re: RE.lineHash, cls: 'comment' }, |
| 167 | { re: RE.bashVar, cls: 'type' }, |
| 168 | { re: RE.dStr, cls: 'string' }, |
| 169 | { re: RE.sStr, cls: 'string' }, |
| 170 | { re: RE.num, cls: 'number' }, |
| 171 | { re: RE.ident, cls: 'ident' }, |
| 172 | ], |
| 173 | }, |
| 174 | json: { |
| 175 | upperType: false, |
| 176 | keywords: wordSet([]), |
| 177 | types: wordSet([]), |
| 178 | literals: wordSet(['true', 'false', 'null']), |
| 179 | rules: [ |
| 180 | { re: RE.dStr, cls: 'string' }, |
| 181 | { re: RE.num, cls: 'number' }, |
| 182 | { re: RE.ident, cls: 'ident' }, |
| 183 | ], |
| 184 | }, |
| 185 | }; |
| 186 | |
| 187 | // Map common aliases to canonical language keys. |
| 188 | var ALIAS = { |
| 189 | js: 'javascript', javascript: 'javascript', node: 'javascript', |
| 190 | jsx: 'javascript', mjs: 'javascript', ts: 'javascript', |
| 191 | typescript: 'javascript', tsx: 'javascript', |
| 192 | rs: 'rust', rust: 'rust', |
| 193 | py: 'python', python: 'python', |
| 194 | sh: 'bash', shell: 'bash', bash: 'bash', zsh: 'bash', console: 'bash', |
| 195 | json: 'json', jsonc: 'json', json5: 'json', |
| 196 | }; |
| 197 | |
| 198 | /// Resolve a fence language token to a canonical key, or '' if |
| 199 | /// unsupported. |
| 200 | function canonLang(tok) { |
| 201 | if (!tok) return ''; |
| 202 | return ALIAS[tok.toLowerCase()] || ''; |
| 203 | } |
| 204 | |
| 205 | // ── Highlighter ──────────────────────────────────────────── |
| 206 | |
| 207 | /// Classify a bare identifier against a language spec. |
| 208 | function classifyIdent(word, spec) { |
| 209 | if (spec.keywords[word]) { |
| 210 | return '<span class="tok-keyword">' + escapeHtml(word) + '</span>'; |
| 211 | } |
| 212 | if (spec.literals[word]) { |
| 213 | return '<span class="tok-literal">' + escapeHtml(word) + '</span>'; |
| 214 | } |
| 215 | if (spec.types[word]) { |
| 216 | return '<span class="tok-type">' + escapeHtml(word) + '</span>'; |
| 217 | } |
| 218 | if (spec.upperType && /^[A-Z]/.test(word)) { |
| 219 | return '<span class="tok-type">' + escapeHtml(word) + '</span>'; |
| 220 | } |
| 221 | return escapeHtml(word); |
| 222 | } |
| 223 | |
| 224 | /// Highlight raw code for a canonical language. Unknown or empty |
| 225 | /// languages fall back to a plain HTML-escaped render. Never |
| 226 | /// throws. |
| 227 | function highlight(code, lang) { |
| 228 | var spec = LANGS[lang]; |
| 229 | if (!spec) return escapeHtml(code); |
| 230 | var out = ''; |
| 231 | var i = 0; |
| 232 | var n = code.length; |
| 233 | var rules = spec.rules; |
| 234 | var guard = 0; |
| 235 | while (i < n) { |
| 236 | // Safety valve against any pathological zero-width match. |
| 237 | if (++guard > n + 16) { out += escapeHtml(code.slice(i)); break; } |
| 238 | var matched = false; |
| 239 | for (var r = 0; r < rules.length; r++) { |
| 240 | var rule = rules[r]; |
| 241 | rule.re.lastIndex = i; |
| 242 | var m = rule.re.exec(code); |
| 243 | if (m && m.index === i && m[0].length > 0) { |
| 244 | var txt = m[0]; |
| 245 | if (rule.cls === 'ident') { |
| 246 | out += classifyIdent(txt, spec); |
| 247 | } else { |
| 248 | out += '<span class="tok-' + rule.cls + '">' + |
| 249 | escapeHtml(txt) + '</span>'; |
| 250 | } |
| 251 | i += txt.length; |
| 252 | matched = true; |
| 253 | break; |
| 254 | } |
| 255 | } |
| 256 | if (!matched) { |
| 257 | out += escapeHtml(code.charAt(i)); |
| 258 | i++; |
| 259 | } |
| 260 | } |
| 261 | return out; |
| 262 | } |
| 263 | |
| 264 | // ── Code-block enhancement ───────────────────────────────── |
| 265 | |
| 266 | // Matches a marked-emitted fenced code block. The body is already |
| 267 | // entity-escaped by marked, so `</code></pre>` is unambiguous. |
| 268 | var CODE_RE = /<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g; |
| 269 | |
| 270 | /// Replace each `<pre><code>` block with a titled container that |
| 271 | /// carries a language label, a copy button, and highlighted code. |
| 272 | function enhanceCodeBlocks(html) { |
| 273 | return html.replace(CODE_RE, function (whole, attrs, body) { |
| 274 | try { |
| 275 | var tok = ''; |
| 276 | var cm = /class="([^"]*)"/.exec(attrs); |
| 277 | if (cm) { |
| 278 | var lm = /language-([A-Za-z0-9_+#.-]+)/.exec(cm[1]); |
| 279 | if (lm) tok = lm[1]; |
| 280 | } |
| 281 | var lang = canonLang(tok); |
| 282 | // Recover the raw source, dropping the trailing newline |
| 283 | // that marked appends. |
| 284 | var raw = unescapeHtml(body).replace(/\n$/, ''); |
| 285 | var hi = highlight(raw, lang); |
| 286 | var label = tok ? tok.toLowerCase() : 'text'; |
| 287 | // Raw code parked in a data attribute for the copy button; |
| 288 | // escapeHtml makes it attribute-safe and it decodes back to |
| 289 | // the exact source via getAttribute(). |
| 290 | var enc = escapeHtml(raw); |
| 291 | return '<div class="code-block" data-lang="' + escapeHtml(label) + '">' + |
| 292 | '<div class="code-block-head">' + |
| 293 | '<span class="code-block-lang">' + escapeHtml(label) + '</span>' + |
| 294 | '<button class="code-copy-btn" type="button" data-code="' + enc + '">' |
| 295 | + escapeHtml(t('common.copy')) + '</button>' + |
| 296 | '</div>' + |
| 297 | '<pre><code>' + hi + '</code></pre>' + |
| 298 | '</div>'; |
| 299 | } catch (e) { |
| 300 | // Never lose the content on an odd edge case. |
| 301 | return whole; |
| 302 | } |
| 303 | }); |
| 304 | } |
| 305 | |
| 306 | // ── Sanitisation (H5: escape-by-default) ─────────────────── |
| 307 | // The rendered surface is now the whole app, so model output must |
| 308 | // never introduce live markup. `marked` passes raw HTML through |
| 309 | // untouched, so its output is sanitised against a tag/attribute |
| 310 | // whitelist before it ever reaches the DOM. A `<template>` holds |
| 311 | // the parse inertly (no scripts run, no resources load); any tag |
| 312 | // outside the whitelist is reduced to its text, dangerous elements |
| 313 | // are dropped whole, and only vetted attributes and URLs survive. |
| 314 | |
| 315 | // Inline formatting, lists, headings, tables, code — the shape of |
| 316 | // ordinary markdown output, nothing that can execute. |
| 317 | // |
| 318 | // DETAILS and SUMMARY are here because a model that writes a fold was |
| 319 | // being punished for it. Neither tag was on this list nor on TAG_DROP, |
| 320 | // so a `<details>` fell to the unknown-wrapper branch in `scrub` and the |
| 321 | // WHOLE fold — every heading, list, code block and paragraph inside it — |
| 322 | // was replaced by one unformatted text node. That is worse than not |
| 323 | // folding at all: the markup a reader would have seen without the fold is |
| 324 | // destroyed by the attempt to add one. Both tags are inert (a disclosure |
| 325 | // widget the browser opens and closes; no script, no fetch, no navigation), |
| 326 | // so admitting them costs nothing this list exists to withhold. |
| 327 | var TAG_OK = wordSet([ |
| 328 | 'A', 'ABBR', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'DEL', 'DETAILS', 'DIV', |
| 329 | 'EM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'I', 'IMG', 'KBD', |
| 330 | 'LI', 'OL', 'P', 'PRE', 'S', 'SPAN', 'STRONG', 'SUB', 'SUMMARY', 'SUP', |
| 331 | 'TABLE', 'TBODY', 'TD', 'TH', 'THEAD', 'TR', 'U', 'UL', |
| 332 | ]); |
| 333 | // Elements dropped whole (content and all), never merely unwrapped. |
| 334 | var TAG_DROP = wordSet([ |
| 335 | 'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', |
| 336 | 'TEMPLATE', 'NOSCRIPT', 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', |
| 337 | 'SELECT', 'SVG', 'MATH', |
| 338 | ]); |
| 339 | // Attributes safe on any allowed element. |
| 340 | var ATTR_OK = wordSet(['CLASS', 'TITLE', 'ALT', 'ALIGN']); |
| 341 | |
| 342 | /// True when a URL is safe to keep — http(s), mailto, in-page or |
| 343 | /// root-relative, or an inline image data URI. Everything else |
| 344 | /// (notably `javascript:`) is rejected. |
| 345 | function safeUrl(u) { |
| 346 | var v = String(u == null ? '' : u).trim(); |
| 347 | if (/^(https?:|mailto:|#|\/)/i.test(v)) return true; |
| 348 | if (/^data:image\/(png|jpe?g|gif|webp|svg\+xml);/i.test(v)) return true; |
| 349 | return false; |
| 350 | } |
| 351 | |
| 352 | /// Recursively scrub a node's children in place against the |
| 353 | /// whitelist. Elements are visited on a static snapshot so |
| 354 | /// live-collection surprises cannot skip a node. |
| 355 | function scrub(node) { |
| 356 | var kids = Array.prototype.slice.call(node.childNodes); |
| 357 | for (var i = 0; i < kids.length; i++) { |
| 358 | var ch = kids[i]; |
| 359 | if (ch.nodeType === 8) { node.removeChild(ch); continue; } // comment |
| 360 | if (ch.nodeType !== 1) continue; // keep text |
| 361 | var tag = ch.tagName; |
| 362 | if (TAG_DROP[tag]) { node.removeChild(ch); continue; } |
| 363 | // A `<summary>` anywhere but directly inside a `<details>` is a |
| 364 | // disclosure label with nothing to disclose. The browser still |
| 365 | // draws the triangle, so it reads as a control and does nothing |
| 366 | // when pressed — a lie about what is on screen. It takes the same |
| 367 | // treatment as an unknown wrapper, for the same reason: the words |
| 368 | // are the reader's, the markup is not to be trusted. |
| 369 | var orphanSummary = (tag === 'SUMMARY' && node.nodeName !== 'DETAILS'); |
| 370 | if (!TAG_OK[tag] || orphanSummary) { |
| 371 | // Unknown wrapper: keep the text, discard the markup. |
| 372 | node.replaceChild(document.createTextNode(ch.textContent || ''), ch); |
| 373 | continue; |
| 374 | } |
| 375 | var attrs = Array.prototype.slice.call(ch.attributes); |
| 376 | for (var a = 0; a < attrs.length; a++) { |
| 377 | var name = attrs[a].name; |
| 378 | var up = name.toUpperCase(); |
| 379 | var keep = ATTR_OK[up]; |
| 380 | if (!keep && up === 'HREF' && tag === 'A') keep = safeUrl(attrs[a].value); |
| 381 | if (!keep && up === 'SRC' && tag === 'IMG') keep = safeUrl(attrs[a].value); |
| 382 | // The one attribute a fold needs: `open` says whether it starts |
| 383 | // expanded, which is the model's call and not the renderer's. |
| 384 | // Scoped to its element like HREF and SRC above, so it does not |
| 385 | // become a spare attribute anybody may write on anything. |
| 386 | if (!keep && up === 'OPEN' && tag === 'DETAILS') keep = true; |
| 387 | if (!keep) ch.removeAttribute(name); |
| 388 | } |
| 389 | if (tag === 'A') ch.setAttribute('rel', 'noopener noreferrer nofollow'); |
| 390 | // Stamped rather than left to a bare `details` selector in the |
| 391 | // stylesheet, because the app draws a `<details>` of its own — the |
| 392 | // release notes' list of sealed builds — and that one must keep the |
| 393 | // browser's look rather than inherit a model's. |
| 394 | if (tag === 'DETAILS') ch.classList.add('md-fold'); |
| 395 | scrub(ch); |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | /// Sanitise an HTML string, returning safe HTML. Falls back to a |
| 400 | /// fully-escaped render if the DOM APIs are unavailable. |
| 401 | function sanitize(html) { |
| 402 | if (typeof document === 'undefined' || !document.createElement) { |
| 403 | return escapeHtml(html); |
| 404 | } |
| 405 | var tpl = document.createElement('template'); |
| 406 | tpl.innerHTML = String(html == null ? '' : html); |
| 407 | scrub(tpl.content); |
| 408 | return tpl.innerHTML; |
| 409 | } |
| 410 | |
| 411 | // ── A model's own fold, read out of the text ─────────────── |
| 412 | // |
| 413 | // The chat draws a streaming fold as a real `<details>` that it KEEPS across |
| 414 | // frames, so it has to know where a fold begins before `marked` has turned |
| 415 | // anything into markup. The same scan names the fold, and that name has to |
| 416 | // agree character for character with the Rust half that strips a closed |
| 417 | // fold's body out of the next payload -- dev/CONTRACT_FOLD.md §2, pinned by |
| 418 | // dev/fixtures/fold_keys.json. |
| 419 | // |
| 420 | // Nothing here touches the DOM, and that is the point. A DOM parse decodes |
| 421 | // entities, lowercases tag names and normalises attributes; the Rust half |
| 422 | // does none of those, so a key taken off a parsed tree would part company |
| 423 | // with a key taken off the text exactly where a summary happened to contain |
| 424 | // an `&`. |
| 425 | |
| 426 | // A CommonMark fence: three or more backticks or tildes, up to three spaces |
| 427 | // of indent, and -- for backticks -- an info string with no backtick in it. |
| 428 | var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/; |
| 429 | var DETAILS_TOK = /<details(?:\s[^>]*)?>|<\/details\s*>/gi; |
| 430 | // Sticky: the label has to be the FIRST thing inside the element, or the |
| 431 | // `<details>` is a disclosure with nothing on its front and takes no ordinal. |
| 432 | var SUMMARY_AT = /\s*<summary(?:\s[^>]*)?>([\s\S]*?)<\/summary\s*>/iy; |
| 433 | // `open` as an attribute of its own, not the tail of `data-open` and not the |
| 434 | // word inside `class="reopen"`. |
| 435 | var OPEN_ATTR = /\sopen(?=[\s=>])/i; |
| 436 | |
| 437 | /// The half-open character ranges that fenced code covers, the fence lines |
| 438 | /// included. A `<details>` inside one is a model SHOWING markup rather than |
| 439 | /// writing it, and it is not a fold. |
| 440 | function fencedRanges(src) { |
| 441 | var out = [], lines = src.split('\n'), pos = 0, open = '', openAt = 0; |
| 442 | for (var i = 0; i < lines.length; i++) { |
| 443 | var line = lines[i]; |
| 444 | var end = pos + line.length + (i < lines.length - 1 ? 1 : 0); |
| 445 | var m = FENCE.exec(line); |
| 446 | if (!open) { |
| 447 | if (m && !(m[1].charAt(0) === '`' && m[2].indexOf('`') >= 0)) { |
| 448 | open = m[1]; openAt = pos; |
| 449 | } |
| 450 | } else if (m && m[1].charAt(0) === open.charAt(0) && m[1].length >= open.length |
| 451 | && /^[ \t]*$/.test(m[2])) { |
| 452 | out.push([openAt, end]); |
| 453 | open = ''; |
| 454 | } |
| 455 | pos = end; |
| 456 | } |
| 457 | // A fence still open at the end of the buffer runs to the end of it. That |
| 458 | // is the ordinary mid-stream case, and it is why a half-written code block |
| 459 | // showing a fold never briefly grows one. |
| 460 | if (open) out.push([openAt, src.length]); |
| 461 | return out; |
| 462 | } |
| 463 | |
| 464 | function inRanges(ranges, i) { |
| 465 | for (var r = 0; r < ranges.length; r++) { |
| 466 | if (i >= ranges[r][0] && i < ranges[r][1]) return true; |
| 467 | } |
| 468 | return false; |
| 469 | } |
| 470 | |
| 471 | /// A `<summary>`'s words: tags out, ends trimmed, every internal run of |
| 472 | /// whitespace one space. Not lowercased, not truncated, not decoded. |
| 473 | function summaryText(raw) { |
| 474 | return String(raw == null ? '' : raw) |
| 475 | .replace(/<[^>]*>/g, '') |
| 476 | .replace(/\s+/g, ' ') |
| 477 | .trim(); |
| 478 | } |
| 479 | |
| 480 | /// Every TOP-LEVEL fold in `text`, in the order the openings appear, each with |
| 481 | /// the key both halves of the app know it by. |
| 482 | /// |
| 483 | /// A fold left unclosed -- the ordinary case mid-stream -- keys exactly as a |
| 484 | /// closed one does and its body runs to the end of what has arrived, so the |
| 485 | /// name does not change under the reader when `</details>` finally lands. |
| 486 | function foldScan(text) { |
| 487 | var src = String(text == null ? '' : text); |
| 488 | var fenced = fencedRanges(src); |
| 489 | var stack = [], found = [], m; |
| 490 | DETAILS_TOK.lastIndex = 0; |
| 491 | while ((m = DETAILS_TOK.exec(src)) !== null) { |
| 492 | if (inRanges(fenced, m.index)) continue; |
| 493 | if (m[0].charAt(1) === '/') { |
| 494 | var done = stack.pop(); |
| 495 | if (done) { done.bodyEnd = m.index; done.end = m.index + m[0].length; } |
| 496 | continue; |
| 497 | } |
| 498 | var top = stack.length === 0; |
| 499 | SUMMARY_AT.lastIndex = m.index + m[0].length; |
| 500 | var sm = SUMMARY_AT.exec(src); |
| 501 | // An unlabelled `<details>` still nests, so it goes on the stack and |
| 502 | // claims its own `</details>`; it simply is not a fold. |
| 503 | if (!sm) { stack.push(null); continue; } |
| 504 | var fold = { |
| 505 | ord: 0, |
| 506 | key: '', |
| 507 | summary: '', |
| 508 | label: sm[1], // the summary's inner markup, as written |
| 509 | body: '', |
| 510 | open: OPEN_ATTR.test(m[0]), // the model asked for it expanded |
| 511 | start: m.index, |
| 512 | bodyStart: SUMMARY_AT.lastIndex, |
| 513 | bodyEnd: -1, |
| 514 | end: -1, |
| 515 | }; |
| 516 | // A fold inside a fold takes no ordinal, no key and no strip of its own |
| 517 | // (dev/CONTRACT_FOLD.md §8): the outer body is what travels or does |
| 518 | // not, and an inner fold goes with it. It still goes on the stack, or |
| 519 | // the outer one would pair with the WRONG closing tag and the two |
| 520 | // halves would disagree about where the fold ends. |
| 521 | if (top) found.push(fold); |
| 522 | stack.push(fold); |
| 523 | } |
| 524 | for (var i = 0; i < found.length; i++) { |
| 525 | var f = found[i]; |
| 526 | f.ord = i; |
| 527 | f.summary = summaryText(f.label); |
| 528 | f.key = i + ':' + f.summary; |
| 529 | f.body = (f.bodyEnd < 0 ? src.slice(f.bodyStart) |
| 530 | : src.slice(f.bodyStart, f.bodyEnd)).trim(); |
| 531 | } |
| 532 | return found; |
| 533 | } |
| 534 | |
| 535 | /// Could what follows an unfinished `<details>` still turn into a fold? |
| 536 | /// |
| 537 | /// MEASURED, not assumed: between `<details>` arriving and `</summary>` |
| 538 | /// arriving there are a few frames in which the element is real markup with a |
| 539 | /// half-typed label, and the HTML parser auto-closes it at the end of the |
| 540 | /// fragment. So `marked` draws a closed disclosure whose label grows, and the |
| 541 | /// control proper then replaces it -- a fold that appears shut and snaps open, |
| 542 | /// for no reason a reader can see. Those frames draw nothing at all instead. |
| 543 | /// |
| 544 | /// Only while it could STILL become a fold. A `<details>` that is plainly |
| 545 | /// never going to have a label is drawn at once, or a model that wrote a bare |
| 546 | /// one by mistake would have the rest of its answer held back for ever. |
| 547 | function foldPending(rest) { |
| 548 | if (/<\/summary\s*>/i.test(rest)) return false; // it already has one |
| 549 | var r = rest.replace(/^\s+/, ''); |
| 550 | if (r === '') return true; |
| 551 | if (/^<summary(?:\s[^>]*)?>/i.test(r)) return true; // inside the label |
| 552 | // Part way through writing the tag that opens it. |
| 553 | return /^<[a-z]*(?:\s[^>]*)?$/i.test(r) |
| 554 | && '<summary'.indexOf(r.split(/[\s>]/)[0]) === 0; |
| 555 | } |
| 556 | |
| 557 | // ── The seam the APP places ──────────────────────────────── |
| 558 | // |
| 559 | // Three wordings by three authors asked the model to write the `<details>` |
| 560 | // itself, and 5 answers in 76 carried one (dev/PROMPT_NOTES.md §5, |
| 561 | // dev/REGISTER_NOTES.md §11). So the markup is the app's now and the model |
| 562 | // writes one line: `Fold:` and a sentence or two of what the working below it |
| 563 | // concludes. Everything under `foldScan` is unchanged -- the line is expanded |
| 564 | // into exactly the element a model used to write, and the key, the strip and |
| 565 | // the drawing never learn that anything is different. |
| 566 | // |
| 567 | // **The app refuses more often than it folds, and that is the point.** A |
| 568 | // length threshold applied blindly produces FOLD-ALL, which |
| 569 | // dev/CONTRACT_FOLD.md §5 calls worse than no control at all. So the two |
| 570 | // failures a wording could never prevent are unreachable here instead: with |
| 571 | // too little above the line, or too few words in the summary, or too little |
| 572 | // below it, there is no fold and the sentence is left as prose. |
| 573 | |
| 574 | // Enough above the seam to be an answer. The same 40 characters |
| 575 | // `dev/probe_notes.mjs` calls FOLD-ALL below, measured the same way -- the |
| 576 | // whitespace collapsed and the ends trimmed -- because it is the same rule. |
| 577 | var SEAM_LEAD_MIN = 40; |
| 578 | // Enough below it to be worth a control. CONTRACT_FOLD.md §5's carve-out: an |
| 579 | // answer with no second depth opens on nothing. |
| 580 | var SEAM_BODY_MIN = 240; |
| 581 | // A summary is a summary and not a label (§13). |
| 582 | var SEAM_WORDS_MIN = 6; |
| 583 | |
| 584 | /// The summary a `Fold:` line carries, or `null` if the line is not one. |
| 585 | /// |
| 586 | /// Generous in what it accepts, because a model that has understood the note |
| 587 | /// and reached for a heading or for bold should not lose its fold over the |
| 588 | /// decoration: `Fold:`, `**Fold:**`, `## Fold:` and `_Fold:_` all seam. What |
| 589 | /// it will not accept is a line whose summary is empty. |
| 590 | function seamLine(line) { |
| 591 | var s = String(line == null ? '' : line); |
| 592 | if (/^ {4,}|^\t/.test(s)) return null; // an indented code block |
| 593 | s = s.replace(/^ {0,3}/, '').replace(/^#{1,6}[ \t]+/, ''); |
| 594 | var lead = /^(\*\*|__|\*|_)/.exec(s); |
| 595 | if (lead) s = s.slice(lead[1].length); |
| 596 | if (!/^Fold[ \t]*:/i.test(s)) return null; |
| 597 | var rest = s.slice(s.indexOf(':') + 1); |
| 598 | var shut = /^(\*\*|__|\*|_)/.exec(rest); |
| 599 | if (shut) rest = rest.slice(shut[1].length); |
| 600 | else if (lead) { |
| 601 | var tail = new RegExp('(\\*\\*|__|\\*|_)$').exec(rest); |
| 602 | if (tail) rest = rest.slice(0, rest.length - tail[1].length); |
| 603 | } |
| 604 | rest = rest.replace(/\s+/g, ' ').trim(); |
| 605 | return rest ? rest : null; |
| 606 | } |
| 607 | |
| 608 | /// How much of `t` a reader would actually see, measured as the ladder measures |
| 609 | /// the text above a fold. |
| 610 | function seamVisible(t) { return String(t == null ? '' : t).replace(/\s+/g, ' ').trim().length; } |
| 611 | |
| 612 | /// `text` with the model's `Fold:` line turned into the element it stands for. |
| 613 | /// |
| 614 | /// Four outcomes and only one of them is a fold: |
| 615 | /// |
| 616 | /// - **no line, or the model wrote its own `<details>`** — the text is returned |
| 617 | /// untouched, so an answer that folded itself is never folded twice; |
| 618 | /// - **the line is there and the answer qualifies** — one top-level fold, |
| 619 | /// blank lines and all, exactly as CONTRACT_FOLD.md §1 wants it; |
| 620 | /// - **it does not qualify** — the line loses its `Fold:` and stays as prose. |
| 621 | /// Nothing is hidden and nothing is lost; |
| 622 | /// - **it does not qualify YET, mid-stream** — the line is held back, the way |
| 623 | /// `foldPending` holds back a half-written `<details>`, because a fold that |
| 624 | /// appears and then unwinds is worse than one that arrives late. |
| 625 | /// |
| 626 | /// `settled` says no more text is coming, which is what tells the third case |
| 627 | /// from the fourth. |
| 628 | function seamText(src, settled) { |
| 629 | var s = String(src == null ? '' : src); |
| 630 | if (!/fold[ \t]*:/i.test(s)) return s; |
| 631 | // A model that wrote the markup itself has already placed its seam. |
| 632 | if (s.indexOf('<details') >= 0 && foldScan(s).length) return s; |
| 633 | // Every `Fold:` line outside a fence, with where it starts and what it says. |
| 634 | // A fenced one is a model showing the convention rather than using it, which |
| 635 | // is the rule `fencedRanges` already carries for `<details>`. |
| 636 | var fenced = fencedRanges(s), lines = s.split('\n'), at = 0, marks = []; |
| 637 | for (var i = 0; i < lines.length; i++) { |
| 638 | var got = inRanges(fenced, at) ? null : seamLine(lines[i]); |
| 639 | if (got !== null) marks.push({ line: i, at: at, len: lines[i].length, sum: got }); |
| 640 | at += lines[i].length + 1; |
| 641 | } |
| 642 | if (!marks.length) return s; |
| 643 | // The later ones are prose: only the first is the seam, and a second would |
| 644 | // otherwise reach the reader with its marker still on it. |
| 645 | var bare = function (from) { |
| 646 | var keep = lines.slice(0); |
| 647 | for (var k = 0; k < marks.length; k++) { |
| 648 | if (marks[k].line >= from) keep[marks[k].line] = marks[k].sum; |
| 649 | } |
| 650 | return keep.join('\n'); |
| 651 | }; |
| 652 | var head = marks[0]; |
| 653 | var above = s.slice(0, head.at); |
| 654 | var body = s.slice(head.at + head.len + 1); |
| 655 | // A summary carrying the very tags this builds would close the element |
| 656 | // early and leave the rest of the answer outside it. |
| 657 | if (/<\/?(summary|details)/i.test(head.sum) |
| 658 | || head.sum.split(/\s+/).length < SEAM_WORDS_MIN |
| 659 | || seamVisible(above) < SEAM_LEAD_MIN) { |
| 660 | return bare(0); |
| 661 | } |
| 662 | if (seamVisible(body) < SEAM_BODY_MIN) { |
| 663 | return settled ? bare(0) : s.slice(0, head.at); |
| 664 | } |
| 665 | var rest = bare(head.line + 1).split('\n').slice(head.line + 1).join('\n'); |
| 666 | return above.replace(/\s+$/, '') |
| 667 | + '\n\n<details>\n<summary>' + head.sum + '</summary>\n\n' |
| 668 | + rest.replace(/^\s+/, '').replace(/\s+$/, '') |
| 669 | + '\n\n</details>'; |
| 670 | } |
| 671 | |
| 672 | /// `text` cut into the pieces the chat draws: runs of ordinary markdown, and |
| 673 | /// the TOP-LEVEL folds between them. |
| 674 | /// |
| 675 | /// A nested fold stays inside its parent's body and is drawn there in the |
| 676 | /// ordinary way -- it has no key of its own to be drawn against. |
| 677 | /// |
| 678 | /// `settled` says no more text is coming, which turns off the hold-back in |
| 679 | /// `foldPending`: a turn that died half way through a `<summary>` should show |
| 680 | /// the words that did arrive rather than wait for a frame that never comes. |
| 681 | function foldSegments(text, settled) { |
| 682 | // The app's own seam first, so everything below this line is looking at |
| 683 | // one kind of fold and not two. |
| 684 | var src = seamText(text, settled); |
| 685 | var all = foldScan(src), segs = [], at = 0; |
| 686 | for (var i = 0; i < all.length; i++) { |
| 687 | var f = all[i]; |
| 688 | if (f.start < at) continue; // already inside one that is drawn |
| 689 | if (f.start > at) segs.push({ kind: 'text', text: src.slice(at, f.start) }); |
| 690 | segs.push({ |
| 691 | kind: 'fold', ord: f.ord, key: f.key, summary: f.summary, |
| 692 | label: f.label, body: f.body, open: f.open, closed: f.end >= 0, |
| 693 | }); |
| 694 | at = f.end >= 0 ? f.end : src.length; |
| 695 | } |
| 696 | if (at < src.length) { |
| 697 | var tail = src.slice(at), cut = tail.length; |
| 698 | // The guard keeps the ordinary answer -- the overwhelming |
| 699 | // majority, which has no fold in it at all -- off a second line scan |
| 700 | // of the whole message on every frame of every stream. |
| 701 | if (!settled && /<details/i.test(tail)) { |
| 702 | // The LAST opener in what is left is the only one that can still be |
| 703 | // being written; everything before it has had its chance. |
| 704 | var fenced = fencedRanges(src), mm, openAt = -1, openEnd = 0; |
| 705 | DETAILS_TOK.lastIndex = 0; |
| 706 | while ((mm = DETAILS_TOK.exec(tail)) !== null) { |
| 707 | if (mm[0].charAt(1) === '/' || inRanges(fenced, at + mm.index)) continue; |
| 708 | openAt = mm.index; openEnd = mm.index + mm[0].length; |
| 709 | } |
| 710 | if (openAt >= 0 && foldPending(tail.slice(openEnd))) cut = openAt; |
| 711 | } |
| 712 | if (cut > 0) segs.push({ kind: 'text', text: tail.slice(0, cut) }); |
| 713 | } |
| 714 | return segs; |
| 715 | } |
| 716 | |
| 717 | // ── Public render ────────────────────────────────────────── |
| 718 | |
| 719 | /// Render markdown `text` to a sanitised HTML string. |
| 720 | function md(text) { |
| 721 | var src = (text == null) ? '' : String(text); |
| 722 | var html; |
| 723 | try { |
| 724 | html = marked.parse(src, { breaks: true }); |
| 725 | } catch (e) { |
| 726 | return escapeHtml(src); |
| 727 | } |
| 728 | // Sanitise the model-authored markup first, then apply the |
| 729 | // trusted code-block transform (which builds its own markup |
| 730 | // from already-escaped source). |
| 731 | try { |
| 732 | html = sanitize(html); |
| 733 | } catch (e) { return escapeHtml(src); } |
| 734 | try { |
| 735 | html = enhanceCodeBlocks(html); |
| 736 | } catch (e) { /* keep unenhanced html */ } |
| 737 | return html; |
| 738 | } |
| 739 | |
| 740 | // ── Copy-to-clipboard (event delegation) ─────────────────── |
| 741 | |
| 742 | /// Copy `text` to the clipboard, with a legacy fallback for |
| 743 | /// browsers without the async clipboard API. |
| 744 | function copyText(text) { |
| 745 | if (navigator.clipboard && navigator.clipboard.writeText) { |
| 746 | return navigator.clipboard.writeText(text); |
| 747 | } |
| 748 | return new Promise(function (resolve, reject) { |
| 749 | try { |
| 750 | var ta = document.createElement('textarea'); |
| 751 | ta.value = text; |
| 752 | ta.setAttribute('readonly', ''); |
| 753 | ta.style.position = 'fixed'; |
| 754 | ta.style.left = '-9999px'; |
| 755 | ta.style.opacity = '0'; |
| 756 | document.body.appendChild(ta); |
| 757 | ta.select(); |
| 758 | var ok = document.execCommand('copy'); |
| 759 | document.body.removeChild(ta); |
| 760 | if (ok) { resolve(); } else { reject(new Error('copy failed')); } |
| 761 | } catch (e) { reject(e); } |
| 762 | }); |
| 763 | } |
| 764 | |
| 765 | // A single delegated listener services every copy button, present |
| 766 | // or future, so app.js needs no per-button wiring. |
| 767 | document.addEventListener('click', function (ev) { |
| 768 | var btn = ev.target; |
| 769 | if (!btn || !btn.classList || !btn.classList.contains('code-copy-btn')) return; |
| 770 | var code = btn.getAttribute('data-code') || ''; |
| 771 | var restore = function (label) { |
| 772 | btn.textContent = label; |
| 773 | setTimeout(function () { |
| 774 | btn.textContent = t('common.copy'); |
| 775 | btn.classList.remove('copied'); |
| 776 | }, 1400); |
| 777 | }; |
| 778 | copyText(code).then(function () { |
| 779 | btn.classList.add('copied'); |
| 780 | restore(t('toast.copied')); |
| 781 | }, function () { |
| 782 | restore(t('render.copy_failed')); |
| 783 | }); |
| 784 | }); |
| 785 | |
| 786 | // ── Export ───────────────────────────────────────────────── |
| 787 | window.DaimondRender = { |
| 788 | md: md, escapeHtml: escapeHtml, sanitize: sanitize, |
| 789 | foldScan: foldScan, foldSegments: foldSegments, summaryText: summaryText, |
| 790 | seamText: seamText, seamLine: seamLine, |
| 791 | }; |
| 792 | })(); |