feat: add kitchen view command and parser skeleton (task 0003)
Cut the first vertical slice through both layers: a runnable `kitchen view <file>` that reads a Recipe File, parses it, and renders it styled to the terminal. @kitchen-md/core exposes a pure, total `parse(input): DocumentAST` built on a minimal remark pipeline (parse + frontmatter) with a translation layer to core's own AST types — frontmatter passthrough, HeadingBlock, ParagraphBlock, and TextNode. remark types never leak into the public API. @kitchen-md/bin's `view` subcommand (commander) reads the file and hands the DocumentAST to a pure `render(ast): string` (chalk, ANSI auto-suppressed off a TTY). Fallible file I/O is modelled as a neverthrow Result over a tagged-union CliError, matched at the boundary: stdout on success, stderr and exit 1 on failure. The command functions sit beside the import.meta.main-guarded CLI entry so they are testable in-process. Tests follow ADR 0009's tiers — unit (parse, render), integration (the view command's Result), and e2e (the binary as a subprocess) — with coverage, a path-scoped test-report generator, and a prose fixture rounding out the tooling. ADR 0008 records errors-as-values at the CLI boundary; ADR 0009 records the testing tiers.
This commit was merged in pull request #2.
This commit is contained in:
@@ -11,6 +11,10 @@
|
||||
"build": "bun build --compile ./src/index.ts --outfile kitchen"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kitchen-md/core": "workspace:*"
|
||||
"@kitchen-md/core": "workspace:*",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^15.0.0",
|
||||
"neverthrow": "^8.2.0",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
65
packages/bin/src/end_to_end_test.ts
Normal file
65
packages/bin/src/end_to_end_test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
|
||||
const repoRoot = join(import.meta.dir, "..", "..", "..");
|
||||
const entry = join(repoRoot, "packages", "bin", "src", "index.ts");
|
||||
|
||||
async function runKitchen(args: string[]) {
|
||||
const proc = Bun.spawn(["bun", entry, ...args], {
|
||||
cwd: repoRoot,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
const exitCode = await proc.exited;
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
describe("kitchen CLI (e2e)", () => {
|
||||
test("view renders a recipe file to stdout and exits 0", async () => {
|
||||
const { exitCode, stdout, stderr } = await runKitchen(["view", "fixtures/prose.md"]);
|
||||
const out = Bun.stripANSI(stdout);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(out).toContain("Buttered Toast");
|
||||
expect(out).toContain("Method");
|
||||
expect(out).toContain("Serving");
|
||||
expect(out).toContain("Cut into triangles and serve at once.");
|
||||
});
|
||||
|
||||
test("view preserves document order in the output", async () => {
|
||||
const { stdout } = await runKitchen(["view", "fixtures/prose.md"]);
|
||||
const out = Bun.stripANSI(stdout);
|
||||
expect(out.indexOf("Method")).toBeLessThan(out.indexOf("Serving"));
|
||||
});
|
||||
|
||||
test("ANSI styling is suppressed when stdout is not a TTY (piped)", async () => {
|
||||
const { stdout } = await runKitchen(["view", "fixtures/prose.md"]);
|
||||
expect(stdout).not.toContain("\x1b");
|
||||
});
|
||||
|
||||
test("a nonexistent file prints a human-readable error to stderr and exits 1", async () => {
|
||||
const { exitCode, stdout, stderr } = await runKitchen(["view", "does/not/exist.md"]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toContain("cannot read");
|
||||
expect(stderr).toContain("does/not/exist.md");
|
||||
expect(stderr.startsWith("kitchen:")).toBe(true);
|
||||
});
|
||||
|
||||
test("a missing file argument prints usage to stderr and exits 1", async () => {
|
||||
const { exitCode, stderr } = await runKitchen(["view"]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).not.toBe("");
|
||||
expect(stderr.toLowerCase()).toContain("missing required argument");
|
||||
});
|
||||
|
||||
test("--help lists the view command and exits 0", async () => {
|
||||
const { exitCode, stdout } = await runKitchen(["--help"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("view");
|
||||
});
|
||||
});
|
||||
@@ -1 +1,54 @@
|
||||
// CLI entry point
|
||||
#!/usr/bin/env bun
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse } from "@kitchen-md/core";
|
||||
import { Command } from "commander";
|
||||
import { err, ok, type Result } from "neverthrow";
|
||||
import { render } from "./render.ts";
|
||||
|
||||
export type ViewError = { tag: "read-failed"; path: string; cause: string };
|
||||
|
||||
// The union of every error a command can surface to the boundary; new fallible commands add their variants here.
|
||||
export type CliError = ViewError;
|
||||
|
||||
export function viewFile(path: string): Result<string, ViewError> {
|
||||
return readFile(path).map((content) => render(parse(content)));
|
||||
}
|
||||
|
||||
export function formatError(error: CliError): string {
|
||||
switch (error.tag) {
|
||||
case "read-failed":
|
||||
return `cannot read '${error.path}': ${error.cause}`;
|
||||
}
|
||||
}
|
||||
|
||||
// The one place a throwing API is turned into a Result; nothing above this leaks exceptions.
|
||||
function readFile(path: string): Result<string, ViewError> {
|
||||
try {
|
||||
return ok(readFileSync(path, "utf8"));
|
||||
} catch (error) {
|
||||
const cause = error instanceof Error ? error.message : String(error);
|
||||
return err({ tag: "read-failed", path, cause });
|
||||
}
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name("kitchen").description("Read and view KitchenMD Recipe Files");
|
||||
|
||||
program
|
||||
.command("view")
|
||||
.description("Render a Recipe File to the terminal")
|
||||
.argument("<file>", "path to a Recipe File")
|
||||
.action((file: string) => {
|
||||
viewFile(file).match(
|
||||
(output) => process.stdout.write(output),
|
||||
(error) => {
|
||||
process.stderr.write(`kitchen: ${formatError(error)}\n`);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (import.meta.main) {
|
||||
program.parse();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,86 @@
|
||||
import { describe, test } from "bun:test";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { formatError, type ViewError, viewFile } from "./index.ts";
|
||||
|
||||
describe("cli", () => {
|
||||
test.todo("exits with non-zero code when no file argument is given");
|
||||
test.todo("exits with non-zero code when file does not exist");
|
||||
describe("viewFile", () => {
|
||||
let dir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const writeRecipe = (name: string, content: string): string => {
|
||||
dir = mkdtempSync(join(tmpdir(), "kitchen-view-"));
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
};
|
||||
|
||||
test("viewFile returns ok with the rendered document for a readable file", () => {
|
||||
const path = writeRecipe(
|
||||
"toast.md",
|
||||
`# Buttered Toast
|
||||
|
||||
Toast the bread until golden.
|
||||
`,
|
||||
);
|
||||
|
||||
const result = viewFile(path);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const rendered = Bun.stripANSI(result._unsafeUnwrap());
|
||||
expect(rendered).toContain("Buttered Toast");
|
||||
expect(rendered).toContain("Toast the bread until golden.");
|
||||
});
|
||||
|
||||
test("viewFile returns ok and passes frontmatter through to the rendered output", () => {
|
||||
const path = writeRecipe(
|
||||
"toast.md",
|
||||
`---
|
||||
title: Buttered Toast
|
||||
servings: 2
|
||||
---
|
||||
|
||||
# Buttered Toast
|
||||
`,
|
||||
);
|
||||
|
||||
const result = viewFile(path);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const rendered = Bun.stripANSI(result._unsafeUnwrap());
|
||||
expect(rendered).toContain("title: Buttered Toast");
|
||||
expect(rendered).toContain("servings: 2");
|
||||
});
|
||||
|
||||
test("viewFile returns a read-failed error for a nonexistent path", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "kitchen-view-"));
|
||||
const missing = join(dir, "does-not-exist.md");
|
||||
|
||||
const result = viewFile(missing);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const e = result._unsafeUnwrapErr();
|
||||
expect(e.tag).toBe("read-failed");
|
||||
expect(e.path).toBe(missing);
|
||||
expect(typeof e.cause).toBe("string");
|
||||
expect(e.cause.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatError", () => {
|
||||
test("formatError renders a read-failed error as a human-readable line", () => {
|
||||
const error: ViewError = {
|
||||
tag: "read-failed",
|
||||
path: "/nope/recipe.md",
|
||||
cause: "no such file",
|
||||
};
|
||||
|
||||
expect(formatError(error)).toBe("cannot read '/nope/recipe.md': no such file");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { describe, test } from "bun:test";
|
||||
|
||||
describe("cli — integration", () => {
|
||||
test.todo("invokes core parser and produces output for a real fixture file");
|
||||
});
|
||||
46
packages/bin/src/render.ts
Normal file
46
packages/bin/src/render.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Block, DocumentAST, Frontmatter, HeadingBlock, InlineNode } from "@kitchen-md/core";
|
||||
import chalk from "chalk";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
|
||||
const SEPARATOR = "─".repeat(40);
|
||||
|
||||
export function render(ast: DocumentAST): string {
|
||||
const body = ast.blocks.map(renderBlock).join("");
|
||||
return renderFrontmatter(ast.frontmatter) + body;
|
||||
}
|
||||
|
||||
function renderFrontmatter(frontmatter: Frontmatter): string {
|
||||
if (Object.keys(frontmatter).length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `${stringifyYaml(frontmatter)}${chalk.dim(SEPARATOR)}\n`;
|
||||
}
|
||||
|
||||
function renderBlock(block: Block): string {
|
||||
if (block.type === "heading") {
|
||||
return `${styleHeading(block.level)(renderInline(block.children))}\n`;
|
||||
}
|
||||
return `${renderInline(block.children)}\n\n`;
|
||||
}
|
||||
|
||||
// Each level gets a distinct style, tapering from bold at level 1 toward dim at level 6.
|
||||
function styleHeading(level: HeadingBlock["level"]): (text: string) => string {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return chalk.bold.underline;
|
||||
case 2:
|
||||
return chalk.bold;
|
||||
case 3:
|
||||
return chalk.bold.dim;
|
||||
case 4:
|
||||
return chalk.dim.underline;
|
||||
case 5:
|
||||
return chalk.dim;
|
||||
case 6:
|
||||
return chalk.dim.italic;
|
||||
}
|
||||
}
|
||||
|
||||
function renderInline(nodes: InlineNode[]): string {
|
||||
return nodes.map((node) => node.value).join("");
|
||||
}
|
||||
99
packages/bin/src/render_test.ts
Normal file
99
packages/bin/src/render_test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { DocumentAST, HeadingBlock } from "@kitchen-md/core";
|
||||
import { render } from "./render.ts";
|
||||
|
||||
describe("render", () => {
|
||||
test("a heading renders its text followed by a single newline", () => {
|
||||
const ast: DocumentAST = {
|
||||
frontmatter: {},
|
||||
blocks: [{ type: "heading", level: 2, children: [{ type: "text", value: "Method" }] }],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
expect(Bun.stripANSI(render(ast))).toBe("Method\n");
|
||||
});
|
||||
|
||||
test("a paragraph renders its text followed by a blank line", () => {
|
||||
const ast: DocumentAST = {
|
||||
frontmatter: {},
|
||||
blocks: [{ type: "paragraph", children: [{ type: "text", value: "Toast the bread." }] }],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
expect(Bun.stripANSI(render(ast))).toBe("Toast the bread.\n\n");
|
||||
});
|
||||
|
||||
test("frontmatter renders as YAML before the body, followed by a separator rule", () => {
|
||||
const ast: DocumentAST = {
|
||||
frontmatter: { title: "Buttered Toast", servings: 2 },
|
||||
blocks: [
|
||||
{ type: "heading", level: 1, children: [{ type: "text", value: "Buttered Toast" }] },
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const stripped = Bun.stripANSI(render(ast));
|
||||
|
||||
expect(stripped).toContain("title: Buttered Toast");
|
||||
expect(stripped).toContain("servings: 2");
|
||||
expect(stripped).toContain("──────");
|
||||
|
||||
const titleIndex = stripped.indexOf("title: Buttered Toast");
|
||||
const separatorIndex = stripped.indexOf("──────");
|
||||
const bodyIndex = stripped.lastIndexOf("Buttered Toast");
|
||||
|
||||
expect(titleIndex).toBeLessThan(separatorIndex);
|
||||
expect(separatorIndex).toBeLessThan(bodyIndex);
|
||||
});
|
||||
|
||||
test("no frontmatter emits neither YAML nor a separator", () => {
|
||||
const ast: DocumentAST = {
|
||||
frontmatter: {},
|
||||
blocks: [{ type: "paragraph", children: [{ type: "text", value: "Body only." }] }],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const stripped = Bun.stripANSI(render(ast));
|
||||
|
||||
expect(stripped).toBe("Body only.\n\n");
|
||||
expect(stripped).not.toContain("─");
|
||||
});
|
||||
|
||||
test("blocks render in document order", () => {
|
||||
const ast: DocumentAST = {
|
||||
frontmatter: {},
|
||||
blocks: [
|
||||
{ type: "heading", level: 1, children: [{ type: "text", value: "Title" }] },
|
||||
{ type: "paragraph", children: [{ type: "text", value: "First para." }] },
|
||||
{ type: "heading", level: 2, children: [{ type: "text", value: "Next" }] },
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const stripped = Bun.stripANSI(render(ast));
|
||||
|
||||
expect(stripped.indexOf("Title")).toBeLessThan(stripped.indexOf("First para."));
|
||||
expect(stripped.indexOf("First para.")).toBeLessThan(stripped.indexOf("Next"));
|
||||
});
|
||||
|
||||
test("a heading renders its text at every level 1–6", () => {
|
||||
const cases: [HeadingBlock["level"], string][] = [
|
||||
[1, "Level One"],
|
||||
[2, "Level Two"],
|
||||
[3, "Level Three"],
|
||||
[4, "Level Four"],
|
||||
[5, "Level Five"],
|
||||
[6, "Level Six"],
|
||||
];
|
||||
|
||||
for (const [level, title] of cases) {
|
||||
const ast: DocumentAST = {
|
||||
frontmatter: {},
|
||||
blocks: [{ type: "heading", level, children: [{ type: "text", value: title }] }],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
expect(Bun.stripANSI(render(ast))).toBe(`${title}\n`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { describe, test } from "bun:test";
|
||||
|
||||
describe("smoke", () => {
|
||||
test.todo("kitchen --help exits with code 0");
|
||||
test.todo("kitchen parse <fixture> outputs structured JSON covering all annotation types");
|
||||
});
|
||||
@@ -8,5 +8,11 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"unified": "^11.0.5",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export {};
|
||||
export { parse } from "./parse.ts";
|
||||
export type * from "./types.ts";
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, test } from "bun:test";
|
||||
|
||||
describe("parser", () => {
|
||||
describe("frontmatter", () => {
|
||||
test.todo("parses frontmatter fields as-is");
|
||||
test.todo("returns an empty object for empty frontmatter");
|
||||
test.todo("returns an empty object when there is no frontmatter");
|
||||
test.todo(
|
||||
"does not throw on malformed frontmatter: frontmatter is {}, body still parses, and an invalid-frontmatter diagnostic preserves the raw YAML",
|
||||
);
|
||||
});
|
||||
|
||||
describe("blocks", () => {
|
||||
test.todo("parses headings at every level (1-6)");
|
||||
test.todo("parses paragraphs with typed inline nodes");
|
||||
test.todo("parses an ordered list");
|
||||
test.todo("parses an unordered list");
|
||||
test.todo("models a list item as a container wrapping a paragraph, not a bare inline array");
|
||||
test.todo("parses a code block and does not annotate its content");
|
||||
test.todo("parses a thematic break");
|
||||
test.todo("parses a blockquote as a container block");
|
||||
test.todo("parses a callout (> [!note]) as an ordinary blockquote");
|
||||
});
|
||||
|
||||
describe("inline nodes", () => {
|
||||
test.todo("parses plain text");
|
||||
test.todo("parses emphasis");
|
||||
test.todo("parses strong");
|
||||
test.todo("parses a code span and does not annotate its content");
|
||||
test.todo("parses a link with href and inline content");
|
||||
test.todo("parses a wikilink");
|
||||
test.todo("parses a wikilink with an anchor");
|
||||
test.todo("parses a wikilink with a display alias");
|
||||
test.todo("parses a transclusion");
|
||||
test.todo("parses a transclusion with a display alias");
|
||||
});
|
||||
|
||||
describe("raw fallbacks", () => {
|
||||
test.todo(
|
||||
"preserves an unmodelled block (e.g. a GFM table) as a RawBlock with verbatim source",
|
||||
);
|
||||
test.todo(
|
||||
"preserves an unmodelled inline (e.g. strikethrough) as a RawInline with verbatim source",
|
||||
);
|
||||
});
|
||||
|
||||
describe("ingredient annotations", () => {
|
||||
test.todo("extracts ingredient name, quantity, and unit");
|
||||
test.todo("extracts multi-word ingredient name");
|
||||
test.todo("extracts ingredient with a unit-less quantity");
|
||||
test.todo("extracts ingredient with no quantity");
|
||||
});
|
||||
|
||||
describe("cookware annotations", () => {
|
||||
test.todo("extracts cookware with quantity and unit");
|
||||
test.todo("extracts cookware with no quantity");
|
||||
test.todo("extracts multi-word cookware name");
|
||||
});
|
||||
|
||||
describe("timer annotations", () => {
|
||||
test.todo("extracts timer as a single value");
|
||||
test.todo("extracts timer as a range");
|
||||
test.todo("normalises timer unit aliases to canonical form");
|
||||
test.todo("matches timer units case-insensitively (~5 Mins -> min)");
|
||||
});
|
||||
|
||||
describe("unit normalisation", () => {
|
||||
test.todo("normalises a known alias to canonical (grams -> g)");
|
||||
test.todo("matches unit aliases case-insensitively (Tbsp -> tbsp)");
|
||||
test.todo("normalises a multi-word alias (fluid ounces -> fl oz)");
|
||||
test.todo("passes an unknown unit through verbatim");
|
||||
});
|
||||
|
||||
describe("annotation scope", () => {
|
||||
test.todo("captures annotations embedded mid-sentence");
|
||||
test.todo("captures annotations inside a container (blockquote or list item)");
|
||||
test.todo("does not extract annotations inside a code span");
|
||||
test.todo("does not extract annotations inside a code block");
|
||||
});
|
||||
|
||||
describe("transclusion", () => {
|
||||
test.todo("passes a Step Reference anchor through as-is");
|
||||
});
|
||||
|
||||
test.todo("standard Markdown elements pass through without interference");
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { describe, test } from "bun:test";
|
||||
|
||||
describe("parser — integration", () => {
|
||||
test.todo("parses the primary fixture into the complete Document AST");
|
||||
test.todo("extracts every annotation type from the primary fixture");
|
||||
test.todo("extracts an annotation from inside the fixture's blockquote");
|
||||
test.todo("normalises the fixture's non-canonical unit (tablespoons -> tbsp)");
|
||||
});
|
||||
46
packages/core/src/parse.ts
Normal file
46
packages/core/src/parse.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { PhrasingContent, Root, RootContent } from "mdast";
|
||||
import remarkFrontmatter from "remark-frontmatter";
|
||||
import remarkParse from "remark-parse";
|
||||
import { unified } from "unified";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import type { Block, DocumentAST, Frontmatter, InlineNode } from "./types.ts";
|
||||
|
||||
const processor = unified().use(remarkParse).use(remarkFrontmatter);
|
||||
|
||||
export function parse(input: string): DocumentAST {
|
||||
const tree = processor.parse(input);
|
||||
const frontmatter = extractFrontmatter(tree);
|
||||
const blocks = tree.children.flatMap(translateBlock);
|
||||
return { frontmatter, blocks, diagnostics: [] };
|
||||
}
|
||||
|
||||
function extractFrontmatter(tree: Root): Frontmatter {
|
||||
const yamlNode = tree.children.find((node) => node.type === "yaml");
|
||||
if (!yamlNode) {
|
||||
return {};
|
||||
}
|
||||
const data = parseYaml(yamlNode.value);
|
||||
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
|
||||
return data as Frontmatter;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function translateBlock(node: RootContent): Block[] {
|
||||
if (node.type === "heading") {
|
||||
return [{ type: "heading", level: node.depth, children: translateInline(node.children) }];
|
||||
}
|
||||
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 }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
102
packages/core/src/parse_test.ts
Normal file
102
packages/core/src/parse_test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
35
packages/core/src/types.ts
Normal file
35
packages/core/src/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// The public AST node and document types returned by parse.
|
||||
|
||||
export type Frontmatter = Record<string, unknown>;
|
||||
|
||||
export interface Diagnostic {
|
||||
severity: "warning";
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TextNode {
|
||||
type: "text";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type InlineNode = TextNode;
|
||||
|
||||
export interface HeadingBlock {
|
||||
type: "heading";
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
children: InlineNode[];
|
||||
}
|
||||
|
||||
export interface ParagraphBlock {
|
||||
type: "paragraph";
|
||||
children: InlineNode[];
|
||||
}
|
||||
|
||||
export type Block = HeadingBlock | ParagraphBlock;
|
||||
|
||||
export interface DocumentAST {
|
||||
frontmatter: Frontmatter;
|
||||
blocks: Block[];
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
Reference in New Issue
Block a user