docs: resolve core parser design decisions

Capture the design decisions reached while grilling the core-parser spec:

- Parser is a total function; invalid frontmatter YAML surfaces through a
  new Document AST `diagnostics` channel instead of throwing (ADR 0004).
- Parse all of OFM losslessly via `RawBlock`/`RawInline` fallbacks with
  position-sliced verbatim source and a minimal plugin set (ADR 0005).
- Grammar-driven (not last-space) quantity/unit split, fixing the
  mixed-number collision, with trimming and multi-word unit rules.
- Container blocks nest `Block[]` so annotations surface inside
  blockquotes and list items; unified wikilink/transclusion shape.

Update SPEC.md and CONTEXT.md accordingly; no parser code yet.
This commit is contained in:
2026-07-14 20:52:15 -04:00
parent 66bb497aca
commit 3ffd2b3f9f
4 changed files with 178 additions and 13 deletions

View File

@@ -21,7 +21,11 @@ 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.
When both quantity and unit are present, they are delimited by the last space inside the braces — so `@butter{1 1/2 tbsp}` parses as quantity `1 1/2`, unit `tbsp`.
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`.
Valid name characters: any character except `{`, `}`, `@`, `$`, `~`.
## Cookware Annotation
@@ -31,7 +35,7 @@ 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; unit is free-form; delimited by the last space inside the braces.
Quantity and unit follow the same rules as Ingredient: valid formats are integer, decimal, simple fraction, mixed number; unit is free-form; the split is grammar-driven, with non-numeric content preserved wholesale as the quantity string.
Valid name characters: any character except `{`, `}`, `@`, `$`, `~`.
## Timer Annotation
@@ -43,7 +47,10 @@ The `~` sigil is followed immediately by a number or `NN` range, a space, the
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) 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`.
## Recipe File
@@ -109,28 +116,71 @@ 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[] }`.
`frontmatter` is the raw YAML metadata, passed through without schema enforcement.
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 and ADR 0003.
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 top-level 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, etc.
Block types are defined by core; the exact catalogue is not yet finalised — see open questions below.
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
- **Block node type catalogue** — the exact set of Block types core defines (heading, paragraph, list, code block, blockquote, etc.) and how unrecognised types are represented.
- **Unit normalisation** — the parser should normalise known aliases to a canonical abbreviation (`grams``g`, `kilograms``kg`); exact alias table and canonical forms are TBD. This is a parser concern, not a format constraint.
- **Unit normalisation** for Ingredient/Cookware — the parser should normalise known aliases to a canonical abbreviation (`grams``g`, `kilograms``kg`); exact alias table and canonical forms are TBD. This is a parser concern, not a format constraint. (Timer unit normalisation is already fully specified above.)