This repository has been archived on 2026-07-29. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
kitchen-md/.claude/CONTEXT.md
alexion 9958b6e6d5 docs: sync core-parser spec and add unit normalisation
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.
2026-07-14 21:55:09 -04:00

203 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `NN` 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 `.md` extension, 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|Raw Block]].
## 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.