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.
203 lines
14 KiB
Markdown
203 lines
14 KiB
Markdown
# 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 `N–N` 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.
|