Capture the design decisions reached while grilling the core-parser spec: - Parser is a total function; invalid frontmatter YAML surfaces through a new Document AST `diagnostics` channel instead of throwing (ADR 0004). - Parse all of OFM losslessly via `RawBlock`/`RawInline` fallbacks with position-sliced verbatim source and a minimal plugin set (ADR 0005). - Grammar-driven (not last-space) quantity/unit split, fixing the mixed-number collision, with trimming and multi-word unit rules. - Container blocks nest `Block[]` so annotations surface inside blockquotes and list items; unified wikilink/transclusion shape. Update SPEC.md and CONTEXT.md accordingly; no parser code yet.
7.3 KiB
KitchenMD Language Specification
KitchenMD is Obsidian Flavored Markdown with three inline annotation extensions (Ingredient, Cookware, Timer) and one structural extension (Step Reference).
A KitchenMD file is a valid .md file and renders correctly in Obsidian without a plugin.
Base language
Obsidian Flavored Markdown is itself a superset of CommonMark.
All Obsidian Flavored Markdown constructs are valid and carry their standard meaning: headings, paragraphs, lists, blockquotes, code spans, fenced code blocks, links, wikilinks ([[page]]), transclusion (![[page]]), YAML frontmatter, and so on.
YAML frontmatter (standard --- delimiters) is passed through as-is.
The format places no schema on frontmatter fields; consumers read whatever fields they need.
Annotations
Annotations are inline markers that carry structured data about ingredients, cookware, and timers. They are written as plain text and do not conflict with any CommonMark syntax.
Scope
Annotations are processed wherever CommonMark processes inline content:
- Parsed: paragraphs, headings, list items, blockquotes
- Ignored: code spans, fenced/indented code blocks, HTML comments, raw HTML blocks
No escape mechanism
A bare @ or $ not followed by a valid annotation pattern is passed through as plain text — no backslash escape is needed.
For ~, the restricted unit vocabulary (see Timer) prevents false positives.
Ingredient
@name{}
@name{quantity}
@name{quantity unit}
Sigil: @
Name: everything between @ and {.
Valid characters: any character except {, }, @, $, ~.
Multi-word names work naturally: @unsalted butter{30 g}.
Braces: mandatory. Bare @name without braces is not a valid annotation.
Quantity: optional. Valid formats:
- Integer:
2 - Decimal:
0.5 - Simple fraction:
1/2 - Mixed number:
1 1/2
Unit: optional, free-form string.
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} → 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.
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.
Examples:
@eggs{2}
@flour{200 g}
@unsalted butter{30 g}
@butter{1 1/2 tbsp}
@black pepper{}
Cookware
$name{}
$name{quantity}
$name{quantity unit}
Sigil: $
Name: same rules as Ingredient — any character except {, }, @, $, ~.
Braces: mandatory.
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.
Examples:
$mixing bowl{}
$non-stick frying pan{}
$plate{3}
$cupcake liner{12 large}
Timer
~N unit
~N-N unit
Sigil: ~
Number (N): follows the same format rules as Ingredient/Cookware quantity (integer, decimal, simple fraction, mixed number).
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.
Unit: one word from the known set, normalised to its canonical abbreviation:
| Written | Canonical |
|---|---|
sec, secs, second, seconds |
s |
min, mins, minute, minutes |
min |
hr, hrs, hour, hours |
hr |
A ~ followed by a number but not a known unit word is not a valid annotation and passes through as plain text.
Termination: the annotation ends after the unit word. Trailing prose is ignored.
~1 min more → timer of 1 min; "more" is plain text.
Examples:
~1 min
~2-3 mins
~1.5 hrs
~1/2-1 hr
Recipe structure
A KitchenMD file is prose instructions only. There is no separate ingredients list section. Ingredients, cookware, and timers are derived entirely by tooling from inline annotations.
Steps
A step is the atomic unit of a recipe instruction. Steps are counted by tooling; authors write naturally without numbering them.
A step is one of:
- A single non-empty line in a prose context (paragraph or heading body).
- A single ordered list item.
Headings are not steps. Step numbers are 1-based and section-local: they reset at each heading and do not change when steps are added to other sections.
Step References
A Step Reference embeds a specific step from another Recipe File. It is used in Combined Recipes to sequence steps across files.
![[filename#section:N]] (step N within a named section)
![[filename#N]] (step N in a headingless recipe)
filename— the target Recipe File, without the.mdextension.section— the heading text of the section containing the step, exactly as written (case-sensitive).N— the 1-based step number within that section.
Examples:
![[italian meatballs#rolling:2]]
![[basic brine#3]]
Combined Recipes
A Combined Recipe is a Recipe File that contains only Step References (and optionally frontmatter and prose headings for organisation). It has no annotations of its own — ingredients and timers belong to the referenced recipes. Its purpose is to define the execution order of steps across two or more recipes, such as an index card for a multi-component meal.
Compatibility note: Step References are a KitchenMD extension.
Vanilla Obsidian does not recognise #section:N or #N as valid anchors and will show an "unable to find" error.
Individual (non-combined) Recipe Files are unaffected.
Formal grammar (ABNF sketch)
ingredient = "@" name "{" [quantity [SP unit]] "}"
cookware = "$" name "{" [quantity [SP unit]] "}"
timer = "~" quantity SP time-unit
name = 1*(any-char)
any-char = %x00-7E except "{" / "}" / "@" / "$" / "~"
quantity = mixed-number / fraction / decimal / integer
integer = 1*DIGIT
decimal = 1*DIGIT "." 1*DIGIT
fraction = integer "/" integer
mixed-number = integer SP fraction
time-unit = "sec" / "secs" / "second" / "seconds"
/ "min" / "mins" / "minute" / "minutes"
/ "hr" / "hrs" / "hour" / "hours"
timer-range = "~" quantity "-" quantity SP time-unit
step-ref-sectioned = "![[" filename "#" section ":" step-num "]]"
step-ref-headingless = "![[" filename "#" step-num "]]"
filename = 1*(any-char)
section = 1*(any-char except ":" / "]")
step-num = 1*DIGIT
Note:
unitin ingredient/cookware is free-form and not captured in the grammar above. At parse time the quantity is matched greedily against thequantityproduction anchored at the start of{}; any non-empty remainder after the delimiting space is the unit. If the content does not begin with a grammar-validquantity, the whole brace content becomes the quantity string and the unit is empty.