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.
5.5 KiB
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
IngredientNodeandCookwareNodecarry a canonicalunitwhen 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.