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.
14 KiB
KitchenMD — Domain Glossary
A living glossary of canonical terms for this project. Implementation details belong in specs or code, not here.
Annotation
An inline sigil-based marker embedded in recipe prose that carries structured data. There are exactly three annotation types: Ingredient, Cookware, and Timer. An annotation is valid Markdown plain text — it does not break rendering in Obsidian or any standard Markdown renderer. Annotations follow CommonMark's inline processing rules: parsed wherever CommonMark processes inline content (paragraphs, headings, list items, blockquotes); ignored wherever CommonMark treats content as literal (code spans, code blocks, HTML comments, raw HTML blocks).
Ingredient Annotation
Syntax: @name{quantity} or @name{quantity unit}
The { character terminates the name, so multi-word names work without escaping.
The {} wrapper is mandatory — bare @name without braces is not valid.
Quantity is optional; @black pepper{} is valid with no quantity.
Valid quantity formats: integer (2), decimal (0.5), simple fraction (1/2), mixed number (1 1/2).
Unit is optional and free-form — the format places no constraints on what unit string is written.
The quantity/unit split is grammar-driven, not last-space-driven: the parser matches the quantity 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.
@butter{1 1/2 tbsp} → quantity 1 1/2, unit tbsp; @butter{1 1/2} → quantity 1 1/2, no unit (a plain last-space rule would wrongly split this into 1 / 1/2, which is why 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 — 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 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 {, }, @, $, ~.
Cookware Annotation
Syntax: $name{}, $name{quantity}, or $name{quantity unit}
Same {-terminated name rule as Ingredient.
The {} wrapper is mandatory.
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; 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 {, }, @, $, ~.
Timer Annotation
Syntax: ~N unit or ~N-N unit
No braces.
The ~ sigil is followed immediately by a number or N–N range, a space, then a time unit word from the known set.
N follows the same format rules as ingredient/cookware quantity: integer, decimal, simple fraction, or mixed number.
Supports natural range syntax: ~10-15 mins.
In range form, the - separator follows the first quantity expression — since negative quantities don't exist, the - is unambiguous. ~1/2-1 hr parses as a range of 1/2 hr to 1 hr.
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.
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), 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.
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
A valid .md file that may contain any combination of Obsidian Flavored Markdown, YAML frontmatter, and Annotations.
The file renders correctly in Obsidian without a plugin.
A Recipe File contains prose instructions only — there is no separate ingredients list section.
Ingredients are derived entirely by the Parser from inline Ingredient Annotations.
Step
The atomic unit of a recipe instruction. A step is a single non-empty line in a prose context, or a single ordered list item. Headings are not steps. Step numbers are section-local — they reset at each heading and do not change when steps are added to other sections.
Combined Recipe
A Recipe File whose purpose is to sequence steps from two or more other Recipe Files. It contains no steps or ingredients of its own — only Step References in the order they should be executed.
Step Reference
A KitchenMD extension of Obsidian transclusion syntax for embedding a specific step from another Recipe File.
Syntax:
-
![[filename#section-heading:N]]— step N within a named section. -
![[filename#N]]— step N in a headingless recipe (bare number, no section prefix). -
filename— the Recipe File to reference (no.mdextension, matching Obsidian wikilink convention). -
section-heading— the heading text of the section containing the step. -
N— the 1-based step number within that section or file.
Examples:
![[italian meatballs#rolling:2]]— second step of the "rolling" section.![[basic brine#2]]— second step of a headingless recipe.
This syntax is a compatibility break with vanilla Obsidian: #section:N does not match any Obsidian heading anchor, so Step References will not render correctly without KitchenMD tooling.
Frontmatter
YAML metadata block at the top of a Recipe File (standard --- delimiters).
The parser passes frontmatter through as-is with no schema enforcement.
Consumers are responsible for reading whatever fields they need.
Sigil
The single punctuation character that opens each annotation type:
@— Ingredient$— Cookware~— Timer
Parser
The TypeScript library (@kitchen-md/core) that accepts a Recipe File string and returns a structured data object.
It is a pure function of its input string — no filesystem access.
Structured Output
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[], diagnostics: Diagnostic[] }.
frontmatter is the raw YAML metadata, passed through without schema enforcement (empty object when absent or invalid).
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.
diagnostics is a list of non-fatal Diagnostic warnings surfaced during parsing (empty in the normal case).
The Parser is a total function: it never throws. Genuinely invalid input (currently only malformed frontmatter YAML) is reported through diagnostics, not exceptions, so a consumer always receives a usable Document AST — mirroring how Obsidian still renders a note whose frontmatter is broken. See ADR 0004.
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, ADR 0003, ADR 0004, and ADR 0005.
Diagnostic
A non-fatal warning surfaced by the Parser through the Document AST's diagnostics array, instead of throwing.
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 (e.g. the invalid YAML); position locates it in the input.
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 a diagnostic for the consumer to render as a warning.
Diagnostics are the extensible channel for any future non-fatal parse issue.
See ADR 0004.
Block
A 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, a blockquote, etc.
Block types are defined by core.
Some blocks are container blocks that nest other blocks rather than holding inline content directly: a blockquote holds Block[], and a list item is itself a block container holding Block[] (so - a $ladle{} becomes a list item wrapping a paragraph, not a bare inline array).
This mirrors how CommonMark actually models these constructs and is what lets annotation parsing reach inside blockquotes and list items, as the language spec's annotation scope requires.
Annotation extraction runs over the internal Markdown tree's text nodes everywhere outside code, so annotations surface in any container automatically; the translation layer recurses into container children instead of collapsing them to a Raw Block / Raw Inline.
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).
Wikilink / Transclusion
The two OFM link Inline Nodes.
A Wikilink ([[…]]) is a reference; a Transclusion (![[…]]) is an embed.
Their inner syntax is identical in OFM — they differ only by the leading ! — so they share one structural shape: { target, anchor?, display? }.
target is the referenced filename without the .md extension.
anchor is the part after #, passed through verbatim: a heading (#Batter), a block reference (#^abc), or a KitchenMD Step Reference (#section:N or #N).
display is the alias after | ([[page|shown]], ![[page|alt]]).
Anchor resolution — including Step Reference resolution — is a consumer concern, not the Parser's.
Lossless OFM Parsing
The Parser must parse all of Obsidian Flavored Markdown without ever failing or discarding source.
The required internal remark plugin set is deliberately minimal: remark-parse + remark-frontmatter + remark-gfm (OFM's tables, task lists, strikethrough, autolinks) + remark-wiki-link (links and embeds), plus core's own annotation transform.
That set is sufficient because most non-CommonMark OFM syntax is already lossless with no plugin — remark leaves ==highlight==, %%comment%%, $math$, and $$block math$$ as literal text with their markers intact, and a callout (> [!note]) parses as an ordinary blockquote whose text is preserved.
Only the constructs consumers actually use get dedicated typed nodes: the three Annotations, Wikilink, Transclusion, and the core CommonMark blocks and inlines.
Anything remark does tokenise into a node core doesn't model (e.g. a GFM table, strikethrough) is preserved losslessly through a raw fallback — a Raw Block at block level, a Raw Inline at inline level.
Any construct can later be promoted (adding its plugin and/or a typed node) as an additive, non-breaking change once a consumer needs to recognise it.
See ADR 0005.
Raw Block / Raw Inline
The fallback nodes that make Lossless OFM Parsing possible.
Raw Block carries the verbatim source of any block-level remark node not explicitly modelled by core's Block catalogue.
Raw Inline carries the verbatim source of any inline-level remark node not explicitly modelled by core's Inline Node catalogue.
They are safety valves, not targets: the implementation models every construct that appears in recipe fixtures explicitly and lets everything else fall through to raw.
The verbatim source is captured by position-slicing — slicing the original input string using the offsets remark records on each node — so the value is byte-for-byte what the author wrote and round-trips unchanged (never re-stringified/normalised).
A raw node exposes only that value string; it carries no kind/type hint (that would reintroduce a type vocabulary with no consumer yet). A construct is promoted to a dedicated typed node when a consumer needs its structure.
Open / Unresolved
- (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.