/**
* The block grammar for `Markdown`: folds markdown source into an ordered list of
* blocks. Pure and total — any string parses, and text that matches no block
* construct becomes a paragraph rather than being discarded.
*/
/** The heading levels ATX syntax can express, `#` through `######`. */
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
/** A parsed block. Inline markup inside `text` is resolved at render time. */
export type MarkdownBlock =
| { readonly type: 'heading'; readonly level: HeadingLevel; readonly text: string }
| { readonly type: 'paragraph'; readonly text: string }
| { readonly type: 'code'; readonly language: string | undefined; readonly code: string }
| {
readonly type: 'list';
readonly ordered: boolean;
readonly items: readonly string[];
/** The first item's number on an ordered list, so the render can offset `
`. */
readonly start?: number;
}
| { readonly type: 'blockquote'; readonly children: readonly MarkdownBlock[] }
| { readonly type: 'thematicBreak' };
const BLANK = /^[ \t]*$/;
/** A fence opener: three-or-more backticks or tildes, then an info string. */
const FENCE_OPEN = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
/**
* An ATX heading: 1–6 hashes, a required space, then text. A closing hash run is
* stripped only when whitespace separates it from the content, so `# C#` keeps
* its literal `C#`.
*/
const HEADING = /^[ \t]{0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/;
/** A thematic break: three-or-more `-`, `*` or `_`, spaces allowed between. */
const THEMATIC_BREAK = /^[ \t]{0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
/** A blockquote line: a `>` marker and its (optionally space-prefixed) content. */
const BLOCKQUOTE = /^[ \t]{0,3}>[ \t]?(.*)$/;
/** An unordered list item: a `-`, `*` or `+` bullet, a space, then content. */
const UNORDERED_ITEM = /^[ \t]*[-*+][ \t]+(.*)$/;
/** An ordered list item: a start number, a `.` or `)` delimiter, a space, then content. */
const ORDERED_ITEM = /^[ \t]*(\d{1,9})[.)][ \t]+(.*)$/;
/** True when a line starts a block that interrupts an open paragraph. */
function startsBlock(line: string): boolean {
return (
FENCE_OPEN.test(line) ||
HEADING.test(line) ||
THEMATIC_BREAK.test(line) ||
BLOCKQUOTE.test(line) ||
UNORDERED_ITEM.test(line) ||
ORDERED_ITEM.test(line)
);
}
/**
* The `closer` for an open fence: the same marker character, at least as long as
* the opener, alone on its line. A source that never closes its fence takes the
* rest of the document as code — the same run-to-end rule CommonMark applies —
* rather than silently dropping the block.
*/
function fenceCloser(marker: string): RegExp {
const char = marker.startsWith('`') ? '`' : '~';
return new RegExp(`^[ \\t]{0,3}${char}{${String(marker.length)},}[ \\t]*$`);
}
/** One parsed block and the line index the parse advanced past. */
interface ParseStep {
readonly block: MarkdownBlock;
readonly next: number;
}
/** A fenced code block; runs to its closing fence or the end of the document. */
function parseFence(lines: readonly string[], i: number): ParseStep | null {
const fence = FENCE_OPEN.exec(lines[i] ?? '');
if (fence === null) return null;
const closer = fenceCloser(fence[1] ?? '');
const info = (fence[2] ?? '').trim();
const body: string[] = [];
let j = i + 1;
for (; j < lines.length; j += 1) {
const next = lines[j] ?? '';
if (closer.test(next)) break;
body.push(next);
}
// Step past the closing fence when one is present; at end-of-document there is
// nothing to step past.
if (j < lines.length) j += 1;
return {
block: { type: 'code', language: info.length > 0 ? info : undefined, code: body.join('\n') },
next: j,
};
}
/** An ATX heading on a single line. */
function parseHeading(lines: readonly string[], i: number): ParseStep | null {
const heading = HEADING.exec(lines[i] ?? '');
if (heading === null) return null;
return {
block: {
type: 'heading',
level: (heading[1] ?? '').length as HeadingLevel,
text: heading[2] ?? '',
},
next: i + 1,
};
}
/** A thematic break on a single line. */
function parseThematicBreak(lines: readonly string[], i: number): ParseStep | null {
if (!THEMATIC_BREAK.test(lines[i] ?? '')) return null;
return { block: { type: 'thematicBreak' }, next: i + 1 };
}
/** A blockquote: the run of `>`-marked lines, parsed recursively as its own blocks. */
function parseBlockquote(lines: readonly string[], i: number): ParseStep | null {
if (!BLOCKQUOTE.test(lines[i] ?? '')) return null;
const inner: string[] = [];
let j = i;
for (; j < lines.length; j += 1) {
const quoted = BLOCKQUOTE.exec(lines[j] ?? '');
if (quoted === null) break;
inner.push(quoted[1] ?? '');
}
return { block: { type: 'blockquote', children: parseBlocks(inner) }, next: j };
}
/** An ordered or unordered list: the run of item lines sharing the leading marker. */
function parseList(lines: readonly string[], i: number): ParseStep | null {
const line = lines[i] ?? '';
const unordered = UNORDERED_ITEM.exec(line);
const ordered = unordered === null ? ORDERED_ITEM.exec(line) : null;
if (unordered === null && ordered === null) return null;
const pattern = ordered !== null ? ORDERED_ITEM : UNORDERED_ITEM;
// An ordered item's content is its second group, behind the start number; an
// unordered item's content is its first.
const textGroup = ordered !== null ? 2 : 1;
const items: string[] = [];
let j = i;
for (; j < lines.length; j += 1) {
const item = pattern.exec(lines[j] ?? '');
if (item === null) break;
items.push(item[textGroup] ?? '');
}
const block: MarkdownBlock =
ordered !== null
? { type: 'list', ordered: true, items, start: Number(ordered[1] ?? '1') }
: { type: 'list', ordered: false, items };
return { block, next: j };
}
/**
* A paragraph: this line and the following lines up to a blank line or the start
* of another block. The total fallback — always succeeds. Soft line breaks are
* kept as newlines.
*/
function parseParagraph(lines: readonly string[], i: number): ParseStep {
const paragraph: string[] = [lines[i] ?? ''];
let j = i + 1;
for (; j < lines.length; j += 1) {
const next = lines[j] ?? '';
if (BLANK.test(next) || startsBlock(next)) break;
paragraph.push(next);
}
return { block: { type: 'paragraph', text: paragraph.join('\n') }, next: j };
}
function parseBlocks(lines: readonly string[]): MarkdownBlock[] {
const blocks: MarkdownBlock[] = [];
let i = 0;
while (i < lines.length) {
if (BLANK.test(lines[i] ?? '')) {
i += 1;
continue;
}
const step =
parseFence(lines, i) ??
parseHeading(lines, i) ??
parseThematicBreak(lines, i) ??
parseBlockquote(lines, i) ??
parseList(lines, i) ??
parseParagraph(lines, i);
blocks.push(step.block);
i = step.next;
}
return blocks;
}
/**
* Folds markdown source into an ordered list of blocks. Pure and total: any
* string parses, and text that matches no block construct becomes a paragraph
* rather than being discarded.
*/
export function parseMarkdown(markdown: string): MarkdownBlock[] {
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
return parseBlocks(lines);
}