Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/ask/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'},
Expand Down
54 changes: 47 additions & 7 deletions src/markdown/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre>`, 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;

Expand Down Expand Up @@ -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('<!--')) return 2;
const name = /^<\/?([A-Za-z][A-Za-z0-9-]{0,31})(?=[\s/>]|$)/u.exec(text)?.[1]?.toLowerCase();
if (!name) return undefined;
if (BLOCK.has(name)) return 6;
// One complete tag alone on its line (`<picture>`, `<img …>`): only names HTML has, so `<T>` stays text.
return KNOWN.has(name) && text.length <= 4096 && /^<[^<>]*>\s*$/u.test(text) ? 7 : undefined;
}

/** Index just past an HTML block starting at `start`: kinds 1 and 2 to their end marker, the others to a blank line. */
function htmlBlockEnd(lines: readonly string[], start: number, kind: 1 | 2 | 6 | 7): number {
const closes = kind === 1 ? /<\/(?:pre|script|style|textarea)>/iu : kind === 2 ? /-->/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();
Expand All @@ -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; }

Expand All @@ -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;
Expand Down Expand Up @@ -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<Block, {kind: 'list'}>; next: number} {
function parseList(lines: readonly string[], start: number, depth: number, html: boolean): {block: Extract<Block, {kind: 'list'}>; next: number} {
const first = ITEM.exec(lines[start]!)!;
const ordered = /\d/u.test(first[2]!);
const delimiter = ordered ? first[2]!.slice(-1) : first[2]!;
Expand Down Expand Up @@ -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};
}
Loading
Loading