feat: add kitchen view command and parser skeleton (task 0003)

Cut the first complete thread through both layers with the smallest set
of node types.

@kitchen-md/core gains a pure, total `parse` returning a
`DocumentAST` of `{ frontmatter, blocks, diagnostics }`, built from a
minimal remark pipeline (remark-parse + remark-frontmatter) with an
internal translation layer to core's own types. This slice models
frontmatter passthrough, HeadingBlock, ParagraphBlock, and TextNode;
remark's mdast does not surface in the public API.

@kitchen-md/bin gains the `view` subcommand (commander) that reads a
file, calls `parse`, and passes the AST to a pure `render` that returns
an ANSI-styled string via chalk (auto-suppressed off a TTY). Frontmatter
prints as raw YAML followed by a separator, headings styled distinctly
by level, paragraphs as prose. A missing argument prints usage and a
missing/unreadable file a human-readable error, both exiting 1.

Richer blocks/inline plus raw fallbacks are task 0004; malformed
frontmatter diagnostics and the basic.md smoke test are task 0007.
This commit is contained in:
2026-07-26 07:24:32 -04:00
parent fdede89e0c
commit d1c1f330c5
15 changed files with 836 additions and 14 deletions

View File

@@ -11,6 +11,9 @@
"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",
"yaml": "^2.9.0"
}
}

View File

@@ -1 +1,27 @@
// CLI entry point
#!/usr/bin/env bun
import { readFileSync } from "node:fs";
import { parse } from "@kitchen-md/core";
import { Command } from "commander";
import { render } from "./render.ts";
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) => {
let content: string;
try {
content = readFileSync(file, "utf8");
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
process.stderr.write(`kitchen: cannot read '${file}': ${reason}\n`);
process.exit(1);
}
process.stdout.write(render(parse(content)));
});
program.parse();

View File

@@ -1,6 +1,25 @@
import { describe, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { runCli } from "./test-support.ts";
const CLI = `${import.meta.dir}/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");
test("exits with non-zero code when no file argument is given", async () => {
const { exitCode, stderr } = await runCli(["view"], CLI);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/missing required argument|usage/i);
});
test("exits with non-zero code when file does not exist", async () => {
const { exitCode, stderr } = await runCli(
["view", "/no/such/kitchen-recipe-does-not-exist.md"],
CLI,
);
expect(exitCode).toBe(1);
expect(stderr.trim().length).toBeGreaterThan(0);
expect(stderr).toMatch(/cannot read|no such file|ENOENT/i);
expect(stderr).toContain("/no/such/kitchen-recipe-does-not-exist.md");
});
});

View File

@@ -1,5 +1,33 @@
import { describe, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCli, stripAnsi } from "./test-support.ts";
const CLI = `${import.meta.dir}/index.ts`;
describe("cli — integration", () => {
test.todo("invokes core parser and produces output for a real fixture file");
test("invokes core parser and produces output for a real fixture file", async () => {
const dir = mkdtempSync(join(tmpdir(), "kitchen-md-"));
const file = join(dir, "recipe.md");
writeFileSync(
file,
"---\ntitle: Test Recipe\nservings: 2\n---\n\n# Heading One\n\nA plain paragraph of prose.\n",
);
try {
const { exitCode, stdout } = await runCli(["view", file], CLI);
const output = stripAnsi(stdout);
expect(exitCode).toBe(0);
expect(output).toContain("title: Test Recipe");
expect(output).toContain("servings: 2");
expect(output).toMatch(/─+/);
expect(output).toContain("Heading One");
expect(output).toContain("A plain paragraph of prose.");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

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,87 @@
import { describe, expect, test } from "bun:test";
import type { DocumentAST } from "@kitchen-md/core";
import chalk from "chalk";
import { render } from "./render.ts";
import { stripAnsi } from "./test-support.ts";
describe("render", () => {
test("renders a paragraph as prose followed by a blank line", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "paragraph", children: [{ type: "text", value: "Hello world" }] }],
diagnostics: [],
};
expect(stripAnsi(render(ast))).toBe("Hello world\n\n");
});
test("renders a heading followed by a single newline (not a blank line)", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "heading", level: 2, children: [{ type: "text", value: "Batter" }] }],
diagnostics: [],
};
expect(stripAnsi(render(ast))).toBe("Batter\n");
});
test("renders frontmatter as raw YAML before the body", () => {
const ast: DocumentAST = {
frontmatter: { title: "Classic Pancakes", servings: 4 },
blocks: [{ type: "paragraph", children: [{ type: "text", value: "A simple breakfast." }] }],
diagnostics: [],
};
const output = stripAnsi(render(ast));
expect(output.startsWith("title: Classic Pancakes\nservings: 4\n")).toBe(true);
expect(output).toContain("A simple breakfast.");
expect(output.indexOf("title: Classic Pancakes")).toBeLessThan(
output.indexOf("A simple breakfast."),
);
});
test("renders a visual separator between the frontmatter and the body", () => {
const ast: DocumentAST = {
frontmatter: { title: "Classic Pancakes", servings: 4 },
blocks: [{ type: "paragraph", children: [{ type: "text", value: "A simple breakfast." }] }],
diagnostics: [],
};
const lines = stripAnsi(render(ast)).split("\n");
const separatorIndex = lines.findIndex((line) => /^─+$/.test(line));
const frontmatterIndex = lines.findIndex((line) => line.includes("servings: 4"));
const bodyIndex = lines.findIndex((line) => line.includes("A simple breakfast."));
expect(separatorIndex).toBeGreaterThan(-1);
expect(separatorIndex).toBeGreaterThan(frontmatterIndex);
expect(separatorIndex).toBeLessThan(bodyIndex);
});
test("styles headings distinctly by level and suppresses ANSI when colour is disabled", () => {
const h1: DocumentAST = {
frontmatter: {},
blocks: [{ type: "heading", level: 1, children: [{ type: "text", value: "Title" }] }],
diagnostics: [],
};
const h2: DocumentAST = {
frontmatter: {},
blocks: [{ type: "heading", level: 2, children: [{ type: "text", value: "Title" }] }],
diagnostics: [],
};
const original = chalk.level;
try {
chalk.level = 1;
expect(render(h1)).toContain("\x1b[");
expect(render(h1)).not.toBe(render(h2));
chalk.level = 0;
expect(render(h1)).not.toContain("\x1b[");
} finally {
chalk.level = original;
}
});
});

View File

@@ -0,0 +1,9 @@
export const stripAnsi = (s: string): string => s.replace(/\[[0-9;]*m/g, "");
export async function runCli(args: string[], cli: string) {
const proc = Bun.spawn(["bun", cli, ...args], { stdout: "pipe", stderr: "pipe" });
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { exitCode, stdout, stderr };
}

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,18 +1,93 @@
import { describe, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { parse } from "@kitchen-md/core";
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("parses frontmatter fields as-is", () => {
const source =
"---\ntitle: Classic Pancakes\nservings: 4\ntags: [breakfast, quick]\n---\n\n# Classic Pancakes";
const result = parse(source);
expect(result.frontmatter).toEqual({
title: "Classic Pancakes",
servings: 4,
tags: ["breakfast", "quick"],
});
});
test("returns an empty object for empty frontmatter", () => {
const source = "---\n---\n\n# Title";
const result = parse(source);
expect(result.frontmatter).toEqual({});
expect(result.blocks).toContainEqual({
type: "heading",
level: 1,
children: [{ type: "text", value: "Title" }],
});
expect(result.diagnostics).toEqual([]);
});
test("returns an empty object when there is no frontmatter", () => {
const result = parse("# Title");
expect(result.frontmatter).toEqual({});
expect(result.diagnostics).toEqual([]);
});
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("parses headings at every level (1-6)", () => {
const source = "# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n\n###### H6";
const result = parse(source);
expect(result).toEqual({
frontmatter: {},
blocks: [
{ type: "heading", level: 1, children: [{ type: "text", value: "H1" }] },
{ type: "heading", level: 2, children: [{ type: "text", value: "H2" }] },
{ type: "heading", level: 3, children: [{ type: "text", value: "H3" }] },
{ type: "heading", level: 4, children: [{ type: "text", value: "H4" }] },
{ type: "heading", level: 5, children: [{ type: "text", value: "H5" }] },
{ type: "heading", level: 6, children: [{ type: "text", value: "H6" }] },
],
diagnostics: [],
});
});
test("keeps blocks flat and in document order (a heading is a sibling of the following paragraph)", () => {
const result = parse("# Batter\n\nSift the flour into a bowl.");
expect(result).toEqual({
frontmatter: {},
blocks: [
{ type: "heading", level: 1, children: [{ type: "text", value: "Batter" }] },
{
type: "paragraph",
children: [{ type: "text", value: "Sift the flour into a bowl." }],
},
],
diagnostics: [],
});
});
test("parses paragraphs with typed inline nodes", () => {
const result = parse("Hello world");
expect(result).toEqual({
frontmatter: {},
blocks: [
{
type: "paragraph",
children: [{ type: "text", value: "Hello world" }],
},
],
diagnostics: [],
});
});
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");

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,48 @@
// The public AST node and document types returned by parse.
export type Frontmatter = Record<string, unknown>;
export interface Point {
line: number;
column: number;
offset?: number;
}
export interface Position {
start: Point;
end: Point;
}
export interface Diagnostic {
severity: "warning";
code: string;
message: string;
source?: string;
position?: Position;
}
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[];
}