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

@@ -1,8 +1,17 @@
import type { Block, DocumentAST, Frontmatter, HeadingBlock, InlineNode } from "@kitchen-md/core";
import type {
Block,
BlockquoteBlock,
DocumentAST,
Frontmatter,
HeadingBlock,
InlineNode,
ListBlock,
ListItemBlock,
} from "@kitchen-md/core";
import chalk from "chalk";
import { stringify as stringifyYaml } from "yaml";
const SEPARATOR = "─".repeat(40);
const RULE = "─".repeat(40);
export function render(ast: DocumentAST): string {
const body = ast.blocks.map(renderBlock).join("");
@@ -13,14 +22,26 @@ function renderFrontmatter(frontmatter: Frontmatter): string {
if (Object.keys(frontmatter).length === 0) {
return "";
}
return `${stringifyYaml(frontmatter)}${chalk.dim(SEPARATOR)}\n`;
return `${stringifyYaml(frontmatter)}${chalk.dim(RULE)}\n`;
}
function renderBlock(block: Block): string {
if (block.type === "heading") {
return `${styleHeading(block.level)(renderInline(block.children))}\n`;
switch (block.type) {
case "heading":
return `${styleHeading(block.level)(renderInline(block.children))}\n`;
case "paragraph":
return `${renderInline(block.children)}\n\n`;
case "list":
return renderList(block);
case "blockquote":
return renderBlockquote(block);
case "code":
return `${chalk.dim(block.value)}\n\n`;
case "thematicBreak":
return `${chalk.dim(RULE)}\n\n`;
case "raw":
return `${block.value}\n\n`;
}
return `${renderInline(block.children)}\n\n`;
}
// Each level gets a distinct style, tapering from bold at level 1 toward dim at level 6.
@@ -41,6 +62,46 @@ function styleHeading(level: HeadingBlock["level"]): (text: string) => string {
}
}
function renderInline(nodes: InlineNode[]): string {
return nodes.map((node) => node.value).join("");
function renderList(block: ListBlock): string {
const lines = block.items.map((item, index) => {
const marker = block.ordered ? `${index + 1}. ` : "• ";
return marker + renderItemContent(item);
});
return `${lines.join("\n")}\n\n`;
}
// The item's child blocks, collapsed onto the marker line without their own
// trailing block spacing.
function renderItemContent(item: ListItemBlock): string {
return item.children.map(renderBlock).join("").trimEnd();
}
function renderBlockquote(block: BlockquoteBlock): string {
const inner = block.children.map(renderBlock).join("").trimEnd();
const quoted = inner
.split("\n")
.map((line) => chalk.dim("│ ") + line)
.join("\n");
return `${quoted}\n\n`;
}
function renderInline(nodes: InlineNode[]): string {
return nodes.map(renderInlineNode).join("");
}
function renderInlineNode(node: InlineNode): string {
switch (node.type) {
case "text":
return node.value;
case "emphasis":
return chalk.italic(renderInline(node.children));
case "strong":
return chalk.bold(renderInline(node.children));
case "codeSpan":
return chalk.inverse(node.value);
case "link":
return chalk.underline(renderInline(node.children));
case "rawInline":
return node.value;
}
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { DocumentAST, HeadingBlock } from "@kitchen-md/core";
import chalk from "chalk";
import { render } from "./render.ts";
describe("render", () => {
@@ -96,4 +97,135 @@ describe("render", () => {
expect(Bun.stripANSI(render(ast))).toBe(`${title}\n`);
}
});
const bodyOf = (block: DocumentAST["blocks"][number]): string =>
Bun.stripANSI(render({ frontmatter: {}, blocks: [block], diagnostics: [] }));
test("an unordered list renders one bulleted item per line", () => {
const stripped = bodyOf({
type: "list",
ordered: false,
items: [
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Flour" }] }],
},
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Sugar" }] }],
},
],
});
expect(stripped).toBe("• Flour\n• Sugar\n\n");
});
test("an ordered list renders sequential numbers, one item per line", () => {
const stripped = bodyOf({
type: "list",
ordered: true,
items: [
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Mix" }] }],
},
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Bake" }] }],
},
],
});
expect(stripped).toBe("1. Mix\n2. Bake\n\n");
});
test("a code block renders its literal text with no highlighting", () => {
const stripped = bodyOf({ type: "code", lang: "js", value: "const x = 1;" });
expect(stripped).toContain("const x = 1;");
});
test("a thematic break renders a horizontal rule", () => {
const stripped = bodyOf({ type: "thematicBreak" });
expect(stripped).toContain("──────");
});
test("a blockquote renders its inner text", () => {
const stripped = bodyOf({
type: "blockquote",
children: [{ type: "paragraph", children: [{ type: "text", value: "Take care." }] }],
});
expect(stripped).toContain("Take care.");
});
test("a raw block renders its verbatim value", () => {
const table = "| a | b |\n| - | - |";
const stripped = bodyOf({ type: "raw", value: table });
expect(stripped).toContain(table);
});
test("emphasis, strong, code span, and links render their inline content", () => {
const stripped = bodyOf({
type: "paragraph",
children: [
{ type: "emphasis", children: [{ type: "text", value: "soft" }] },
{ type: "text", value: " " },
{ type: "strong", children: [{ type: "text", value: "hard" }] },
{ type: "text", value: " " },
{ type: "codeSpan", value: "code" },
{ type: "text", value: " " },
{ type: "link", href: "https://example.com", children: [{ type: "text", value: "docs" }] },
],
});
expect(stripped).toBe("soft hard code docs\n\n");
expect(stripped).not.toContain("https://example.com");
});
test("emphasis, strong, and code span each carry styling distinct from plain text", () => {
const previousLevel = chalk.level;
chalk.level = 1;
try {
const styled = (block: DocumentAST["blocks"][number]): string =>
render({ frontmatter: {}, blocks: [block], diagnostics: [] });
const emphasis = styled({
type: "paragraph",
children: [{ type: "emphasis", children: [{ type: "text", value: "x" }] }],
});
const strong = styled({
type: "paragraph",
children: [{ type: "strong", children: [{ type: "text", value: "x" }] }],
});
const codeSpan = styled({ type: "paragraph", children: [{ type: "codeSpan", value: "x" }] });
// Each carries ANSI styling, so the raw string differs from the stripped one.
expect(emphasis).not.toBe(Bun.stripANSI(emphasis));
expect(strong).not.toBe(Bun.stripANSI(strong));
expect(codeSpan).not.toBe(Bun.stripANSI(codeSpan));
// The three styles are mutually distinct.
expect(emphasis).not.toBe(strong);
expect(strong).not.toBe(codeSpan);
expect(emphasis).not.toBe(codeSpan);
} finally {
chalk.level = previousLevel;
}
});
test("a rawInline node renders its verbatim value", () => {
const stripped = bodyOf({
type: "paragraph",
children: [
{ type: "text", value: "a " },
{ type: "rawInline", value: "~~b~~" },
{ type: "text", value: " c" },
],
});
expect(stripped).toBe("a ~~b~~ c\n\n");
});
});