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.
260 lines
8.9 KiB
Markdown
260 lines
8.9 KiB
Markdown
# KitchenMD Language Specification
|
|
|
|
KitchenMD is [Obsidian Flavored Markdown](https://help.obsidian.md/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](https://help.obsidian.md/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](#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](#units) 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](#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 `.md` extension.
|
|
- `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)
|
|
|
|
```abnf
|
|
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: `unit` in ingredient/cookware is free-form and not captured in the grammar above.
|
|
> At parse time the quantity is matched greedily against the `quantity` production 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-valid `quantity`, the whole brace content becomes the quantity string and the unit is empty.
|