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:
2026-07-28 23:31:03 -04:00
parent fdede89e0c
commit ab309d3226
26 changed files with 1431 additions and 118 deletions

View File

@@ -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"
}
}

View 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");
});
});

View File

@@ -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();
}

View File

@@ -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");
});
});

View 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");
});

View 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("");
}

View 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 16", () => {
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`);
}
});
});

View File

@@ -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");
});

View File

@@ -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"
}
}

View File

@@ -1 +1,2 @@
export {};
export { parse } from "./parse.ts";
export type * from "./types.ts";

View File

@@ -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");
});

View File

@@ -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)");
});

View 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 [];
});
}

View 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 16 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([]);
});
});

View 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[];
}