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:
@@ -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