chore: add Biome and document design decisions from grill session

- Add @biomejs/biome 2.5.3 with 2-space indent, double quotes, trailing commas
- Add lint and format scripts to root package.json
- Update CONTEXT.md: resolve Structured Output shape, add Document AST, Block, Inline Node terms
- Add ADR 0002 (remark as internal Markdown parser)
- Add ADR 0003 (core defines own AST types)
- Add spec: core-parser (Parser implementation and Document AST)
- Add spec: cli-view (kitchen view <file> command)
This commit is contained in:
2026-07-08 23:29:09 -04:00
parent d37988ec59
commit 5841ae5de8
8 changed files with 390 additions and 5 deletions

View File

@@ -103,12 +103,34 @@ It is a pure function of its input string — no filesystem access.
## Structured Output
The data object returned by the Parser for a given Recipe File.
Shape is not yet fully specified — see open questions below.
The Document AST returned by the Parser for a given Recipe File.
See **Document AST** for the exact shape.
## Document AST
The full structured representation of a Recipe File returned by the Parser.
Top-level shape: `{ frontmatter: Record<string, unknown>, blocks: Block[] }`.
`frontmatter` is the raw YAML metadata, passed through without schema enforcement.
`blocks` is a flat, ordered list of Block nodes representing the document body in document order.
The flat structure means headings and their following content are siblings, not parent/child.
Consumers that need section grouping derive it by scanning for Heading nodes.
Core defines its own AST node types; remark (the internal Markdown parser) is a private implementation detail and its types do not appear in the public API.
See ADR 0002 and ADR 0003.
## Block
A top-level node in the Document AST's `blocks` array.
Each Block represents one logical unit of document structure: a heading, a paragraph, a list, a code block, etc.
Block types are defined by core; the exact catalogue is not yet finalised — see open questions below.
## Inline Node
A node representing inline content within a Block (e.g. within a paragraph or list item).
Inline nodes include: plain text, emphasis, strong, code span, link, Wikilink, and the three Annotation types (Ingredient, Cookware, Timer).
---
## Open / Unresolved
- **Structured Output shape** — exact fields, types, and nesting of the parse result.
- **Block node type catalogue** — the exact set of Block types core defines (heading, paragraph, list, code block, blockquote, etc.) and how unrecognised types are represented.
- **Unit normalisation** — the parser should normalise known aliases to a canonical abbreviation (`grams``g`, `kilograms``kg`); exact alias table and canonical forms are TBD. This is a parser concern, not a format constraint.

View File

@@ -0,0 +1,39 @@
# ADR 0002 — remark as Core's Internal Markdown Parser
**Status**: Accepted
## Context
`@kitchen-md/core` must parse CommonMark Markdown (plus Obsidian Flavored Markdown extensions) to build a Document AST.
The spec requires that Annotations follow CommonMark's inline processing rules — they are parsed inside paragraphs and list items, and ignored inside code spans and code blocks.
Correctly detecting those boundaries from scratch requires a complete CommonMark implementation.
Two approaches were considered:
**Option A — Use remark/unified**
remark is a CommonMark-compliant Markdown parser that produces mdast (a typed Markdown AST).
It has a plugin/transform architecture: OFM extensions (wikilinks, transclusion) are handled by existing community plugins (`remark-wiki-link`), and KitchenMD annotations are handled by a custom transform plugin that walks mdast text nodes and splits them into annotation nodes.
TypeScript support is first-class.
**Option B — Build the Markdown parser from scratch**
Write a custom CommonMark parser as part of `@kitchen-md/core`, with no external Markdown dependency.
## Decision
**Option A** — remark/unified as core's internal Markdown parser.
## Rationale
CommonMark is a large, precisely specified standard.
Building a correct implementation from scratch is significant work with no unique value to the project — the interesting problems are annotation parsing and document rendering, not Markdown tokenisation.
The "built from scratch" statement in the project spec refers to not forking Cooklang's parser, not to avoiding all external dependencies.
remark's plugin architecture maps cleanly to core's needs: one plugin for OFM wikilinks, one plugin for KitchenMD annotations, both operating as transform passes over an existing mdast.
## Consequences
- remark and its plugin ecosystem are build-time dependencies of `@kitchen-md/core`.
- remark must remain a private implementation detail — its types must not appear in core's public API (see ADR 0003).
- OFM support requires `remark-wiki-link` (and the underlying `micromark-extension-wiki-link` / `mdast-util-wiki-link`).
- KitchenMD annotation parsing is implemented as a custom remark transform plugin within core.

View File

@@ -0,0 +1,39 @@
# ADR 0003 — Core Defines Its Own AST Types
**Status**: Accepted
## Context
remark produces mdast — a well-typed Markdown AST with its own node type definitions.
`@kitchen-md/core` builds its Document AST on top of remark's output.
Two options exist for what core exposes in its public API:
**Option A — Expose mdast types directly**
Core re-exports remark's node types, augmented with KitchenMD annotation nodes via TypeScript module augmentation.
Consumers import `@kitchen-md/core` and receive mdast-typed nodes.
**Option B — Core defines its own AST types**
Core defines its own `DocumentAST`, `Block`, and `InlineNode` types.
remark is an internal implementation detail; its types never appear in the public API.
Consumers import only from `@kitchen-md/core`.
## Decision
**Option B** — core defines its own AST types.
## Rationale
The public API is a long-lived contract with multiple future consumers: the `kitchen` CLI, and the future Obsidian plugin.
Leaking mdast into that contract means any remark major version that changes mdast types breaks all consumers, even if core's own logic is unchanged.
mdast also exposes many node types that are irrelevant to KitchenMD consumers.
Core's own types can be a clean, minimal vocabulary scoped to what consumers actually need.
The future Obsidian plugin should not need to know or care that remark is involved.
## Consequences
- Core maintains its own type definitions for all public AST nodes (Document AST, Block types, Inline Node types).
- An internal translation layer maps remark's mdast nodes to core's types before returning from the Parser.
- Changing core's internal Markdown parser in the future does not require a public API change.
- Consumers do not take a transitive dependency on remark.

96
.claude/spec/cli-view.md Normal file
View File

@@ -0,0 +1,96 @@
## Problem Statement
`@kitchen-md/bin` has an empty CLI entry point.
Users have no way to view a Recipe File in the terminal — they must open a text editor or Markdown viewer to read a recipe.
The `kitchen` binary exists in the scaffold but accepts no commands and produces no output.
## Solution
Implement the `kitchen view <file>` subcommand.
It reads a Recipe File from disk, passes the content to `@kitchen-md/core`'s `parse` function, and walks the resulting Document AST through a renderer that produces ANSI-styled terminal output.
Markdown structure is styled for readability; KitchenMD annotations are rendered as their raw source text for now.
## User Stories
1. As a CLI user, I want to run `kitchen view <file>` and see the recipe rendered in the terminal so that I can read it without opening a text editor.
2. As a CLI user, I want headings styled distinctly by level so that I can scan the recipe's structure at a glance.
3. As a CLI user, I want bold and italic text preserved in the terminal output so that emphasis in the recipe prose is visible.
4. As a CLI user, I want frontmatter shown as raw YAML before the recipe body so that I can see the recipe's metadata.
5. As a CLI user, I want wikilinks rendered distinctly from surrounding prose so that cross-references are visually identifiable.
6. As a CLI user, I want a clear error message when the file does not exist so that I am not left with a cryptic failure.
7. As a CLI user, I want the binary to exit with a non-zero code on error so that shell scripts can detect failure.
8. As a CLI user, I want ANSI styling suppressed automatically when output is piped so that downstream tools receive clean text.
## Implementation Decisions
- commander is used for argument parsing.
The `view` subcommand takes a single required positional argument: the path to a Recipe File.
No flags or options beyond the file path are implemented at this stage.
- The entry point reads the file from disk, passes the content string to `parse` from `@kitchen-md/core`, and passes the returned `DocumentAST` to the renderer.
File I/O is confined to the entry point.
- The renderer is a pure function `render(ast: DocumentAST): string`.
It walks the Document AST and returns an ANSI-styled string.
It has no filesystem access and no side effects.
The separation between the entry point (file I/O + commander) and the renderer (pure AST walk) allows the renderer to be tested without invoking a subprocess.
- **Rendering rules:**
- Frontmatter: serialised back to YAML and printed before the document body, followed by a visual separator.
- `HeadingBlock`: bold text; level 1 is the most visually prominent, with levels 26 progressively less so.
- `ParagraphBlock`: inline-rendered text followed by a blank line.
- `ListBlock`: each item on its own line, preceded by a bullet character (unordered) or sequential number (ordered).
- `CodeBlock`: literal text content, rendered as-is with no syntax highlighting.
- `ThematicBreakBlock`: a horizontal rule character string.
- `TextNode`: plain string output.
- `EmphasisNode` (italic): chalk italic.
- `StrongNode` (bold): chalk bold.
- `CodeSpanNode`: a visually distinct style (e.g. chalk dim or inverse).
- `LinkNode`: rendered as the inline content only; href is not shown.
- `WikilinkNode`: display text (or target if no display text), rendered with an underline or distinct colour.
- `TransclusionNode`: rendered as its raw source text (e.g. `![[file#section:1]]`).
- `IngredientNode`, `CookwareNode`, `TimerNode`: rendered as their raw annotation source text (e.g. `@flour{200 g}`, `$pan{}`, `~2-3 mins`).
Distinct annotation styling is out of scope for this spec.
- chalk is used for all ANSI styling.
chalk auto-detects whether stdout is a TTY and disables colour codes when output is piped, satisfying user story 8 without an explicit flag.
- **Error handling:**
- If the file path argument is missing, commander prints usage to stderr and exits with code 1.
- If the file does not exist or cannot be read, the entry point prints a human-readable error message to stderr and exits with code 1.
- No other error conditions are handled at this stage; parse errors from `@kitchen-md/core` are not expected (the parser is resilient to any valid Markdown string).
## Testing Decisions
- Two seams are tested:
**Renderer unit tests** — given a `DocumentAST` constructed inline in the test, assert the `render` function's output with ANSI codes stripped.
Tests cover every Block type, every Inline Node type, frontmatter output, and the visual separator between frontmatter and body.
No subprocess invocation; no filesystem access.
**Smoke tests** — invoke the `kitchen view` binary as a subprocess, capture stdout, strip ANSI codes, and assert the plain text content.
Smoke tests use `fixtures/basic.md` as input and assert that the output contains the expected rendered text for the fixture's headings, paragraphs, frontmatter, and wikilinks.
Smoke tests use Bun's subprocess API, consistent with the project scaffold's smoke test approach.
- ANSI colour and weight choices are visual decisions verified by inspection, not automated test assertions.
Assertions operate on stripped plain text only.
- Tests are co-located with source and follow the naming conventions established in the project scaffold.
## Out of Scope
- Distinct styling for KitchenMD annotation nodes (Ingredient, Cookware, Timer).
Annotations render as raw source text in this spec.
- Pager support.
- Multiple file arguments.
- `--no-color` flag (chalk auto-detects TTY).
- Syntax highlighting for code blocks.
- Formatted frontmatter display (title, servings, tags as a styled header).
- Any subcommand other than `view`.
## Further Notes
- Annotation raw source text (`@flour{200 g}`, `$pan{}`, `~2-3 mins`) reads naturally as plain text in the terminal.
This matches how Recipe Files render in Obsidian without a plugin — the format is designed to be readable without tooling.
- The renderer's purity is intentional.
Keeping file I/O in the entry point and rendering in a pure function is the minimum seam needed for deterministic unit tests without subprocess overhead.

126
.claude/spec/core-parser.md Normal file
View File

@@ -0,0 +1,126 @@
## Problem Statement
`@kitchen-md/core` has an empty entry point.
There is no Parser implementation, no public API, and no AST types.
Tooling that needs to extract structured data from Recipe Files — the CLI, and eventually an Obsidian plugin — has nothing to build on.
## Solution
Implement the Parser in `@kitchen-md/core`: a pure function that accepts a Recipe File string and returns a Document AST.
The Parser uses remark internally to handle CommonMark and OFM syntax, runs a custom annotation transform to surface Ingredient, Cookware, and Timer nodes, and exposes a clean public API using core's own types — no remark internals leak through.
## User Stories
1. As a tooling user, I want to call `parse` with a Recipe File string and receive a Document AST so that I can build structured tooling without writing a parser myself.
2. As a tooling user, I want frontmatter passed through as a plain object so that I can read whatever fields I need without the parser imposing a schema.
3. As a tooling user, I want heading blocks in the Document AST so that I can render or reason about document structure.
4. As a tooling user, I want paragraph blocks with typed inline nodes so that I can render prose and distinguish plain text from structured content.
5. As a tooling user, I want list blocks with ordered and unordered variants so that recipe steps written as lists are represented faithfully.
6. As a tooling user, I want code blocks passed through without annotation parsing so that example syntax in documentation is not mistakenly extracted as recipe data.
7. As a tooling user, I want wikilinks represented as typed Inline Nodes so that I can render or process `[[links]]` distinctly from plain text.
8. As a tooling user, I want transclusions — including KitchenMD Step References — represented as typed Inline Nodes so that tooling can resolve them.
9. As a tooling user, I want Ingredient Annotations represented as Inline Nodes carrying name, quantity, and unit so that I can extract ingredient data from any recipe prose.
10. As a tooling user, I want Cookware Annotations represented as Inline Nodes carrying name and optional quantity so that I can extract equipment requirements.
11. As a tooling user, I want Timer Annotations represented as Inline Nodes carrying a value or range and a canonical unit so that I can extract timing information.
12. As a tooling user, I want annotations inside code spans and code blocks to be ignored so that the parser follows CommonMark inline processing rules.
13. As a future Obsidian plugin author, I want to import `@kitchen-md/core` without taking a transitive dependency on remark so that my plugin bundle does not include remark's internals.
## Implementation Decisions
- The public API exports a single `parse` function.
Given a Recipe File string, it returns a `DocumentAST`.
It is a pure function — no filesystem access, no side effects.
- `DocumentAST` top-level shape: `{ frontmatter, blocks }`.
- `frontmatter` — the raw parsed YAML metadata as a plain object, passed through without schema enforcement.
Empty object when no frontmatter is present.
- `blocks` — a flat, ordered array of Block nodes representing the document body.
The flat structure means headings are siblings of their following content, not parents of it.
See ADR 0003 and the Document AST entry in the domain glossary.
- **Block types** (initial set):
- `HeadingBlock` — level (16) and an inline content array.
- `ParagraphBlock` — an inline content array.
- `ListBlock` — ordered flag and an array of list items, each carrying an inline content array.
- `CodeBlock` — optional language identifier and a literal text string.
Annotation parsing does not run on code block content.
- `ThematicBreakBlock` — no content fields.
- `RawBlock` — carries the raw text of any remark node type not explicitly modelled above.
Ensures forward compatibility when the fixture or future files use block types not yet in the catalogue.
- **Inline Node types**:
- `TextNode` — a literal string of plain text.
- `EmphasisNode` — an inline content array (italic).
- `StrongNode` — an inline content array (bold).
- `CodeSpanNode` — a literal string.
Annotation parsing does not run on code span content.
- `LinkNode` — href string and an inline content array.
- `WikilinkNode` — target (filename without extension) and optional display text.
Covers both `[[link]]` and `[[link|display]]`.
- `TransclusionNode` — target and optional anchor string.
Covers `![[link]]`, `![[link#heading]]`, and KitchenMD Step References (`![[file#section:N]]` and `![[file#N]]`).
The anchor is passed through as-is; Step Reference resolution is out of scope for the parser.
- `IngredientNode` — name, optional quantity string, optional unit string.
- `CookwareNode` — name, optional quantity string, optional unit string.
- `TimerNode` — value string (single) or value + high strings (range), and a canonical unit abbreviation.
- remark is used internally as the CommonMark parser (see ADR 0002).
OFM wikilinks and transclusions are handled by `remark-wiki-link` and its underlying micromark and mdast-util packages.
KitchenMD annotations are processed by a custom remark transform plugin that walks mdast text nodes outside code contexts and splits them into annotation Inline Nodes.
remark types do not appear in `@kitchen-md/core`'s public API (see ADR 0003).
An internal translation layer maps remark's mdast output to core's own types before returning.
- Annotation parsing naturally respects CommonMark inline scoping: the annotation transform plugin operates only on mdast text nodes, which do not appear inside code spans or code blocks.
No additional scoping logic is required.
- Timer unit aliases are normalised to canonical abbreviations at parse time (`mins``min`, `hours``hr`, etc.).
The full alias table is defined in the Timer Annotation entry of the domain glossary.
- Quantity strings are preserved as-is (e.g. `"1 1/2"`, `"0.5"`, `"1/2"`).
No numeric coercion or arithmetic is performed at the parser level.
- Core's own types are defined in a dedicated types module within `@kitchen-md/core` and re-exported from the package entry point alongside `parse`.
## Testing Decisions
- All tests assert the public `parse` function's output.
No remark internals, no transform plugin internals, no mdast types appear in tests.
- **Unit tests** use inline raw strings only — no filesystem access.
Each test passes a Recipe File string to `parse` and asserts the returned Document AST.
Coverage must include:
- Frontmatter passthrough (arbitrary fields, empty frontmatter, no frontmatter)
- Each Block type: heading (all levels), paragraph, ordered list, unordered list, code block, thematic break
- Each Inline Node type: plain text, emphasis, strong, code span, link, WikilinkNode, TransclusionNode
- Ingredient Annotation: name, quantity, unit; multi-word name; unit-less quantity; no quantity
- Cookware Annotation: name with quantity and unit; name with no quantity; multi-word name
- Timer Annotation: single value form; range form; all supported unit aliases normalised to canonical form
- Annotations embedded mid-sentence (not at the start of a line)
- Annotations inside code spans — not extracted
- Annotations inside code blocks — not extracted
- A Step Reference transclusion — anchor passed through as-is
- **Integration tests** pass the content of `fixtures/basic.md` through `parse` and assert the complete Document AST, covering all annotation types, OFM features, and frontmatter together in a single realistic input.
- Tests are co-located with the source module and follow the `{module}_test.ts` naming convention established in the project scaffold.
## Out of Scope
- Quantity arithmetic or numeric normalisation (e.g. `1/2``0.5`).
Quantities are preserved as strings.
- Unit normalisation for Ingredient and Cookware units.
Those units are free-form and passed through as-is.
- Step counting and Step Reference resolution.
The parser surfaces `TransclusionNode` with the raw anchor; resolution is a consumer concern.
- Combined Recipe validation.
- Aisle mapping.
- Shopping list generation.
- CLI implementation (covered by the cli-view spec).
## Further Notes
- The "built from scratch" statement in the project spec refers to not forking Cooklang's parser.
Using remark as an internal dependency is consistent with that intent — see ADR 0002.
- The annotation transform plugin running only on text nodes is what enforces CommonMark scoping for free, without any explicit code-span or code-block detection logic in the plugin itself.
- `RawBlock` is a safety valve, not a target.
Implementation should aim to model all block types that appear in recipe fixtures explicitly.