Oregami
Repositories/oxedyne/daimond

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, '&amp;')
37 .replace(/</g, '&lt;')
38 .replace(/>/g, '&gt;')
39 .replace(/"/g, '&quot;')
40 .replace(/'/g, '&#39;');
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(/&lt;/g, '<')
48 .replace(/&gt;/g, '>')
49 .replace(/&quot;/g, '"')
50 .replace(/&#0?39;/g, "'")
51 .replace(/&#x27;/gi, "'")
52 .replace(/&amp;/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})();