blocks; the heading level
+// starts at h2 for top-level groups and increments for each sub-level.
+//
+// WARNING: This method is a work-in-progress and not yet ready for production
+// use. Known limitation: ingredient links reference raw .md file paths without
+// any resolution or conversion to HTML-friendly URLs.
func (p *Parser) RenderHTML(r *Recipe, rounding int) string {
funcs := htmlFuncMap(p, rounding)
funcs["topGroups"] = func(groups []IngredientGroup) []htmlGroupCtx {
diff --git a/render_json.go b/render_json.go
index 77bc7fd..ec8b1a6 100644
--- a/render_json.go
+++ b/render_json.go
@@ -2,7 +2,11 @@ package recipemd
import "encoding/json"
-// RenderJSON serializes a Recipe as compact JSON.
+// RenderJSON serialises r as compact JSON.
+//
+// Amount factors are encoded as quoted strings (via [Amount.MarshalJSON]) to
+// preserve human-readable precision. All other fields use their standard JSON
+// representations.
func (p *Parser) RenderJSON(r *Recipe) ([]byte, error) {
return json.Marshal(r)
}
diff --git a/render_markdown.go b/render_markdown.go
index 49b8be2..289a25b 100644
--- a/render_markdown.go
+++ b/render_markdown.go
@@ -46,7 +46,13 @@ const mdGroupsTmpl = `{{ range . }}
{{ .Heading }} {{ .Title }}
{{ template "ingredients" .Ingredients }}{{ template "groups" (.Subgroups) }}{{ end }}`
-// RenderMarkdown formats a Recipe as RecipeMD markdown.
+// RenderMarkdown renders r as a RecipeMD-formatted markdown string.
+//
+// Numeric amounts are rounded to rounding decimal places (trailing zeros are
+// removed). Pass a negative rounding value to use full float64 precision.
+//
+// The returned string contains a complete, parseable RecipeMD document that
+// [Parser.Parse] can round-trip back to an equivalent [Recipe].
func (p *Parser) RenderMarkdown(r *Recipe, rounding int) string {
funcs := renderFuncMap(rounding)
funcs["topGroups"] = func(groups []IngredientGroup) []mdGroupCtx {
From c78601925117f9a729d122779e1c67bcab36091d Mon Sep 17 00:00:00 2001
From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com>
Date: Tue, 17 Mar 2026 00:36:47 -0400
Subject: [PATCH 23/29] fix: gofmt codebase
---
parser.go | 12 ++++----
parser_test.go | 2 --
recipe.go | 76 ++++++++++++++++++++++++--------------------------
recipe_test.go | 16 +++++------
4 files changed, 51 insertions(+), 55 deletions(-)
diff --git a/parser.go b/parser.go
index be8032b..47d2e4a 100644
--- a/parser.go
+++ b/parser.go
@@ -174,7 +174,7 @@ func (p *Parser) Parse(r io.Reader) (*Recipe, error) {
}
if em, ok := isOnlyEmphasis(para, bold); ok {
if yieldsFound {
- errs = errors.Join(errs,newParseError(source, nodeStartOffset(c), "yields already set"))
+ errs = errors.Join(errs, newParseError(source, nodeStartOffset(c), "yields already set"))
c = c.NextSibling()
continue
}
@@ -196,7 +196,7 @@ func (p *Parser) Parse(r io.Reader) (*Recipe, error) {
continue
}
if tagsYieldsMode {
- errs = errors.Join(errs,newParseError(source, nodeStartOffset(c), "unexpected content in tags/yields section"))
+ errs = errors.Join(errs, newParseError(source, nodeStartOffset(c), "unexpected content in tags/yields section"))
c = c.NextSibling()
continue
}
@@ -227,7 +227,7 @@ func (p *Parser) Parse(r io.Reader) (*Recipe, error) {
// --- Ingredients ---
for c != nil {
if para, ok := c.(*ast.Paragraph); ok {
- errs = errors.Join(errs,newParseError(source, nodeStartOffset(para), "paragraph not valid in ingredients section"))
+ errs = errors.Join(errs, newParseError(source, nodeStartOffset(para), "paragraph not valid in ingredients section"))
c = c.NextSibling()
continue
}
@@ -475,9 +475,9 @@ func parseIngredient(c ast.Node, source []byte, skipCheckbox bool) (Ingredient,
// 8. Let i be an ingredient with amount a, name n, link l
n = strings.TrimSpace(n)
- if n == "" {
- return Ingredient{}, fmt.Errorf("ingredient must have a name")
- }
+ if n == "" {
+ return Ingredient{}, fmt.Errorf("ingredient must have a name")
+ }
return Ingredient{Amount: a, Name: n, Link: l}, nil
}
diff --git a/parser_test.go b/parser_test.go
index 86b1ad2..ef39063 100644
--- a/parser_test.go
+++ b/parser_test.go
@@ -697,7 +697,6 @@ func TestParse(t *testing.T) {
})
}
-
func TestEncodeURLPath(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -795,4 +794,3 @@ func TestSkipLine(t *testing.T) {
})
}
}
-
diff --git a/recipe.go b/recipe.go
index a0a33f1..1d58c1d 100644
--- a/recipe.go
+++ b/recipe.go
@@ -132,61 +132,61 @@ func (a Amount) FormatFactor(rounding int) string {
// ScaleForYield returns an error when desiredYield has a unit that does not
// match any yield in the recipe.
func (r *Recipe) ScaleForYield(desiredYield Amount) error {
- for _, y := range r.Yields {
- if y.Unit == nil && desiredYield.Unit == nil {
- r.Scale(desiredYield.Factor/y.Factor)
- return nil
- }
- if y.Unit != nil && desiredYield.Unit != nil && *y.Unit == *desiredYield.Unit {
- r.Scale(desiredYield.Factor/y.Factor)
- return nil
- }
- }
-
- // fallback on scaling the whole recipe
- if desiredYield.Unit == nil {
- r.Scale(desiredYield.Factor)
- return nil
- }
-
- return errors.New("no matching yield unit found")
+ for _, y := range r.Yields {
+ if y.Unit == nil && desiredYield.Unit == nil {
+ r.Scale(desiredYield.Factor / y.Factor)
+ return nil
+ }
+ if y.Unit != nil && desiredYield.Unit != nil && *y.Unit == *desiredYield.Unit {
+ r.Scale(desiredYield.Factor / y.Factor)
+ return nil
+ }
+ }
+
+ // fallback on scaling the whole recipe
+ if desiredYield.Unit == nil {
+ r.Scale(desiredYield.Factor)
+ return nil
+ }
+
+ return errors.New("no matching yield unit found")
}
// Scale multiplies every ingredient amount and every yield in the recipe by
// factor. The recipe title, description, tags, and instructions are unchanged.
func (r *Recipe) Scale(factor float64) {
- for i := range(r.Yields) {
- r.Yields[i].Scale(factor)
- }
- for j := range(r.Ingredients) {
- r.Ingredients[j].Scale(factor)
- }
- for k := range(r.IngredientGroups) {
- r.IngredientGroups[k].Scale(factor)
- }
+ for i := range r.Yields {
+ r.Yields[i].Scale(factor)
+ }
+ for j := range r.Ingredients {
+ r.Ingredients[j].Scale(factor)
+ }
+ for k := range r.IngredientGroups {
+ r.IngredientGroups[k].Scale(factor)
+ }
}
// Scale multiplies the amount's factor by factor.
func (a *Amount) Scale(factor float64) {
- a.Factor *= factor
+ a.Factor *= factor
}
// Scale scales the ingredient's amount by factor. It is a no-op when the
// ingredient has no amount.
func (i *Ingredient) Scale(factor float64) {
- if i.Amount != nil {
- i.Amount.Scale(factor)
- }
+ if i.Amount != nil {
+ i.Amount.Scale(factor)
+ }
}
// Scale recursively scales all ingredients and nested sub-groups by factor.
func (g *IngredientGroup) Scale(factor float64) {
- for i := range(g.Ingredients) {
- g.Ingredients[i].Scale(factor)
- }
- for j := range(g.IngredientGroups) {
- g.IngredientGroups[j].Scale(factor)
- }
+ for i := range g.Ingredients {
+ g.Ingredients[i].Scale(factor)
+ }
+ for j := range g.IngredientGroups {
+ g.IngredientGroups[j].Scale(factor)
+ }
}
// Serialize formats the amount as a human-readable string.
@@ -237,5 +237,3 @@ func (g *IngredientGroup) LeafIngredients() []Ingredient {
}
return result
}
-
-
diff --git a/recipe_test.go b/recipe_test.go
index 129caa6..5c966a3 100644
--- a/recipe_test.go
+++ b/recipe_test.go
@@ -90,8 +90,8 @@ func TestIngredient_Serialize(t *testing.T) {
want string
}{
{"name only", Ingredient{Name: "salt"}, "salt"},
- {"with amount", Ingredient{Name: "flour", Amount: &Amount{Factor:2, Unit: new("cups")}}, "2 cups flour"},
- {"amount no unit", Ingredient{Name: "eggs", Amount: &Amount{Factor:3, Unit: nil}}, "3 eggs"},
+ {"with amount", Ingredient{Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cups")}}, "2 cups flour"},
+ {"amount no unit", Ingredient{Name: "eggs", Amount: &Amount{Factor: 3, Unit: nil}}, "3 eggs"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -120,7 +120,7 @@ func TestIngredient_Scale(t *testing.T) {
t.Parallel()
t.Run("with amount", func(t *testing.T) {
t.Parallel()
- i := Ingredient{Name: "flour", Amount: &Amount{Factor:2, Unit: new("cups")}}
+ i := Ingredient{Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cups")}}
i.Scale(0.5)
if i.Amount.Factor != 1 {
t.Errorf("Factor = %v, want 1", i.Amount.Factor)
@@ -138,13 +138,13 @@ func TestIngredientGroup_Scale(t *testing.T) {
g := IngredientGroup{
Title: "Sauce",
Ingredients: []Ingredient{
- {Name: "tomato", Amount: &Amount{Factor:2, Unit: new("cups")}},
+ {Name: "tomato", Amount: &Amount{Factor: 2, Unit: new("cups")}},
{Name: "basil"},
},
IngredientGroups: []IngredientGroup{
{
Title: "Spices",
- Ingredients: []Ingredient{{Name: "pepper", Amount: &Amount{Factor:1, Unit: new("tsp")}}},
+ Ingredients: []Ingredient{{Name: "pepper", Amount: &Amount{Factor: 1, Unit: new("tsp")}}},
},
},
}
@@ -164,12 +164,12 @@ func TestRecipe_Scale(t *testing.T) {
{Factor: 4, Unit: new("servings")},
},
Ingredients: []Ingredient{
- {Name: "flour", Amount: &Amount{Factor:2, Unit: new("cups")}},
+ {Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cups")}},
},
IngredientGroups: []IngredientGroup{
{
Title: "Sauce",
- Ingredients: []Ingredient{{Name: "tomato", Amount: &Amount{Factor:1, Unit: nil}}},
+ Ingredients: []Ingredient{{Name: "tomato", Amount: &Amount{Factor: 1, Unit: nil}}},
},
},
}
@@ -224,7 +224,7 @@ func TestRecipe_ScaleForYield(t *testing.T) {
t.Parallel()
r := &Recipe{
Yields: tt.yields,
- Ingredients: []Ingredient{{Name: "x", Amount: &Amount{Factor:2, Unit: nil}}},
+ Ingredients: []Ingredient{{Name: "x", Amount: &Amount{Factor: 2, Unit: nil}}},
}
err := r.ScaleForYield(tt.desired)
if tt.wantErr {
From cdf9f879238359df5733e4b61e4b23d6feaf8ca4 Mon Sep 17 00:00:00 2001
From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com>
Date: Tue, 17 Mar 2026 00:44:33 -0400
Subject: [PATCH 24/29] docs: add reflink limitation to HTML renderer
---
render_html.go | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/render_html.go b/render_html.go
index b5344f3..00c53dd 100644
--- a/render_html.go
+++ b/render_html.go
@@ -82,8 +82,14 @@ const htmlGroupsTmpl = `{{ range . -}}
// starts at h2 for top-level groups and increments for each sub-level.
//
// WARNING: This method is a work-in-progress and not yet ready for production
-// use. Known limitation: ingredient links reference raw .md file paths without
-// any resolution or conversion to HTML-friendly URLs.
+// use. Known limitations:
+// - Ingredient links reference raw .md file paths without any resolution or
+// conversion to HTML-friendly URLs.
+// - CommonMark reference-style links (e.g. [text][ref] with a separate
+// [ref]: url definition) only resolve correctly when the definition appears
+// in the same section (Description or Instructions) as the usage. Definitions
+// in one section are not visible when rendering the other, so cross-section
+// reflinks are silently left unresolved.
func (p *Parser) RenderHTML(r *Recipe, rounding int) string {
funcs := htmlFuncMap(p, rounding)
funcs["topGroups"] = func(groups []IngredientGroup) []htmlGroupCtx {
From c38572607c434ad4ac3473d13d64e48db6a7ce15 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 14 Mar 2026 05:38:35 +0000
Subject: [PATCH 25/29] docs: write README as a valid RecipeMD recipe
---
README.md | 324 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 323 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index e57627d..752d470 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,325 @@
# recipemd-go
-Go implementation of RecipeMD parser.
+
+
+
+
+
+A Go library for parsing, scaling, and rendering recipes in the [RecipeMD](https://recipemd.org) format.
+This format builds on top of structured Markdown such that both humans and programs can digest it.
+
+*Go, parser, RecipeMD, Markdown*
+
+**1 Go module**
+
+---
+
+- *1 pkg* `github.com/xcapaldi/recipemd-go`
+- *1 pkg* `github.com/yuin/goldmark` Markdown parser
+
+## Examples
+
+- *1* `parse` — parse a recipe and output JSON
+- *1* `scale` — scale a recipe by factor or target yield
+- *1* `flatten` — inline all linked sub-recipe ingredients
+- *1* `renderhtml` — render a recipe as an HTML ``
+
+---
+
+## Installation
+
+```bash
+go get github.com/xcapaldi/recipemd-go
+```
+
+## What is RecipeMD?
+
+[RecipeMD](https://recipemd.org/specification.html) is a Markdown-based format for writing recipes.
+A recipe file is plain Markdown with a defined structure:
+
+```markdown
+# Carbonara
+
+A classic Roman pasta.
+
+*Italian, pasta*
+
+**2 servings**
+
+---
+
+- *200 g* spaghetti
+- *100 g* guanciale
+- *2* eggs
+- *50 g* Pecorino Romano
+
+## Sauce
+
+- *1 tbsp* black pepper, coarsely ground
+
+---
+
+Boil pasta. Render guanciale. Whisk eggs with cheese and pepper.
+Toss together off the heat.
+```
+
+The document has three sections divided by `---` thematic breaks:
+
+| Section | Content |
+|---|---|
+| Preamble | H1 title, optional description, optional *tags* (italic), optional **yields** (bold) |
+| Ingredients | Unordered lists; H2+ headings introduce named ingredient groups |
+| Instructions | Free-form Markdown text |
+
+Amounts are wrapped in emphasis: `*2 tbsp*`. Supported number formats:
+integers (`3`), decimals (`1.5`), fractions (`1/2`), improper fractions
+(`1 1/2`), and Unicode vulgar fractions (`½ ¼ ¾`).
+
+## Why RecipeMD?
+
+Shockingly there are multiple competing specifications in the world of plaintext cooking:
+
+- [Open Recipe Format](https://open-recipe-format.readthedocs.io/en/latest/) -- YAML
+- [hrecipe](https://microformats.org/wiki/hrecipe) -- XML
+- [schema.org Recipe](https://schema.org/Recipe)
+- [Cooklang](https://cooklang.org) -- DSL
+
+Of the above, the only one that goes beyond a strictly formatted schema is Cooklang.
+It is an extensive project with a CLI, web server and thoughtful features like inline ingredent declarations (these are extracted at render time).
+Despite this, it lacks one fundamental advantage working in plain text -- it's not very readable in it's raw format.
+This may seem like a minor inconvenience but it makes reading and writing recipes less accessible to humans and (increasingly important) AI agents.
+The beauty of RecipeMD is that it's a ruleset on standard Markdown syntax with all of that format's inherent flexibility (images/tables/etc).
+The nature of Cooklang's strict DSL enables some advanced features but I believe they can be acheived in RecipeMD through contextual analysis.
+
+As an example working in the raw formats, here is a moderately complex recipe in both:
+
+### RecipeMD
+
+```markdown
+# Chicken Tikka Masala
+
+Tender charred chicken in a rich, spiced tomato-cream sauce.
+
+*Indian, chicken, curry*
+
+**4 servings**
+
+---
+
+## Marinade
+
+- *700 g* boneless chicken thighs, cut into chunks
+- *150 g* plain yogurt
+- *2 tbsp* lemon juice
+- *3 cloves* garlic, minced
+- *1 tsp* fresh ginger, grated
+- *1 tsp* garam masala
+- *1 tsp* cumin
+- *1/2 tsp* turmeric
+- *1/2 tsp* cayenne pepper
+- *1 tsp* salt
+
+## Sauce
+
+- *2 tbsp* ghee
+- *1* large onion, finely diced
+- *4 cloves* garlic, minced
+- *1 tbsp* fresh ginger, grated
+- *1 tbsp* garam masala
+- *1 tsp* cumin
+- *1 tsp* coriander
+- *1/2 tsp* turmeric
+- *1/2 tsp* cayenne pepper
+- *400 g* canned crushed tomatoes
+- *200 ml* heavy cream
+- *1 tsp* salt
+
+## Serving
+
+- *300 g* basmati rice
+- *1* handful fresh cilantro, roughly chopped
+
+---
+
+Combine chicken with all marinade ingredients in a bowl. Cover and refrigerate for at least 1 hour, ideally overnight.
+
+Preheat a broiler or grill to high. Thread chicken onto skewers and cook for 10–12 minutes, turning once, until charred in spots and just cooked through. Set aside.
+
+Cook rice according to package directions.
+
+Heat ghee in a large saucepan over medium heat. Add onion and cook for 8–10 minutes until deep golden. Add garlic and ginger; cook 2 minutes until fragrant.
+
+Stir in garam masala, cumin, coriander, turmeric, and cayenne. Toast 1 minute. Add crushed tomatoes and simmer uncovered for 15 minutes, stirring occasionally, until sauce thickens.
+
+Pour in cream and stir to combine. Add the grilled chicken and simmer 5 minutes to meld flavors. Season with salt.
+
+Serve over rice, garnished with fresh cilantro.
+```
+
+### Cooklang
+
+```
+---
+title: Chicken Tikka Masala
+description: Tender charred chicken in a rich, spiced tomato-cream sauce.
+tags: Indian, chicken, curry
+servings: 4
+---
+
+== Marinate ==
+
+Combine @chicken thighs{700%g}(boneless, cut into chunks) with @plain yogurt{150%g},
+@lemon juice{2%tbsp}, @garlic{3%cloves}(minced), @fresh ginger{1%tsp}(grated),
+@garam masala{1%tsp}, @cumin{1%tsp}, @turmeric{1/2%tsp}, @cayenne pepper{1/2%tsp},
+and @salt{1%tsp} in a #large bowl{}.
+
+Cover and refrigerate for ~marinating{1%hour} (or overnight for best results).
+
+== Grill Chicken ==
+
+Preheat a #grill or broiler to high. Thread chicken onto #skewers and cook for
+~grilling{12%minutes}, turning once, until charred in spots and cooked through. Set aside.
+
+== Cook Rice ==
+
+Cook @basmati rice{300%g} in a #saucepan{} according to package directions (~{18%minutes}).
+
+== Make Sauce ==
+
+Heat @ghee{2%tbsp} in a #large saucepan{} over medium heat.
+Add @onion{1%large}(finely diced) and cook for ~onion{10%minutes} until deep golden.
+
+Add @garlic{4%cloves}(minced) and @fresh ginger{1%tbsp}(grated); cook ~{2%minutes} until fragrant.
+
+Stir in @garam masala{1%tbsp}, @cumin{1%tsp}, @coriander{1%tsp}, @turmeric{1/2%tsp}, and @cayenne pepper{1/2%tsp}.
+Toast ~{1%minute}, then add @crushed tomatoes{400%g} and simmer uncovered for ~{15%minutes},
+stirring occasionally, until thickened.
+
+Pour in @heavy cream{200%ml} and stir to combine.
+Add grilled chicken and simmer ~finishing{5%minutes} to meld flavors.
+Season with @salt{1%tsp}.
+
+== Serve ==
+
+Plate over rice and garnish with @fresh cilantro{1%handful}(roughly chopped).
+```
+
+## Library Usage
+
+### Parsing
+
+`Parse` accepts any `io.Reader`:
+
+```go
+import recipemd "github.com/xcapaldi/recipemd-go"
+
+p := recipemd.NewParser()
+
+f, _ := os.Open("carbonara.md")
+defer f.Close()
+
+recipe, err := p.Parse(f)
+if err != nil {
+ log.Fatal(err)
+}
+
+fmt.Println(recipe.Title) // "Carbonara"
+fmt.Println(recipe.Tags) // ["Italian", "pasta"]
+fmt.Println(recipe.Yields) // [{Factor:2 Unit:"servings"}]
+```
+
+Parse collects all structural and value-level problems via `errors.Join`,
+so a single call surfaces every issue at once. Individual errors carry line
+and column information:
+
+```go
+if parseError, ok := errors.AsType[*recipemd.ParseError](err); ok {
+ fmt.Printf("line %d, col %d: %s\n", parseError.Line, parseError.Column, parseError.Message)
+}
+```
+
+#### Parser options
+
+While not part of the official spec, this implementation supports some parser options to make the format more ergonomic.
+In particular `WithFrontmatter` will strip YAML/TOML frontmatter before parsing the remaining content as spec-complient RecipeMD.
+This is particularly useful if you combine this format with another note management system (like [Denote](https://protesilaos.com/emacs/denote)) that relies on frontmatter.
+In addition `WithGithubFormattedMarkdown` enables support for GFM features like tables, autolinks, task lists (in ingredients as well) and strikethrough.
+
+```go
+parser := recipemd.NewParser(
+ recipemd.WithFrontmatter(), // strip YAML/TOML front matter
+ recipemd.WithGithubFormattedMarkdown(), // enable GFM (tables, task lists, …)
+)
+```
+
+### Scaling
+
+```go
+// Multiply all amounts by a factor.
+recipe.Scale(2)
+
+// Scale to a specific yield.
+desired, _ := recipemd.ParseAmountString("6 servings")
+if err := recipe.ScaleForYield(desired); err != nil {
+ log.Fatal(err)
+}
+```
+
+### Rendering
+
+```go
+// RecipeMD Markdown (amounts rounded to 2 decimal places)
+fmt.Print(p.RenderMarkdown(recipe, 2))
+
+// Compact JSON
+data, err := p.RenderJSON(recipe)
+
+// HTML element (amounts rounded to 3 decimal places) (still WIP)
+fmt.Println(p.RenderHTML(recipe, 3))
+```
+
+## Examples
+
+The `examples/` directory contains small, self-contained programs that
+demonstrate common use cases:
+
+| Example | Description |
+|---|---|
+| [`examples/parse`](examples/parse) | Parse a recipe and write compact JSON to stdout |
+| [`examples/scale`](examples/scale) | Scale a recipe by factor or target yield, write RecipeMD to stdout |
+| [`examples/flatten`](examples/flatten) | Inline all linked sub-recipes, write RecipeMD to stdout |
+| [`examples/renderhtml`](examples/renderhtml) | Flatten and render as an HTML `` |
+
+Run any example directly:
+
+```bash
+go run ./examples/parse carbonara.md
+go run ./examples/scale carbonara.md "4 servings"
+go run ./examples/flatten main_dish.md
+go run ./examples/renderhtml carbonara.md
+```
+
+## Running tests
+
+```bash
+go test ./...
+```
+
+The suite includes the [RecipeMD canonical test suite](https://github.com/tstehr/RecipeMD/tree/master/test),
+extensive golden tests and unit tests.
+
+## Performance
+
+This library is quite performant compared to the reference implementation but not due to any clever optimizations.
+Go is simply much faster and this translates directly.
+In practice, the difference should be almost unnoticeable for any level of personal use.
+Nevertheless, here is a very crude benchmark parsing and scaling a short recipe:
+
+| Implementation | Command | Runs | Total | Avg/run | Speedup |
+|----------------|---------|-----:|------:|--------:|--------:|
+| Python recipemd 5.0.0 | `recipemd -y "10 ml" testdata/canonical/recipe.md` | 100 | 13,956 ms | 139.5 ms | 1x |
+| Go (examples/scale) | `scale testdata/canonical/recipe.md "10 ml"` | 100 | 434 ms | 4.3 ms | **32x** |
+
+## License
+
+MIT — see [LICENSE](LICENSE).
From d088df1c7a9e745310335fe0024d9ef8e4ad9826 Mon Sep 17 00:00:00 2001
From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com>
Date: Tue, 17 Mar 2026 01:57:07 -0400
Subject: [PATCH 26/29] docs: fix badges in README
---
README.md | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 752d470..c21ca8c 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,7 @@
# recipemd-go
-
-
-
-
+[](https://pkg.go.dev/github.com/xcapaldi/recipemd-go)
+
A Go library for parsing, scaling, and rendering recipes in the [RecipeMD](https://recipemd.org) format.
This format builds on top of structured Markdown such that both humans and programs can digest it.
From 187b63760e62e156e92e491866e77c076a3e386b Mon Sep 17 00:00:00 2001
From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com>
Date: Sat, 28 Mar 2026 00:51:47 -0400
Subject: [PATCH 27/29] docs: agent skill to work with recipemd format
---
.agents/skills/recipemd-format/SKILL.md | 155 ++++++++++++++++++++++++
1 file changed, 155 insertions(+)
create mode 100644 .agents/skills/recipemd-format/SKILL.md
diff --git a/.agents/skills/recipemd-format/SKILL.md b/.agents/skills/recipemd-format/SKILL.md
new file mode 100644
index 0000000..2e2a093
--- /dev/null
+++ b/.agents/skills/recipemd-format/SKILL.md
@@ -0,0 +1,155 @@
+---
+name: recipemd-format
+description: Read, write, and edit recipes in RecipeMD format. Use when working with .md recipe files that follow the RecipeMD specification or when creating new recipes in structured Markdown.
+license: MIT
+---
+
+# RecipeMD Format
+
+[RecipeMD](https://recipemd.org/specification.html) is a Markdown-based format for structured recipes.
+A recipe is a valid CommonMark document with a defined three-section layout.
+
+## When to use
+
+- Editing or reviewing `.md` files that contain recipes
+- Creating new recipes in plain Markdown
+- Converting recipes from other formats to RecipeMD
+
+## Document structure
+
+Three sections separated by `---` (thematic breaks):
+
+```
+# Title <-- Preamble (required)
+...
+---
+- ingredients <-- Ingredients (required)
+---
+instructions <-- Instructions (optional)
+```
+
+## Preamble
+
+The preamble is everything before the first `---`. It must start with an H1 title.
+
+| Element | Format | Required |
+|---------|--------|----------|
+| Title | `# Recipe Name` (H1 heading) | Yes |
+| Description | Markdown paragraphs after the title | No |
+| Tags | `*tag1, tag2, tag3*` (italic paragraph) | No |
+| Yields | `**4 servings**` (bold paragraph) | No |
+
+Tags and yields each occupy their own paragraph. Multiple yields are comma-separated within one bold span: `**4 servings, 800 ml**`.
+
+## Ingredients
+
+Everything between the first and second `---`. Ingredients are unordered list items.
+
+### Basic ingredient
+
+```markdown
+- ingredient name
+```
+
+### With amount
+
+Wrap the amount in emphasis (single `*`):
+
+```markdown
+- *200 g* spaghetti
+- *2* eggs
+- *1 tbsp* olive oil
+```
+
+The amount is always the first element in the list item. The format is `*[number][unit]*` where unit is optional.
+
+### Ingredient groups
+
+Use H2 or deeper headings to create named groups:
+
+```markdown
+## Marinade
+
+- *2 tbsp* soy sauce
+- *1 tbsp* sesame oil
+
+## Stir Fry
+
+- *200 g* tofu
+- *1* bell pepper, sliced
+```
+
+Groups can nest (H3 inside H2, etc).
+
+### Linked ingredients
+
+Link an ingredient name to another recipe file:
+
+```markdown
+- *200 ml* [tomato sauce](./tomato-sauce.md)
+```
+
+## Instructions
+
+Everything after the second `---`. Free-form Markdown -- any valid CommonMark content (paragraphs, lists, images, tables, etc).
+
+The second `---` and instructions section are optional. A recipe with only a preamble and ingredients is valid.
+
+## Amount number formats
+
+Amounts support several number formats:
+
+| Format | Examples |
+|--------|----------|
+| Integer | `3`, `12` |
+| Decimal | `1.5`, `0.75` |
+| Decimal (comma) | `1,5` |
+| Fraction | `1/2`, `3/4` |
+| Mixed number | `1 1/2`, `2 3/4` |
+| Vulgar fraction | `½`, `¼`, `¾`, `⅓`, `⅔` |
+
+## Complete example
+
+```markdown
+# Carbonara
+
+A classic Roman pasta.
+
+*Italian, pasta*
+
+**2 servings**
+
+---
+
+- *200 g* spaghetti
+- *100 g* guanciale
+- *2* eggs
+- *50 g* Pecorino Romano
+
+## Sauce
+
+- *1 tbsp* black pepper, coarsely ground
+
+---
+
+Boil pasta. Render guanciale. Whisk eggs with cheese and pepper.
+Toss together off the heat.
+```
+
+## Common mistakes
+
+- Missing `---` between sections (preamble, ingredients, instructions must be separated by thematic breaks)
+- Using `**bold**` for amounts instead of `*italic*` (amounts use single emphasis)
+- Placing amounts outside emphasis markers: `200 g spaghetti` instead of `*200 g* spaghetti`
+- Using ordered lists for ingredients (must be unordered `- `)
+- Forgetting that tags use italic (`*...*`) and yields use bold (`**...**`)
+
+## Frontmatter (non-standard extension)
+
+Some tools support YAML (`---`) or TOML (`+++`) frontmatter before the recipe content.
+This is not part of the official spec but my be useful when combining RecipeMD with note management systems.
+Parsers that support this strip the frontmatter before applying the standard rules.
+
+## Reference
+
+Full specification: https://recipemd.org/specification.html
From 2a1fe54c4f0c879b7bcfb32d85cbfc721fc8bc78 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 20 Mar 2026 05:52:12 +0000
Subject: [PATCH 28/29] Add InlineIngredients function for amount injection
into instructions
Adds InlineIngredients(r *Recipe, rounding int) *string which returns a
copy of the instructions with each ingredient's amount injected before its
name wherever it appears in the text. For example, "*1/2 tsp* cinnamon"
in the ingredient list causes "add cinnamon and mix" to become
"add 0.5 tsp cinnamon and mix".
Matching is case-insensitive and respects word boundaries so partial words
(e.g. "salt" inside "salted") are not affected. A single combined regex
pass handles all ingredients at once, ensuring that a longer name like
"brown sugar" is always matched before the shorter "sugar" and that the
shorter name is not spuriously injected into already-replaced text.
All ingredients nested inside IngredientGroups are also considered via
Recipe.LeafIngredients().
https://claude.ai/code/session_012wP9EExNKgdFcpp9JJELGC
---
inline_ingredients.go | 83 +++++++++++++++++
inline_ingredients_test.go | 184 +++++++++++++++++++++++++++++++++++++
2 files changed, 267 insertions(+)
create mode 100644 inline_ingredients.go
create mode 100644 inline_ingredients_test.go
diff --git a/inline_ingredients.go b/inline_ingredients.go
new file mode 100644
index 0000000..0c2b45b
--- /dev/null
+++ b/inline_ingredients.go
@@ -0,0 +1,83 @@
+package recipemd
+
+import (
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// InlineIngredients returns a copy of the recipe's Instructions with ingredient
+// amounts injected before each matching ingredient name.
+//
+// For each ingredient that has an amount, every occurrence of the ingredient's
+// name in the instructions (matched case-insensitively at word boundaries) is
+// replaced with " ". For example, if an ingredient is
+// "*1/2 tsp* cinnamon" and the instructions contain "add cinnamon and mix",
+// the result is "add 1/2 tsp cinnamon and mix". The original casing of the
+// matched word(s) in the instructions text is preserved.
+//
+// Multi-word ingredient names (e.g. "olive oil") are matched as complete
+// phrases. When one ingredient name is a substring of another (e.g. "sugar"
+// and "brown sugar"), the longer name is matched and replaced first so that
+// the shorter name is not spuriously injected inside the already-replaced text.
+//
+// Amounts are formatted with the given rounding via [Amount.Serialize].
+// Ingredients without an amount are not injected.
+//
+// Returns nil when r.Instructions is nil.
+func InlineIngredients(r *Recipe, rounding int) *string {
+ if r.Instructions == nil {
+ return nil
+ }
+
+ type entry struct {
+ name string
+ amount string
+ }
+
+ ingredients := r.LeafIngredients()
+ entries := make([]entry, 0, len(ingredients))
+ for _, ing := range ingredients {
+ if ing.Amount == nil {
+ continue
+ }
+ entries = append(entries, entry{
+ name: ing.Name,
+ amount: ing.Amount.Serialize(rounding),
+ })
+ }
+
+ if len(entries) == 0 {
+ s := *r.Instructions
+ return &s
+ }
+
+ // Sort longest first so that "brown sugar" is listed before "sugar" in the
+ // alternation, ensuring the longer phrase wins when both could match.
+ sort.Slice(entries, func(i, j int) bool {
+ return len(entries[i].name) > len(entries[j].name)
+ })
+
+ // Build a single combined regex so all replacements happen in one pass.
+ // A single pass prevents a short ingredient (e.g. "sugar") from matching
+ // inside text that was already injected for a longer one ("brown sugar").
+ alts := make([]string, len(entries))
+ for i, e := range entries {
+ alts[i] = regexp.QuoteMeta(e.name)
+ }
+ pattern := `(?i)\b(?:` + strings.Join(alts, "|") + `)\b`
+ re := regexp.MustCompile(pattern)
+
+ // Build a lookup map keyed by lowercased ingredient name.
+ amountFor := make(map[string]string, len(entries))
+ for _, e := range entries {
+ amountFor[strings.ToLower(e.name)] = e.amount
+ }
+
+ result := re.ReplaceAllStringFunc(*r.Instructions, func(match string) string {
+ amount := amountFor[strings.ToLower(match)]
+ return amount + " " + match
+ })
+
+ return &result
+}
diff --git a/inline_ingredients_test.go b/inline_ingredients_test.go
new file mode 100644
index 0000000..7708fdd
--- /dev/null
+++ b/inline_ingredients_test.go
@@ -0,0 +1,184 @@
+package recipemd
+
+import (
+ "testing"
+)
+
+func TestInlineIngredients(t *testing.T) {
+ t.Parallel()
+
+ t.Run("nil instructions returns nil", func(t *testing.T) {
+ t.Parallel()
+ r := &Recipe{
+ Instructions: nil,
+ Ingredients: []Ingredient{{Name: "salt", Amount: &Amount{Factor: 1}}},
+ }
+ if got := InlineIngredients(r, 3); got != nil {
+ t.Errorf("expected nil, got %q", *got)
+ }
+ })
+
+ t.Run("ingredient without amount is not injected", func(t *testing.T) {
+ t.Parallel()
+ instructions := "add salt and stir"
+ r := &Recipe{
+ Instructions: &instructions,
+ Ingredients: []Ingredient{{Name: "salt"}},
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := InlineIngredients(r, 3)
+ if got == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if *got != instructions {
+ t.Errorf("expected %q, got %q", instructions, *got)
+ }
+ })
+
+ t.Run("single ingredient injected", func(t *testing.T) {
+ t.Parallel()
+ instructions := "then add cinnamon and mix"
+ r := &Recipe{
+ Instructions: &instructions,
+ Ingredients: []Ingredient{
+ {Name: "cinnamon", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := InlineIngredients(r, 3)
+ if got == nil {
+ t.Fatal("expected non-nil result")
+ }
+ want := "then add 0.5 tsp cinnamon and mix"
+ if *got != want {
+ t.Errorf("expected %q, got %q", want, *got)
+ }
+ })
+
+ t.Run("case-insensitive match preserves original casing", func(t *testing.T) {
+ t.Parallel()
+ instructions := "Add Cinnamon to the bowl"
+ r := &Recipe{
+ Instructions: &instructions,
+ Ingredients: []Ingredient{
+ {Name: "cinnamon", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := InlineIngredients(r, 3)
+ if got == nil {
+ t.Fatal("expected non-nil result")
+ }
+ want := "Add 0.5 tsp Cinnamon to the bowl"
+ if *got != want {
+ t.Errorf("expected %q, got %q", want, *got)
+ }
+ })
+
+ t.Run("multi-word ingredient matched before partial", func(t *testing.T) {
+ t.Parallel()
+ instructions := "stir in brown sugar then add more sugar"
+ r := &Recipe{
+ Instructions: &instructions,
+ Ingredients: []Ingredient{
+ {Name: "brown sugar", Amount: &Amount{Factor: 1, Unit: new("cup")}},
+ {Name: "sugar", Amount: &Amount{Factor: 2, Unit: new("tsp")}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := InlineIngredients(r, 3)
+ if got == nil {
+ t.Fatal("expected non-nil result")
+ }
+ want := "stir in 1 cup brown sugar then add more 2 tsp sugar"
+ if *got != want {
+ t.Errorf("expected %q, got %q", want, *got)
+ }
+ })
+
+ t.Run("multiple occurrences are all replaced", func(t *testing.T) {
+ t.Parallel()
+ instructions := "add flour, then more flour"
+ r := &Recipe{
+ Instructions: &instructions,
+ Ingredients: []Ingredient{
+ {Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cup")}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := InlineIngredients(r, 3)
+ if got == nil {
+ t.Fatal("expected non-nil result")
+ }
+ want := "add 2 cup flour, then more 2 cup flour"
+ if *got != want {
+ t.Errorf("expected %q, got %q", want, *got)
+ }
+ })
+
+ t.Run("ingredient in group is also considered", func(t *testing.T) {
+ t.Parallel()
+ instructions := "fold in vanilla"
+ r := &Recipe{
+ Instructions: &instructions,
+ Ingredients: []Ingredient{},
+ IngredientGroups: []IngredientGroup{
+ {
+ Title: "Flavouring",
+ Ingredients: []Ingredient{
+ {Name: "vanilla", Amount: &Amount{Factor: 1, Unit: new("tsp")}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ },
+ },
+ }
+ got := InlineIngredients(r, 3)
+ if got == nil {
+ t.Fatal("expected non-nil result")
+ }
+ want := "fold in 1 tsp vanilla"
+ if *got != want {
+ t.Errorf("expected %q, got %q", want, *got)
+ }
+ })
+
+ t.Run("word boundary prevents partial match", func(t *testing.T) {
+ t.Parallel()
+ instructions := "add salted butter"
+ r := &Recipe{
+ Instructions: &instructions,
+ Ingredients: []Ingredient{
+ {Name: "salt", Amount: &Amount{Factor: 1, Unit: new("tsp")}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := InlineIngredients(r, 3)
+ if got == nil {
+ t.Fatal("expected non-nil result")
+ }
+ // "salt" must NOT match inside "salted"
+ if *got != instructions {
+ t.Errorf("expected %q unchanged, got %q", instructions, *got)
+ }
+ })
+
+ t.Run("unitless amount injected", func(t *testing.T) {
+ t.Parallel()
+ instructions := "crack eggs into bowl"
+ r := &Recipe{
+ Instructions: &instructions,
+ Ingredients: []Ingredient{
+ {Name: "eggs", Amount: &Amount{Factor: 3}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := InlineIngredients(r, 3)
+ if got == nil {
+ t.Fatal("expected non-nil result")
+ }
+ want := "crack 3 eggs into bowl"
+ if *got != want {
+ t.Errorf("expected %q, got %q", want, *got)
+ }
+ })
+}
From bb7b4dc9a6daa423b5a5aedc3fef780f6ca9504c Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 21 Mar 2026 04:02:46 +0000
Subject: [PATCH 29/29] Rework inline ingredients as a parser option with
render-time injection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Removes the standalone InlineIngredients function in favour of a parser-level
option: WithInlineIngredients(...InlineIngredientsOption). Injection now
happens automatically in RenderMarkdown and RenderHTML.
Sub-options:
• WithInlineFormat(InlineIngredientsBefore|InlineIngredientsAfter)
Controls whether the amount appears before the name ("3 eggs") or after
it in parentheses ("eggs (3)"). Default is InlineIngredientsBefore.
• WithInlineHTMLHover()
For RenderHTML only: places the amount in the span's title attribute so
it appears as a CSS/browser tooltip rather than inline text. Each matched
name is wrapped in .
• WithInlinePrepSeparators(seps ...string)
Splits ingredient names on the first matching separator to extract a
preparation note. "garlic, minced" (sep=",") → base="garlic", prep="minced".
Paired closers are stripped automatically ("(" strips trailing ")").
Prep is included in the rendered amount:
InlineIngredientsBefore → "3 cloves minced garlic"
InlineIngredientsAfter → "garlic (3 cloves minced)"
Hover → title="3 cloves minced"
Pluralization (built-in, no external dependency):
Matching is case-insensitive and covers both singular and plural forms so
that ingredient "egg" also matches "eggs" in the instructions. wordForms()
applies standard English suffix rules (y→ies, f→ves, ch/sh/x/z→es, +s)
with an irregulars table for common food vocabulary (potato/potatoes,
leaf/leaves, knife/knives, etc.) and invariant uncountable nouns (flour,
garlic, rice, sugar, …).
A single-pass combined regex ensures that longer names (e.g. "brown sugar")
are always matched and replaced before shorter overlapping names ("sugar"),
preventing double-injection into already-replaced text.
HTML rendering post-processes the goldmark HTML output with a lightweight
text-node splitter that leaves tag markup untouched, so no unsafe HTML is
injected into the markdown source.
https://claude.ai/code/session_012wP9EExNKgdFcpp9JJELGC
---
inline_ingredients.go | 466 ++++++++++++++++++++++++++++----
inline_ingredients_test.go | 539 ++++++++++++++++++++++++++++---------
parser.go | 10 +-
render_html.go | 29 +-
render_markdown.go | 12 +
5 files changed, 871 insertions(+), 185 deletions(-)
diff --git a/inline_ingredients.go b/inline_ingredients.go
index 0c2b45b..aeddc5e 100644
--- a/inline_ingredients.go
+++ b/inline_ingredients.go
@@ -1,83 +1,451 @@
package recipemd
import (
+ "html"
"regexp"
"sort"
"strings"
+ "unicode"
)
-// InlineIngredients returns a copy of the recipe's Instructions with ingredient
-// amounts injected before each matching ingredient name.
-//
-// For each ingredient that has an amount, every occurrence of the ingredient's
-// name in the instructions (matched case-insensitively at word boundaries) is
-// replaced with " ". For example, if an ingredient is
-// "*1/2 tsp* cinnamon" and the instructions contain "add cinnamon and mix",
-// the result is "add 1/2 tsp cinnamon and mix". The original casing of the
-// matched word(s) in the instructions text is preserved.
+// InlineIngredientsFormat controls where the injected amount appears relative
+// to the ingredient name in the rendered output.
+type InlineIngredientsFormat int
+
+const (
+ // InlineIngredientsBefore places the formatted amount before the matched
+ // name: "add 3 eggs to the bowl".
+ InlineIngredientsBefore InlineIngredientsFormat = iota
+
+ // InlineIngredientsAfter places the formatted amount in parentheses after
+ // the matched name: "add eggs (3) to the bowl".
+ InlineIngredientsAfter
+)
+
+// InlineIngredientsOption is a functional option for configuring inline
+// ingredient injection. Options are passed to [WithInlineIngredients].
+type InlineIngredientsOption func(*inlineIngredientsConfig)
+
+type inlineIngredientsConfig struct {
+ format InlineIngredientsFormat
+ htmlHover bool
+ prepSeparators []string
+}
+
+// WithInlineFormat sets how the injected amount is positioned relative to the
+// ingredient name. The default is [InlineIngredientsBefore].
+func WithInlineFormat(f InlineIngredientsFormat) InlineIngredientsOption {
+ return func(c *inlineIngredientsConfig) { c.format = f }
+}
+
+// WithInlineHTMLHover renders the amount as a tooltip (HTML title attribute)
+// rather than inline visible text when using [Parser.RenderHTML]. The
+// ingredient name receives a "recipemd-inline-ingredient" span whose title
+// attribute holds the amount (and prep note when applicable). The display
+// format option is ignored when hover is enabled.
+func WithInlineHTMLHover() InlineIngredientsOption {
+ return func(c *inlineIngredientsConfig) { c.htmlHover = true }
+}
+
+// WithInlinePrepSeparators registers one or more separator strings that split
+// an ingredient name into a base name and a preparation note.
//
-// Multi-word ingredient names (e.g. "olive oil") are matched as complete
-// phrases. When one ingredient name is a substring of another (e.g. "sugar"
-// and "brown sugar"), the longer name is matched and replaced first so that
-// the shorter name is not spuriously injected inside the already-replaced text.
+// For example, with separator "," the ingredient "3 cloves garlic, chopped"
+// yields base="garlic" and prep="chopped". With separator "(" the ingredient
+// "3 cloves garlic (chopped)" yields base="garlic" and prep="chopped" — the
+// paired closing ")" is stripped automatically.
//
-// Amounts are formatted with the given rounding via [Amount.Serialize].
-// Ingredients without an amount are not injected.
+// Only the first matching separator is applied. The base name is used for
+// matching in the instructions; the prep note is included in the injected text.
//
-// Returns nil when r.Instructions is nil.
-func InlineIngredients(r *Recipe, rounding int) *string {
- if r.Instructions == nil {
- return nil
+// - [InlineIngredientsBefore]: "3 cloves chopped garlic"
+// - [InlineIngredientsAfter]: "garlic (3 cloves chopped)"
+// - Hover: title="3 cloves chopped", visible text="garlic"
+func WithInlinePrepSeparators(seps ...string) InlineIngredientsOption {
+ return func(c *inlineIngredientsConfig) {
+ c.prepSeparators = append(c.prepSeparators, seps...)
}
+}
- type entry struct {
- name string
- amount string
+// WithInlineIngredients returns an [Option] that enables inline ingredient
+// injection at render time. Every occurrence of an ingredient name in the
+// instructions is annotated with its amount by [Parser.RenderMarkdown] and
+// [Parser.RenderHTML].
+//
+// Matching is case-insensitive and respects word boundaries so partial matches
+// (e.g. "salt" inside "salted") are avoided. Both singular and plural forms
+// of each ingredient name are matched (e.g. ingredient "egg" also matches
+// "eggs" in the instructions). Multi-word names are matched as complete phrases
+// and take priority over any shorter overlapping names.
+//
+// Sub-options control the display format, HTML hover behaviour, and optional
+// preparation-note separators; see [WithInlineFormat], [WithInlineHTMLHover],
+// and [WithInlinePrepSeparators].
+//
+// p := recipemd.NewParser(
+// recipemd.WithInlineIngredients(
+// recipemd.WithInlineFormat(recipemd.InlineIngredientsBefore),
+// recipemd.WithInlinePrepSeparators(",", "("),
+// ),
+// )
+func WithInlineIngredients(opts ...InlineIngredientsOption) Option {
+ return func(p *Parser) {
+ cfg := &inlineIngredientsConfig{}
+ for _, o := range opts {
+ o(cfg)
+ }
+ p.inlineIngredients = true
+ p.inlineIngredientsCfg = *cfg
}
+}
+// ---------------------------------------------------------------------------
+// Internal injector
+// ---------------------------------------------------------------------------
+
+type inlineEntry struct {
+ amount string // formatted amount string, e.g. "3 cups"
+ prep string // preparation note stripped from name, e.g. "chopped"
+}
+
+type inlineInjector struct {
+ cfg inlineIngredientsConfig
+ re *regexp.Regexp
+ lookup map[string]*inlineEntry // lowercase match key → entry
+}
+
+// buildInjector constructs an inlineInjector for the given recipe. Returns nil
+// when no ingredients with amounts are found.
+func buildInjector(r *Recipe, cfg inlineIngredientsConfig, rounding int) *inlineInjector {
ingredients := r.LeafIngredients()
- entries := make([]entry, 0, len(ingredients))
+
+ lookup := make(map[string]*inlineEntry, len(ingredients)*2)
+ var alts []string
+
for _, ing := range ingredients {
if ing.Amount == nil {
continue
}
- entries = append(entries, entry{
- name: ing.Name,
+
+ name := ing.Name
+ prep := ""
+
+ // Split out preparation note using configured separators.
+ for _, sep := range cfg.prepSeparators {
+ if idx := strings.Index(name, sep); idx >= 0 {
+ raw := strings.TrimSpace(name[idx+len(sep):])
+ // Strip the paired closing bracket when separator is an opener.
+ raw = stripClosingBracket(raw, sep)
+ prep = strings.TrimSpace(raw)
+ name = strings.TrimSpace(name[:idx])
+ break
+ }
+ }
+
+ entry := &inlineEntry{
amount: ing.Amount.Serialize(rounding),
- })
+ prep: prep,
+ }
+
+ // Register all word forms (singular + plural) so that "egg" matches
+ // "eggs" in the instructions and vice-versa.
+ for _, form := range wordForms(name) {
+ if _, exists := lookup[form]; !exists {
+ lookup[form] = entry
+ alts = append(alts, regexp.QuoteMeta(form))
+ }
+ }
}
- if len(entries) == 0 {
- s := *r.Instructions
- return &s
+ if len(alts) == 0 {
+ return nil
}
- // Sort longest first so that "brown sugar" is listed before "sugar" in the
- // alternation, ensuring the longer phrase wins when both could match.
- sort.Slice(entries, func(i, j int) bool {
- return len(entries[i].name) > len(entries[j].name)
- })
+ // Sort longest alternatives first so that multi-word names (e.g. "brown
+ // sugar") take precedence over shorter sub-names (e.g. "sugar") within
+ // the combined alternation.
+ sort.Slice(alts, func(i, j int) bool { return len(alts[i]) > len(alts[j]) })
- // Build a single combined regex so all replacements happen in one pass.
- // A single pass prevents a short ingredient (e.g. "sugar") from matching
- // inside text that was already injected for a longer one ("brown sugar").
- alts := make([]string, len(entries))
- for i, e := range entries {
- alts[i] = regexp.QuoteMeta(e.name)
- }
pattern := `(?i)\b(?:` + strings.Join(alts, "|") + `)\b`
re := regexp.MustCompile(pattern)
- // Build a lookup map keyed by lowercased ingredient name.
- amountFor := make(map[string]string, len(entries))
- for _, e := range entries {
- amountFor[strings.ToLower(e.name)] = e.amount
+ return &inlineInjector{cfg: cfg, re: re, lookup: lookup}
+}
+
+// stripClosingBracket removes the paired closing bracket from s when sep is
+// an opening bracket character, e.g. "(" → strip trailing ")".
+func stripClosingBracket(s, sep string) string {
+ pairs := map[string]byte{"(": ')', "[": ']', "{": '}'}
+ if close, ok := pairs[sep]; ok {
+ s = strings.TrimRight(s, string(close))
}
+ return s
+}
+
+// injectText applies inline injection to a plain-text (markdown) string.
+// The result can be used directly in markdown output.
+func (inj *inlineInjector) injectText(text string) string {
+ return inj.re.ReplaceAllStringFunc(text, func(match string) string {
+ e := inj.lookup[strings.ToLower(match)]
+ return inj.formatText(match, e)
+ })
+}
- result := re.ReplaceAllStringFunc(*r.Instructions, func(match string) string {
- amount := amountFor[strings.ToLower(match)]
- return amount + " " + match
+// injectHTML applies inline injection to an HTML string produced by goldmark,
+// touching only text nodes (content between tags) and producing span elements.
+func (inj *inlineInjector) injectHTML(htmlStr string) string {
+ return injectHTMLTextNodes(htmlStr, inj.re, func(match string) string {
+ e := inj.lookup[strings.ToLower(match)]
+ if inj.cfg.htmlHover {
+ return inj.formatHTMLHover(match, e)
+ }
+ return inj.formatHTMLSpan(match, e)
})
+}
+
+// formatText builds the plain-text replacement for match.
+func (inj *inlineInjector) formatText(match string, e *inlineEntry) string {
+ if e.prep != "" {
+ switch inj.cfg.format {
+ case InlineIngredientsBefore:
+ return e.amount + " " + e.prep + " " + match
+ case InlineIngredientsAfter:
+ return match + " (" + e.amount + " " + e.prep + ")"
+ }
+ }
+ switch inj.cfg.format {
+ case InlineIngredientsBefore:
+ return e.amount + " " + match
+ case InlineIngredientsAfter:
+ return match + " (" + e.amount + ")"
+ }
+ return match
+}
+
+// formatHTMLSpan wraps the replacement in a recipemd-inline-ingredient span.
+// match is raw text content from the HTML (already entity-encoded for simple
+// ASCII names) and is reused verbatim to preserve entity encoding.
+func (inj *inlineInjector) formatHTMLSpan(match string, e *inlineEntry) string {
+ var inner string
+ if e.prep != "" {
+ switch inj.cfg.format {
+ case InlineIngredientsBefore:
+ inner = html.EscapeString(e.amount+" "+e.prep) + " " + match
+ case InlineIngredientsAfter:
+ inner = match + " (" + html.EscapeString(e.amount+" "+e.prep) + ")"
+ }
+ } else {
+ switch inj.cfg.format {
+ case InlineIngredientsBefore:
+ inner = html.EscapeString(e.amount) + " " + match
+ case InlineIngredientsAfter:
+ inner = match + " (" + html.EscapeString(e.amount) + ")"
+ }
+ }
+ return `` + inner + ``
+}
+
+// formatHTMLHover wraps match in a span whose title attribute holds the amount
+// (and prep note). The visible text is the matched name only.
+func (inj *inlineInjector) formatHTMLHover(match string, e *inlineEntry) string {
+ title := e.amount
+ if e.prep != "" {
+ title += " " + e.prep
+ }
+ return `` + match + ``
+}
+
+// injectHTMLTextNodes runs replacer over text segments of an HTML string,
+// leaving all tag markup untouched. It does not parse HTML fully; it splits
+// on '<' and '>' which is sufficient for well-formed goldmark output.
+func injectHTMLTextNodes(htmlStr string, re *regexp.Regexp, replacer func(string) string) string {
+ var b strings.Builder
+ b.Grow(len(htmlStr))
+ for len(htmlStr) > 0 {
+ lt := strings.IndexByte(htmlStr, '<')
+ if lt < 0 {
+ b.WriteString(re.ReplaceAllStringFunc(htmlStr, replacer))
+ break
+ }
+ if lt > 0 {
+ b.WriteString(re.ReplaceAllStringFunc(htmlStr[:lt], replacer))
+ }
+ gt := strings.IndexByte(htmlStr[lt:], '>')
+ if gt < 0 {
+ b.WriteString(htmlStr[lt:])
+ break
+ }
+ b.WriteString(htmlStr[lt : lt+gt+1])
+ htmlStr = htmlStr[lt+gt+1:]
+ }
+ return b.String()
+}
+
+// ---------------------------------------------------------------------------
+// Pluralization helpers
+// ---------------------------------------------------------------------------
+
+// wordForms returns the unique lowercase word forms (singular and plural) that
+// should be matched for an ingredient name. For multi-word names each form is
+// derived by transforming the last word only.
+//
+// The implementation covers standard English pluralization rules with a
+// focused irregulars table for common food vocabulary, inspired by the
+// go-pluralize package (github.com/gertd/go-pluralize).
+func wordForms(word string) []string {
+ lower := strings.ToLower(word)
+
+ // For multi-word names derive forms from the last word only.
+ parts := strings.Fields(lower)
+ if len(parts) > 1 {
+ prefix := strings.Join(parts[:len(parts)-1], " ") + " "
+ last := parts[len(parts)-1]
+ var forms []string
+ for _, f := range singleWordForms(last) {
+ forms = append(forms, prefix+f)
+ }
+ return dedup(forms)
+ }
+
+ return singleWordForms(lower)
+}
+
+func singleWordForms(lower string) []string {
+ sg := toSingular(lower)
+ pl := toPlural(lower)
+ if sg == pl {
+ return []string{sg}
+ }
+ return dedup([]string{sg, pl})
+}
+
+func dedup(ss []string) []string {
+ seen := make(map[string]bool, len(ss))
+ out := ss[:0]
+ for _, s := range ss {
+ if !seen[s] {
+ seen[s] = true
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+// toPlural converts a lowercase singular word to its plural form.
+func toPlural(word string) string {
+ if pl, ok := irregularsToPlural[word]; ok {
+ return pl
+ }
+ if _, ok := irregularsToSingular[word]; ok {
+ return word // already plural
+ }
+ switch {
+ case hasSuffix(word, "ies"): // already plural
+ return word
+ case hasSuffix(word, "y") && len(word) > 1 && isConsonant(rune(word[len(word)-2])):
+ return word[:len(word)-1] + "ies"
+ case hasSuffix(word, "fe"):
+ return word[:len(word)-2] + "ves"
+ case hasSuffix(word, "f") && !hasSuffix(word, "ff"):
+ return word[:len(word)-1] + "ves"
+ case hasSuffixAny(word, "ch", "sh", "x", "z", "s"):
+ return word + "es"
+ default:
+ return word + "s"
+ }
+}
+
+// toSingular converts a lowercase plural word to its singular form.
+func toSingular(word string) string {
+ if sg, ok := irregularsToSingular[word]; ok {
+ return sg
+ }
+ if _, ok := irregularsToPlural[word]; ok {
+ return word // already singular
+ }
+ switch {
+ case hasSuffix(word, "ies") && len(word) > 3:
+ return word[:len(word)-3] + "y"
+ case hasSuffix(word, "ves") && len(word) > 3:
+ // knives → knife, leaves → leaf: fall back to removing -ves and adding -fe/-f.
+ // Use the irregulars table for exact known cases; approximation otherwise.
+ return word[:len(word)-3] + "f"
+ case hasSuffixAny(word, "shes", "ches", "xes", "zes") && len(word) > 4:
+ return word[:len(word)-2]
+ case hasSuffix(word, "ses") && len(word) > 3:
+ return word[:len(word)-2]
+ case hasSuffix(word, "s") && !hasSuffix(word, "ss"):
+ return word[:len(word)-1]
+ default:
+ return word
+ }
+}
+
+// irregularsToPlural maps singular → plural for words that don't follow the
+// standard rules, with emphasis on food-relevant vocabulary.
+var irregularsToPlural = map[string]string{
+ // -o words that take -es
+ "potato": "potatoes",
+ "tomato": "tomatoes",
+ "hero": "heroes",
+ "mango": "mangoes",
+ // -f/-fe words
+ "leaf": "leaves",
+ "loaf": "loaves",
+ "half": "halves",
+ "shelf": "shelves",
+ "knife": "knives",
+ "wife": "wives",
+ "life": "lives",
+ "wolf": "wolves",
+ // Common English irregulars
+ "man": "men",
+ "woman": "women",
+ "child": "children",
+ "person": "people",
+ "tooth": "teeth",
+ "foot": "feet",
+ "goose": "geese",
+ "mouse": "mice",
+ // Invariant / uncountable culinary nouns
+ "flour": "flour",
+ "sugar": "sugar",
+ "salt": "salt",
+ "pepper": "pepper",
+ "rice": "rice",
+ "garlic": "garlic",
+ "ginger": "ginger",
+ "honey": "honey",
+ "oil": "oil",
+ "butter": "butter",
+ "milk": "milk",
+ "water": "water",
+ "vinegar": "vinegar",
+}
+
+// irregularsToSingular is the reverse of irregularsToPlural.
+var irregularsToSingular = func() map[string]string {
+ m := make(map[string]string, len(irregularsToPlural))
+ for sg, pl := range irregularsToPlural {
+ if sg != pl { // skip invariants
+ m[pl] = sg
+ }
+ }
+ return m
+}()
+
+func hasSuffix(s, suffix string) bool { return strings.HasSuffix(s, suffix) }
+
+func hasSuffixAny(s string, suffixes ...string) bool {
+ for _, suf := range suffixes {
+ if strings.HasSuffix(s, suf) {
+ return true
+ }
+ }
+ return false
+}
- return &result
+func isConsonant(r rune) bool {
+ return !strings.ContainsRune("aeiou", unicode.ToLower(r))
}
diff --git a/inline_ingredients_test.go b/inline_ingredients_test.go
index 7708fdd..2bf1611 100644
--- a/inline_ingredients_test.go
+++ b/inline_ingredients_test.go
@@ -1,184 +1,465 @@
package recipemd
import (
+ "strings"
"testing"
)
-func TestInlineIngredients(t *testing.T) {
+// ---------------------------------------------------------------------------
+// wordForms (pluralization)
+// ---------------------------------------------------------------------------
+
+func TestWordForms(t *testing.T) {
t.Parallel()
- t.Run("nil instructions returns nil", func(t *testing.T) {
+ cases := []struct {
+ word string
+ forms []string // expected lowercased forms
+ }{
+ // Regular +s
+ {"apple", []string{"apple", "apples"}},
+ {"egg", []string{"egg", "eggs"}},
+ {"clove", []string{"clove", "cloves"}},
+ // consonant+y → ies
+ {"berry", []string{"berry", "berries"}},
+ {"cherry", []string{"cherry", "cherries"}},
+ // -o irregulars
+ {"potato", []string{"potato", "potatoes"}},
+ {"tomato", []string{"tomato", "tomatoes"}},
+ // -f → ves
+ {"leaf", []string{"leaf", "leaves"}},
+ {"loaf", []string{"loaf", "loaves"}},
+ // -fe → ves
+ {"knife", []string{"knife", "knives"}},
+ // -ch → es
+ {"peach", []string{"peach", "peaches"}},
+ // Invariant uncountables
+ {"flour", []string{"flour"}},
+ {"garlic", []string{"garlic"}},
+ {"rice", []string{"rice"}},
+ {"sugar", []string{"sugar"}},
+ // Multi-word: only last word is inflected
+ {"bay leaf", []string{"bay leaf", "bay leaves"}},
+ {"olive oil", []string{"olive oil"}}, // oil is invariant
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.word, func(t *testing.T) {
+ t.Parallel()
+ got := wordForms(tc.word)
+ if len(got) != len(tc.forms) {
+ t.Fatalf("wordForms(%q) = %v, want %v", tc.word, got, tc.forms)
+ }
+ for i, f := range tc.forms {
+ if got[i] != f {
+ t.Errorf("wordForms(%q)[%d] = %q, want %q", tc.word, i, got[i], f)
+ }
+ }
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// WithInlineIngredients — parser option smoke test
+// ---------------------------------------------------------------------------
+
+func TestWithInlineIngredients_DefaultNoop(t *testing.T) {
+ t.Parallel()
+ // Without the option, instructions are unchanged.
+ p := NewParser()
+ instructions := "add eggs and flour"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{{Name: "egg", Amount: &Amount{Factor: 3}}},
+ IngredientGroups: []IngredientGroup{},
+ }
+ md := p.RenderMarkdown(r, 3)
+ // Plain parser must not inject amounts.
+ if strings.Contains(md, "3 eggs") || strings.Contains(md, "3 egg") {
+ t.Errorf("plain parser injected amount unexpectedly: %q", md)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// RenderMarkdown — inline before/after
+// ---------------------------------------------------------------------------
+
+func TestRenderMarkdown_InlineBefore(t *testing.T) {
+ t.Parallel()
+ p := NewParser(WithInlineIngredients(
+ WithInlineFormat(InlineIngredientsBefore),
+ ))
+ instructions := "crack eggs into the bowl then add flour"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{
+ {Name: "egg", Amount: &Amount{Factor: 3}},
+ {Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cups")}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ md := p.RenderMarkdown(r, 3)
+ if !strings.Contains(md, "3 eggs") {
+ t.Errorf("expected plural match injected as '3 eggs' in: %q", md)
+ }
+ if !strings.Contains(md, "2 cups flour") {
+ t.Errorf("expected '2 cups flour' in: %q", md)
+ }
+}
+
+func TestRenderMarkdown_InlineAfter(t *testing.T) {
+ t.Parallel()
+ p := NewParser(WithInlineIngredients(
+ WithInlineFormat(InlineIngredientsAfter),
+ ))
+ instructions := "add eggs and stir"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{
+ {Name: "egg", Amount: &Amount{Factor: 2}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ md := p.RenderMarkdown(r, 3)
+ if !strings.Contains(md, "eggs (2)") {
+ t.Errorf("expected 'eggs (2)' in: %q", md)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Pluralization matching
+// ---------------------------------------------------------------------------
+
+func TestRenderMarkdown_Pluralization(t *testing.T) {
+ t.Parallel()
+ p := NewParser(WithInlineIngredients())
+ t.Run("singular ingredient matches plural in instructions", func(t *testing.T) {
t.Parallel()
+ instructions := "fold in the berries"
r := &Recipe{
- Instructions: nil,
- Ingredients: []Ingredient{{Name: "salt", Amount: &Amount{Factor: 1}}},
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{{Name: "berry", Amount: &Amount{Factor: 200, Unit: new("g")}}},
+ IngredientGroups: []IngredientGroup{},
}
- if got := InlineIngredients(r, 3); got != nil {
- t.Errorf("expected nil, got %q", *got)
+ md := p.RenderMarkdown(r, 3)
+ if !strings.Contains(md, "200 g berries") {
+ t.Errorf("expected plural match in: %q", md)
}
})
- t.Run("ingredient without amount is not injected", func(t *testing.T) {
+ t.Run("word boundary not partial", func(t *testing.T) {
t.Parallel()
- instructions := "add salt and stir"
+ instructions := "use salted butter"
r := &Recipe{
+ Title: "T",
Instructions: &instructions,
- Ingredients: []Ingredient{{Name: "salt"}},
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{{Name: "salt", Amount: &Amount{Factor: 1, Unit: new("tsp")}}},
IngredientGroups: []IngredientGroup{},
}
- got := InlineIngredients(r, 3)
- if got == nil {
- t.Fatal("expected non-nil result")
- }
- if *got != instructions {
- t.Errorf("expected %q, got %q", instructions, *got)
+ md := p.RenderMarkdown(r, 3)
+ if strings.Contains(md, "1 tsp") {
+ t.Errorf("'salt' should not match inside 'salted': %q", md)
}
})
- t.Run("single ingredient injected", func(t *testing.T) {
+ t.Run("case-insensitive preserves original case", func(t *testing.T) {
t.Parallel()
- instructions := "then add cinnamon and mix"
+ instructions := "Add Eggs to the bowl"
r := &Recipe{
- Instructions: &instructions,
- Ingredients: []Ingredient{
- {Name: "cinnamon", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}},
- },
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{{Name: "egg", Amount: &Amount{Factor: 3}}},
IngredientGroups: []IngredientGroup{},
}
- got := InlineIngredients(r, 3)
- if got == nil {
- t.Fatal("expected non-nil result")
- }
- want := "then add 0.5 tsp cinnamon and mix"
- if *got != want {
- t.Errorf("expected %q, got %q", want, *got)
+ md := p.RenderMarkdown(r, 3)
+ if !strings.Contains(md, "3 Eggs") {
+ t.Errorf("expected original casing preserved: %q", md)
}
})
+}
+
+// ---------------------------------------------------------------------------
+// Preparation separators
+// ---------------------------------------------------------------------------
+
+func TestRenderMarkdown_PrepSeparators(t *testing.T) {
+ t.Parallel()
- t.Run("case-insensitive match preserves original casing", func(t *testing.T) {
+ t.Run("comma separator before format", func(t *testing.T) {
t.Parallel()
- instructions := "Add Cinnamon to the bowl"
+ p := NewParser(WithInlineIngredients(
+ WithInlineFormat(InlineIngredientsBefore),
+ WithInlinePrepSeparators(","),
+ ))
+ instructions := "mince garlic and add to pan"
r := &Recipe{
- Instructions: &instructions,
- Ingredients: []Ingredient{
- {Name: "cinnamon", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}},
- },
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{{Name: "garlic, minced", Amount: &Amount{Factor: 3, Unit: new("cloves")}}},
IngredientGroups: []IngredientGroup{},
}
- got := InlineIngredients(r, 3)
- if got == nil {
- t.Fatal("expected non-nil result")
- }
- want := "Add 0.5 tsp Cinnamon to the bowl"
- if *got != want {
- t.Errorf("expected %q, got %q", want, *got)
+ md := p.RenderMarkdown(r, 3)
+ // base="garlic", prep="minced" → "3 cloves minced garlic"
+ if !strings.Contains(md, "3 cloves minced garlic") {
+ t.Errorf("expected '3 cloves minced garlic' in: %q", md)
}
})
- t.Run("multi-word ingredient matched before partial", func(t *testing.T) {
+ t.Run("paren separator after format", func(t *testing.T) {
t.Parallel()
- instructions := "stir in brown sugar then add more sugar"
+ p := NewParser(WithInlineIngredients(
+ WithInlineFormat(InlineIngredientsAfter),
+ WithInlinePrepSeparators("("),
+ ))
+ instructions := "slice garlic thin and cook"
r := &Recipe{
- Instructions: &instructions,
- Ingredients: []Ingredient{
- {Name: "brown sugar", Amount: &Amount{Factor: 1, Unit: new("cup")}},
- {Name: "sugar", Amount: &Amount{Factor: 2, Unit: new("tsp")}},
- },
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{{Name: "garlic (sliced)", Amount: &Amount{Factor: 4, Unit: new("cloves")}}},
IngredientGroups: []IngredientGroup{},
}
- got := InlineIngredients(r, 3)
- if got == nil {
- t.Fatal("expected non-nil result")
- }
- want := "stir in 1 cup brown sugar then add more 2 tsp sugar"
- if *got != want {
- t.Errorf("expected %q, got %q", want, *got)
+ md := p.RenderMarkdown(r, 3)
+ // base="garlic", prep="sliced" → "garlic (4 cloves sliced)"
+ if !strings.Contains(md, "garlic (4 cloves sliced)") {
+ t.Errorf("expected 'garlic (4 cloves sliced)' in: %q", md)
}
})
- t.Run("multiple occurrences are all replaced", func(t *testing.T) {
+ t.Run("multi-word base with prep", func(t *testing.T) {
t.Parallel()
- instructions := "add flour, then more flour"
+ p := NewParser(WithInlineIngredients(
+ WithInlineFormat(InlineIngredientsBefore),
+ WithInlinePrepSeparators(","),
+ ))
+ instructions := "add brown sugar and mix"
r := &Recipe{
- Instructions: &instructions,
- Ingredients: []Ingredient{
- {Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cup")}},
- },
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{{Name: "brown sugar, packed", Amount: &Amount{Factor: 1, Unit: new("cup")}}},
IngredientGroups: []IngredientGroup{},
}
- got := InlineIngredients(r, 3)
- if got == nil {
- t.Fatal("expected non-nil result")
- }
- want := "add 2 cup flour, then more 2 cup flour"
- if *got != want {
- t.Errorf("expected %q, got %q", want, *got)
+ md := p.RenderMarkdown(r, 3)
+ // base="brown sugar", prep="packed" → "1 cup packed brown sugar"
+ if !strings.Contains(md, "1 cup packed brown sugar") {
+ t.Errorf("expected '1 cup packed brown sugar' in: %q", md)
}
})
+}
- t.Run("ingredient in group is also considered", func(t *testing.T) {
- t.Parallel()
- instructions := "fold in vanilla"
- r := &Recipe{
- Instructions: &instructions,
- Ingredients: []Ingredient{},
- IngredientGroups: []IngredientGroup{
- {
- Title: "Flavouring",
- Ingredients: []Ingredient{
- {Name: "vanilla", Amount: &Amount{Factor: 1, Unit: new("tsp")}},
- },
- IngredientGroups: []IngredientGroup{},
+// ---------------------------------------------------------------------------
+// Multi-word priority
+// ---------------------------------------------------------------------------
+
+func TestRenderMarkdown_MultiWordPriority(t *testing.T) {
+ t.Parallel()
+ p := NewParser(WithInlineIngredients(
+ WithInlineFormat(InlineIngredientsBefore),
+ ))
+ instructions := "mix brown sugar then add plain sugar"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{
+ {Name: "brown sugar", Amount: &Amount{Factor: 1, Unit: new("cup")}},
+ {Name: "sugar", Amount: &Amount{Factor: 2, Unit: new("tsp")}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ md := p.RenderMarkdown(r, 3)
+ if !strings.Contains(md, "1 cup brown sugar") {
+ t.Errorf("expected '1 cup brown sugar' in: %q", md)
+ }
+ if !strings.Contains(md, "2 tsp sugar") {
+ t.Errorf("expected '2 tsp sugar' in: %q", md)
+ }
+ // "sugar" inside "brown sugar" must not be double-injected.
+ if strings.Contains(md, "2 tsp brown sugar") || strings.Contains(md, "1 cup brown 2 tsp sugar") {
+ t.Errorf("double injection detected: %q", md)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Ingredient groups
+// ---------------------------------------------------------------------------
+
+func TestRenderMarkdown_IngredientsInGroups(t *testing.T) {
+ t.Parallel()
+ p := NewParser(WithInlineIngredients())
+ instructions := "fold in vanilla and top with blueberries"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{},
+ IngredientGroups: []IngredientGroup{
+ {
+ Title: "Batter",
+ Ingredients: []Ingredient{
+ {Name: "vanilla", Amount: &Amount{Factor: 1, Unit: new("tsp")}},
},
+ IngredientGroups: []IngredientGroup{},
},
- }
- got := InlineIngredients(r, 3)
- if got == nil {
- t.Fatal("expected non-nil result")
- }
- want := "fold in 1 tsp vanilla"
- if *got != want {
- t.Errorf("expected %q, got %q", want, *got)
- }
- })
-
- t.Run("word boundary prevents partial match", func(t *testing.T) {
- t.Parallel()
- instructions := "add salted butter"
- r := &Recipe{
- Instructions: &instructions,
- Ingredients: []Ingredient{
- {Name: "salt", Amount: &Amount{Factor: 1, Unit: new("tsp")}},
+ {
+ Title: "Topping",
+ Ingredients: []Ingredient{
+ {Name: "blueberry", Amount: &Amount{Factor: 100, Unit: new("g")}},
+ },
+ IngredientGroups: []IngredientGroup{},
},
- IngredientGroups: []IngredientGroup{},
- }
- got := InlineIngredients(r, 3)
- if got == nil {
- t.Fatal("expected non-nil result")
- }
- // "salt" must NOT match inside "salted"
- if *got != instructions {
- t.Errorf("expected %q unchanged, got %q", instructions, *got)
- }
- })
+ },
+ }
+ md := p.RenderMarkdown(r, 3)
+ if !strings.Contains(md, "1 tsp vanilla") {
+ t.Errorf("expected '1 tsp vanilla' in: %q", md)
+ }
+ if !strings.Contains(md, "100 g blueberries") {
+ t.Errorf("expected plural '100 g blueberries' in: %q", md)
+ }
+}
- t.Run("unitless amount injected", func(t *testing.T) {
- t.Parallel()
- instructions := "crack eggs into bowl"
- r := &Recipe{
- Instructions: &instructions,
- Ingredients: []Ingredient{
- {Name: "eggs", Amount: &Amount{Factor: 3}},
- },
- IngredientGroups: []IngredientGroup{},
- }
- got := InlineIngredients(r, 3)
- if got == nil {
- t.Fatal("expected non-nil result")
- }
- want := "crack 3 eggs into bowl"
- if *got != want {
- t.Errorf("expected %q, got %q", want, *got)
- }
- })
+// ---------------------------------------------------------------------------
+// RenderHTML — span wrapping and hover
+// ---------------------------------------------------------------------------
+
+func TestRenderHTML_InlineSpan(t *testing.T) {
+ t.Parallel()
+ p := NewParser(WithInlineIngredients(
+ WithInlineFormat(InlineIngredientsBefore),
+ ))
+ instructions := "add eggs to the bowl"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{
+ {Name: "egg", Amount: &Amount{Factor: 3}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := p.RenderHTML(r, 3)
+ if !strings.Contains(got, `class="recipemd-inline-ingredient"`) {
+ t.Errorf("expected inline-ingredient span in: %q", got)
+ }
+ if !strings.Contains(got, "3 eggs") {
+ t.Errorf("expected '3 eggs' in HTML output: %q", got)
+ }
+}
+
+func TestRenderHTML_InlineAfterSpan(t *testing.T) {
+ t.Parallel()
+ p := NewParser(WithInlineIngredients(
+ WithInlineFormat(InlineIngredientsAfter),
+ ))
+ instructions := "beat eggs until frothy"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{
+ {Name: "egg", Amount: &Amount{Factor: 2}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := p.RenderHTML(r, 3)
+ if !strings.Contains(got, "eggs (2)") {
+ t.Errorf("expected 'eggs (2)' in HTML output: %q", got)
+ }
+}
+
+func TestRenderHTML_Hover(t *testing.T) {
+ t.Parallel()
+ p := NewParser(WithInlineIngredients(
+ WithInlineHTMLHover(),
+ ))
+ instructions := "season with pepper to taste"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{
+ {Name: "pepper", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := p.RenderHTML(r, 3)
+ if !strings.Contains(got, `title="0.5 tsp"`) {
+ t.Errorf("expected title attribute in: %q", got)
+ }
+ if !strings.Contains(got, `>pepper<`) {
+ t.Errorf("expected ingredient name as visible text in: %q", got)
+ }
+}
+
+func TestRenderHTML_HoverWithPrep(t *testing.T) {
+ t.Parallel()
+ p := NewParser(WithInlineIngredients(
+ WithInlineHTMLHover(),
+ WithInlinePrepSeparators(","),
+ ))
+ instructions := "sauté onion until translucent"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{
+ {Name: "onion, diced", Amount: &Amount{Factor: 1}},
+ },
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := p.RenderHTML(r, 3)
+ // title should include amount + prep
+ if !strings.Contains(got, `title="1 diced"`) {
+ t.Errorf("expected title='1 diced' in: %q", got)
+ }
+ if !strings.Contains(got, `>onion<`) {
+ t.Errorf("expected 'onion' as visible text in: %q", got)
+ }
+}
+
+func TestRenderHTML_NoInjectionWithoutOption(t *testing.T) {
+ t.Parallel()
+ p := NewParser()
+ instructions := "add eggs"
+ r := &Recipe{
+ Title: "T",
+ Instructions: &instructions,
+ Yields: []Amount{},
+ Tags: []string{},
+ Ingredients: []Ingredient{{Name: "egg", Amount: &Amount{Factor: 3}}},
+ IngredientGroups: []IngredientGroup{},
+ }
+ got := p.RenderHTML(r, 3)
+ if strings.Contains(got, `class="recipemd-inline-ingredient"`) {
+ t.Errorf("unexpected inline span without option: %q", got)
+ }
}
diff --git a/parser.go b/parser.go
index 47d2e4a..e82486c 100644
--- a/parser.go
+++ b/parser.go
@@ -54,10 +54,12 @@ func WithGithubFormattedMarkdown() Option {
type Parser struct {
// Frontmatter reports whether the parser strips YAML/TOML front matter
// before parsing. Set via [WithFrontmatter].
- Frontmatter bool
- hasTaskList bool
- goldmarkProcessor goldmark.Markdown
- goldmarkExtensions []goldmark.Extender
+ Frontmatter bool
+ hasTaskList bool
+ goldmarkProcessor goldmark.Markdown
+ goldmarkExtensions []goldmark.Extender
+ inlineIngredients bool
+ inlineIngredientsCfg inlineIngredientsConfig
}
// NewParser creates a new Parser, applying any supplied options.
diff --git a/render_html.go b/render_html.go
index 00c53dd..75dc4cb 100644
--- a/render_html.go
+++ b/render_html.go
@@ -42,7 +42,7 @@ const htmlMainTmpl = `
{{- with deref .Instructions }}
-
+{{ renderInstructionsMD . }}
{{- end }}
`
@@ -81,6 +81,12 @@ const htmlGroupsTmpl = `{{ range . -}}
// Ingredient groups are rendered as nested blocks; the heading level
// starts at h2 for top-level groups and increments for each sub-level.
//
+// When the parser was configured with [WithInlineIngredients], ingredient
+// amounts are injected into the instructions HTML. Each matched ingredient
+// name is wrapped in a element.
+// With [WithInlineHTMLHover] the amount is placed in the span's title
+// attribute instead of the visible text.
+//
// WARNING: This method is a work-in-progress and not yet ready for production
// use. Known limitations:
// - Ingredient links reference raw .md file paths without any resolution or
@@ -90,8 +96,16 @@ const htmlGroupsTmpl = `{{ range . -}}
// in the same section (Description or Instructions) as the usage. Definitions
// in one section are not visible when rendering the other, so cross-section
// reflinks are silently left unresolved.
+// - Ingredient names containing HTML-special characters (& < >) may not be
+// matched in inline injection because goldmark entity-encodes them in the
+// output.
func (p *Parser) RenderHTML(r *Recipe, rounding int) string {
- funcs := htmlFuncMap(p, rounding)
+ var inj *inlineInjector
+ if p.inlineIngredients {
+ inj = buildInjector(r, p.inlineIngredientsCfg, rounding)
+ }
+
+ funcs := htmlFuncMap(p, rounding, inj)
funcs["topGroups"] = func(groups []IngredientGroup) []htmlGroupCtx {
out := make([]htmlGroupCtx, len(groups))
for i, g := range groups {
@@ -109,7 +123,7 @@ func (p *Parser) RenderHTML(r *Recipe, rounding int) string {
return buf.String()
}
-func htmlFuncMap(p *Parser, rounding int) template.FuncMap {
+func htmlFuncMap(p *Parser, rounding int, inj *inlineInjector) template.FuncMap {
return template.FuncMap{
"join": strings.Join,
"deref": func(s *string) string {
@@ -136,6 +150,15 @@ func htmlFuncMap(p *Parser, rounding int) template.FuncMap {
_ = p.goldmarkProcessor.Convert([]byte(md), &buf)
return template.HTML(buf.String())
},
+ "renderInstructionsMD": func(md string) template.HTML {
+ var buf bytes.Buffer
+ _ = p.goldmarkProcessor.Convert([]byte(md), &buf)
+ htmlStr := buf.String()
+ if inj != nil {
+ htmlStr = inj.injectHTML(htmlStr)
+ }
+ return template.HTML(htmlStr)
+ },
"heading": func(level int, title string) template.HTML {
escaped := template.HTMLEscapeString(title)
return template.HTML(fmt.Sprintf(`%s`, level, escaped, level))
diff --git a/render_markdown.go b/render_markdown.go
index 289a25b..ed0ac0c 100644
--- a/render_markdown.go
+++ b/render_markdown.go
@@ -53,7 +53,19 @@ const mdGroupsTmpl = `{{ range . }}
//
// The returned string contains a complete, parseable RecipeMD document that
// [Parser.Parse] can round-trip back to an equivalent [Recipe].
+//
+// When the parser was configured with [WithInlineIngredients], ingredient
+// amounts are injected into the instructions text before rendering.
func (p *Parser) RenderMarkdown(r *Recipe, rounding int) string {
+ if p.inlineIngredients && r.Instructions != nil {
+ if inj := buildInjector(r, p.inlineIngredientsCfg, rounding); inj != nil {
+ injected := inj.injectText(*r.Instructions)
+ r2 := *r
+ r2.Instructions = &injected
+ r = &r2
+ }
+ }
+
funcs := renderFuncMap(rounding)
funcs["topGroups"] = func(groups []IngredientGroup) []mdGroupCtx {
out := make([]mdGroupCtx, len(groups))