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

@@ -0,0 +1,57 @@
# ADR 0004 — Total-Function Parser with a Diagnostics Channel
**Status**: Accepted
## Context
`@kitchen-md/core`'s `parse` is a pure function from a Recipe File string to a Document AST.
Almost every input is forgiving by construction: CommonMark has no syntax errors, so any Markdown body is valid; unknown OFM constructs fall through to a raw fallback (see ADR 0005); and a malformed annotation such as bare `@foo` simply stays as plain text.
The one input class that can be genuinely invalid is **frontmatter YAML**.
`remark-frontmatter` only extracts the `---` block as a raw string; core still has to parse that string into the plain object the spec requires, and YAML can be syntactically broken (bad indentation, an unclosed quote, a tab where spaces are required).
Three approaches were considered for what `parse` does when the frontmatter YAML is invalid:
**Option A — Throw**
`parse` raises a typed error on invalid YAML.
The body never throws; only broken frontmatter does.
**Option B — Total function, silently empty**
Invalid YAML yields `frontmatter: {}` and parsing continues.
`parse` never throws.
**Option C — Total function with a diagnostics channel**
Invalid YAML yields `frontmatter: {}`, parsing continues, and a non-fatal warning is surfaced through a new top-level `diagnostics` array on the Document AST.
The raw invalid YAML and the parse error are preserved in the diagnostic.
`parse` never throws.
## Decision
**Option C**`parse` is a total function that never throws, and reports genuinely invalid input (currently only malformed frontmatter YAML) through a `diagnostics` array on the Document AST.
The Document AST top-level shape becomes `{ frontmatter, blocks, diagnostics }`.
`frontmatter` stays a plain `Record<string, unknown>` (empty when absent or invalid).
`diagnostics` is a list of `{ severity, code, message, source?, position? }`, empty in the normal case.
The invalid-frontmatter case emits `{ severity: "warning", code: "invalid-frontmatter", message, source: <raw YAML>, position }`.
## Rationale
Throwing (Option A) makes `parse` partial and pushes a `try`/`catch` onto every consumer for a condition that is not fatal to the rest of the document — the body is still perfectly parseable.
It also makes the CLI and the future Obsidian plugin each responsible for reconstructing a graceful-degradation story that the parser is better placed to provide once.
Silently emptying (Option B) is total but lossy: a consumer cannot distinguish "this file has no frontmatter" from "this file has broken frontmatter," so real authoring mistakes vanish without a trace.
Option C keeps the forgiving behaviour everywhere it makes sense while refusing to swallow the one class of input that is unambiguously the author's mistake.
It mirrors how Obsidian itself behaves: it still renders a note whose frontmatter is broken, and shows a warning rather than refusing the file.
A separate `diagnostics` array — rather than widening `frontmatter` into a discriminated union such as `{ valid: true, data } | { valid: false, raw, error }` — keeps the common path clean.
User story 2 wants frontmatter as "a plain object so I can read whatever fields I need"; a union would force every consumer to narrow on validity before reading a single field.
A diagnostics array leaves the happy path untouched and gives the parser a natural, extensible home for any future non-fatal warning.
## Consequences
- The Document AST gains a third top-level key, `diagnostics`, always present (empty when there is nothing to report).
- `parse` is total: no input causes it to throw. Consumers never need to wrap it in `try`/`catch`.
- Consumers decide how to surface diagnostics — the CLI can print a warning to stderr, the Obsidian plugin can show a banner — using the preserved `source` and `position`.
- `Diagnostic` is a public API type with a stable, machine-readable `code`. Adding new diagnostic codes later is additive and non-breaking.
- The only diagnostic emitted today is `invalid-frontmatter`; the channel exists to absorb future non-fatal parse issues without further shape changes.

View File

@@ -0,0 +1,54 @@
# ADR 0005 — Lossless OFM Parsing via Raw Fallbacks
**Status**: Accepted
## Context
The parser must accept **all** of Obsidian Flavored Markdown without ever failing or discarding source, so that the CLI and the future Obsidian plugin can rely on it for any real Recipe File.
OFM is large: on top of CommonMark it adds wikilinks, embeds, callouts, comments, footnotes, LaTeX math, diagrams, block references, tags, highlights, tables, task lists, and strikethrough.
ADR 0003 already established that core defines its own AST types and keeps a clean, minimal vocabulary scoped to what consumers actually need.
That leaves an open question: what happens to every OFM construct core does **not** model?
The spec's block catalogue included a `RawBlock` safety valve but no inline equivalent, so an unmodelled inline construct (a highlight, inline math, a footnote reference) had nowhere faithful to go and would silently collapse to its text, losing the markup.
Two approaches were considered:
**Option A — Model every OFM construct**
Give each construct its own first-class typed node: `CalloutBlock`, `MathNode`, `HighlightNode`, `TableBlock`, `TagNode`, `BlockRefNode`, and so on.
This requires a mature remark plugin for each and a large, growing public API.
**Option B — Lossless parse with raw fallbacks**
Model only the constructs consumers actually use (the three Annotations, Wikilink, Transclusion, and the core CommonMark blocks and inlines).
Preserve everything else losslessly through raw fallback nodes: a `RawBlock` at block level and a new `RawInline` at inline level, each carrying the original source verbatim.
## Decision
**Option B** — lossless parse with raw fallbacks.
- The public AST models the annotations, wikilinks, transclusions, and core CommonMark blocks/inlines explicitly.
- Any node remark produces that core does not model is preserved as `RawBlock` (block level) or `RawInline` (inline level).
- A raw node exposes a single `value` string and nothing else — no `kind` or type hint that would reintroduce a type vocabulary with no consumer.
- `value` is captured by **position-slicing**: the original input string is sliced using the offsets remark records on each node, so the value is byte-for-byte what the author wrote and round-trips unchanged. It is never re-stringified, which would normalise formatting and break losslessness.
- The required internal plugin set is deliberately minimal: `remark-parse` + `remark-frontmatter` + `remark-gfm` (tables, task lists, strikethrough, autolinks) + `remark-wiki-link` (links and embeds), plus core's annotation transform. Most other OFM syntax (`==highlight==`, `%%comment%%`, `$math$`, callouts) is already lossless as plain text or an ordinary blockquote, so it needs no dedicated plugin.
## Rationale
"The parser can parse all of OFM" is satisfied by lossless, never-fails handling; it does not require a typed node for every construct.
Option A would commit the project to modelling and maintaining a dozen node types — and their plugins, several of which are niche — with no consumer asking for any of them, in direct tension with ADR 0003's minimal-vocabulary goal.
Option B keeps the public API small today and stays fully faithful: a consumer that does not care about a construct ignores its raw node, and a consumer that wants to render it re-parses or re-renders the raw Markdown.
Any construct can be **promoted** from a raw fallback to a dedicated typed node (adding its plugin and structured fields) the day a consumer genuinely needs to distinguish it — an additive, non-breaking change.
Position-slicing rather than re-stringifying matters specifically for the round-trip case: the Obsidian plugin writing a document back out must not silently reformat every table, callout, and math block the parser did not model — including ones the user never touched.
Byte-for-byte preservation is the only guarantee that avoids that.
Leaving the raw node opaque (`value` only) avoids shipping a half-measure: a `kind` string is neither structured enough to be useful nor cheap enough to be free, since it forces core to define and maintain a construct-name vocabulary. Promotion to a real typed node is strictly more useful when the need arrives.
## Consequences
- Core defines a new `RawInline` node type alongside the existing `RawBlock`, closing the inline-fallback gap.
- Raw nodes are opaque verbatim source; consumers treat `value` as Markdown.
- The parser depends on `remark-gfm` and `remark-wiki-link` in addition to `remark-parse` and `remark-frontmatter`; adding plugins later to recognise more constructs is additive.
- Because raw values are position-sliced, the internal annotation transform must not invalidate the positions of the nodes that become raw. It only rewrites text nodes (which are always modelled, never raw), so raw nodes keep their original parse positions.
- Round-tripping a document preserves every unmodelled construct exactly as written.
- Raw fallbacks are a safety valve, not a target: the implementation models every construct that appears in recipe fixtures explicitly and lets only genuinely unmodelled syntax fall through.