|
1 | 1 | # recipemd-go |
2 | 2 |
|
3 | | -Go implementation of RecipeMD parser. |
| 3 | +<p> |
| 4 | + <a href="https://github.com/xcapaldi/recipemd-go/releases"><img src="https://img.shields.io/github/release/xcapaldi/recipemd-go.svg" alt="Latest Release"></a> |
| 5 | + <a href="https://pkg.go.dev/xcapaldi/recipemd-go?tab=doc"><img src="https://godoc.org/xcapaldi/recipemd-go?status.svg" alt="GoDoc"></a> |
| 6 | +</p> |
| 7 | + |
| 8 | +A Go library for parsing, scaling, and rendering recipes in the [RecipeMD](https://recipemd.org) format. |
| 9 | +This format builds on top of structured Markdown such that both humans and programs can digest it. |
| 10 | + |
| 11 | +*Go, parser, RecipeMD, Markdown* |
| 12 | + |
| 13 | +**1 Go module** |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +- *1 pkg* `github.com/xcapaldi/recipemd-go` |
| 18 | +- *1 pkg* `github.com/yuin/goldmark` Markdown parser |
| 19 | + |
| 20 | +## Examples |
| 21 | + |
| 22 | +- *1* `parse` — parse a recipe and output JSON |
| 23 | +- *1* `scale` — scale a recipe by factor or target yield |
| 24 | +- *1* `flatten` — inline all linked sub-recipe ingredients |
| 25 | +- *1* `renderhtml` — render a recipe as an HTML `<article>` |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## Installation |
| 30 | + |
| 31 | +```bash |
| 32 | +go get github.com/xcapaldi/recipemd-go |
| 33 | +``` |
| 34 | + |
| 35 | +## What is RecipeMD? |
| 36 | + |
| 37 | +[RecipeMD](https://recipemd.org/specification.html) is a Markdown-based format for writing recipes. |
| 38 | +A recipe file is plain Markdown with a defined structure: |
| 39 | + |
| 40 | +```markdown |
| 41 | +# Carbonara |
| 42 | + |
| 43 | +A classic Roman pasta. |
| 44 | + |
| 45 | +*Italian, pasta* |
| 46 | + |
| 47 | +**2 servings** |
| 48 | + |
| 49 | +--- |
| 50 | + |
| 51 | +- *200 g* spaghetti |
| 52 | +- *100 g* guanciale |
| 53 | +- *2* eggs |
| 54 | +- *50 g* Pecorino Romano |
| 55 | + |
| 56 | +## Sauce |
| 57 | + |
| 58 | +- *1 tbsp* black pepper, coarsely ground |
| 59 | + |
| 60 | +--- |
| 61 | + |
| 62 | +Boil pasta. Render guanciale. Whisk eggs with cheese and pepper. |
| 63 | +Toss together off the heat. |
| 64 | +``` |
| 65 | + |
| 66 | +The document has three sections divided by `---` thematic breaks: |
| 67 | + |
| 68 | +| Section | Content | |
| 69 | +|---|---| |
| 70 | +| Preamble | H1 title, optional description, optional *tags* (italic), optional **yields** (bold) | |
| 71 | +| Ingredients | Unordered lists; H2+ headings introduce named ingredient groups | |
| 72 | +| Instructions | Free-form Markdown text | |
| 73 | + |
| 74 | +Amounts are wrapped in emphasis: `*2 tbsp*`. Supported number formats: |
| 75 | +integers (`3`), decimals (`1.5`), fractions (`1/2`), improper fractions |
| 76 | +(`1 1/2`), and Unicode vulgar fractions (`½ ¼ ¾`). |
| 77 | + |
| 78 | +## Why RecipeMD? |
| 79 | + |
| 80 | +Shockingly there are multiple competing specifications in the world of plaintext cooking: |
| 81 | + |
| 82 | +- [Open Recipe Format](https://open-recipe-format.readthedocs.io/en/latest/) -- YAML |
| 83 | +- [hrecipe](https://microformats.org/wiki/hrecipe) -- XML |
| 84 | +- [schema.org Recipe](https://schema.org/Recipe) |
| 85 | +- [Cooklang](https://cooklang.org) -- DSL |
| 86 | + |
| 87 | +Of the above, the only one that goes beyond a strictly formatted schema is Cooklang. |
| 88 | +It is an extensive project with a CLI, web server and thoughtful features like inline ingredent declarations (these are extracted at render time). |
| 89 | +Despite this, it lacks one fundamental advantage working in plain text -- it's not very readable in it's raw format. |
| 90 | +This may seem like a minor inconvenience but it makes reading and writing recipes less accessible to humans and (increasingly important) AI agents. |
| 91 | +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). |
| 92 | +The nature of Cooklang's strict DSL enables some advanced features but I believe they can be acheived in RecipeMD through contextual analysis. |
| 93 | + |
| 94 | +As an example working in the raw formats, here is a moderately complex recipe in both: |
| 95 | + |
| 96 | +### RecipeMD |
| 97 | + |
| 98 | +```markdown |
| 99 | +# Chicken Tikka Masala |
| 100 | + |
| 101 | +Tender charred chicken in a rich, spiced tomato-cream sauce. |
| 102 | + |
| 103 | +*Indian, chicken, curry* |
| 104 | + |
| 105 | +**4 servings** |
| 106 | + |
| 107 | +--- |
| 108 | + |
| 109 | +## Marinade |
| 110 | + |
| 111 | +- *700 g* boneless chicken thighs, cut into chunks |
| 112 | +- *150 g* plain yogurt |
| 113 | +- *2 tbsp* lemon juice |
| 114 | +- *3 cloves* garlic, minced |
| 115 | +- *1 tsp* fresh ginger, grated |
| 116 | +- *1 tsp* garam masala |
| 117 | +- *1 tsp* cumin |
| 118 | +- *1/2 tsp* turmeric |
| 119 | +- *1/2 tsp* cayenne pepper |
| 120 | +- *1 tsp* salt |
| 121 | + |
| 122 | +## Sauce |
| 123 | + |
| 124 | +- *2 tbsp* ghee |
| 125 | +- *1* large onion, finely diced |
| 126 | +- *4 cloves* garlic, minced |
| 127 | +- *1 tbsp* fresh ginger, grated |
| 128 | +- *1 tbsp* garam masala |
| 129 | +- *1 tsp* cumin |
| 130 | +- *1 tsp* coriander |
| 131 | +- *1/2 tsp* turmeric |
| 132 | +- *1/2 tsp* cayenne pepper |
| 133 | +- *400 g* canned crushed tomatoes |
| 134 | +- *200 ml* heavy cream |
| 135 | +- *1 tsp* salt |
| 136 | + |
| 137 | +## Serving |
| 138 | + |
| 139 | +- *300 g* basmati rice |
| 140 | +- *1* handful fresh cilantro, roughly chopped |
| 141 | + |
| 142 | +--- |
| 143 | + |
| 144 | +Combine chicken with all marinade ingredients in a bowl. Cover and refrigerate for at least 1 hour, ideally overnight. |
| 145 | + |
| 146 | +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. |
| 147 | + |
| 148 | +Cook rice according to package directions. |
| 149 | + |
| 150 | +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. |
| 151 | + |
| 152 | +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. |
| 153 | + |
| 154 | +Pour in cream and stir to combine. Add the grilled chicken and simmer 5 minutes to meld flavors. Season with salt. |
| 155 | + |
| 156 | +Serve over rice, garnished with fresh cilantro. |
| 157 | +``` |
| 158 | + |
| 159 | +### Cooklang |
| 160 | + |
| 161 | +``` |
| 162 | +--- |
| 163 | +title: Chicken Tikka Masala |
| 164 | +description: Tender charred chicken in a rich, spiced tomato-cream sauce. |
| 165 | +tags: Indian, chicken, curry |
| 166 | +servings: 4 |
| 167 | +--- |
| 168 | +
|
| 169 | +== Marinate == |
| 170 | +
|
| 171 | +Combine @chicken thighs{700%g}(boneless, cut into chunks) with @plain yogurt{150%g}, |
| 172 | +@lemon juice{2%tbsp}, @garlic{3%cloves}(minced), @fresh ginger{1%tsp}(grated), |
| 173 | +@garam masala{1%tsp}, @cumin{1%tsp}, @turmeric{1/2%tsp}, @cayenne pepper{1/2%tsp}, |
| 174 | +and @salt{1%tsp} in a #large bowl{}. |
| 175 | +
|
| 176 | +Cover and refrigerate for ~marinating{1%hour} (or overnight for best results). |
| 177 | +
|
| 178 | +== Grill Chicken == |
| 179 | +
|
| 180 | +Preheat a #grill or broiler to high. Thread chicken onto #skewers and cook for |
| 181 | +~grilling{12%minutes}, turning once, until charred in spots and cooked through. Set aside. |
| 182 | +
|
| 183 | +== Cook Rice == |
| 184 | +
|
| 185 | +Cook @basmati rice{300%g} in a #saucepan{} according to package directions (~{18%minutes}). |
| 186 | +
|
| 187 | +== Make Sauce == |
| 188 | +
|
| 189 | +Heat @ghee{2%tbsp} in a #large saucepan{} over medium heat. |
| 190 | +Add @onion{1%large}(finely diced) and cook for ~onion{10%minutes} until deep golden. |
| 191 | +
|
| 192 | +Add @garlic{4%cloves}(minced) and @fresh ginger{1%tbsp}(grated); cook ~{2%minutes} until fragrant. |
| 193 | +
|
| 194 | +Stir in @garam masala{1%tbsp}, @cumin{1%tsp}, @coriander{1%tsp}, @turmeric{1/2%tsp}, and @cayenne pepper{1/2%tsp}. |
| 195 | +Toast ~{1%minute}, then add @crushed tomatoes{400%g} and simmer uncovered for ~{15%minutes}, |
| 196 | +stirring occasionally, until thickened. |
| 197 | +
|
| 198 | +Pour in @heavy cream{200%ml} and stir to combine. |
| 199 | +Add grilled chicken and simmer ~finishing{5%minutes} to meld flavors. |
| 200 | +Season with @salt{1%tsp}. |
| 201 | +
|
| 202 | +== Serve == |
| 203 | +
|
| 204 | +Plate over rice and garnish with @fresh cilantro{1%handful}(roughly chopped). |
| 205 | +``` |
| 206 | + |
| 207 | +## Library Usage |
| 208 | + |
| 209 | +### Parsing |
| 210 | + |
| 211 | +`Parse` accepts any `io.Reader`: |
| 212 | + |
| 213 | +```go |
| 214 | +import recipemd "github.com/xcapaldi/recipemd-go" |
| 215 | + |
| 216 | +p := recipemd.NewParser() |
| 217 | + |
| 218 | +f, _ := os.Open("carbonara.md") |
| 219 | +defer f.Close() |
| 220 | + |
| 221 | +recipe, err := p.Parse(f) |
| 222 | +if err != nil { |
| 223 | + log.Fatal(err) |
| 224 | +} |
| 225 | + |
| 226 | +fmt.Println(recipe.Title) // "Carbonara" |
| 227 | +fmt.Println(recipe.Tags) // ["Italian", "pasta"] |
| 228 | +fmt.Println(recipe.Yields) // [{Factor:2 Unit:"servings"}] |
| 229 | +``` |
| 230 | + |
| 231 | +Parse collects all structural and value-level problems via `errors.Join`, |
| 232 | +so a single call surfaces every issue at once. Individual errors carry line |
| 233 | +and column information: |
| 234 | + |
| 235 | +```go |
| 236 | +if parseError, ok := errors.AsType[*recipemd.ParseError](err); ok { |
| 237 | + fmt.Printf("line %d, col %d: %s\n", parseError.Line, parseError.Column, parseError.Message) |
| 238 | +} |
| 239 | +``` |
| 240 | + |
| 241 | +#### Parser options |
| 242 | + |
| 243 | +While not part of the official spec, this implementation supports some parser options to make the format more ergonomic. |
| 244 | +In particular `WithFrontmatter` will strip YAML/TOML frontmatter before parsing the remaining content as spec-complient RecipeMD. |
| 245 | +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. |
| 246 | +In addition `WithGithubFormattedMarkdown` enables support for GFM features like tables, autolinks, task lists (in ingredients as well) and strikethrough. |
| 247 | + |
| 248 | +```go |
| 249 | +parser := recipemd.NewParser( |
| 250 | + recipemd.WithFrontmatter(), // strip YAML/TOML front matter |
| 251 | + recipemd.WithGithubFormattedMarkdown(), // enable GFM (tables, task lists, …) |
| 252 | +) |
| 253 | +``` |
| 254 | + |
| 255 | +### Scaling |
| 256 | + |
| 257 | +```go |
| 258 | +// Multiply all amounts by a factor. |
| 259 | +recipe.Scale(2) |
| 260 | + |
| 261 | +// Scale to a specific yield. |
| 262 | +desired, _ := recipemd.ParseAmountString("6 servings") |
| 263 | +if err := recipe.ScaleForYield(desired); err != nil { |
| 264 | + log.Fatal(err) |
| 265 | +} |
| 266 | +``` |
| 267 | + |
| 268 | +### Rendering |
| 269 | + |
| 270 | +```go |
| 271 | +// RecipeMD Markdown (amounts rounded to 2 decimal places) |
| 272 | +fmt.Print(p.RenderMarkdown(recipe, 2)) |
| 273 | + |
| 274 | +// Compact JSON |
| 275 | +data, err := p.RenderJSON(recipe) |
| 276 | + |
| 277 | +// HTML <article> element (amounts rounded to 3 decimal places) (still WIP) |
| 278 | +fmt.Println(p.RenderHTML(recipe, 3)) |
| 279 | +``` |
| 280 | + |
| 281 | +## Examples |
| 282 | + |
| 283 | +The `examples/` directory contains small, self-contained programs that |
| 284 | +demonstrate common use cases: |
| 285 | + |
| 286 | +| Example | Description | |
| 287 | +|---|---| |
| 288 | +| [`examples/parse`](examples/parse) | Parse a recipe and write compact JSON to stdout | |
| 289 | +| [`examples/scale`](examples/scale) | Scale a recipe by factor or target yield, write RecipeMD to stdout | |
| 290 | +| [`examples/flatten`](examples/flatten) | Inline all linked sub-recipes, write RecipeMD to stdout | |
| 291 | +| [`examples/renderhtml`](examples/renderhtml) | Flatten and render as an HTML `<article>` | |
| 292 | + |
| 293 | +Run any example directly: |
| 294 | + |
| 295 | +```bash |
| 296 | +go run ./examples/parse carbonara.md |
| 297 | +go run ./examples/scale carbonara.md "4 servings" |
| 298 | +go run ./examples/flatten main_dish.md |
| 299 | +go run ./examples/renderhtml carbonara.md |
| 300 | +``` |
| 301 | + |
| 302 | +## Running tests |
| 303 | + |
| 304 | +```bash |
| 305 | +go test ./... |
| 306 | +``` |
| 307 | + |
| 308 | +The suite includes the [RecipeMD canonical test suite](https://github.com/tstehr/RecipeMD/tree/master/test), |
| 309 | +extensive golden tests and unit tests. |
| 310 | + |
| 311 | +## Performance |
| 312 | + |
| 313 | +This library is quite performant compared to the reference implementation but not due to any clever optimizations. |
| 314 | +Go is simply much faster and this translates directly. |
| 315 | +In practice, the difference should be almost unnoticeable for any level of personal use. |
| 316 | +Nevertheless, here is a very crude benchmark parsing and scaling a short recipe: |
| 317 | + |
| 318 | +| Implementation | Command | Runs | Total | Avg/run | Speedup | |
| 319 | +|----------------|---------|-----:|------:|--------:|--------:| |
| 320 | +| Python recipemd 5.0.0 | `recipemd -y "10 ml" testdata/canonical/recipe.md` | 100 | 13,956 ms | 139.5 ms | 1x | |
| 321 | +| Go (examples/scale) | `scale testdata/canonical/recipe.md "10 ml"` | 100 | 434 ms | 4.3 ms | **32x** | |
| 322 | + |
| 323 | +## License |
| 324 | + |
| 325 | +MIT — see [LICENSE](LICENSE). |
0 commit comments