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.
264 lines
7.5 KiB
TypeScript
264 lines
7.5 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
import { parse } from "@kitchen-md/core";
|
||
|
||
describe("parse", () => {
|
||
test("frontmatter passthrough — arbitrary fields become a plain object", () => {
|
||
const input = `---
|
||
title: Buttered Toast
|
||
servings: 2
|
||
tags: [breakfast, simple]
|
||
---
|
||
|
||
# Buttered Toast
|
||
`;
|
||
|
||
const result = parse(input);
|
||
|
||
expect(result.frontmatter).toEqual({
|
||
title: "Buttered Toast",
|
||
servings: 2,
|
||
tags: ["breakfast", "simple"],
|
||
});
|
||
});
|
||
|
||
test("frontmatter is an empty object when absent", () => {
|
||
const result = parse("# Just a heading");
|
||
|
||
expect(result.frontmatter).toEqual({});
|
||
});
|
||
|
||
test("frontmatter is an empty object when the block is empty", () => {
|
||
const input = `---
|
||
---
|
||
|
||
# Heading
|
||
`;
|
||
|
||
const result = parse(input);
|
||
|
||
expect(result.frontmatter).toEqual({});
|
||
});
|
||
|
||
test("headings are modelled at every level 1–6 with TextNode children", () => {
|
||
const cases: [string, 1 | 2 | 3 | 4 | 5 | 6, string][] = [
|
||
["# Level One", 1, "Level One"],
|
||
["## Level Two", 2, "Level Two"],
|
||
["### Level Three", 3, "Level Three"],
|
||
["#### Level Four", 4, "Level Four"],
|
||
["##### Level Five", 5, "Level Five"],
|
||
["###### Level Six", 6, "Level Six"],
|
||
];
|
||
|
||
for (const [markdown, level, text] of cases) {
|
||
const result = parse(markdown);
|
||
|
||
expect(result.blocks).toEqual([
|
||
{
|
||
type: "heading",
|
||
level,
|
||
children: [{ type: "text", value: text }],
|
||
},
|
||
]);
|
||
}
|
||
});
|
||
|
||
test("a paragraph is a ParagraphBlock with TextNode content", () => {
|
||
const result = parse("Just some plain prose.");
|
||
|
||
expect(result.blocks).toEqual([
|
||
{
|
||
type: "paragraph",
|
||
children: [{ type: "text", value: "Just some plain prose." }],
|
||
},
|
||
]);
|
||
});
|
||
|
||
test("blocks are flat and in document order — a heading is a sibling of the following paragraph", () => {
|
||
const input = `# Title
|
||
|
||
A paragraph under it.
|
||
`;
|
||
|
||
const result = parse(input);
|
||
|
||
expect(result.blocks).toEqual([
|
||
{
|
||
type: "heading",
|
||
level: 1,
|
||
children: [{ type: "text", value: "Title" }],
|
||
},
|
||
{
|
||
type: "paragraph",
|
||
children: [{ type: "text", value: "A paragraph under it." }],
|
||
},
|
||
]);
|
||
});
|
||
|
||
test("diagnostics are empty in the normal case", () => {
|
||
const result = parse("# Ok");
|
||
|
||
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." }] },
|
||
]);
|
||
});
|
||
});
|