From 3342ca8530ff0a3450c72eb84a57004dad43c04b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:08 +0530 Subject: [PATCH 1/3] Markdown: wrapping never glues two words together A space was dropped when the space and the next word did not fit, but the next word itself was then measured without it: when the word alone fit exactly, it was drawn straight after the previous one ("aaaa bbbb" at 8 columns gave "aaaabbbb"; a README line gave "A calmerterminal"). Every Markdown surface used this wrap (agent replies, help, /view, GitHub bodies). The next word now starts the next row. Found while testing /view's HTML rendering (QA F01); present on 68c1cbb7. --- src/markdown/render.ts | 5 ++++- tests/markdownRender.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 272a91e9..001079a3 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -158,7 +158,10 @@ function wrapAtoms(atoms: readonly Atom[], width: number, first: string, rest: s // Spaces never start a row and never trail one. if (empty) continue; const next = atoms[index + 1]; - if (!next || used + 1 + displayWidth(next.text) > width) continue; + if (!next) continue; + // The next word does not fit after this space: it starts the next row (it may fit without the space, but must + // never be glued to the word before it). + if (used + 1 + displayWidth(next.text) > width) { push(); continue; } ansi += paintAtom(atom); plain += ' '; used += 1; continue; } diff --git a/tests/markdownRender.test.ts b/tests/markdownRender.test.ts index 36395c31..69016659 100644 --- a/tests/markdownRender.test.ts +++ b/tests/markdownRender.test.ts @@ -220,3 +220,15 @@ test('security: pathological lines parse and render in linear time', () => { assert.ok(Date.now() - started < 1500, `${JSON.stringify(input.slice(0, 20))}… took ${Date.now() - started} ms`); } }); + +test('wrapping never glues two words: a word that fits only without the space before it starts the next row', () => { + // Found while testing /view's HTML (QA F01); present before it in every Markdown surface. + assert.deepEqual(renderMarkdown('aaaa bbbb', 8, {level: 'none'}).map(row => row.plain), ['aaaa', 'bbbb']); + assert.deepEqual(renderMarkdown('aaaa bbbb', 9, {level: 'none'}).map(row => row.plain), ['aaaa bbbb']); + const rows = renderMarkdown('[CI status](https://github.com/raiseCatError/notMyShell/actions) **A calmer terminal** for *zsh*, Bash and Fish.', 80, {level: 'truecolor'}); + assert.deepEqual(rows.map(row => row.plain), ['CI status (https://github.com/raiseCatError/notMyShell/actions) A calmer', 'terminal for zsh, Bash and Fish.']); + for (const columns of [10, 17, 23, 40]) { + const text = 'one two three four five six seven eight nine ten'; + assert.equal(renderMarkdown(text, columns, {level: 'none'}).map(row => row.plain).join(' '), text, `${columns}`); + } +}); From bee509a7bf3aa802476736e3b851eab174c2387a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:52 +0530 Subject: [PATCH 2/3] /view: render HTML natively, HTML files and HTML inside Markdown (QA F01) Requested in the v0.22 physical QA: README files showed their embedded , and
literally. /view now renders HTML as terminal rows, with no browser engine, through the Markdown renderer's own blocks, so wrapping, narrow widths, search, NO_COLOR and Safe glyphs come from the tested renderer. Supported subset: headings, paragraphs and containers (div, section, center, details/summary, figure), ordered and unordered lists (nested, task check boxes), tables (header row, alignment),
 code with its
language class, block quotes, rules, definition lists; inline emphasis,
code and keys, strikethrough, links and images. In Markdown, HTML blocks
follow CommonMark's kinds and Markdown between them reads as Markdown,
as on GitHub; inline tags are read from a whitelist, and an unknown
 stays text. .html/.htm/.xhtml open rendered; m shows the source.

Never run or loaded: scripts, styles, templates, embedded and interactive
content and the document head are dropped with their contents; event
handlers and every attribute but a few are ignored; CSS is not applied;
an image is its alt text (never fetched or read, so no local file is
disclosed); links keep the renderer's http(s)-only rule and a refused
target shows as text; character references decode through the existing
safe decoder (moved to markup.ts unchanged). The parser is linear, at
most 200,000 tokens and 48 levels deep (deeper content stays, flattened),
4 KiB per tag, 2 KiB per attribute; rendering stays under the existing
1 MiB limit. html.ts imports nothing that reads files or the network.

HTML is rendered only where /view asks (MarkdownOptions.html): agent
replies, help and GitHub bodies keep showing tags as text.

Tests: tests/viewHtml.test.ts (README, document structure, hostile
markup, controls via text/entities/attributes, size and nesting bounds,
no I/O imports, opt-in, narrow/NO_COLOR/Safe, viewer integration).
Rendering, viewer, agent, Ask and report suites: 604 pass, 0 fail.
---
 src/ask/concepts.ts       |   4 +-
 src/markdown/blocks.ts    |  54 ++++++--
 src/markdown/html.ts      | 257 ++++++++++++++++++++++++++++++++++++++
 src/markdown/inline.ts    | 107 ++++++++++++----
 src/markdown/markup.ts    | 134 ++++++++++++++++++++
 src/markdown/render.ts    |  26 +++-
 src/viewer/ViewerPanel.ts |  29 +++--
 src/viewer/document.ts    |   6 +-
 tests/viewHtml.test.ts    | 191 ++++++++++++++++++++++++++++
 9 files changed, 756 insertions(+), 52 deletions(-)
 create mode 100644 src/markdown/html.ts
 create mode 100644 src/markdown/markup.ts
 create mode 100644 tests/viewHtml.test.ts

diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts
index 0b6b4c64..f99c7d6b 100644
--- a/src/ask/concepts.ts
+++ b/src/ask/concepts.ts
@@ -254,8 +254,8 @@ export const CONCEPTS: readonly Concept[] = [
     aliases: ['compare output', 'compare outputs', 'diff output', 'compare runs', 'what changed between runs', 'compare with previous run'],
     description: '/compare shows what changed between two command outputs (by default the latest and the previous run of the same command; /compare N M picks them by /copy number). Also Actions → Compare output…. Added, removed and changed lines; c copies the diff, r a comparison report.'},
   {id: 'view', label: 'View a file or output', support: 'actionable', open: '/view', covers: ['/view'],
-    aliases: ['view file', 'view a file', 'read a file', 'open a file read only', 'view output', 'read markdown', 'preview markdown', 'view log', 'pager'],
-    description: '/view opens a read-only native viewer: /view README.md for a file (Markdown renders; m shows the source), /view N for completed output N (numbered as /copy), /view alone for the latest output. Also Actions → View output. Find with /, n/N, go to a line with :, w wraps, l numbers, c copies the whole text; for a file, e edits it in /edit and o opens it in your own editor at the line shown (as /open does). less, bat and Glow still work as usual.'},
+    aliases: ['view file', 'view a file', 'read a file', 'open a file read only', 'view output', 'read markdown', 'preview markdown', 'view html', 'preview html', 'view log', 'pager'],
+    description: '/view opens a read-only native viewer: /view README.md for a file (Markdown and HTML render natively, embedded HTML included: never run or loaded, images as their text; m shows the source), /view N for completed output N (numbered as /copy), /view alone for the latest output. Also Actions → View output. Find with /, n/N, go to a line with :, w wraps, l numbers, c copies the whole text; for a file, e edits it in /edit and o opens it in your own editor at the line shown (as /open does). less, bat and Glow still work as usual.'},
   {id: 'edit-file', label: 'Edit a small text file', support: 'actionable', open: '/edit', covers: ['/edit'],
     aliases: ['edit a file', 'edit file', 'edit text file', 'change a config file', 'quick edit', 'small editor', 'native editor', 'edit notes'],
     description: '/edit notes.md opens a small text file (up to 1 MiB, your own, UTF-8) in NMSh\'s editor: type, select with Shift, Ctrl+Z undo, Ctrl+Y redo, Ctrl+F find, Ctrl+C/Ctrl+X copy and cut, Ctrl+S saves (atomically; if the file changed on disk you choose Overwrite, Reload or Keep editing), Esc closes and asks about unsaved changes. e in /view edits the file shown. vim, nano, micro and your $EDITOR still work as usual.'},
diff --git a/src/markdown/blocks.ts b/src/markdown/blocks.ts
index 6ed72200..752c5624 100644
--- a/src/markdown/blocks.ts
+++ b/src/markdown/blocks.ts
@@ -4,7 +4,13 @@
  * replies and GitHub text, not a conformance implementation. Streaming-safe: an unclosed fence runs to the end and
  * is marked open, so a half-arrived reply still reads as code. Bounded in depth and lines; tabs are expected to be
  * expanded by the caller's display scrubber.
+ *
+ * With `html` (`/view`, QA F01), an HTML block (CommonMark's kinds: a raw block such as `
`, a comment, a block-level
+ * tag, or one complete known tag alone on its line) runs to its end and becomes blocks through the HTML converter;
+ * Markdown between HTML blocks reads as Markdown, as on GitHub. Without it, HTML is paragraph text.
  */
+import {htmlToBlocks} from './html.js';
+import {BLOCK, KNOWN} from './markup.js';
 
 export type Alignment = 'left' | 'center' | 'right' | undefined;
 
@@ -133,13 +139,39 @@ function interrupts(line: string, next: string | undefined): boolean {
   return Boolean(line.includes('|') && next !== undefined && delimiterRow(next) && splitRow(line).length === delimiterRow(next)!.length);
 }
 
-export function parseBlocks(source: string): Block[] {
+export function parseBlocks(source: string, options: {html?: boolean} = {}): Block[] {
   const lines = source.replace(/\r\n?/gu, '\n').split('\n');
   if (lines.length > MAX_LINES) lines.length = MAX_LINES;
-  return parseLines(lines, 0);
+  return parseLines(lines, 0, Boolean(options.html));
 }
 
-function parseLines(lines: readonly string[], depth: number): Block[] {
+/** The kind of HTML block a line starts (CommonMark 1, 2, 6, 7), or undefined. */
+function htmlBlockKind(line: string): 1 | 2 | 6 | 7 | undefined {
+  if (indentOf(line) > 3) return undefined;
+  const text = line.trimStart();
+  if (text[0] !== '<') return undefined;
+  if (/^<(?:pre|script|style|textarea)(?:[\s>]|$)/iu.test(text)) return 1;
+  if (text.startsWith('/u : undefined;
+  let index = start;
+  if (closes) {
+    for (; index < lines.length; index++) if (closes.test(lines[index]!) && (index > start || closes.test(lines[start]!.slice(4)))) return index + 1;
+    return index;
+  }
+  while (index < lines.length && !blank(lines[index]!)) index++;
+  return index;
+}
+
+function parseLines(lines: readonly string[], depth: number, html = false): Block[] {
   const blocks: Block[] = [];
   if (depth > MAX_DEPTH) {
     const text = lines.join('\n').trim();
@@ -165,6 +197,14 @@ function parseLines(lines: readonly string[], depth: number): Block[] {
       continue;
     }
 
+    const markup = html ? htmlBlockKind(line) : undefined;
+    if (markup) {
+      const end = htmlBlockEnd(lines, index, markup);
+      blocks.push(...htmlToBlocks(lines.slice(index, end).join('\n')));
+      index = end;
+      continue;
+    }
+
     const heading = headingOf(line);
     if (heading) { blocks.push({kind: 'heading', level: heading.level, text: heading.text}); index++; continue; }
 
@@ -181,13 +221,13 @@ function parseLines(lines: readonly string[], depth: number): Block[] {
         else break;
         index++;
       }
-      blocks.push({kind: 'quote', blocks: parseLines(inner, depth + 1)});
+      blocks.push({kind: 'quote', blocks: parseLines(inner, depth + 1, html)});
       continue;
     }
 
     const item = ITEM.exec(line);
     if (item) {
-      const parsed = parseList(lines, index, depth);
+      const parsed = parseList(lines, index, depth, html);
       blocks.push(parsed.block);
       index = parsed.next;
       continue;
@@ -238,7 +278,7 @@ function parseLines(lines: readonly string[], depth: number): Block[] {
   return blocks;
 }
 
-function parseList(lines: readonly string[], start: number, depth: number): {block: Extract; next: number} {
+function parseList(lines: readonly string[], start: number, depth: number, html: boolean): {block: Extract; next: number} {
   const first = ITEM.exec(lines[start]!)!;
   const ordered = /\d/u.test(first[2]!);
   const delimiter = ordered ? first[2]!.slice(-1) : first[2]!;
@@ -272,7 +312,7 @@ function parseList(lines: readonly string[], start: number, depth: number): {blo
     let task: ListItem['task'];
     const taskMatch = /^\[([ xX])\][ \t]+/u.exec(body[0] ?? '');
     if (taskMatch) { task = taskMatch[1] === ' ' ? 'open' : 'done'; body[0] = body[0]!.slice(taskMatch[0].length); }
-    items.push({...(ordered ? {number: Number(match[2]!.slice(0, -1))} : {}), ...(task ? {task} : {}), blocks: parseLines(body, depth + 1)});
+    items.push({...(ordered ? {number: Number(match[2]!.slice(0, -1))} : {}), ...(task ? {task} : {}), blocks: parseLines(body, depth + 1, html)});
   }
   return {block: {kind: 'list', ordered, start: items[0]?.number ?? 1, loose, items}, next: index};
 }
diff --git a/src/markdown/html.ts b/src/markdown/html.ts
new file mode 100644
index 00000000..12f89b76
--- /dev/null
+++ b/src/markdown/html.ts
@@ -0,0 +1,257 @@
+import type {Alignment, Block, ListItem} from './blocks.js';
+import {BLOCK, decodeEntities, DROPPED, imageLabel, RAW_TEXT, readTag, VOID} from './markup.js';
+
+/**
+ * HTML as the Markdown renderer's own blocks (QA F01): what `/view` shows for an HTML file and for HTML inside
+ * Markdown. A supported subset, mapped onto blocks the renderer already draws safely at any width:
+ *   headings, paragraphs and containers (div, section, center, details…), ordered and unordered lists (nested, task
+ *   check boxes), tables (header row, alignment), preformatted code (with its language class), block quotes, rules,
+ *   definition lists; inline emphasis, code, keys, strikethrough, links and images.
+ * Inline content is handed on as normalised inline HTML (escaped text and the few tags the inline parser reads), so
+ * links follow the renderer's http(s)-only rule and an image is its alt text, never fetched or read.
+ *
+ * Nothing is executed or loaded: scripts, styles, templates, embedded and interactive content and the document head
+ * are dropped with their contents, attributes other than a few (href, alt, src for a label, align, start, type,
+ * checked, class for a code language) are ignored, CSS is never applied. The scan is linear; at most MAX_TOKENS
+ * tokens are read and MAX_DEPTH elements nest (deeper content stays, flattened), so hostile markup stays bounded.
+ */
+
+const MAX_TOKENS = 200_000;
+const MAX_DEPTH = 48;
+const MAX_COLUMNS = 32;
+
+type Token = {kind: 'text'; text: string} | {kind: 'open'; name: string; attributes: Record; self: boolean} | {kind: 'close'; name: string};
+interface Element {name: string; attributes: Record; children: Node[]}
+type Node = string | Element;
+
+function tokenize(source: string): Token[] {
+  const tokens: Token[] = [];
+  const lower = source.toLowerCase();
+  let index = 0;
+  while (index < source.length) {
+    if (tokens.length >= MAX_TOKENS) {
+      tokens.push({kind: 'text', text: ' … (the rest is too long to render here; m shows the source)'});
+      break;
+    }
+    const open = source.indexOf('<', index);
+    if (open < 0) { tokens.push({kind: 'text', text: source.slice(index)}); break; }
+    if (open > index) tokens.push({kind: 'text', text: source.slice(index, open)});
+    if (source.startsWith('', open + 4);
+      index = end < 0 ? source.length : end + 3;
+      continue;
+    }
+    if (source[open + 1] === '!' || source[open + 1] === '?') {
+      // A doctype, CDATA section or processing instruction: never content.
+      const end = source.indexOf('>', open);
+      index = end < 0 ? source.length : end + 1;
+      continue;
+    }
+    const tag = readTag(source, open);
+    if (!tag) { tokens.push({kind: 'text', text: '<'}); index = open + 1; continue; }
+    index = tag.end;
+    if (tag.closing) { tokens.push({kind: 'close', name: tag.name}); continue; }
+    tokens.push({kind: 'open', name: tag.name, attributes: tag.attributes, self: tag.selfClosing});
+    if (RAW_TEXT.has(tag.name) && !tag.selfClosing) {
+      // Its content is never markup: skip to its end tag (or the end), unread.
+      const close = lower.indexOf(`', close);
+      index = end < 0 ? source.length : end + 1;
+      tokens.push({kind: 'close', name: tag.name});
+    }
+  }
+  return tokens;
+}
+
+/** Elements an opening tag of the same family closes (an unclosed `
  • `, `

    `, ``, a cell). */ +const IMPLIED: Record = {li: ['li'], p: ['p'], tr: ['tr', 'td', 'th'], td: ['td', 'th'], th: ['td', 'th'], dt: ['dt', 'dd'], dd: ['dt', 'dd']}; +/** Where an implied close stops looking: it never reaches past its own list, table or row. */ +const SCOPE = new Set(['ul', 'ol', 'table', 'tbody', 'thead', 'tfoot', 'tr', 'dl', 'blockquote', 'div', 'body', 'html']); + +function build(tokens: readonly Token[]): Element { + const root: Element = {name: '#root', attributes: {}, children: []}; + const stack: Element[] = [root]; + for (const token of tokens) { + const top = stack.at(-1)!; + if (token.kind === 'text') { top.children.push(token.text); continue; } + if (token.kind === 'close') { + // The nearest open element of that name closes, with everything inside it; a stray end tag is ignored. + for (let depth = stack.length - 1; depth > 0; depth--) if (stack[depth]!.name === token.name) { stack.length = depth; break; } + continue; + } + const implied = IMPLIED[token.name]; + if (implied) { + for (let depth = stack.length - 1; depth > 0; depth--) { + const name = stack[depth]!.name; + if (implied.includes(name)) { stack.length = depth; break; } + if (SCOPE.has(name)) break; + } + } + const element: Element = {name: token.name, attributes: token.attributes, children: []}; + stack.at(-1)!.children.push(element); + // Past the depth bound an element keeps its place but not its own level: what follows is its sibling. + if (!VOID.has(token.name) && !token.self && stack.length < MAX_DEPTH) stack.push(element); + } + return root; +} + +// ---- Inline ------------------------------------------------------------------------------------------------------- + +/** Decoded text as the inline parser reads it literally: Markdown punctuation escaped, `&` and `<` as references. */ +function escapeText(text: string): string { + return text.replace(/&/gu, '&').replace(/[\\`*_~[\]!]/gu, char => `\\${char}`).replace(/ value.replace(/&/gu, '&').replace(/"/gu, '"').replace(//gu, '>'); + +/** All text inside, as written (for code and preformatted text). */ +function textContent(nodes: readonly Node[]): string { + let out = ''; + const walk = (list: readonly Node[]) => { + for (const node of list) { + if (typeof node === 'string') out += node; + else if (node.name === 'br') out += '\n'; + else if (!DROPPED.has(node.name)) walk(node.children); + } + }; + walk(nodes); + return out; +} + +const STRONG = new Set(['b', 'strong']); +const EMPHASIS = new Set(['i', 'em', 'cite', 'dfn', 'var']); +const STRIKE = new Set(['s', 'del', 'strike']); +const CODE = new Set(['code', 'kbd', 'samp', 'tt']); + +/** Inline content as normalised inline HTML: whitespace collapsed, text escaped, only tags the inline parser reads. */ +function inlineText(nodes: readonly Node[]): string { + let out = ''; + for (const node of nodes) { + if (typeof node === 'string') { out += escapeText(decodeEntities(node).replace(/\s+/gu, ' ')); continue; } + const name = node.name; + if (DROPPED.has(name)) continue; + if (name === 'br') out += ' '; + else if (name === 'img') out += `${escapeAttribute(imageLabel(node.attributes))}`; + else if (name === 'input') out += node.attributes.type?.toLowerCase() === 'checkbox' ? ('checked' in node.attributes ? '☑ ' : '☐ ') : ''; + else if (CODE.has(name)) out += `${escapeAttribute(decodeEntities(textContent(node.children)).replace(/\s+/gu, ' '))}`; + else if (STRONG.has(name)) out += `${inlineText(node.children)}`; + else if (EMPHASIS.has(name)) out += `${inlineText(node.children)}`; + else if (STRIKE.has(name)) out += `${inlineText(node.children)}`; + else if (name === 'a' && node.attributes.href) out += `${inlineText(node.children)}`; + else if (BLOCK.has(name)) out += ` ${inlineText(node.children)} `; + else out += inlineText(node.children); + } + return out; +} + +const clean = (text: string) => text.replace(/ {2,}/gu, ' ').trim(); + +// ---- Blocks ------------------------------------------------------------------------------------------------------- + +function blocksOf(nodes: readonly Node[]): Block[] { + const out: Block[] = []; + let inline: Node[] = []; + const flush = () => { + const text = clean(inlineText(inline)); + if (text) out.push({kind: 'paragraph', text}); + inline = []; + }; + for (const node of nodes) { + if (typeof node !== 'string' && DROPPED.has(node.name)) continue; + if (typeof node !== 'string' && BLOCK.has(node.name)) { flush(); out.push(...blockOf(node)); } + else inline.push(node); + } + flush(); + return out; +} + +function blockOf(element: Element): Block[] { + const name = element.name; + const heading = /^h([1-6])$/u.exec(name); + if (heading) { + const text = clean(inlineText(element.children)); + return text ? [{kind: 'heading', level: Number(heading[1]), text}] : []; + } + switch (name) { + case 'hr': return [{kind: 'rule'}]; + case 'pre': return [preformatted(element)]; + case 'blockquote': return [{kind: 'quote', blocks: blocksOf(element.children)}]; + case 'ul': case 'ol': case 'menu': case 'dir': return [list(element)]; + case 'table': return table(element); + case 'summary': case 'dt': case 'caption': case 'legend': { + const text = clean(inlineText(element.children)); + return text ? [{kind: 'paragraph', text: `${text}`}] : []; + } + default: return blocksOf(element.children); + } +} + +function preformatted(element: Element): Block { + const code = element.children.find((node): node is Element => typeof node !== 'string' && node.name === 'code'); + const classes = `${code?.attributes.class ?? ''} ${element.attributes.class ?? ''}`; + const language = /(?:^|\s)(?:language|lang)-([\w+#.-]{1,40})/u.exec(classes)?.[1]?.toLowerCase(); + const lines = decodeEntities(textContent(element.children)).replace(/\r\n?/gu, '\n').replace(/^\n/u, '').split('\n'); + while (lines.length > 1 && !lines.at(-1)!.trim()) lines.pop(); + return {kind: 'code', ...(language ? {language} : {}), lines, closed: true}; +} + +function list(element: Element): Block { + const ordered = element.name === 'ol'; + const start = Math.min(1_000_000_000, Math.max(0, Number.parseInt(element.attributes.start ?? '1', 10) || 1)); + const items: ListItem[] = []; + for (const node of element.children) { + if (typeof node === 'string') { + if (node.trim()) items.push({blocks: [{kind: 'paragraph', text: clean(escapeText(decodeEntities(node).replace(/\s+/gu, ' ')))}]}); + continue; + } + if (node.name === 'li') items.push(item(node)); + // A list written straight inside a list belongs to the item before it. + else if ((node.name === 'ul' || node.name === 'ol') && items.length) items.at(-1)!.blocks.push(list(node)); + else if (!DROPPED.has(node.name)) items.push({blocks: blocksOf([node])}); + } + return {kind: 'list', ordered, start, loose: false, items: items.map((entry, index) => ordered ? {...entry, number: start + index} : entry)}; +} + +function item(element: Element): ListItem { + const children = [...element.children]; + // A check box first in the item makes it a task: shown as the renderer's task marker, never as a control. + const first = children.findIndex(node => typeof node !== 'string' || node.trim()); + const box = children[first]; + let task: ListItem['task']; + if (box && typeof box !== 'string' && box.name === 'input' && box.attributes.type?.toLowerCase() === 'checkbox') { + task = 'checked' in box.attributes ? 'done' : 'open'; + children.splice(first, 1); + } + return {...(task ? {task} : {}), blocks: blocksOf(children)}; +} + +function table(element: Element): Block[] { + const rows: Array> = []; + const before: Block[] = []; + const collect = (nodes: readonly Node[]) => { + for (const node of nodes) { + if (typeof node === 'string') continue; + if (node.name === 'caption') before.push(...blockOf(node)); + else if (node.name === 'thead' || node.name === 'tbody' || node.name === 'tfoot') collect(node.children); + else if (node.name === 'tr') { + const cells = node.children.filter((cell): cell is Element => typeof cell !== 'string' && (cell.name === 'td' || cell.name === 'th')).slice(0, MAX_COLUMNS); + if (cells.length) rows.push(cells.map(cell => ({text: clean(inlineText(cell.children)), header: cell.name === 'th', align: alignment(cell.attributes.align)}))); + } + } + }; + collect(element.children); + if (!rows.length) return before; + const width = Math.max(...rows.map(row => row.length)); + const pad = (row: ReadonlyArray<{text: string}>) => Array.from({length: width}, (_, column) => row[column]?.text ?? ''); + const [header, ...body] = rows; + return [...before, {kind: 'table', align: Array.from({length: width}, (_, column) => header![column]?.align), header: pad(header!), rows: body.map(pad)}]; +} + +function alignment(value: string | undefined): Alignment { + const align = value?.trim().toLowerCase(); + return align === 'left' || align === 'center' || align === 'right' ? align : undefined; +} + +/** HTML (a document, or a block inside Markdown) as blocks; its inline text is for the inline parser in HTML mode. */ +export function htmlToBlocks(source: string): Block[] { + return blocksOf(build(tokenize(source)).children); +} diff --git a/src/markdown/inline.ts b/src/markdown/inline.ts index 1a1c1407..08cdb548 100644 --- a/src/markdown/inline.ts +++ b/src/markdown/inline.ts @@ -1,4 +1,5 @@ import {safeHyperlinkTarget} from '../output/Hyperlinks.js'; +import {decodeEntities, decodeEntity, DROPPED, imageLabel, KNOWN, RAW_TEXT, readTag} from './markup.js'; /** * Inline Markdown for untrusted text (agent replies, GitHub bodies): code spans, emphasis, strong, strikethrough, @@ -6,6 +7,10 @@ import {safeHyperlinkTarget} from '../output/Hyperlinks.js'; * module only decides styling. A link target is kept only when it is a safe http(s) URL, so a hostile * `[docs](javascript:...)` or `[x](file:///...)` renders as plain text. Parsing is linear and bounded: a span's * delimiters never nest more than a few levels and unmatched delimiters stay literal text. + * + * With `html` (what `/view` shows, QA F01) a few HTML tags are read too: b/strong, i/em, s/del, code/kbd, a (the same + * http(s)-only rule), img (its alt text), br, and script-like elements dropped with their content; any other known + * element's tags are dropped and its text kept, and an unknown `` stays text. Without it, tags are text. */ export interface InlineStyle { @@ -38,28 +43,22 @@ function safeLink(url: string): string | undefined { return target && /^https?:/u.test(target) ? target : undefined; } -const INVISIBLE = /[\p{Cc}\p{Cf}\p{Co}\p{Zl}\p{Zp}]/u; - -const ENTITIES: Record = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', copy: '©', reg: '®', mdash: '—', ndash: '–', hellip: '…', rarr: '→', larr: '←', times: '×'}; - -function decodeEntity(source: string, index: number): {text: string; length: number} | undefined { - const match = /^&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]{2,8}));/u.exec(source.slice(index, index + 12)); - if (!match) return undefined; - if (match[3]) { const named = ENTITIES[match[3]]; return named ? {text: named, length: match[0].length} : undefined; } - const code = match[1] ? Number(match[1]) : parseInt(match[2]!, 16); - if (!code || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return undefined; - const text = String.fromCodePoint(code); - // An entity never smuggles in what the display scrubber removes: controls, bidi and other format characters - // (direction overrides, zero-width joiners), separators or private-use code points. - if (INVISIBLE.test(text)) return undefined; - return {text, length: match[0].length}; +export interface InlineOptions { + /** Read the HTML tags `/view` renders (see the module comment); otherwise tags are text. */ + html?: boolean; } /** Raw tokens before emphasis resolution: literal text, finished spans (code, links) and delimiter runs. */ type Token = | {kind: 'text'; text: string} | {kind: 'span'; span: InlineSpan[]} - | {kind: 'delim'; char: '*' | '_' | '~'; count: number; open: boolean; close: boolean}; + | {kind: 'delim'; char: '*' | '_' | '~'; count: number; open: boolean; close: boolean} + /** An HTML start or end tag that styles what is between (html mode); `target` is a link's refused URL, shown as text. */ + | {kind: 'tag'; style: 'strong' | 'em' | 'strike' | 'link'; open: boolean; link?: string; target?: string}; + +const TAG_STYLES: Record = {b: 'strong', strong: 'strong', i: 'em', em: 'em', cite: 'em', dfn: 'em', var: 'em', + s: 'strike', del: 'strike', strike: 'strike', a: 'link'}; +const CODE_TAGS = new Set(['code', 'kbd', 'samp', 'tt']); /** The text of a bracket pair starting at `start` (`[`), honoring one level of nested brackets and escapes. */ function bracket(source: string, start: number): number { @@ -162,8 +161,9 @@ function backtickRuns(source: string): Map { return runs; } -function tokenize(source: string): Token[] { +function tokenize(source: string, options: InlineOptions): Token[] { const tokens: Token[] = []; + const lower = options.html ? source.toLowerCase() : ''; const runs = backtickRuns(source); /** Per run length, how many runs are already behind the scan position. */ const consumed = new Map(); @@ -210,6 +210,12 @@ function tokenize(source: string): Token[] { index += auto[0].length; continue; } + const tag = options.html ? readTag(source, index) : undefined; + if (tag && KNOWN.has(tag.name)) { + flush(); + index = htmlTag(tag, source, lower, tokens); + continue; + } } if (char === '!' && source[index + 1] === '[' || char === '[') { const image = char === '!'; @@ -222,7 +228,7 @@ function tokenize(source: string): Token[] { const url = safeLink(target.url); if (image) tokens.push({kind: 'span', span: [{text: label.trim() || 'image', style: {image: true}}]}); else { - const inner = parseInline(label).map(span => ({text: span.text, style: {...span.style, ...(url && !span.style.code ? {link: url} : {})}})); + const inner = parseInline(label, options).map(span => ({text: span.text, style: {...span.style, ...(url && !span.style.code ? {link: url} : {})}})); // A link whose target was refused still shows its text, and the refused target as text so nothing is hidden. tokens.push({kind: 'span', span: url ? inner : [...inner, ...(target.url ? [{text: ` (${target.url.slice(0, 200)})`, style: {}}] : [])]}); } @@ -256,13 +262,49 @@ function tokenize(source: string): Token[] { return tokens; } +/** One HTML tag in html mode: its tokens, and where reading goes on. */ +function htmlTag(tag: NonNullable>, source: string, lower: string, tokens: Token[]): number { + const name = tag.name; + // An element dropped with its content (a script, a style, a form control) leaves nothing, up to its end tag. + if (!tag.closing && !tag.selfClosing && (DROPPED.has(name) || RAW_TEXT.has(name))) { + const close = lower.indexOf(`', close); + return end < 0 ? source.length : end + 1; + } + if (tag.closing) { + const style = TAG_STYLES[name]; + if (style) tokens.push({kind: 'tag', style, open: false}); + return tag.end; + } + if (name === 'br') { tokens.push({kind: 'text', text: ' '}); return tag.end; } + if (name === 'img') { tokens.push({kind: 'span', span: [{text: imageLabel(tag.attributes), style: {image: true}}]}); return tag.end; } + if (name === 'input' && tag.attributes.type?.toLowerCase() === 'checkbox') { tokens.push({kind: 'text', text: 'checked' in tag.attributes ? '☑ ' : '☐ '}); return tag.end; } + if (CODE_TAGS.has(name) && !tag.selfClosing) { + // Code is literal: its text up to the end tag, any markup inside dropped, references decoded (safely). + const close = lower.indexOf(`', close); + const code = decodeEntities(source.slice(tag.end, close).replace(/<[^<>]{0,200}>/gu, '')).replace(/\s+/gu, ' '); + if (code) tokens.push({kind: 'span', span: [{text: code, style: {code: true}}]}); + return end < 0 ? source.length : end + 1; + } + const style = TAG_STYLES[name]; + if (style === 'link') { + const target = decodeEntities(tag.attributes.href ?? '').trim(); + const link = target ? safeLink(target) : undefined; + tokens.push({kind: 'tag', style, open: true, ...(link ? {link} : {}), ...(!link && target ? {target: target.slice(0, 200)} : {})}); + } else if (style) tokens.push({kind: 'tag', style, open: true}); + // Any other known element: its tags go, its text stays. + return tag.end; +} + /** * Resolve emphasis with a simplified CommonMark delimiter stack: a closer pairs with the nearest compatible opener of * the same character; two or more on both sides make strong, `~~` makes strikethrough. Unpaired delimiters become text. */ function resolve(tokens: Token[]): InlineSpan[] { type Delim = {kind: 'delim'; char: '*' | '_' | '~'; count: number; length: number; open: boolean; close: boolean}; - type Node = {kind: 'text'; text: string} | {kind: 'span'; span: InlineSpan[]} | Delim; + type Node = Exclude | Delim; const nodes: Node[] = tokens.map(token => token.kind === 'delim' ? {...token, length: token.count} : token); type Wrap = {from: number; to: number; style: 'strong' | 'em' | 'strike'}; const wraps: Wrap[] = []; @@ -294,6 +336,9 @@ function resolve(tokens: Token[]): InlineSpan[] { } const spans: InlineSpan[] = []; const active = {strong: 0, em: 0, strike: 0}; + // HTML tags (html mode) style what they enclose; links nest, the innermost wins. + const tagged = {strong: 0, em: 0, strike: 0}; + const links: Array<{link?: string; target?: string}> = []; const starting = new Map(), ending = new Map(); for (const wrap of wraps) { starting.set(wrap.from, [...(starting.get(wrap.from) ?? []), wrap]); @@ -303,7 +348,21 @@ function resolve(tokens: Token[]): InlineSpan[] { // Leftover delimiter characters sit outside their pair: close before the closer, open after the opener. for (const wrap of ending.get(position) ?? []) active[wrap.style]--; const node = nodes[position]!; - const style: InlineStyle = {...(active.strong > 0 ? {strong: true} : {}), ...(active.em > 0 ? {em: true} : {}), ...(active.strike > 0 ? {strike: true} : {})}; + if (node.kind === 'tag') { + if (node.style === 'link') { + if (node.open) links.push({...(node.link ? {link: node.link} : {}), ...(node.target ? {target: node.target} : {})}); + else { + const closed = links.pop(); + // A link whose target was refused still shows its text, and the refused target as text so nothing is hidden. + if (closed?.target) spans.push({text: ` (${closed.target})`, style: {}}); + } + } else if (node.open) tagged[node.style]++; + else if (tagged[node.style] > 0) tagged[node.style]--; + continue; + } + const link = links.at(-1)?.link; + const style: InlineStyle = {...(active.strong + tagged.strong > 0 ? {strong: true} : {}), ...(active.em + tagged.em > 0 ? {em: true} : {}), + ...(active.strike + tagged.strike > 0 ? {strike: true} : {}), ...(link ? {link} : {})}; if (node.kind === 'text') spans.push({text: node.text, style}); else if (node.kind === 'span') for (const span of node.span) spans.push({text: span.text, style: {...style, ...span.style}}); else if (node.count > 0) spans.push({text: node.char.repeat(node.count), style}); @@ -320,9 +379,9 @@ function resolve(tokens: Token[]): InlineSpan[] { } /** Inline spans for one paragraph's source text. Soft line breaks become spaces; text past the bound stays plain. */ -export function parseInline(source: string): InlineSpan[] { +export function parseInline(source: string, options: InlineOptions = {}): InlineSpan[] { const bounded = source.length > MAX_INLINE ? source.slice(0, MAX_INLINE) : source; - const spans = resolve(tokenize(softBreaks(bounded))); + const spans = resolve(tokenize(softBreaks(bounded), options)); if (bounded.length < source.length) spans.push({text: softBreaks(source.slice(MAX_INLINE)), style: {}}); return spans; } @@ -335,6 +394,6 @@ function softBreaks(text: string): string { } /** The text a reader sees, without styling (search, copy-visible, width measurement). */ -export function plainInline(source: string): string { - return parseInline(source).map(span => span.text).join(''); +export function plainInline(source: string, options: InlineOptions = {}): string { + return parseInline(source, options).map(span => span.text).join(''); } diff --git a/src/markdown/markup.ts b/src/markdown/markup.ts new file mode 100644 index 00000000..22201f14 --- /dev/null +++ b/src/markdown/markup.ts @@ -0,0 +1,134 @@ +/** + * The pieces of HTML that `/view` reads (QA F01): character references and single tags, read by bounded linear scans. + * Nothing here builds a document, resolves a URL, loads a resource or runs anything. Shared by the HTML converter + * (html.ts) and the inline parser, which reads the same tags inside Markdown. + */ + +const ENTITIES: Record = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', copy: '©', reg: '®', mdash: '—', ndash: '–', hellip: '…', rarr: '→', larr: '←', times: '×'}; + +const INVISIBLE = /[\p{Cc}\p{Cf}\p{Co}\p{Zl}\p{Zp}]/u; + +/** One character reference at `index`, or undefined. */ +export function decodeEntity(source: string, index: number): {text: string; length: number} | undefined { + const match = /^&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]{2,8}));/u.exec(source.slice(index, index + 12)); + if (!match) return undefined; + if (match[3]) { const named = ENTITIES[match[3]]; return named ? {text: named, length: match[0].length} : undefined; } + const code = match[1] ? Number(match[1]) : parseInt(match[2]!, 16); + if (!code || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return undefined; + const text = String.fromCodePoint(code); + // An entity never smuggles in what the display scrubber removes: controls, bidi and other format characters + // (direction overrides, zero-width joiners), separators or private-use code points. + if (INVISIBLE.test(text)) return undefined; + return {text, length: match[0].length}; +} + +/** Every character reference in `text` decoded (unknown or unsafe ones stay as written). */ +export function decodeEntities(text: string): string { + if (!text.includes('&')) return text; + let out = ''; + for (let index = 0; index < text.length;) { + const entity = text[index] === '&' ? decodeEntity(text, index) : undefined; + if (entity) { out += entity.text; index += entity.length; } else { out += text[index]; index += 1; } + } + return out; +} + +/** How far one tag may run, and how much of an attribute value is kept. */ +const MAX_TAG = 4096; +const MAX_ATTRIBUTE = 2048; + +export interface Tag { + /** Lowercase element name. */ + name: string; + closing: boolean; + selfClosing: boolean; + /** Lowercase attribute names; raw values (references not decoded), each at most MAX_ATTRIBUTE characters. */ + attributes: Record; + /** Index just past the tag's `>`. */ + end: number; +} + +const NAME_START = /[A-Za-z]/u; +const NAME = /[A-Za-z0-9-]/u; +const SPACE = /[\s]/u; + +/** The tag starting at `start` (a `<`), or undefined when it is not one (then the `<` is text). */ +export function readTag(source: string, start: number): Tag | undefined { + const limit = Math.min(source.length, start + MAX_TAG); + let index = start + 1; + const closing = source[index] === '/'; + if (closing) index += 1; + if (!NAME_START.test(source[index] ?? '')) return undefined; + const nameStart = index; + while (index < limit && NAME.test(source[index]!) && index - nameStart < 32) index += 1; + const name = source.slice(nameStart, index).toLowerCase(); + if (index < limit && !SPACE.test(source[index]!) && source[index] !== '>' && source[index] !== '/') return undefined; + const attributes: Record = Object.create(null) as Record; + let count = 0; + while (index < limit) { + while (index < limit && SPACE.test(source[index]!)) index += 1; + const char = source[index]; + if (char === '>') return {name, closing, selfClosing: false, attributes, end: index + 1}; + if (char === '/' && source[index + 1] === '>') return {name, closing, selfClosing: true, attributes, end: index + 2}; + if (char === '/') { index += 1; continue; } + if (char === undefined || char === '<') return undefined; + const keyStart = index; + while (index < limit && !SPACE.test(source[index]!) && !'"\'>/=<'.includes(source[index]!)) index += 1; + const key = source.slice(keyStart, Math.min(index, keyStart + 64)).toLowerCase(); + if (index === keyStart) { index += 1; continue; } + while (index < limit && SPACE.test(source[index]!)) index += 1; + let value = ''; + if (source[index] === '=') { + index += 1; + while (index < limit && SPACE.test(source[index]!)) index += 1; + const quote = source[index]; + if (quote === '"' || quote === "'") { + const close = source.indexOf(quote, index + 1); + if (close < 0 || close >= limit) return undefined; + value = source.slice(index + 1, close); + index = close + 1; + } else { + const valueStart = index; + while (index < limit && !SPACE.test(source[index]!) && source[index] !== '>') index += 1; + value = source.slice(valueStart, index); + } + } + // Only the first of a repeated attribute counts, and a hostile tag cannot make the record unbounded. + if (!(key in attributes) && count < 64) { attributes[key] = value.slice(0, MAX_ATTRIBUTE); count += 1; } + } + return undefined; +} + +/** Elements with no content or end tag. */ +export const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']); + +/** + * Elements dropped with everything in them: code (script), styling (style), templates and fallbacks for scripts, + * embedded or interactive content, and the document's metadata. None of it is shown, loaded or run. + */ +export const DROPPED = new Set(['script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'applet', 'svg', 'math', 'canvas', 'audio', 'video', + 'form', 'button', 'select', 'textarea', 'option', 'optgroup', 'datalist', 'head', 'title', 'meta', 'link', 'base', 'frame', 'frameset', 'noframes', 'dialog', + 'map', 'portal', 'xmp', 'plaintext', 'noembed', 'slot']); + +/** Elements whose content is raw text up to their end tag: never read as markup (all of them are dropped). */ +export const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'plaintext', 'noscript', 'iframe', 'noembed', 'noframes', 'template']); + +/** Block-level elements (CommonMark's HTML block names, and a few more containers). */ +export const BLOCK = new Set(['address', 'article', 'aside', 'blockquote', 'body', 'caption', 'center', 'colgroup', 'dd', 'details', 'dir', 'div', 'dl', 'dt', + 'fieldset', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'html', 'legend', 'li', 'main', 'menu', 'nav', 'ol', + 'p', 'pre', 'search', 'section', 'summary', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul']); + +/** Inline elements read for their meaning (emphasis, code, links, images, breaks) or just for their text. */ +export const INLINE = new Set(['a', 'abbr', 'b', 'bdi', 'big', 'br', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'font', 'i', 'img', 'input', 'ins', 'kbd', + 'label', 'mark', 'picture', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small', 'source', 'span', 'strike', 'strong', 'sub', 'sup', 'time', 'tt', 'u', 'var', 'wbr', 'col', 'track', 'param', 'area', 'keygen']); + +/** Names NMSh reads as HTML at all; any other `` stays text (`a generic `). */ +export const KNOWN = new Set([...VOID, ...DROPPED, ...RAW_TEXT, ...BLOCK, ...INLINE]); + +/** An image's words: its alt text, else its file name, else "image". */ +export function imageLabel(attributes: Record): string { + const alt = decodeEntities(attributes.alt ?? '').replace(/\s+/gu, ' ').trim(); + if (alt) return alt.slice(0, 300); + const name = decodeEntities(attributes.src ?? '').split(/[?#]/u)[0]!.split('/').filter(Boolean).pop() ?? ''; + return name.slice(0, 120) || 'image'; +} diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 001079a3..7fe8aa36 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -6,6 +6,7 @@ import type {SyntaxSgr} from '../input/syntaxTheme.js'; import {displayWidth, truncateAnsi} from '../util/text.js'; import {stripTerminalControls} from '../util/terminalControls.js'; import {parseBlocks, type Alignment, type Block} from './blocks.js'; +import {htmlToBlocks} from './html.js'; import {highlightCode, type CodeTokenKind} from './highlight.js'; import {parseInline, plainInline, type InlineSpan, type InlineStyle} from './inline.js'; @@ -37,6 +38,8 @@ export interface MarkdownOptions { syntax?: SyntaxSgr; /** Base text role for prose (agent replies use primary; quieter contexts may pass secondary). */ tone?: 'primary' | 'secondary'; + /** Render HTML inside the Markdown (`/view`, QA F01); otherwise tags are shown as text. */ + html?: boolean; } const RESET = '\u001b[0m'; @@ -54,6 +57,7 @@ interface Context { fg: (color: RgbColor) => string; tone: RgbColor; codeIndex: number; + html: boolean; } function glyph(context: Context, nerd: string, safe: string): string { @@ -188,7 +192,7 @@ function wrapAtoms(atoms: readonly Atom[], width: number, first: string, rest: s function inlineRows(source: string, width: number, context: Context, base: string, first = '', rest = first): Array<{ansi: string; plain: string}> { // A hard break inside a paragraph is a new row with the same hanging indent. - return source.split('\n').flatMap((line, index) => wrapAtoms(atomsOf(parseInline(line), context, base), width, index === 0 ? first : rest, rest)); + return source.split('\n').flatMap((line, index) => wrapAtoms(atomsOf(parseInline(line, {html: context.html}), context, base), width, index === 0 ? first : rest, rest)); } // ---- Code --------------------------------------------------------------------------------------------------------- @@ -216,7 +220,7 @@ function tokenSgr(kind: CodeTokenKind, context: Context): string { export function codeTokenStyle(kind: CodeTokenKind, options: {syntax?: SyntaxSgr; level?: ColorLevel} = {}): string { const level = options.level ?? colorLevel(); return tokenSgr(kind, {level, safe: false, hyperlinks: false, ...(options.syntax ? {syntax: options.syntax} : {}), - fg: color => colorEscape(38, color, level), tone: UI_COLORS.primary, codeIndex: 0}); + fg: color => colorEscape(38, color, level), tone: UI_COLORS.primary, codeIndex: 0, html: false}); } function codeRows(block: Extract, width: number, context: Context): MarkdownRow[] { @@ -264,11 +268,11 @@ function alignCell(text: string, width: number, align: Alignment): string { function tableRows(block: Extract, width: number, context: Context): MarkdownRow[] { const columns = block.header.length; const all = [block.header, ...block.rows]; - const natural = Array.from({length: columns}, (_, column) => Math.max(1, ...all.map(row => displayWidth(plainInline(row[column] ?? ''))))); + const natural = Array.from({length: columns}, (_, column) => Math.max(1, ...all.map(row => displayWidth(plainInline(row[column] ?? '', {html: context.html}))))); const separator = glyph(context, ' │ ', ' | '); const chrome = 3 * (columns - 1); const available = width - chrome; - const minimum = Array.from({length: columns}, (_, column) => Math.min(natural[column]!, Math.max(3, Math.min(10, ...[block.header[column] ?? ''].map(cell => displayWidth(plainInline(cell))))))); + const minimum = Array.from({length: columns}, (_, column) => Math.min(natural[column]!, Math.max(3, Math.min(10, ...[block.header[column] ?? ''].map(cell => displayWidth(plainInline(cell, {html: context.html}))))))); if (available < minimum.reduce((a, b) => a + b, 0)) { // Too narrow for a grid: each row becomes a short record of "Header: value" lines. const rows: MarkdownRow[] = []; @@ -276,7 +280,7 @@ function tableRows(block: Extract, width: number, contex block.rows.forEach((row, index) => { if (index) rows.push({ansi: '', plain: '', kind: 'blank'}); row.forEach((cell, column) => { - const label = plainInline(block.header[column] ?? ''); + const label = plainInline(block.header[column] ?? '', {html: context.html}); const lead = `${subtle}${label}:${RESET} `; for (const line of inlineRows(cell || '—', width, context, base, lead, ' ')) rows.push({...line, kind: 'table'}); }); @@ -378,6 +382,15 @@ function renderBlocks(blocks: readonly Block[], width: number, context: Context, /** Rows for untrusted Markdown at `columns` cells. */ export function renderMarkdown(source: string, columns: number, options: MarkdownOptions = {}): MarkdownRow[] { + return renderSource(source, columns, options, safe => parseBlocks(safe, {html: Boolean(options.html)})); +} + +/** Rows for an untrusted HTML document at `columns` cells (`/view`, QA F01): its supported subset, never run or loaded. */ +export function renderHtml(source: string, columns: number, options: Omit = {}): MarkdownRow[] { + return renderSource(source, columns, {...options, html: true}, htmlToBlocks); +} + +function renderSource(source: string, columns: number, options: MarkdownOptions, parse: (safe: string) => Block[]): MarkdownRow[] { const width = Math.max(4, columns); const level = options.level ?? colorLevel(); const context: Context = { @@ -388,10 +401,11 @@ export function renderMarkdown(source: string, columns: number, options: Markdow fg: color => colorEscape(38, color, level), tone: options.tone === 'secondary' ? UI_COLORS.secondary : UI_COLORS.primary, codeIndex: 0, + html: Boolean(options.html), }; // The renderer never trusts its caller to have scrubbed: terminal controls and bidi formatting go, tabs become spaces. const safe = source.split('\n').map(line => stripTerminalControls(line.replace(/\t/gu, ' '), Number.MAX_SAFE_INTEGER)).join('\n'); - const rows = renderBlocks(parseBlocks(safe), width, context, 0); + const rows = renderBlocks(parse(safe), width, context, 0); while (rows.length && rows.at(-1)!.kind === 'blank') rows.pop(); // A last line of defence: no row may exceed the width whatever a rule above computed. return rows.map(row => displayWidth(row.ansi) > width ? {...row, ansi: truncateAnsi(row.ansi, width)} : row); diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index e4759eec..6d383a6f 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -1,7 +1,7 @@ import type {Key} from '../terminal/keys.js'; import type {SyntaxSgr} from '../input/syntaxTheme.js'; import {graphemes} from '../input/inputLayout.js'; -import {codeTokenStyle, renderMarkdown, type MarkdownRow} from '../markdown/render.js'; +import {codeTokenStyle, renderHtml, renderMarkdown, type MarkdownRow} from '../markdown/render.js'; import {highlightCode, type CodeToken} from '../markdown/highlight.js'; import {colorLevel} from '../presentation/capabilities.js'; import {renderControlRows} from '../ui/controls.js'; @@ -14,9 +14,10 @@ import {formatBytes, inert, type ViewDocument} from './document.js'; /** * The native read-only viewer (#397): one document in a full-height panel, never the transcript. Drawing is bounded * to what is visible: only the lines on screen are laid out, one wrapped line takes at most MAX_WRAP_ROWS rows, syntax - * roles cover the first HIGHLIGHT_LINES lines and Markdown renders up to RENDER_MARKDOWN_BYTES. Every character is - * drawn inert: control and format characters (bidi overrides, zero-width marks) are shown as visible marks and tabs - * as spaces, and Markdown links are links only for the renderer's allowed targets. + * roles cover the first HIGHLIGHT_LINES lines and Markdown and HTML render up to RENDER_MARKDOWN_BYTES. Every character + * is drawn inert: control and format characters (bidi overrides, zero-width marks) are shown as visible marks and tabs + * as spaces, and links are links only for the renderer's allowed targets. HTML (a file, or inside Markdown) is + * rendered natively from its supported subset: never run, styled by CSS or loaded (QA F01). */ const HIGHLIGHT_LINES = 20_000; @@ -63,11 +64,16 @@ export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: export interface ViewerLook {syntax?: SyntaxSgr; hyperlinks?: boolean} export function createViewer(document: ViewDocument): ViewerState { - const markdown = document.format === 'markdown' && Buffer.byteLength(document.text) <= RENDER_MARKDOWN_BYTES; - const note = document.format === 'markdown' && !markdown ? 'Too large to render: showing the Markdown source.' : undefined; + const renderable = renders(document); + const markdown = renderable && Buffer.byteLength(document.text) <= RENDER_MARKDOWN_BYTES; + const note = renderable && !markdown ? `Too large to render: showing the ${sourceName(document)} source.` : undefined; return {document, top: 0, sub: 0, left: 0, wrap: true, numbers: !markdown, rendered: markdown, ...(note ? {note} : {})}; } +/** Markdown and HTML have a rendered view beside their source. */ +const renders = (document: ViewDocument) => document.format === 'markdown' || document.format === 'html'; +const sourceName = (document: ViewDocument) => document.format === 'html' ? 'HTML' : 'Markdown'; + // ---- Text --------------------------------------------------------------------------------------------------------- /** Characters drawn as visible marks: controls (not tab) and invisible format, line and paragraph separators. */ @@ -144,7 +150,8 @@ function gutterWidth(state: ViewerState): number { function markdownRows(state: ViewerState, width: number, look: ViewerLook): MarkdownRow[] { if (state.markdown?.width !== width) { - state.markdown = {width, rows: renderMarkdown(state.document.text, width, {...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks)})}; + const options = {...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks)}; + state.markdown = {width, rows: state.document.format === 'html' ? renderHtml(state.document.text, width, options) : renderMarkdown(state.document.text, width, {...options, html: true})}; } return state.markdown.rows; } @@ -393,8 +400,8 @@ export function viewerKey(state: ViewerState, key: Key, columns: number, height: else if ((key.kind === 'left' || key.kind === 'right') && !state.wrap && !state.rendered) state.left = Math.max(0, state.left + (key.kind === 'left' ? -HORIZONTAL_STEP : HORIZONTAL_STEP)); else if (text === 'w' && !state.rendered) { state.wrap = !state.wrap; state.left = 0; state.sub = 0; clampTop(state, width, body, look); } else if (text === 'l' && !state.rendered) { state.numbers = !state.numbers; state.sub = 0; } - else if (text === 'm' && state.document.format === 'markdown') { - if (!state.rendered && Buffer.byteLength(state.document.text) > RENDER_MARKDOWN_BYTES) state.note = 'Too large to render: showing the Markdown source.'; + else if (text === 'm' && renders(state.document)) { + if (!state.rendered && Buffer.byteLength(state.document.text) > RENDER_MARKDOWN_BYTES) state.note = `Too large to render: showing the ${sourceName(state.document)} source.`; else { state.rendered = !state.rendered; state.numbers = !state.rendered; state.top = 0; state.sub = 0; state.match = undefined; state.matchCount = undefined; } } else if (text === '/' || key.kind === 'find') state.input = {kind: 'search', value: state.query ?? ''}; @@ -416,7 +423,7 @@ function detail(document: ViewDocument): string { const status = document.origin.exitCode === undefined ? '' : ` · exit ${document.origin.exitCode}`; return `output ${document.origin.number}${status} · ${lines}${document.format === 'diff' ? ' · diff' : ''}`; } - const kind = document.format === 'markdown' ? 'Markdown' : document.format === 'diff' ? 'diff' : document.language ?? 'text'; + const kind = document.format === 'markdown' ? 'Markdown' : document.format === 'html' ? 'HTML' : document.format === 'diff' ? 'diff' : document.language ?? 'text'; const ending = document.lineEnding === 'crlf' ? ' · CRLF' : document.lineEnding === 'mixed' ? ' · mixed line endings' : ''; const link = document.origin.symlink ? ` · → ${inert(document.origin.realPath)}` : ''; return `${kind} · ${lines} · ${formatBytes(document.origin.bytes)}${ending}${link}`; @@ -440,7 +447,7 @@ function controls(state: ViewerState, columns: number): string[] { if (state.input) return renderControlRows([['Enter', state.input.kind === 'search' ? 'find' : 'go'], ['Esc', 'cancel']], width); return renderControlRows([ ['↑↓', 'scroll'], ['/', 'find'], ...(state.query ? [['n/N', 'next/previous'] as const] : []), [':', 'line'], - ...(state.document.format === 'markdown' ? [['m', state.rendered ? 'source' : 'rendered'] as const] : []), + ...(renders(state.document) ? [['m', state.rendered ? 'source' : 'rendered'] as const] : []), ...(!state.rendered ? [['w', state.wrap ? 'no wrap' : 'wrap'] as const, ['l', 'numbers'] as const] : []), ['c', 'copy all'], ...(state.document.origin.kind === 'file' ? [['e', 'edit'] as const, ['o', 'open in editor'] as const] : []), ['Esc', 'close'], ], width); diff --git a/src/viewer/document.ts b/src/viewer/document.ts index e27db3df..3ef6ef77 100644 --- a/src/viewer/document.ts +++ b/src/viewer/document.ts @@ -17,7 +17,8 @@ export const MAX_VIEW_LINES = 200_000; const SNIFF_BYTES = 8192; const CHUNK = 64 * 1024; -export type DocumentFormat = 'text' | 'markdown' | 'diff'; +/** html: rendered natively, never run or loaded (QA F01). */ +export type DocumentFormat = 'text' | 'markdown' | 'html' | 'diff'; export type DocumentOrigin = | {kind: 'file'; path: string; realPath: string; symlink: boolean; bytes: number; mtimeMs: number} @@ -63,11 +64,12 @@ export function displayPath(path: string, home = homedir()): string { const FILE_NAMES: Record = {makefile: 'makefile', gnumakefile: 'makefile', dockerfile: 'dockerfile', containerfile: 'dockerfile', '.bashrc': 'shell', '.zshrc': 'shell', '.zshenv': 'shell', '.profile': 'shell', '.bash_profile': 'shell', 'config.fish': 'fish', justfile: 'makefile'}; -/** How a file reads: Markdown and diffs by their extension, syntax roles by extension or well-known name. */ +/** How a file reads: Markdown, HTML and diffs by their extension, syntax roles by extension or well-known name. */ export function formatForPath(path: string): {format: DocumentFormat; language?: string} { const name = basename(path).toLowerCase(); const extension = extname(name).slice(1); if (extension === 'md' || extension === 'markdown' || extension === 'mdx') return {format: 'markdown', language: 'markdown'}; + if (extension === 'html' || extension === 'htm' || extension === 'xhtml') return {format: 'html', language: languageId('html') ?? 'markup'}; if (extension === 'diff' || extension === 'patch') return {format: 'diff', language: 'diff'}; const language = languageId(FILE_NAMES[name] ?? extension); return {format: 'text', ...(language ? {language} : {})}; diff --git a/tests/viewHtml.test.ts b/tests/viewHtml.test.ts new file mode 100644 index 00000000..c0b26d6e --- /dev/null +++ b/tests/viewHtml.test.ts @@ -0,0 +1,191 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {renderHtml, renderMarkdown} from '../src/markdown/render.js'; +import {formatForPath, type ViewDocument} from '../src/viewer/document.js'; +import {createViewer, renderViewer, viewerKey} from '../src/viewer/ViewerPanel.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +/** + * QA F01 (v0.22): `/view` showed a README's embedded HTML (``, ``, `

    `) literally. HTML is now + * rendered natively, as terminal rows, for HTML files and for HTML inside Markdown that `/view` shows: a supported + * subset (headings, paragraphs, lists, tables, code, quotes, rules, inline emphasis, code, links and images) mapped onto + * the Markdown renderer's own blocks. Nothing is executed, fetched or read: scripts, styles, embedded content and event + * handlers are dropped, CSS is ignored, an image is its alt text, links follow the renderer's http(s)-only rule, and + * the parser is linear and bounded in depth, tags and attribute length. Agent replies and GitHub bodies keep showing + * tags as text: HTML is rendered only where `/view` asks for it. + */ + +const plain = (rows: ReadonlyArray<{plain: string}>) => rows.map(row => row.plain).join('\n'); +const markdown = (source: string, columns = 80) => renderMarkdown(source, columns, {level: 'truecolor', glyphs: 'nerd', html: true}); +const html = (source: string, columns = 80, hyperlinks = false) => renderHtml(source, columns, {level: 'truecolor', glyphs: 'nerd', hyperlinks}); + +const README = `
    + + + + NMSh logo + + +# notMyShell + +

    + CI status + A calmer terminal for zsh, Bash and Fish. +

    + +
    + +Press Ctrl+C to stop.
    Next line. + +- one +- two`; + +test('F01: a README\'s embedded HTML renders: images as alt text, inline styles, Markdown between the blocks unchanged', () => { + const rows = markdown(README, 120); + const text = plain(rows); + for (const tag of ['', '', '', '', '', '
    ']) assert.ok(!text.includes(tag), `${tag} is not shown`); + assert.match(text, /\[image: NMSh logo\]/u); + assert.match(text, /\[image: CI status\]/u); + assert.ok(rows.some(row => row.kind === 'heading' && row.plain === 'notMyShell'), 'Markdown between HTML blocks still renders'); + assert.match(text, /A calmer terminal for zsh, Bash and Fish\./u); + assert.match(text, /Press Ctrl\+C to stop\. Next line\./u); + assert.match(text, /• one\n• two/u); + const styled = rows.map(row => row.ansi).join('\n'); + assert.match(styled, /\u001b\[1m[^\n]*A/u, ' is bold'); +}); + +test('F01: an HTML document renders its structure: headings, paragraphs, nested lists, tables, code, quotes and rules', () => { + const rows = html(`T +

    Guide & notes

    +

    First paragraph + with strong, em, a < b and old.

    +
    1. three
      • nested
    2. four
    + +
    KeyValue
    a1
    b | c2
    +
    const x = 1 < 2;
    +  return x;
    +

    Quoted

    +
    +
    • done
    • open
    +
    More

    Hidden text

    + `); + const text = plain(rows); + assert.ok(rows.some(row => row.kind === 'heading' && row.plain === 'Guide & notes')); + assert.match(text, /First paragraph with strong, em, a < b and old\./u, 'whitespace collapses; entities decode'); + assert.match(text, /3\. three\n {3}◦ nested\n4\. four/u); + assert.ok(rows.some(row => row.kind === 'table' && /Key/u.test(row.plain) && /Value/u.test(row.plain))); + assert.ok(rows.some(row => row.kind === 'table' && /b \| c/u.test(row.plain)), 'a cell keeps its own pipe'); + assert.ok(rows.some(row => row.kind === 'code' && /const x = 1 < 2;/u.test(row.plain))); + assert.ok(rows.some(row => row.kind === 'code' && /^\s*return x;/u.test(row.plain.replace(/^.*?│ ?/u, ''))) || /return x;/u.test(text)); + assert.ok(rows.some(row => row.kind === 'quote' && /Quoted/u.test(row.plain))); + assert.ok(rows.some(row => row.kind === 'rule')); + assert.match(text, /☑ done\n☐ open/u); + assert.match(text, /More\n*Hidden text/u); + assert.doesNotMatch(text, /color:red||\bT\b\n/u, 'head and styles are not content'); +}); + +test('F01 security: nothing executes, loads or styles: scripts, styles, handlers, embeds and unsafe links are dropped', () => { + const rows = html(`<p onclick="alert(1)">Safe <span style="color:red">text</span></p> + <script>alert('x')</script><style>p{display:none}</style><template><p>tmpl</p></template> + <noscript>ns</noscript><iframe src="https://evil.example/"></iframe><object data="x.swf"></object><embed src="x"> + <svg onload="alert(1)"><text>svgtext</text></svg><math><mi>x</mi></math><form action="https://evil"><input value="secret"><button>Go</button></form> + <img src="x" onerror="alert(1)" alt="pic"> + <p><a href="javascript:alert(1)">js</a> <a href="file:///etc/passwd">file</a> <a href="data:text/html,x">data</a> <a href="https://ok.example/">ok</a></p>`, 80, true); + const text = plain(rows); + for (const banned of ["alert('x')", 'tmpl', 'svgtext', 'secret', 'Go', 'display', 'onclick', 'onerror', 'evil', 'x.swf', '\nns']) assert.ok(!text.includes(banned), `${banned} not shown`); + // A refused link keeps its text and shows its target as inert text, as in Markdown: nothing is hidden or followed. + assert.match(text, /js \(javascript:alert\(1\)\) file \(file:\/\/\/etc\/passwd\)/u); + assert.match(text, /Safe text/u); + assert.match(text, /\[image: pic\]/u); + const styled = rows.map(row => row.ansi).join('\n'); + const links = [...styled.matchAll(/\u001b\]8;;([^\u0007\u001b]*)/gu)].map(match => match[1]).filter(Boolean); + assert.deepEqual(links, ['https://ok.example/'], 'only the http(s) link is a link'); +}); + +test('F01 security: terminal controls and invisible characters never come through text, entities or attributes', () => { + const rows = html(`<p>A\u001b[31mred\u001b]52;c;aGk=\u0007  ]0;t ‮gpj.exe x​y</p> + <img alt="\u001b[2Jalt" src="a"><a href="https://ok.example/\u001b]8;;evil">t</a>`, 60, true); + for (const row of rows) { + assert.doesNotMatch(row.plain, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‮]/u, JSON.stringify(row.plain)); + assert.doesNotMatch(row.ansi, /\u001b\[2J|\u001b\]52|\u001b\]0;/u, 'no screen, clipboard or title control survives'); + } +}); + +test('F01 limits: deep nesting, many tags, long attributes and unclosed markup stay bounded and never throw', () => { + const cases = [ + '<div>'.repeat(100_000) + 'deep' + '</div>'.repeat(100_000), + '<b>'.repeat(50_000) + 'x', + `<img alt="${'a'.repeat(200_000)}">`, + '<p>' + '<span>a</span>'.repeat(60_000), + '<table>' + '<tr><td>x</td></tr>'.repeat(20_000), + '<ul>' + '<li>'.repeat(30_000) + 'item', + '<p unterminated="never closed', + '<!-- comment never closed', + '<a href="https://x/">' + '['.repeat(50_000), + ]; + for (const source of cases) { + const started = performance.now(); + const rows = html(source, 40); + assert.ok(performance.now() - started < 4000, `bounded: ${source.slice(0, 30)}`); + for (const row of rows.slice(0, 200)) assert.ok(displayWidth(row.ansi) <= 40); + } + assert.match(plain(html('<div>'.repeat(100_000) + 'deep' + '</div>'.repeat(100_000))), /deep/u, 'content past the depth bound is still shown'); +}); + +test('F01: rendering HTML reads no files and makes no requests (the module imports nothing that could)', () => { + const source = readFileSync(new URL('../src/markdown/html.ts', import.meta.url), 'utf8'); + const imports = [...source.matchAll(/^import .* from '([^']+)';$/gmu)].map(match => match[1]!); + for (const name of imports) assert.ok(!/^node:|^(?:fs|net|http|https|child_process|dns|tls)$/u.test(name), `html.ts imports ${name}`); +}); + +test('F01: agent replies and GitHub bodies are unchanged: without the HTML option, tags stay text', () => { + const text = plain(renderMarkdown('Use a <div> here, or <b>this</b>.\n\n<p align="center">x</p>', 80, {level: 'truecolor'})); + assert.match(text, /Use a <div> here, or <b>this<\/b>\./u); + assert.match(text, /<p align="center">x<\/p>/u); + // An unknown tag-like word in /view's Markdown also stays text. + assert.match(plain(markdown('A generic <T> and <placeholder> stay.')), /A generic <T> and <placeholder> stay\./u); +}); + +test('F01: narrow widths fit, and NO_COLOR and Safe glyphs keep structure without color', () => { + for (const columns of [20, 40, 80]) { + for (const row of html('<h2>Title that is fairly long</h2><table><tr><th>Name</th><th>Description</th></tr><tr><td>a</td><td>a long cell that wraps</td></tr></table><ul><li>item</li></ul>', columns)) { + assert.ok(displayWidth(row.ansi) <= columns, `${columns}: ${row.plain}`); + } + } + const safe = renderHtml('<ul><li>x</li></ul><hr><blockquote>q</blockquote><code>c</code>', 40, {level: 'none', glyphs: 'safe'}); + assert.match(plain(safe), /- x/u); + assert.match(plain(safe), /`c`/u, 'code keeps backticks without color'); + for (const row of safe) assert.doesNotMatch(row.ansi, /\u001b\[[0-9;]*[34]8;/u, 'no color'); +}); + +function htmlDocument(text: string, path = '/tmp/page.html'): ViewDocument { + return {title: path, origin: {kind: 'file', path, realPath: path, symlink: false, bytes: text.length, mtimeMs: 0}, lines: text.split('\n'), text, + format: formatForPath(path).format, language: formatForPath(path).language, notes: [], lineEnding: 'lf', complete: true}; +} + +test('F01 viewer: .html opens rendered; m shows the source and back; search finds rendered text; narrow fits', () => { + assert.equal(formatForPath('/x/index.html').format, 'html'); + assert.equal(formatForPath('/x/page.HTM').format, 'html'); + assert.equal(formatForPath('/x/README.md').format, 'markdown'); + const state = createViewer(htmlDocument('<h1>Page</h1>\n<p>Hello <b>world</b></p>\n<script>alert(1)</script>')); + assert.equal(state.rendered, true); + let screen = renderViewer(state, 60, 20).map(row => stripAnsi(row)).join('\n'); + assert.match(screen, /Page/u); + assert.match(screen, /Hello world/u); + assert.doesNotMatch(screen, /alert|<p>/u); + viewerKey(state, {kind: 'text', value: 'm'}, 60, 20); + assert.equal(state.rendered, false); + screen = renderViewer(state, 60, 20).map(row => stripAnsi(row)).join('\n'); + assert.match(screen, /<p>Hello <b>world<\/b><\/p>/u, 'source mode shows the file as it is'); + viewerKey(state, {kind: 'text', value: 'm'}, 60, 20); + assert.equal(state.rendered, true); + viewerKey(state, {kind: 'text', value: '/'}, 60, 20); + for (const char of 'world') viewerKey(state, {kind: 'text', value: char}, 60, 20); + viewerKey(state, {kind: 'enter'}, 60, 20); + assert.equal(state.matchCount, 1); + for (const row of renderViewer(state, 24, 12)) assert.ok(displayWidth(row) <= 24); + // Markdown in /view renders its embedded HTML too. + const readme = createViewer({...htmlDocument('# T\n\n<p align="center"><img alt="Logo" src="x.png"></p>', '/tmp/README.md')}); + assert.match(renderViewer(readme, 60, 20).map(row => stripAnsi(row)).join('\n'), /\[image: Logo\]/u); +}); From baad272e282d7a7d61fb43126e0b470c45e4d840 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:13:25 +0530 Subject: [PATCH 3/3] /view HTML: an unclosed quote or tag never makes each tag rescan the document (QA F01) Self-review of the HTML reader: a quoted attribute was searched for its closing quote past the tag's own bound, and in Markdown each unclosed <code> searched to the end again (a 64 KiB paragraph of them took 125 ms, so a 1 MiB document could take seconds to open). The quote search now stops at the tag's bound, and close-tag searches remember their answer per name, so they only move forward. Worst cases measured: 14 ms per 64 KiB paragraph, about 300 ms per MiB of HTML; covered in the limits test. --- src/markdown/inline.ts | 26 +++++++++++++++++++++----- src/markdown/markup.ts | 6 ++++-- tests/viewHtml.test.ts | 7 +++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/markdown/inline.ts b/src/markdown/inline.ts index 08cdb548..9358f9b5 100644 --- a/src/markdown/inline.ts +++ b/src/markdown/inline.ts @@ -163,7 +163,7 @@ function backtickRuns(source: string): Map<number, number[]> { function tokenize(source: string, options: InlineOptions): Token[] { const tokens: Token[] = []; - const lower = options.html ? source.toLowerCase() : ''; + const closes = options.html ? closingTags(source) : () => -1; const runs = backtickRuns(source); /** Per run length, how many runs are already behind the scan position. */ const consumed = new Map<number, number>(); @@ -213,7 +213,7 @@ function tokenize(source: string, options: InlineOptions): Token[] { const tag = options.html ? readTag(source, index) : undefined; if (tag && KNOWN.has(tag.name)) { flush(); - index = htmlTag(tag, source, lower, tokens); + index = htmlTag(tag, source, closes, tokens); continue; } } @@ -262,12 +262,28 @@ function tokenize(source: string, options: InlineOptions): Token[] { return tokens; } +/** + * Where `</name` next appears at or after a position. Searches only move forward and each answer is remembered, so a + * run of unclosed tags (`<code>` after `<code>`) stays linear. + */ +function closingTags(source: string): (name: string, from: number) => number { + const lower = source.toLowerCase(); + const found = new Map<string, number>(); + return (name, from) => { + const known = found.get(name); + if (known !== undefined && (known < 0 || known >= from)) return known; + const at = lower.indexOf(`</${name}`, from); + found.set(name, at); + return at; + }; +} + /** One HTML tag in html mode: its tokens, and where reading goes on. */ -function htmlTag(tag: NonNullable<ReturnType<typeof readTag>>, source: string, lower: string, tokens: Token[]): number { +function htmlTag(tag: NonNullable<ReturnType<typeof readTag>>, source: string, closes: (name: string, from: number) => number, tokens: Token[]): number { const name = tag.name; // An element dropped with its content (a script, a style, a form control) leaves nothing, up to its end tag. if (!tag.closing && !tag.selfClosing && (DROPPED.has(name) || RAW_TEXT.has(name))) { - const close = lower.indexOf(`</${name}`, tag.end); + const close = closes(name, tag.end); const end = close < 0 ? -1 : source.indexOf('>', close); return end < 0 ? source.length : end + 1; } @@ -281,7 +297,7 @@ function htmlTag(tag: NonNullable<ReturnType<typeof readTag>>, source: string, l if (name === 'input' && tag.attributes.type?.toLowerCase() === 'checkbox') { tokens.push({kind: 'text', text: 'checked' in tag.attributes ? '☑ ' : '☐ '}); return tag.end; } if (CODE_TAGS.has(name) && !tag.selfClosing) { // Code is literal: its text up to the end tag, any markup inside dropped, references decoded (safely). - const close = lower.indexOf(`</${name}`, tag.end); + const close = closes(name, tag.end); if (close < 0) return tag.end; const end = source.indexOf('>', close); const code = decodeEntities(source.slice(tag.end, close).replace(/<[^<>]{0,200}>/gu, '')).replace(/\s+/gu, ' '); diff --git a/src/markdown/markup.ts b/src/markdown/markup.ts index 22201f14..5418750c 100644 --- a/src/markdown/markup.ts +++ b/src/markdown/markup.ts @@ -83,8 +83,10 @@ export function readTag(source: string, start: number): Tag | undefined { while (index < limit && SPACE.test(source[index]!)) index += 1; const quote = source[index]; if (quote === '"' || quote === "'") { - const close = source.indexOf(quote, index + 1); - if (close < 0 || close >= limit) return undefined; + // Only within this tag's bound: an unclosed quote never makes one tag scan the rest of the document. + let close = index + 1; + while (close < limit && source[close] !== quote) close += 1; + if (close >= limit) return undefined; value = source.slice(index + 1, close); index = close + 1; } else { diff --git a/tests/viewHtml.test.ts b/tests/viewHtml.test.ts index c0b26d6e..32e45f74 100644 --- a/tests/viewHtml.test.ts +++ b/tests/viewHtml.test.ts @@ -131,6 +131,13 @@ test('F01 limits: deep nesting, many tags, long attributes and unclosed markup s for (const row of rows.slice(0, 200)) assert.ok(displayWidth(row.ansi) <= 40); } assert.match(plain(html('<div>'.repeat(100_000) + 'deep' + '</div>'.repeat(100_000))), /deep/u, 'content past the depth bound is still shown'); + // Unclosed quotes and tags never make each tag rescan the rest: whole documents stay linear. + for (const [source, render] of [["<a x='".repeat(170_000), html], [('<a x="' + 'y'.repeat(4000)).repeat(250), html], + ['x <code>a '.repeat(6_000), markdown], ['x <script> '.repeat(6_000), markdown]] as const) { + const started = performance.now(); + render(source, 80); + assert.ok(performance.now() - started < 2500, `linear: ${source.slice(0, 12)}…`); + } }); test('F01: rendering HTML reads no files and makes no requests (the module imports nothing that could)', () => {