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

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