docs: sync core-parser spec and add unit normalisation

Bring core-parser.md in line with the resolved design in CONTEXT.md and
ADRs 0004/0005, and promote a new decision to normalise ingredient/cookware
units.

- Full sync of the core-parser spec: diagnostics/total-function channel,
  container blocks (ListItemBlock, BlockquoteBlock), RawInline alongside
  RawBlock via position-slicing, the unified WikilinkNode/TransclusionNode
  shape as two typed nodes, and the minimal remark plugin set.
- New ADR 0006: normalise ingredient/cookware units via a known-alias table
  with passthrough, case-insensitive, canonical-only; align Timer matching
  to case-insensitive too.
- SPEC.md: units are normalised (new Units section, alias table); Timer table
  marked case-insensitive.
- CONTEXT.md: add Unit Normalisation entry, resolve the open item.
- fixtures/basic.md: add a blockquote (callout) carrying an annotation and a
  non-canonical unit so the integration test exercises containers and
  normalisation.
- Expand the core test.todo checklists to cover the new behaviour (containers,
  raw fallbacks, diagnostics, unit normalisation, wikilink/transclusion
  variants); still pending, suite stays green.
This commit is contained in:
2026-07-14 21:42:12 -04:00
parent 3ffd2b3f9f
commit 9958b6e6d5
7 changed files with 330 additions and 60 deletions

View File

@@ -26,6 +26,7 @@ The quantity/unit split is **grammar-driven, not last-space-driven**: the parser
If the brace content does not begin with a grammar-valid quantity (e.g. `@stock{a splash}`), the entire content is preserved as the quantity string and the unit is left empty — no annotation is dropped. If the brace content does not begin with a grammar-valid quantity (e.g. `@stock{a splash}`), the entire content is preserved as the quantity string and the unit is left empty — no annotation is dropped.
The name is trimmed of surrounding whitespace (interior spaces preserved, so `@unsalted butter{}` stays `unsalted butter`), and the brace content is trimmed before grammar-matching. The name is trimmed of surrounding whitespace (interior spaces preserved, so `@unsalted butter{}` stays `unsalted butter`), and the brace content is trimmed before grammar-matching.
The unit is the whole remainder after the quantity, so multi-word units work: `@stock{200 fl oz}` → quantity `200`, unit `fl oz`. The unit is the whole remainder after the quantity, so multi-word units work: `@stock{200 fl oz}` → quantity `200`, unit `fl oz`.
The unit is normalised: a unit matching a known alias is rewritten to its canonical abbreviation (case-insensitive, multi-word aware), and any unit not in the table is passed through verbatim. See the [[Unit Normalisation]] entry and ADR 0006.
Valid name characters: any character except `{`, `}`, `@`, `$`, `~`. Valid name characters: any character except `{`, `}`, `@`, `$`, `~`.
## Cookware Annotation ## Cookware Annotation
@@ -35,7 +36,7 @@ Syntax: `$name{}`, `$name{quantity}`, or `$name{quantity unit}`
Same `{`-terminated name rule as Ingredient. Same `{`-terminated name rule as Ingredient.
The `{}` wrapper is mandatory. The `{}` wrapper is mandatory.
Quantity is optional; `$pan{}` is valid with no quantity. Quantity is optional; `$pan{}` is valid with no quantity.
Quantity and unit follow the same rules as Ingredient: valid formats are integer, decimal, simple fraction, mixed number; unit is free-form; the split is grammar-driven, with non-numeric content preserved wholesale as the quantity string. Quantity and unit follow the same rules as Ingredient: valid formats are integer, decimal, simple fraction, mixed number; the split is grammar-driven, with non-numeric content preserved wholesale as the quantity string; and the unit is normalised via the same known-alias table (see [[Unit Normalisation]]).
Valid name characters: any character except `{`, `}`, `@`, `$`, `~`. Valid name characters: any character except `{`, `}`, `@`, `$`, `~`.
## Timer Annotation ## Timer Annotation
@@ -50,9 +51,21 @@ In range form, the `-` separator follows the first quantity expression — since
The range `-` must directly abut both numbers: `~10-15 mins` is a range, but `~10 - 15 mins` (spaces around `-`) is plain prose, not a timer. The range `-` must directly abut both numbers: `~10-15 mins` is a range, but `~10 - 15 mins` (spaces around `-`) is plain prose, not a timer.
One or more spaces separate the number (or range) from the unit. One or more spaces separate the number (or range) from the unit.
The annotation terminates after the unit word — trailing prose is ignored. `~1 min more` parses as a timer of 1 min; "more" is plain text. The annotation terminates after the unit word — trailing prose is ignored. `~1 min more` parses as a timer of 1 min; "more" is plain text.
The unit matches only as a complete word (followed by whitespace, punctuation, or end-of-input) and the longest known alias wins (`seconds` before `sec`); `~5 minsx` is not a timer because `minsx` is not a known unit. The unit matches only as a complete word (followed by whitespace, punctuation, or end-of-input), is matched case-insensitively, and the longest known alias wins (`seconds` before `sec`); `~5 minsx` is not a timer because `minsx` is not a known unit.
Known unit set (aliases → canonical abbreviation): `sec`, `secs`, `second`, `seconds``s`; `min`, `mins`, `minute`, `minutes``min`; `hr`, `hrs`, `hour`, `hours``hr`. Known unit set (aliases → canonical abbreviation): `sec`, `secs`, `second`, `seconds``s`; `min`, `mins`, `minute`, `minutes``min`; `hr`, `hrs`, `hour`, `hours``hr`.
## Unit Normalisation
The Parser normalises Ingredient and Cookware units to a canonical abbreviation from a fixed known-alias table.
A unit whose text matches an alias is rewritten to its canonical form; any unit not in the table is passed through verbatim (original casing and spacing preserved).
Lookup is **case-insensitive**, multi-word aliases (`fluid ounce`) match on the whole unit string, and only the canonical unit is retained — the author's original spelling is not kept.
Single-letter cooking abbreviations (`t`, `T`, `c`) are excluded as ambiguous, and count/descriptive units (`clove`, `pinch`, `to taste`) have no canonical form and pass through unchanged.
Timer units are normalised from their own known set (see [[Timer Annotation]]), also case-insensitively.
Canonical set (aliases → canonical): g, gram, grams → `g`; kg, kilogram, kilograms, kilo, kilos → `kg`; mg, milligram, milligrams → `mg`; oz, ounce, ounces → `oz`; lb, lbs, pound, pounds → `lb`; ml, milliliter, millilitre, milliliters, millilitres → `ml`; l, liter, litre, liters, litres → `l`; tsp, teaspoon, teaspoons → `tsp`; tbsp, tablespoon, tablespoons → `tbsp`; cup, cups → `cup`; fl oz, fluid ounce, fluid ounces → `fl oz`; pt, pint, pints → `pt`; qt, quart, quarts → `qt`; gal, gallon, gallons → `gal`.
The durable definition of this table lives in the core-parser spec and `SPEC.md`; ADR 0006 records the decision.
## Recipe File ## Recipe File
A valid `.md` file that may contain any combination of Obsidian Flavored Markdown, YAML frontmatter, and Annotations. A valid `.md` file that may contain any combination of Obsidian Flavored Markdown, YAML frontmatter, and Annotations.
@@ -183,4 +196,7 @@ A raw node exposes only that `value` string; it carries no `kind`/type hint (tha
## Open / Unresolved ## Open / Unresolved
- **Unit normalisation** for Ingredient/Cookware — 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. (Timer unit normalisation is already fully specified above.) - _(none currently)_
Resolved:
- **Unit normalisation** for Ingredient/Cookware — resolved as a known-alias table with passthrough, case-insensitive, canonical-only. See [[Unit Normalisation]] and ADR 0006.

View File

@@ -0,0 +1,82 @@
# ADR 0006 — Ingredient/Cookware Unit Normalisation
**Status**: Accepted
## Context
Timer annotations already normalise their unit to a canonical abbreviation from a fixed known set (`mins``min`, `hours``hr`).
Ingredient and Cookware units, by contrast, were originally specified as free-form strings passed through verbatim, and unit normalisation for them was listed as out of scope in the core-parser spec.
That left a gap.
A consumer aggregating ingredients for a shopping list receives `200 g`, `200 grams`, and `200 G` as three distinct units, and has to re-implement normalisation itself — once per consumer (the CLI, the future Obsidian plugin).
The domain glossary flagged this as an open question, leaning toward normalising known aliases to a canonical abbreviation but leaving the exact table and canonical forms undecided.
Three approaches were considered:
**Option A — Free-form passthrough (status quo)**
Ingredient/cookware units stay verbatim strings; consumers normalise if they care.
**Option B — Known-alias table with passthrough**
The parser maintains a fixed alias→canonical table.
A unit matching an alias is rewritten to its canonical abbreviation; any unit not in the table passes through unchanged.
**Option C — Full unit system**
Model units as typed quantities with dimensional analysis and conversion (mass/volume, metric/imperial), enabling arithmetic across units.
## Decision
**Option B** — a known-alias table with passthrough — plus **case-insensitive** matching for all annotation unit tables (ingredient/cookware **and** timer).
- A unit whose text matches a known alias is normalised to its canonical abbreviation.
- Lookup is case-insensitive; matching is done on the trimmed unit remainder, and multi-word aliases (`fluid ounce`) match on the whole remainder.
- The node stores only the canonical unit; the author's original spelling is not retained.
- Any unit not in the table is passed through verbatim, with its original casing and spacing preserved.
- Timer unit matching, previously unspecified on case, is made case-insensitive too, so both normalisers follow one casing rule.
The canonical set and aliases:
| Canonical | Aliases |
|-----------|---------|
| `g` | g, gram, grams |
| `kg` | kg, kilogram, kilograms, kilo, kilos |
| `mg` | mg, milligram, milligrams |
| `oz` | oz, ounce, ounces |
| `lb` | lb, lbs, pound, pounds |
| `ml` | ml, milliliter, millilitre, milliliters, millilitres |
| `l` | l, liter, litre, liters, litres |
| `tsp` | tsp, teaspoon, teaspoons |
| `tbsp` | tbsp, tablespoon, tablespoons |
| `cup` | cup, cups |
| `fl oz` | fl oz, fluid ounce, fluid ounces |
| `pt` | pt, pint, pints |
| `qt` | qt, quart, quarts |
| `gal` | gal, gallon, gallons |
Single-letter cooking abbreviations (`t`, `T`, `c`) are excluded as ambiguous — and they would collide under case-insensitive lookup.
Count and descriptive units (`clove`, `pinch`, `dash`, `can`, `large`, `to taste`) have no canonical form and pass through unchanged.
## Rationale
Option A pushes the same normalisation logic onto every consumer and guarantees they drift; the parser is the one place that sees every annotation and is the natural home for it, exactly as it already is for timers.
Consistency with the existing Timer behaviour is the deciding factor — having timers normalise but ingredients not would be an arbitrary split.
Option C is disproportionate.
Recipe units are overwhelmingly written already-canonical or as a short list of aliases; dimensional analysis and cross-unit conversion solve a problem no consumer has asked for, and they force decisions (metric/imperial conversion factors, density for mass↔volume) that belong to a consumer feature, not the parser.
Passthrough for unlisted units is what keeps Option B safe: culinary units are open-ended (`pinch`, `clove`, `to taste`), so any attempt to normalise algorithmically would mangle them.
A fixed table normalises exactly the units with an unambiguous canonical form and leaves everything else untouched, so no annotation's unit is ever dropped or corrupted.
Storing only the canonical unit (rather than also a raw form) matches Timer and keeps the node minimal.
Annotation nodes are not part of the lossless round-trip guarantee — that is ADR 0005's job for unmodelled OFM, and an annotation's sigil and braces are consumed regardless — so there is no losslessness argument for retaining the raw unit.
If a round-trip consumer ever needs the original spelling, adding a `rawUnit` field later is additive and non-breaking.
Case-insensitive matching reflects how authors actually write (`2 Tbsp`, `200 ML`), and aligning Timer to the same rule avoids the footgun of `~5 Mins` silently falling through to plain text while `@flour{200 G}` normalises fine.
## Consequences
- `IngredientNode` and `CookwareNode` carry a canonical `unit` when the written unit matches an alias, and the verbatim unit otherwise.
- The parser owns a single unit-alias table shared in spirit with the Timer table; both use case-insensitive lookup.
- Timer unit matching is now case-insensitive, a small change to the language spec's Timer section.
- Ingredient/cookware unit normalisation moves from "out of scope" to specified parser behaviour in the core-parser spec.
- The alias table can be extended additively; adding an alias or a new canonical unit is non-breaking.
- A future unit-conversion or dimensional-analysis feature, if ever needed, is a consumer-level concern layered on top of these canonical units, not a parser change.

View File

@@ -6,47 +6,69 @@ Tooling that needs to extract structured data from Recipe Files — the CLI, and
## Solution ## Solution
Implement the Parser in `@kitchen-md/core`: a pure function that accepts a Recipe File string and returns a Document AST. Implement the Parser in `@kitchen-md/core`: a single pure, total 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. It never throws — every input produces a usable Document AST, and the one class of genuinely invalid input (malformed frontmatter YAML) is reported through a diagnostics channel rather than as an exception (see ADR 0004).
Internally the Parser uses remark with a deliberately minimal plugin set to handle CommonMark and OFM, runs a custom annotation transform to surface Ingredient, Cookware, and Timer nodes, and exposes a clean public API built entirely from core's own types — no remark internals leak through (see ADR 0003).
## User Stories ## 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. 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. 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. 3. As a tooling user, I want `parse` to never throw and to report malformed frontmatter as a diagnostic so that I never have to wrap it in `try`/`catch`, yet can still distinguish a broken frontmatter block from an absent one.
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. 4. As a tooling user, I want heading blocks in the Document AST so that I can render or reason about document structure.
5. As a tooling user, I want list blocks with ordered and unordered variants so that recipe steps written as lists are represented faithfully. 5. 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.
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. 6. As a tooling user, I want list blocks with ordered and unordered variants, whose items are containers holding blocks, so that recipe steps written as lists are represented faithfully and annotations inside them are still extracted.
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. 7. As a tooling user, I want blockquote blocks modelled as containers so that prose and annotations inside blockquotes and callouts are preserved and extracted rather than lost.
8. As a tooling user, I want transclusions — including KitchenMD Step References — represented as typed Inline Nodes so that tooling can resolve them. 8. 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.
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. 9. As a tooling user, I want any OFM construct the parser does not model preserved verbatim as a raw block or raw inline so that no source is ever discarded and a document round-trips unchanged.
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. 10. As a tooling user, I want wikilinks represented as typed Inline Nodes so that I can render or process `[[links]]` distinctly from plain text.
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. 11. As a tooling user, I want transclusions — including KitchenMD Step References — represented as typed Inline Nodes so that tooling can resolve them.
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. 12. 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.
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. 13. As a tooling user, I want Cookware Annotations represented as Inline Nodes carrying name, quantity, and unit so that I can extract equipment requirements.
14. 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.
15. As a tooling user, I want ingredient and cookware units matching a known alias normalised to a canonical abbreviation, with unknown units passed through unchanged, so that I get consistent units for aggregation without losing unusual free-form ones.
16. 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.
17. 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 ## Implementation Decisions
- The public API exports a single `parse` function. - The public API exports a single `parse` function.
Given a Recipe File string, it returns a `DocumentAST`. Given a Recipe File string, it returns a `DocumentAST`.
It is a pure function — no filesystem access, no side effects. It is a pure function — no filesystem access, no side effects — and a total function that never throws (see ADR 0004).
- `DocumentAST` top-level shape: `{ frontmatter, blocks }`. - `DocumentAST` top-level shape: `{ frontmatter, blocks, diagnostics }`.
- `frontmatter` — the raw parsed YAML metadata as a plain object, passed through without schema enforcement. - `frontmatter` — the raw parsed YAML metadata as a plain object, passed through without schema enforcement.
Empty object when no frontmatter is present. Empty object when no frontmatter is present, and also empty when the frontmatter block is present but invalid.
- `blocks` — a flat, ordered array of Block nodes representing the document body. - `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. The array is flat at the top level: headings are siblings of their following content, not parents of it.
Container blocks (list items, blockquotes) nest their own child blocks; the flatness applies to the top level, not inside containers.
See ADR 0003 and the Document AST entry in the domain glossary. See ADR 0003 and the Document AST entry in the domain glossary.
- `diagnostics` — an ordered array of non-fatal Diagnostic warnings surfaced during parsing.
Empty in the normal case.
- **Total function and diagnostics** (see ADR 0004):
- `parse` never throws.
Genuinely invalid input is reported through `diagnostics`, not exceptions, so a consumer always receives a usable Document AST.
- The only diagnostic emitted today is `invalid-frontmatter`: when the `---` block is present but not valid YAML, the body still parses, `frontmatter` is `{}`, and the raw YAML plus the parse error are preserved in the diagnostic.
- A `Diagnostic` has the shape `{ severity, code, message, source?, position? }`.
`severity` is a level such as `"warning"`; `code` is a stable machine-readable identifier (e.g. `"invalid-frontmatter"`); `message` is human-readable; `source` preserves the offending raw text verbatim; `position` locates it in the input.
`Diagnostic` is a public API type; adding new codes later is additive and non-breaking.
- **Block types** (initial set): - **Block types** (initial set):
- `HeadingBlock` — level (16) and an inline content array. - `HeadingBlock` — level (16) and an inline content array.
- `ParagraphBlock` — an inline content array. - `ParagraphBlock` — an inline content array.
- `ListBlock` — ordered flag and an array of list items, each carrying an inline content array. - `ListBlock` an ordered flag and an array of `ListItemBlock`.
- `ListItemBlock` — a container block holding a `Block[]`.
A list item wraps its own child blocks (typically a paragraph), not a bare inline array, so `- a $ladle{}` becomes a list item containing a paragraph.
This mirrors how CommonMark models list items and is what lets annotation extraction reach inside them.
- `BlockquoteBlock` — a container block holding a `Block[]`.
OFM callouts (`> [!note]`) carry no dedicated node; they parse as ordinary blockquotes whose text is preserved, and annotations inside them are extracted like any other container content.
- `CodeBlock` — optional language identifier and a literal text string. - `CodeBlock` — optional language identifier and a literal text string.
Annotation parsing does not run on code block content. Annotation parsing does not run on code block content.
- `ThematicBreakBlock` — no content fields. - `ThematicBreakBlock` — no content fields.
- `RawBlock`carries the raw text of any remark node type not explicitly modelled above. - `RawBlock`the block-level raw fallback for any remark node type not explicitly modelled above (see ADR 0005).
Ensures forward compatibility when the fixture or future files use block types not yet in the catalogue. It exposes a single `value` string and nothing else — no `kind` or type hint.
`value` is captured by position-slicing the original input, so it is byte-for-byte what the author wrote and round-trips unchanged.
- **Inline Node types**: - **Inline Node types**:
- `TextNode` — a literal string of plain text. - `TextNode` — a literal string of plain text.
@@ -55,29 +77,68 @@ The Parser uses remark internally to handle CommonMark and OFM syntax, runs a cu
- `CodeSpanNode` — a literal string. - `CodeSpanNode` — a literal string.
Annotation parsing does not run on code span content. Annotation parsing does not run on code span content.
- `LinkNode` — href string and an inline content array. - `LinkNode` — href string and an inline content array.
- `WikilinkNode` — target (filename without extension) and optional display text. - `WikilinkNode` `{ target, anchor?, display? }`.
Covers both `[[link]]` and `[[link|display]]`. `target` is the filename without extension; `anchor` is the part after `#`, passed through verbatim; `display` is the alias after `|`.
- `TransclusionNode` — target and optional anchor string. Covers `[[link]]`, `[[link#heading]]`, and `[[link|display]]`.
Covers `![[link]]`, `![[link#heading]]`, and KitchenMD Step References (`![[file#section:N]]` and `![[file#N]]`). - `TransclusionNode` — the same `{ target, anchor?, display? }` shape as `WikilinkNode`, but a distinct node type marking an embed (`![[]]`).
The anchor is passed through as-is; Step Reference resolution is out of scope for the parser. The node type — not a boolean flag — is the discriminator between a reference and an embed.
Covers `![[link]]`, `![[link#heading]]`, `![[link|alt]]`, and KitchenMD Step References (`![[file#section:N]]` and `![[file#N]]`), whose anchor is passed through as-is; Step Reference resolution is out of scope for the parser.
- `IngredientNode` — name, optional quantity string, optional unit string. - `IngredientNode` — name, optional quantity string, optional unit string.
- `CookwareNode` — 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. - `TimerNode` — value string (single) or value + high strings (range), and a canonical unit abbreviation.
- `RawInline` — the inline-level raw fallback for any remark inline node type not explicitly modelled above (see ADR 0005).
Like `RawBlock`, it exposes only a position-sliced verbatim `value` string and no `kind` hint.
- remark is used internally as the CommonMark parser (see ADR 0002). - The internal remark plugin set is deliberately minimal (see ADR 0002 and ADR 0005): `remark-parse` + `remark-frontmatter` + `remark-gfm` (tables, task lists, strikethrough, autolinks) + `remark-wiki-link` (links and embeds), plus core's own annotation transform.
OFM wikilinks and transclusions are handled by `remark-wiki-link` and its underlying micromark and mdast-util packages. Most other OFM syntax needs no dedicated plugin: `==highlight==`, `%%comment%%`, `$math$`, and `$$block math$$` are left as literal text with their markers intact, and callouts parse as ordinary blockquotes.
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. Anything remark tokenises into a node core does not model (e.g. a GFM table, strikethrough) falls through to `RawBlock` or `RawInline`.
remark types do not appear in `@kitchen-md/core`'s public API (see ADR 0003). remark types do not appear in `@kitchen-md/core`'s public API; an internal translation layer maps remark's mdast output to core's own types before returning, recursing into container children rather than collapsing them.
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. - 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.
No additional scoping logic is required. Annotation parsing naturally respects CommonMark inline scoping: the transform operates only on mdast text nodes, which do not appear inside code spans, code blocks, HTML comments, or raw HTML blocks, so no additional scoping logic is required.
Because it only rewrites text nodes (which are always modelled, never raw), it does not invalidate the parse positions the raw fallbacks depend on.
- Timer unit aliases are normalised to canonical abbreviations at parse time (`mins``min`, `hours``hr`, etc.). - The Ingredient/Cookware quantity/unit split is grammar-driven, not last-space-driven.
The full alias table is defined in the Timer Annotation entry of the domain glossary. The quantity is matched greedily against the numeric grammar (mixed number → fraction → decimal → integer) anchored at the start of the braces, and any non-empty remainder after the delimiting space is the unit.
If the brace content does not begin with a grammar-valid quantity (e.g. `@stock{a splash}`), the entire content is preserved as the quantity string and the unit is left empty, so no annotation is dropped.
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.
- Quantity strings are preserved as-is (e.g. `"1 1/2"`, `"0.5"`, `"1/2"`). - **Ingredient and Cookware unit normalisation** (see ADR 0006):
No numeric coercion or arithmetic is performed at the parser level. - A unit whose text matches a known alias is normalised to its canonical abbreviation; any unit not in the table is passed through verbatim (original casing and spacing preserved).
- Lookup is case-insensitive; the node stores only the canonical unit — the author's original spelling is not retained.
- Multi-word aliases match on the whole unit remainder (e.g. `200 fluid ounces` → unit `fl oz`).
- Alias table (case-insensitive; anything unlisted passes through unchanged):
| Canonical | Aliases |
|-----------|---------|
| `g` | g, gram, grams |
| `kg` | kg, kilogram, kilograms, kilo, kilos |
| `mg` | mg, milligram, milligrams |
| `oz` | oz, ounce, ounces |
| `lb` | lb, lbs, pound, pounds |
| `ml` | ml, milliliter, millilitre, milliliters, millilitres |
| `l` | l, liter, litre, liters, litres |
| `tsp` | tsp, teaspoon, teaspoons |
| `tbsp` | tbsp, tablespoon, tablespoons |
| `cup` | cup, cups |
| `fl oz` | fl oz, fluid ounce, fluid ounces |
| `pt` | pt, pint, pints |
| `qt` | qt, quart, quarts |
| `gal` | gal, gallon, gallons |
- Single-letter cooking abbreviations (`t`, `T`, `c`) are intentionally excluded — they are ambiguous and would collide under case-insensitive lookup.
Count and descriptive units (`clove`, `pinch`, `dash`, `can`, `large`, `to taste`) have no canonical form and pass through unchanged.
- **Timer unit normalisation**: aliases are normalised to canonical abbreviations at parse time, using the same case-insensitive matching as ingredient/cookware units (see ADR 0006).
Alias table (case-insensitive):
| Canonical | Aliases |
|-----------|---------|
| `s` | sec, secs, second, seconds |
| `min` | min, mins, minute, minutes |
| `hr` | hr, hrs, hour, hours |
The unit matches only as a complete word and the longest known alias wins; `~5 minsx` is not a timer because `minsx` is not a known unit.
- 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`. - 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`.
@@ -90,17 +151,24 @@ The Parser uses remark internally to handle CommonMark and OFM syntax, runs a cu
Each test passes a Recipe File string to `parse` and asserts the returned Document AST. Each test passes a Recipe File string to `parse` and asserts the returned Document AST.
Coverage must include: Coverage must include:
- Frontmatter passthrough (arbitrary fields, empty frontmatter, no frontmatter) - Frontmatter passthrough (arbitrary fields, empty frontmatter, no frontmatter)
- Each Block type: heading (all levels), paragraph, ordered list, unordered list, code block, thematic break - Malformed frontmatter — `parse` does not throw, `frontmatter` is `{}`, body still parses, and an `invalid-frontmatter` diagnostic is emitted with the raw YAML preserved
- Each Inline Node type: plain text, emphasis, strong, code span, link, WikilinkNode, TransclusionNode - Each Block type: heading (all levels), paragraph, ordered list, unordered list, code block, thematic break, blockquote
- `ListItemBlock` wraps a paragraph (a list item holds child blocks, not a bare inline array)
- Annotation extraction inside a container (an annotation inside a blockquote or list item is surfaced)
- Each Inline Node type: plain text, emphasis, strong, code span, link, `WikilinkNode`, `TransclusionNode`
- `WikilinkNode` with an anchor and with a display alias; `TransclusionNode` with a display alias
- A remark node core does not model falls through to `RawBlock` (block level, e.g. a GFM table) and `RawInline` (inline level), with the verbatim source preserved
- Ingredient Annotation: name, quantity, unit; multi-word name; unit-less quantity; no quantity - 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 - 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 - Timer Annotation: single value form; range form; all supported unit aliases normalised to canonical form
- Ingredient/Cookware unit normalisation: a known alias normalised to canonical (`grams``g`, `Tbsp``tbsp`), case-insensitive matching, a multi-word unit (`fluid ounces``fl oz`), and an unknown unit passed through verbatim
- Timer unit matching is case-insensitive (`~5 Mins``min`)
- Annotations embedded mid-sentence (not at the start of a line) - Annotations embedded mid-sentence (not at the start of a line)
- Annotations inside code spans — not extracted - Annotations inside code spans — not extracted
- Annotations inside code blocks — not extracted - Annotations inside code blocks — not extracted
- A Step Reference transclusion — anchor passed through as-is - 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. - **Integration tests** pass the content of `fixtures/basic.md` through `parse` and assert the complete Document AST, covering all annotation types, OFM features (including a blockquote with an annotation and a non-canonical unit that normalises), 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. - Tests are co-located with the source module and follow the `{module}_test.ts` naming convention established in the project scaffold.
@@ -108,8 +176,6 @@ The Parser uses remark internally to handle CommonMark and OFM syntax, runs a cu
- Quantity arithmetic or numeric normalisation (e.g. `1/2``0.5`). - Quantity arithmetic or numeric normalisation (e.g. `1/2``0.5`).
Quantities are preserved as strings. 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. - Step counting and Step Reference resolution.
The parser surfaces `TransclusionNode` with the raw anchor; resolution is a consumer concern. The parser surfaces `TransclusionNode` with the raw anchor; resolution is a consumer concern.
- Combined Recipe validation. - Combined Recipe validation.
@@ -122,5 +188,6 @@ The Parser uses remark internally to handle CommonMark and OFM syntax, runs a cu
- The "built from scratch" statement in the project spec refers to not forking Cooklang's parser. - 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. 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. - 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. - `RawBlock` and `RawInline` are safety valves, not targets.
Implementation should aim to model all block types that appear in recipe fixtures explicitly. The implementation models every construct that appears in recipe fixtures explicitly and lets only genuinely unmodelled syntax fall through to a raw node.
Their verbatim `value` is captured by position-slicing the original input — never re-stringified — so unmodelled constructs round-trip byte-for-byte.

38
SPEC.md
View File

@@ -56,12 +56,14 @@ Multi-word names work naturally: `@unsalted butter{30 g}`.
- Simple fraction: `1/2` - Simple fraction: `1/2`
- Mixed number: `1 1/2` - Mixed number: `1 1/2`
**Unit**: optional, free-form string. **Unit**: optional.
The quantity/unit split is **grammar-driven**: the parser matches the quantity greedily against the quantity grammar (mixed number → fraction → decimal → integer) anchored at the start of the braces, and any non-empty remainder after the delimiting space is the unit. The quantity/unit split is **grammar-driven**: the parser matches the quantity greedily against the quantity grammar (mixed number → fraction → decimal → integer) anchored at the start of the braces, and any non-empty remainder after the delimiting space is the unit.
`@butter{1 1/2 tbsp}` → quantity `1 1/2`, unit `tbsp`. `@butter{1 1/2 tbsp}` → quantity `1 1/2`, unit `tbsp`.
`@butter{1 1/2}` → quantity `1 1/2`, no unit. `@butter{1 1/2}` → quantity `1 1/2`, no unit.
A plain "last space" rule cannot express this, because a mixed-number quantity itself contains a space; the grammar is authoritative. A plain "last space" rule cannot express this, because a mixed-number quantity itself contains a space; the grammar is authoritative.
If the brace content does not begin with a grammar-valid quantity (e.g. `@stock{a splash}`), the entire content is preserved as the quantity string and the unit is left empty, so no annotation is dropped. If the brace content does not begin with a grammar-valid quantity (e.g. `@stock{a splash}`), the entire content is preserved as the quantity string and the unit is left empty, so no annotation is dropped.
The unit is **normalised**: a unit matching a known alias is rewritten to its canonical abbreviation (case-insensitive), and any unit not in the table is passed through verbatim.
See the [Units](#units) section for the alias table.
**Examples**: **Examples**:
``` ```
@@ -90,7 +92,7 @@ $name{quantity unit}
**Quantity**: optional. Follows the same format rules as Ingredient quantity (integer, decimal, fraction, mixed number). **Quantity**: optional. Follows the same format rules as Ingredient quantity (integer, decimal, fraction, mixed number).
**Unit**: optional, free-form. Same grammar-driven quantity/unit split as Ingredient. **Unit**: optional. Same grammar-driven quantity/unit split and canonical [unit normalisation](#units) as Ingredient.
**Examples**: **Examples**:
``` ```
@@ -116,7 +118,7 @@ $cupcake liner{12 large}
**Range**: two quantities separated by `-`. Since negative quantities do not exist, the `-` is unambiguous. **Range**: two quantities separated by `-`. Since negative quantities do not exist, the `-` is unambiguous.
`~1/2-1 hr` → range of 1/2 hr to 1 hr. `~1/2-1 hr` → range of 1/2 hr to 1 hr.
**Unit**: one word from the known set, normalised to its canonical abbreviation: **Unit**: one word from the known set, matched **case-insensitively** and normalised to its canonical abbreviation:
| Written | Canonical | | Written | Canonical |
|---------|-----------| |---------|-----------|
@@ -139,6 +141,36 @@ A `~` followed by a number but not a known unit word is not a valid annotation a
--- ---
## Units
Ingredient and Cookware units are normalised: a unit matching a known alias is rewritten to its canonical abbreviation, and any unit not in the table is passed through verbatim (its original casing and spacing preserved).
Lookup is **case-insensitive**, and multi-word aliases (`fluid ounce`) match on the whole unit string.
Only the canonical unit is retained; the author's original spelling is not kept.
| Canonical | Aliases |
|-----------|---------|
| `g` | g, gram, grams |
| `kg` | kg, kilogram, kilograms, kilo, kilos |
| `mg` | mg, milligram, milligrams |
| `oz` | oz, ounce, ounces |
| `lb` | lb, lbs, pound, pounds |
| `ml` | ml, milliliter, millilitre, milliliters, millilitres |
| `l` | l, liter, litre, liters, litres |
| `tsp` | tsp, teaspoon, teaspoons |
| `tbsp` | tbsp, tablespoon, tablespoons |
| `cup` | cup, cups |
| `fl oz` | fl oz, fluid ounce, fluid ounces |
| `pt` | pt, pint, pints |
| `qt` | qt, quart, quarts |
| `gal` | gal, gallon, gallons |
Single-letter cooking abbreviations (`t`, `T`, `c`) are intentionally excluded as ambiguous.
Count and descriptive units (`clove`, `pinch`, `dash`, `can`, `large`, `to taste`) have no canonical form and pass through unchanged.
Timer units are normalised from their own known set (see [Timer](#timer)), also case-insensitively.
---
## Recipe structure ## Recipe structure
A KitchenMD file is prose instructions only. A KitchenMD file is prose instructions only.

View File

@@ -24,6 +24,9 @@ Gradually whisk in @milk{300 ml} until you have a smooth, lump-free batter.
Melt @unsalted butter{30 g} in a $non-stick frying pan{}, then stir most of it into the batter, reserving a little for the pan. Melt @unsalted butter{30 g} in a $non-stick frying pan{}, then stir most of it into the batter, reserving a little for the pan.
Season with a pinch of @black pepper{} if you like a savoury edge. Season with a pinch of @black pepper{} if you like a savoury edge.
> [!tip] Fluffier pancakes
> For a lighter texture, whisk in @caster sugar{2 tablespoons} and let the batter rest for ~5 mins before cooking.
### Cooking ### Cooking
1. Set the $non-stick frying pan{} over a medium-high flame and let it heat for ~1 min. 1. Set the $non-stick frying pan{} over a medium-high flame and let it heat for ~1 min.

View File

@@ -1,15 +1,82 @@
import { describe, test } from "bun:test"; import { describe, test } from "bun:test";
describe("parser", () => { describe("parser", () => {
describe("frontmatter", () => {
test.todo("parses frontmatter fields as-is"); 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.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.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");
test.todo("parses a code block and does not annotate its content");
test.todo("parses a thematic break");
test.todo("parses a blockquote as a container block");
test.todo("parses a callout (> [!note]) as an ordinary blockquote");
});
describe("inline nodes", () => {
test.todo("parses plain text");
test.todo("parses emphasis");
test.todo("parses strong");
test.todo("parses a code span and does not annotate its content");
test.todo("parses a link with href and inline content");
test.todo("parses a wikilink");
test.todo("parses a wikilink with an anchor");
test.todo("parses a wikilink with a display alias");
test.todo("parses a transclusion");
test.todo("parses a transclusion with a display alias");
});
describe("raw fallbacks", () => {
test.todo("preserves an unmodelled block (e.g. a GFM table) as a RawBlock with verbatim source");
test.todo("preserves an unmodelled inline (e.g. strikethrough) as a RawInline with verbatim source");
});
describe("ingredient annotations", () => {
test.todo("extracts ingredient name, quantity, and unit"); test.todo("extracts ingredient name, quantity, and unit");
test.todo("extracts ingredient without unit");
test.todo("extracts multi-word ingredient name"); test.todo("extracts multi-word ingredient name");
test.todo("extracts cookware without quantity"); test.todo("extracts ingredient with a unit-less quantity");
test.todo("extracts cookware with quantity"); test.todo("extracts ingredient with no quantity");
});
describe("cookware annotations", () => {
test.todo("extracts cookware with quantity and unit");
test.todo("extracts cookware with no quantity");
test.todo("extracts multi-word cookware name"); test.todo("extracts multi-word cookware name");
test.todo("extracts timer as single value"); });
test.todo("extracts timer as range");
test.todo("annotations embedded mid-sentence are captured"); describe("timer annotations", () => {
test.todo("extracts timer as a single value");
test.todo("extracts timer as a range");
test.todo("normalises timer unit aliases to canonical form");
test.todo("matches timer units case-insensitively (~5 Mins -> min)");
});
describe("unit normalisation", () => {
test.todo("normalises a known alias to canonical (grams -> g)");
test.todo("matches unit aliases case-insensitively (Tbsp -> tbsp)");
test.todo("normalises a multi-word alias (fluid ounces -> fl oz)");
test.todo("passes an unknown unit through verbatim");
});
describe("annotation scope", () => {
test.todo("captures annotations embedded mid-sentence");
test.todo("captures annotations inside a container (blockquote or list item)");
test.todo("does not extract annotations inside a code span");
test.todo("does not extract annotations inside a code block");
});
describe("transclusion", () => {
test.todo("passes a Step Reference anchor through as-is");
});
test.todo("standard Markdown elements pass through without interference"); test.todo("standard Markdown elements pass through without interference");
}); });

View File

@@ -1,5 +1,8 @@
import { describe, test } from "bun:test"; import { describe, test } from "bun:test";
describe("parser — integration", () => { describe("parser — integration", () => {
test.todo("parses the primary fixture and extracts all annotations"); test.todo("parses the primary fixture into the complete Document AST");
test.todo("extracts every annotation type from the primary fixture");
test.todo("extracts an annotation from inside the fixture's blockquote");
test.todo("normalises the fixture's non-canonical unit (tablespoons -> tbsp)");
}); });