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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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." }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user