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.
8.9 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.
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.
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 section for the alias table.
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. Same grammar-driven quantity/unit split and canonical unit normalisation 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, matched case-insensitively and 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
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), also case-insensitively.
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.