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:
@@ -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,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");
|
||||
|
||||
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 [];
|
||||
});
|
||||
}
|
||||
48
packages/core/src/types.ts
Normal file
48
packages/core/src/types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user