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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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("");
|
||||
}
|
||||
87
packages/bin/src/render_test.ts
Normal file
87
packages/bin/src/render_test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
9
packages/bin/src/test-support.ts
Normal file
9
packages/bin/src/test-support.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user