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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
65
packages/bin/src/end_to_end_test.ts
Normal file
65
packages/bin/src/end_to_end_test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
46
packages/bin/src/render.ts
Normal file
46
packages/bin/src/render.ts
Normal 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("");
|
||||
}
|
||||
99
packages/bin/src/render_test.ts
Normal file
99
packages/bin/src/render_test.ts
Normal 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 1–6", () => {
|
||||
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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user