diff --git a/.claude/CONTEXT.md b/.claude/CONTEXT.md new file mode 100644 index 0000000..2b94f2f --- /dev/null +++ b/.claude/CONTEXT.md @@ -0,0 +1,114 @@ +# 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. +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`. +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; unit is free-form; delimited by the last space inside the braces. +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 annotation terminates after the unit word — trailing prose is ignored. `~1 min more` parses as a timer of 1 min; "more" is plain text. +Known unit set (aliases → canonical abbreviation): `sec`, `secs`, `second`, `seconds` → `s`; `min`, `mins`, `minute`, `minutes` → `min`; `hr`, `hrs`, `hour`, `hours` → `hr`. + +## 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 data object returned by the Parser for a given Recipe File. +Shape is not yet fully specified — see open questions below. + +--- + +## Open / Unresolved + +- **Structured Output shape** — exact fields, types, and nesting of the parse result. +- **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. diff --git a/.claude/adr/0001-step-reference-syntax.md b/.claude/adr/0001-step-reference-syntax.md new file mode 100644 index 0000000..3aa1dbb --- /dev/null +++ b/.claude/adr/0001-step-reference-syntax.md @@ -0,0 +1,44 @@ +# ADR 0001 — Step Reference Syntax + +**Status**: Accepted + +## Context + +KitchenMD supports Combined Recipes: files whose sole purpose is to sequence steps from two or more other Recipe Files into a single cooking order (e.g. "penne alla vodka + italian meatballs"). +To express this, a Combined Recipe needs to embed specific steps from other files — not entire files or sections, but individual lines of instruction. + +Obsidian's existing transclusion syntax (`![[file#heading]]`) transclude a section from heading to the next heading. +It has no native concept of step-level granularity. + +Two approaches were considered: + +**Option A — Extend Obsidian's anchor syntax with a `:N` suffix** +`![[file#section:N]]` for a named section, `![[file#N]]` for a headingless recipe. +The `:N` part is a KitchenMD-specific extension; vanilla Obsidian does not recognise it and renders an "unable to find" error for the anchor. + +**Option B — Use Obsidian block references** +Obsidian supports `![[file#^blockid]]` to transclude a specific block. +Authors would manually assign block IDs to every step (e.g. `^step-1`, `^step-2`). +This is valid Obsidian syntax and would render correctly without KitchenMD tooling. + +## Decision + +**Option A** — extend Obsidian's anchor syntax with `:N` for section-scoped steps and bare `#N` for headingless recipes. + +## Rationale + +Option B (block references) requires authors to manually annotate every step with a block ID, which is tedious, error-prone, and pollutes the source file with machine IDs (`^step-1`) that are meaningless to human readers. +The authoring cost is too high for a format designed to be written naturally. + +Option A requires no author effort beyond normal recipe writing. +Step numbers are derived automatically by the parser from document structure. +The compatibility cost is acceptable: vanilla Obsidian renders a visible "unable to find" error rather than silently corrupting content, so the break is obvious and recoverable. +Combined Recipes are a specialised document type — authors who create them are expected to use KitchenMD tooling. + +Step numbers are **section-local** (reset at each heading) rather than global, so adding or removing steps in one section does not renumber steps in other sections and does not break existing Step References elsewhere. + +## Consequences + +- Combined Recipe files will not render correctly in vanilla Obsidian without the KitchenMD plugin; they will show "unable to find" errors for step anchors. +- Individual Recipe Files (non-combined) are unaffected — they contain no Step References and render correctly in vanilla Obsidian. +- The KitchenMD parser and future Obsidian plugin must implement step counting to resolve `![[file#section:N]]` and `![[file#N]]` references. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..604d360 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,223 @@ +# KitchenMD Language Specification + +KitchenMD is [Obsidian Flavored Markdown](https://help.obsidian.md/obsidian-flavored-markdown) with three inline annotation extensions (**Ingredient**, **Cookware**, **Timer**) and one structural extension (**Step Reference**). +A KitchenMD file is a valid `.md` file and renders correctly in Obsidian without a plugin. + +--- + +## Base language + +[Obsidian Flavored Markdown](https://help.obsidian.md/obsidian-flavored-markdown) is itself a superset of CommonMark. +All Obsidian Flavored Markdown constructs are valid and carry their standard meaning: headings, paragraphs, lists, blockquotes, code spans, fenced code blocks, links, wikilinks (`[[page]]`), transclusion (`![[page]]`), YAML frontmatter, and so on. + +YAML frontmatter (standard `---` delimiters) is passed through as-is. +The format places no schema on frontmatter fields; consumers read whatever fields they need. + +--- + +## Annotations + +Annotations are inline markers that carry structured data about ingredients, cookware, and timers. +They are written as plain text and do not conflict with any CommonMark syntax. + +### Scope + +Annotations are processed wherever CommonMark processes inline content: + +- **Parsed**: paragraphs, headings, list items, blockquotes +- **Ignored**: code spans, fenced/indented code blocks, HTML comments, raw HTML blocks + +### No escape mechanism + +A bare `@` or `$` not followed by a valid annotation pattern is passed through as plain text — no backslash escape is needed. +For `~`, the restricted unit vocabulary (see Timer) prevents false positives. + +--- + +## Ingredient + +``` +@name{} +@name{quantity} +@name{quantity unit} +``` + +**Sigil**: `@` + +**Name**: everything between `@` and `{`. +Valid characters: any character except `{`, `}`, `@`, `$`, `~`. +Multi-word names work naturally: `@unsalted butter{30 g}`. + +**Braces**: mandatory. Bare `@name` without braces is not a valid annotation. + +**Quantity**: optional. Valid formats: +- Integer: `2` +- Decimal: `0.5` +- Simple fraction: `1/2` +- Mixed number: `1 1/2` + +**Unit**: optional, free-form string. +When both quantity and unit are present, they are delimited by the **last space** inside the braces. +`@butter{1 1/2 tbsp}` → quantity `1 1/2`, unit `tbsp`. + +**Examples**: +``` +@eggs{2} +@flour{200 g} +@unsalted butter{30 g} +@butter{1 1/2 tbsp} +@black pepper{} +``` + +--- + +## Cookware + +``` +$name{} +$name{quantity} +$name{quantity unit} +``` + +**Sigil**: `$` + +**Name**: same rules as Ingredient — any character except `{`, `}`, `@`, `$`, `~`. + +**Braces**: mandatory. + +**Quantity**: optional. Follows the same format rules as Ingredient quantity (integer, decimal, fraction, mixed number). + +**Unit**: optional, free-form. Same last-space delimiter rule as Ingredient. + +**Examples**: +``` +$mixing bowl{} +$non-stick frying pan{} +$plate{3} +$cupcake liner{12 large} +``` + +--- + +## Timer + +``` +~N unit +~N-N unit +``` + +**Sigil**: `~` + +**Number (N)**: follows the same format rules as Ingredient/Cookware quantity (integer, decimal, simple fraction, mixed number). + +**Range**: two quantities separated by `-`. Since negative quantities do not exist, the `-` is unambiguous. +`~1/2-1 hr` → range of 1/2 hr to 1 hr. + +**Unit**: one word from the known set, normalised to its canonical abbreviation: + +| Written | Canonical | +|---------|-----------| +| `sec`, `secs`, `second`, `seconds` | `s` | +| `min`, `mins`, `minute`, `minutes` | `min` | +| `hr`, `hrs`, `hour`, `hours` | `hr` | + +A `~` followed by a number but not a known unit word is not a valid annotation and passes through as plain text. + +**Termination**: the annotation ends after the unit word. Trailing prose is ignored. +`~1 min more` → timer of 1 min; "more" is plain text. + +**Examples**: +``` +~1 min +~2-3 mins +~1.5 hrs +~1/2-1 hr +``` + +--- + +## Recipe structure + +A KitchenMD file is prose instructions only. +There is no separate ingredients list section. +Ingredients, cookware, and timers are derived entirely by tooling from inline annotations. + +--- + +## Steps + +A **step** is the atomic unit of a recipe instruction. +Steps are counted by tooling; authors write naturally without numbering them. + +A step is one of: +- A single non-empty line in a prose context (paragraph or heading body). +- A single ordered list item. + +Headings are not steps. +Step numbers are **1-based** and **section-local**: they reset at each heading and do not change when steps are added to other sections. + +--- + +## Step References + +A Step Reference embeds a specific step from another Recipe File. +It is used in Combined Recipes to sequence steps across files. + +``` +![[filename#section:N]] (step N within a named section) +![[filename#N]] (step N in a headingless recipe) +``` + +- `filename` — the target Recipe File, without the `.md` extension. +- `section` — the heading text of the section containing the step, exactly as written (case-sensitive). +- `N` — the 1-based step number within that section. + +**Examples**: +``` +![[italian meatballs#rolling:2]] +![[basic brine#3]] +``` + +### Combined Recipes + +A Combined Recipe is a Recipe File that contains only Step References (and optionally frontmatter and prose headings for organisation). +It has no annotations of its own — ingredients and timers belong to the referenced recipes. +Its purpose is to define the execution order of steps across two or more recipes, such as an index card for a multi-component meal. + +**Compatibility note**: Step References are a KitchenMD extension. +Vanilla Obsidian does not recognise `#section:N` or `#N` as valid anchors and will show an "unable to find" error. +Individual (non-combined) Recipe Files are unaffected. + +--- + +## Formal grammar (ABNF sketch) + +```abnf +ingredient = "@" name "{" [quantity [SP unit]] "}" +cookware = "$" name "{" [quantity [SP unit]] "}" +timer = "~" quantity SP time-unit + +name = 1*(any-char) +any-char = %x00-7E except "{" / "}" / "@" / "$" / "~" + +quantity = mixed-number / fraction / decimal / integer +integer = 1*DIGIT +decimal = 1*DIGIT "." 1*DIGIT +fraction = integer "/" integer +mixed-number = integer SP fraction + +time-unit = "sec" / "secs" / "second" / "seconds" + / "min" / "mins" / "minute" / "minutes" + / "hr" / "hrs" / "hour" / "hours" + +timer-range = "~" quantity "-" quantity SP time-unit + +step-ref-sectioned = "![[" filename "#" section ":" step-num "]]" +step-ref-headingless = "![[" filename "#" step-num "]]" +filename = 1*(any-char) +section = 1*(any-char except ":" / "]") +step-num = 1*DIGIT +``` + +> Note: `unit` in ingredient/cookware is free-form and not captured in the grammar above. +> The last-space rule applies at parse time: everything after the last space inside `{}` is the unit; everything before it is the quantity. diff --git a/fixtures/basic.md b/fixtures/basic.md index 0de8a05..b74d899 100644 --- a/fixtures/basic.md +++ b/fixtures/basic.md @@ -10,18 +10,7 @@ source: https://example.com/pancakes A simple weekend breakfast that comes together in minutes. Pairs well with [[maple syrup]] or fresh fruit from the [[farmers market]]. -## Ingredients - -- @flour{200 g} -- @milk{300 ml} -- @eggs{2} -- @unsalted butter{30 g} -- @baking powder{1 tsp} -- @salt{0.5 tsp} - -## Method - -### Batter +## Batter Sift @flour{200 g} and @baking powder{1 tsp} into a $mixing bowl{} along with @salt{0.5 tsp}. Make a well in the centre and crack in @eggs{2}.