- 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)
127 lines
8.7 KiB
Markdown
127 lines
8.7 KiB
Markdown
## 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 (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-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.
|