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(`${tag.name}`, index);
+ const end = close < 0 ? -1 : source.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 += `
`;
+ 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..9358f9b5 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 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();
@@ -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, closes, 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,65 @@ function tokenize(source: string): Token[] {
return tokens;
}
+/**
+ * Where `` after ``) stays linear.
+ */
+function closingTags(source: string): (name: string, from: number) => number {
+ const lower = source.toLowerCase();
+ const found = new Map();
+ 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>, 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 = closes(name, tag.end);
+ const end = close < 0 ? -1 : source.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 = 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, ' ');
+ 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 +352,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 +364,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 +395,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 +410,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..5418750c
--- /dev/null
+++ b/src/markdown/markup.ts
@@ -0,0 +1,136 @@
+/**
+ * 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 === "'") {
+ // 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 {
+ 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 272a91e9..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 {
@@ -158,7 +162,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;
}
@@ -185,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 ---------------------------------------------------------------------------------------------------------
@@ -213,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[] {
@@ -261,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[] = [];
@@ -273,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'});
});
@@ -375,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 = {
@@ -385,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/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}`);
+ }
+});
diff --git a/tests/viewHtml.test.ts b/tests/viewHtml.test.ts
new file mode 100644
index 00000000..32e45f74
--- /dev/null
+++ b/tests/viewHtml.test.ts
@@ -0,0 +1,198 @@
+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 = `
+
+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 ['