feat: render richer blocks and inline nodes (task 0004)

Extend the core parser and the CLI renderer to cover the everyday Markdown
body beyond headings and paragraphs: lists, blockquotes, fenced code,
thematic breaks, and inline emphasis, strong, code spans, and links.

Add remark-gfm to the pipeline so tables and strikethrough parse as their
own nodes. Any block or inline node core does not model falls through to
RawBlock / RawInline, whose verbatim value is position-sliced from the
original input so unmodelled constructs round-trip byte-for-byte.
This commit was merged in pull request #3.
This commit is contained in:
2026-07-29 07:34:56 -04:00
parent ab309d3226
commit 22befaaa71
9 changed files with 681 additions and 26 deletions

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"unified": "^11.0.5",
"yaml": "^2.9.0"

View File

@@ -1,16 +1,17 @@
import type { PhrasingContent, Root, RootContent } from "mdast";
import type { ListItem, Node, PhrasingContent, Root, RootContent } from "mdast";
import remarkFrontmatter from "remark-frontmatter";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import { unified } from "unified";
import { parse as parseYaml } from "yaml";
import type { Block, DocumentAST, Frontmatter, InlineNode } from "./types.ts";
import type { Block, DocumentAST, Frontmatter, InlineNode, ListItemBlock } from "./types.ts";
const processor = unified().use(remarkParse).use(remarkFrontmatter);
const processor = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
export function parse(input: string): DocumentAST {
const tree = processor.parse(input);
const frontmatter = extractFrontmatter(tree);
const blocks = tree.children.flatMap(translateBlock);
const blocks = tree.children.flatMap((node) => translateBlock(node, input));
return { frontmatter, blocks, diagnostics: [] };
}
@@ -26,21 +27,74 @@ function extractFrontmatter(tree: Root): Frontmatter {
return {};
}
function translateBlock(node: RootContent): Block[] {
if (node.type === "heading") {
return [{ type: "heading", level: node.depth, children: translateInline(node.children) }];
function translateBlock(node: RootContent, input: string): Block[] {
switch (node.type) {
case "heading":
return [
{ type: "heading", level: node.depth, children: translateInline(node.children, input) },
];
case "paragraph":
return [{ type: "paragraph", children: translateInline(node.children, input) }];
case "list":
return [
{
type: "list",
ordered: node.ordered ?? false,
items: node.children.map((item) => translateListItem(item, input)),
},
];
case "blockquote":
return [
{
type: "blockquote",
children: node.children.flatMap((child) => translateBlock(child, input)),
},
];
case "code":
return [{ type: "code", ...(node.lang ? { lang: node.lang } : {}), value: node.value }];
case "thematicBreak":
return [{ type: "thematicBreak" }];
// Frontmatter is captured separately and must not double as a block.
case "yaml":
return [];
default:
return [{ type: "raw", value: slice(node, input) }];
}
if (node.type === "paragraph") {
return [{ type: "paragraph", children: translateInline(node.children) }];
}
return [];
}
function translateInline(nodes: PhrasingContent[]): InlineNode[] {
return nodes.flatMap((node) => {
if (node.type === "text") {
return [{ type: "text", value: node.value }];
function translateListItem(item: ListItem, input: string): ListItemBlock {
return {
type: "listItem",
children: item.children.flatMap((child) => translateBlock(child, input)),
};
}
function translateInline(nodes: PhrasingContent[], input: string): InlineNode[] {
return nodes.flatMap((node): InlineNode[] => {
switch (node.type) {
case "text":
return [{ type: "text", value: node.value }];
case "emphasis":
return [{ type: "emphasis", children: translateInline(node.children, input) }];
case "strong":
return [{ type: "strong", children: translateInline(node.children, input) }];
case "inlineCode":
return [{ type: "codeSpan", value: node.value }];
case "link":
return [{ type: "link", href: node.url, children: translateInline(node.children, input) }];
default:
return [{ type: "rawInline", value: slice(node, input) }];
}
return [];
});
}
// Verbatim source for an unmodelled node, taken by position so it round-trips
// byte-for-byte rather than being re-stringified through remark.
function slice(node: Node, input: string): string {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start === undefined || end === undefined) {
return "";
}
return input.slice(start, end);
}

View File

@@ -99,4 +99,165 @@ A paragraph under it.
expect(result.diagnostics).toEqual([]);
});
test("an unordered list is a ListBlock whose items wrap their child blocks", () => {
const result = parse("- First\n- Second\n");
expect(result.blocks).toEqual([
{
type: "list",
ordered: false,
items: [
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "First" }] }],
},
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Second" }] }],
},
],
},
]);
});
test("an ordered list carries the ordered flag", () => {
const result = parse("1. One\n2. Two\n");
expect(result.blocks).toEqual([
{
type: "list",
ordered: true,
items: [
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "One" }] }],
},
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Two" }] }],
},
],
},
]);
});
test("a list item is a container of blocks, not a bare inline array", () => {
const result = parse("- Just text\n");
const list = result.blocks[0];
if (list?.type !== "list") throw new Error("expected a list block");
expect(list.items[0]).toEqual({
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Just text" }] }],
});
});
test("a blockquote is a container holding blocks", () => {
const result = parse("> Quoted prose.\n");
expect(result.blocks).toEqual([
{
type: "blockquote",
children: [{ type: "paragraph", children: [{ type: "text", value: "Quoted prose." }] }],
},
]);
});
test("an OFM callout parses as an ordinary blockquote with its text preserved", () => {
const result = parse("> [!note]\n> Remember this.\n");
const quote = result.blocks[0];
if (quote?.type !== "blockquote") throw new Error("expected a blockquote block");
const rendered = JSON.stringify(quote);
expect(rendered).toContain("[!note]");
expect(rendered).toContain("Remember this.");
});
test("a fenced code block carries its language and literal text", () => {
const result = parse("```js\nconst x = @sugar{1};\n```\n");
expect(result.blocks).toEqual([{ type: "code", lang: "js", value: "const x = @sugar{1};" }]);
});
test("a code block without a language has no lang", () => {
const result = parse("```\nplain text\n```\n");
expect(result.blocks).toEqual([{ type: "code", value: "plain text" }]);
});
test("a thematic break is modelled", () => {
const result = parse("---\n");
expect(result.blocks).toEqual([{ type: "thematicBreak" }]);
});
test("emphasis, strong, and code-span inlines are modelled", () => {
const result = parse("*em* **strong** `code`\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "emphasis", children: [{ type: "text", value: "em" }] },
{ type: "text", value: " " },
{ type: "strong", children: [{ type: "text", value: "strong" }] },
{ type: "text", value: " " },
{ type: "codeSpan", value: "code" },
]);
});
test("a link is modelled with its href and inline children, and the href is not annotation-parsed here", () => {
const result = parse("[the docs](https://example.com/@stuff)\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{
type: "link",
href: "https://example.com/@stuff",
children: [{ type: "text", value: "the docs" }],
},
]);
});
test("a code span preserves its raw content verbatim", () => {
const result = parse("Use `@sugar{1 tbsp}` literally.\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({ type: "codeSpan", value: "@sugar{1 tbsp}" });
});
test("an unmodelled block (a GFM table) falls through to a RawBlock with byte-for-byte source", () => {
const table = "| a | b |\n| - | - |\n| 1 | 2 |";
const result = parse(`${table}\n`);
expect(result.blocks).toEqual([{ type: "raw", value: table }]);
});
test("an unmodelled inline (GFM strikethrough) falls through to a RawInline with byte-for-byte source", () => {
const result = parse("done ~~scratch~~ now\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "text", value: "done " },
{ type: "rawInline", value: "~~scratch~~" },
{ type: "text", value: " now" },
]);
});
test("frontmatter is not emitted as a block", () => {
const input = `---
title: X
---
Body.
`;
const result = parse(input);
expect(result.blocks).toEqual([
{ type: "paragraph", children: [{ type: "text", value: "Body." }] },
]);
});
});

View File

@@ -13,7 +13,35 @@ export interface TextNode {
value: string;
}
export type InlineNode = TextNode;
export interface EmphasisNode {
type: "emphasis";
children: InlineNode[];
}
export interface StrongNode {
type: "strong";
children: InlineNode[];
}
export interface CodeSpanNode {
type: "codeSpan";
value: string;
}
export interface LinkNode {
type: "link";
href: string;
children: InlineNode[];
}
// Verbatim source for any inline construct core does not model, sliced from the
// original input so the unmodelled span round-trips byte-for-byte.
export interface RawInline {
type: "rawInline";
value: string;
}
export type InlineNode = TextNode | EmphasisNode | StrongNode | CodeSpanNode | LinkNode | RawInline;
export interface HeadingBlock {
type: "heading";
@@ -26,7 +54,47 @@ export interface ParagraphBlock {
children: InlineNode[];
}
export type Block = HeadingBlock | ParagraphBlock;
export interface ListItemBlock {
type: "listItem";
children: Block[];
}
export interface ListBlock {
type: "list";
ordered: boolean;
items: ListItemBlock[];
}
export interface BlockquoteBlock {
type: "blockquote";
children: Block[];
}
export interface CodeBlock {
type: "code";
lang?: string;
value: string;
}
export interface ThematicBreakBlock {
type: "thematicBreak";
}
// Verbatim source for any block construct core does not model, sliced from the
// original input so the unmodelled block round-trips byte-for-byte.
export interface RawBlock {
type: "raw";
value: string;
}
export type Block =
| HeadingBlock
| ParagraphBlock
| ListBlock
| BlockquoteBlock
| CodeBlock
| ThematicBreakBlock
| RawBlock;
export interface DocumentAST {
frontmatter: Frontmatter;