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

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