- 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)
8.7 KiB
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
- As a tooling user, I want to call
parsewith a Recipe File string and receive a Document AST so that I can build structured tooling without writing a parser myself. - 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.
- As a tooling user, I want heading blocks in the Document AST so that I can render or reason about document structure.
- 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.
- As a tooling user, I want list blocks with ordered and unordered variants so that recipe steps written as lists are represented faithfully.
- 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.
- As a tooling user, I want wikilinks represented as typed Inline Nodes so that I can render or process
[[links]]distinctly from plain text. - As a tooling user, I want transclusions — including KitchenMD Step References — represented as typed Inline Nodes so that tooling can resolve them.
- 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.
- As a tooling user, I want Cookware Annotations represented as Inline Nodes carrying name and optional quantity so that I can extract equipment requirements.
- 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.
- 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.
- As a future Obsidian plugin author, I want to import
@kitchen-md/corewithout 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
parsefunction. Given a Recipe File string, it returns aDocumentAST. It is a pure function — no filesystem access, no side effects. -
DocumentASTtop-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 (1–6) 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-linkand 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/coreand re-exported from the package entry point alongsideparse.
Testing Decisions
-
All tests assert the public
parsefunction'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
parseand 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.mdthroughparseand 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.tsnaming 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
TransclusionNodewith 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.
RawBlockis a safety valve, not a target. Implementation should aim to model all block types that appear in recipe fixtures explicitly.