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 diff --git a/README.md b/README.md index e57627d..c21ca8c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,323 @@ # recipemd-go -Go implementation of RecipeMD parser. +[![Go Reference](https://pkg.go.dev/badge/github.com/xcapaldi/recipemd-go.svg)](https://pkg.go.dev/github.com/xcapaldi/recipemd-go) +![GitHub Release](https://img.shields.io/github/v/release/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. + +*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). diff --git a/canonical_test.go b/canonical_test.go new file mode 100644 index 0000000..e407cba --- /dev/null +++ b/canonical_test.go @@ -0,0 +1,65 @@ +package recipemd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCanonical(t *testing.T) { + files, err := filepath.Glob("testdata/canonical/*.md") + if err != nil { + t.Fatal(err) + } + + for _, mdFile := range files { + name := strings.TrimSuffix(filepath.Base(mdFile), ".md") + isInvalid := strings.HasSuffix(name, ".invalid") + + t.Run(name, func(t *testing.T) { + input, err := os.ReadFile(mdFile) + if err != nil { + t.Fatal(err) + } + + recipe, parseErr := NewParser().Parse(bytes.NewReader(input)) + + if isInvalid { + if parseErr == nil { + t.Errorf("expected parse error for invalid case") + } + return + } + + if parseErr != nil { + t.Fatalf("Parse error: %v", parseErr) + } + + jsonFile := strings.TrimSuffix(mdFile, ".md") + ".json" + expected, err := os.ReadFile(jsonFile) + if err != nil { + t.Fatal(err) + } + + got, err := json.MarshalIndent(recipe, "", " ") + if err != nil { + t.Fatal(err) + } + + // normalize for comparison + var expectedMap, gotMap map[string]any + json.Unmarshal(expected, &expectedMap) + json.Unmarshal(got, &gotMap) + + expectedNorm, _ := json.Marshal(expectedMap) + gotNorm, _ := json.Marshal(gotMap) + + if string(expectedNorm) != string(gotNorm) { + t.Errorf("mismatch\nexpected:\n%s\ngot:\n%s", expected, got) + } + }) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..3cc29b4 --- /dev/null +++ b/doc.go @@ -0,0 +1,52 @@ +// Package recipemd parses, scales, and renders recipes in the RecipeMD format. +// +// RecipeMD is a Markdown-based recipe format defined at https://recipemd.org. +// A recipe document begins with an H1 title, optional description paragraphs, +// optional italic tags and bold yields, a thematic break (---), and then an +// ingredient section made up of unordered lists and headings. An optional +// second thematic break separates free-form instructions. +// +// # Parsing +// +// Create a [Parser] with [NewParser] and call [Parser.Parse] to convert a +// RecipeMD document into a [Recipe]. Parse accepts any [io.Reader]: +// +// p := recipemd.NewParser() +// f, _ := os.Open("carbonara.md") +// recipe, err := p.Parse(f) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(recipe.Title) +// +// Parse accumulates all structural and value-level problems via [errors.Join], +// so a single call can surface multiple [*ParseError] values at once. +// +// Options such as [WithFrontmatter] and [WithGithubFormattedMarkdown] can be +// passed to [NewParser] to handle YAML/TOML front matter and GitHub Flavored +// Markdown extensions respectively. +// +// # Scaling +// +// Recipes can be scaled by a numeric factor with [Recipe.Scale], or by a +// desired yield amount with [Recipe.ScaleForYield]: +// +// // Double the recipe. +// recipe.Scale(2) +// +// // Scale to 8 servings. +// desired, _ := recipemd.ParseAmountString("8 servings") +// if err := recipe.ScaleForYield(desired); err != nil { +// log.Fatal(err) +// } +// +// # Rendering +// +// A [Recipe] can be rendered back to RecipeMD markdown with +// [Parser.RenderMarkdown], as compact JSON with [Parser.RenderJSON], or as an +// HTML article element with [Parser.RenderHTML]: +// +// md := p.RenderMarkdown(recipe, 2) // rounding to 2 decimal places +// data, err := p.RenderJSON(recipe) +// html := p.RenderHTML(recipe, 2) +package recipemd diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..91a8a11 --- /dev/null +++ b/errors.go @@ -0,0 +1,33 @@ +package recipemd + +import "fmt" + +// ParseError reports a single structural or value-level problem found while +// parsing a RecipeMD document. It implements the error interface. +// +// [Parser.Parse] may return multiple ParseErrors joined with [errors.Join]; +// callers can inspect individual errors with [errors.As]: +// +// var pe *recipemd.ParseError +// if errors.As(err, &pe) { +// fmt.Printf("line %d: %s\n", pe.Line, pe.Message) +// } +// +// The position fields (Offset, Line, Column) are intended to support LSP +// diagnostic reporting. +type ParseError struct { + // Message is a human-readable description of the problem. + Message string + // Offset is the zero-based byte offset of the error in the source document. + Offset int + // Line is the one-based line number of the error. + Line int + // Column is the one-based column number (in bytes) of the error. + Column int +} + +// Error implements the error interface, returning a string of the form +// "line L, col C: message". +func (e *ParseError) Error() string { + return fmt.Sprintf("line %d, col %d: %s", e.Line, e.Column, e.Message) +} diff --git a/examples/flatten/main.go b/examples/flatten/main.go new file mode 100644 index 0000000..3752a2c --- /dev/null +++ b/examples/flatten/main.go @@ -0,0 +1,43 @@ +// Flatten reads a RecipeMD file, inlines all locally-linked recipes, and +// writes the combined recipe as markdown to stdout. +// +// Usage: flatten +// +// Only local file links are resolved. HTTP(S) links are left as-is. +package main + +import ( + "fmt" + "os" + + recipemd "github.com/xcapaldi/recipemd-go" + "github.com/xcapaldi/recipemd-go/examples/helper" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: flatten ") + os.Exit(1) + } + + f, err := os.Open(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer f.Close() + + p := recipemd.NewParser() + r, err := p.Parse(f) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if err := helper.Flatten(p, r, os.Args[1]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Print(p.RenderMarkdown(r, 2)) +} diff --git a/examples/flatten/main_test.go b/examples/flatten/main_test.go new file mode 100644 index 0000000..e3f9efa --- /dev/null +++ b/examples/flatten/main_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + recipemd "github.com/xcapaldi/recipemd-go" + "github.com/xcapaldi/recipemd-go/examples/helper" +) + +// TestFlattenInlinesLinksRecursively verifies that linked ingredients are +// resolved relative to each file, not the working directory, and that chains +// of links (main -> sauce -> subdir/stock) are fully inlined. +func TestFlattenInlinesLinksRecursively(t *testing.T) { + recipeFile, err := filepath.Abs("../../testdata/flatten/main.md") + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(recipeFile) + if err != nil { + t.Fatal(err) + } + + p := recipemd.NewParser() + r, err := p.Parse(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + + if err := helper.Flatten(p, r, recipeFile); err != nil { + t.Fatalf("flatten: %v", err) + } + + want := []string{"pasta", "olive oil", "water", "bouillon cube"} + got := make([]string, len(r.Ingredients)) + for i, ing := range r.Ingredients { + got[i] = ing.Name + } + if len(got) != len(want) { + t.Fatalf("ingredients = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("ingredient[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestFlatten(t *testing.T) { + t.Parallel() + + t.Run("no links unchanged", func(t *testing.T) { + t.Parallel() + p := recipemd.NewParser() + r := &recipemd.Recipe{ + Ingredients: []recipemd.Ingredient{{Name: "salt"}}, + IngredientGroups: []recipemd.IngredientGroup{}, + } + if err := helper.Flatten(p, r, "/fake/recipe.md"); err != nil { + t.Fatal(err) + } + if len(r.Ingredients) != 1 || r.Ingredients[0].Name != "salt" { + t.Errorf("unexpected change: %+v", r.Ingredients) + } + }) + + t.Run("remote link preserved", func(t *testing.T) { + t.Parallel() + p := recipemd.NewParser() + r := &recipemd.Recipe{ + Ingredients: []recipemd.Ingredient{{Name: "sauce", Link: new("https://example.com/sauce.md")}}, + IngredientGroups: []recipemd.IngredientGroup{}, + } + if err := helper.Flatten(p, r, "/fake/recipe.md"); err != nil { + t.Fatal(err) + } + if r.Ingredients[0].Link == nil { + t.Error("remote link should be preserved") + } + }) + + t.Run("local link resolved", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + linked := "# Sauce\n\n---\n\n- *1 cup* tomato\n- basil\n" + if err := os.WriteFile(filepath.Join(dir, "sauce.md"), []byte(linked), 0644); err != nil { + t.Fatal(err) + } + main := filepath.Join(dir, "main.md") + + p := recipemd.NewParser() + r := &recipemd.Recipe{ + Ingredients: []recipemd.Ingredient{{Name: "sauce", Link: new("sauce.md"), Amount: &recipemd.Amount{Factor: 2, Unit: new("cups")}}}, + IngredientGroups: []recipemd.IngredientGroup{}, + } + if err := helper.Flatten(p, r, main); err != nil { + t.Fatal(err) + } + if len(r.Ingredients) < 1 { + t.Fatal("expected inlined ingredients") + } + }) + + t.Run("missing file error", func(t *testing.T) { + t.Parallel() + p := recipemd.NewParser() + r := &recipemd.Recipe{ + Ingredients: []recipemd.Ingredient{{Name: "x", Link: new("nonexistent.md")}}, + IngredientGroups: []recipemd.IngredientGroup{}, + } + if err := helper.Flatten(p, r, "/fake/recipe.md"); err == nil { + t.Fatal("expected error for missing linked file") + } + }) +} + +// TestFlattenHTTPLinksPreserved verifies that HTTP(S) links are left as-is. +func TestFlattenHTTPLinksPreserved(t *testing.T) { + input := []byte("# Recipe\n\n---\n\n- [sauce](https://example.org/sauce.md)\n") + + dir := t.TempDir() + recipeFile := filepath.Join(dir, "recipe.md") + if err := os.WriteFile(recipeFile, input, 0644); err != nil { + t.Fatal(err) + } + + p := recipemd.NewParser() + r, err := p.Parse(bytes.NewReader(input)) + if err != nil { + t.Fatal(err) + } + + if err := helper.Flatten(p, r, recipeFile); err != nil { + t.Fatalf("flatten: %v", err) + } + + if len(r.Ingredients) != 1 { + t.Fatalf("got %d ingredients, want 1", len(r.Ingredients)) + } + if r.Ingredients[0].Link == nil { + t.Error("link ingredient should be preserved with its link") + } +} diff --git a/examples/helper/flatten.go b/examples/helper/flatten.go new file mode 100644 index 0000000..18213c0 --- /dev/null +++ b/examples/helper/flatten.go @@ -0,0 +1,128 @@ +package helper + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + recipemd "github.com/xcapaldi/recipemd-go" +) + +// Flatten resolves all locally-linked ingredients in r, inlining them in place. +// recipeFile is the path to the recipe file, used to resolve relative links. +// HTTP(S) links are left as-is. +func Flatten(p *recipemd.Parser, r *recipemd.Recipe, recipeFile string) error { + baseDir := filepath.Dir(recipeFile) + ingredients, err := flattenIngredients(p, r.Ingredients, baseDir) + if err != nil { + return fmt.Errorf("flattenIngredients: %w", err) + } + r.Ingredients = ingredients + groups, err := flattenIngredientGroups(p, r.IngredientGroups, baseDir) + if err != nil { + return fmt.Errorf("flattenIngredientGroups: %w", err) + } + r.IngredientGroups = groups + return nil +} + +func flattenIngredients(p *recipemd.Parser, ingredients []recipemd.Ingredient, baseDir string) ([]recipemd.Ingredient, error) { + result := make([]recipemd.Ingredient, 0, len(ingredients)) + for _, ing := range ingredients { + if ing.Link != nil { + resolved, err := resolveLinkedRecipe(p, *ing.Link, baseDir, &ing) + if err != nil { + return nil, fmt.Errorf("resolveLinkedRecipe: %w", err) + } + result = append(result, resolved...) + } else { + result = append(result, ing) + } + } + return result, nil +} + +func flattenIngredientGroups(p *recipemd.Parser, groups []recipemd.IngredientGroup, baseDir string) ([]recipemd.IngredientGroup, error) { + result := make([]recipemd.IngredientGroup, 0, len(groups)) + for _, g := range groups { + ingredients, err := flattenIngredients(p, g.Ingredients, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + subGroups, err := flattenIngredientGroups(p, g.IngredientGroups, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredientGroups: %w", err) + } + result = append(result, recipemd.IngredientGroup{ + Title: g.Title, + Ingredients: ingredients, + IngredientGroups: subGroups, + }) + } + return result, nil +} + +func resolveLinkedRecipe(p *recipemd.Parser, link string, baseDir string, parent *recipemd.Ingredient) ([]recipemd.Ingredient, error) { + if strings.Contains(link, "://") { + return []recipemd.Ingredient{*parent}, nil + } + + path := filepath.Join(baseDir, link) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("os.ReadFile: %w", err) + } + + linked, err := p.Parse(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("Parse: %w", err) + } + + if parent.Amount != nil && len(linked.Yields) > 0 { + if err := linked.ScaleForYield(*parent.Amount); err != nil { + return nil, fmt.Errorf("linked.ScaleForYield: %w", err) + } + } + + linkedDir := filepath.Dir(path) + flatIngredients, err := flattenIngredients(p, linked.Ingredients, linkedDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + for _, g := range linked.IngredientGroups { + ingredients, err := flattenIngredients(p, g.Ingredients, linkedDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + flatIngredients = append(flatIngredients, ingredients...) + groupIngredients, err := flattenGroupIngredients(p, g.IngredientGroups, linkedDir) + if err != nil { + return nil, fmt.Errorf("flattenGroupIngredients: %w", err) + } + flatIngredients = append(flatIngredients, groupIngredients...) + } + + if len(flatIngredients) == 0 { + return []recipemd.Ingredient{*parent}, nil + } + return flatIngredients, nil +} + +func flattenGroupIngredients(p *recipemd.Parser, groups []recipemd.IngredientGroup, baseDir string) ([]recipemd.Ingredient, error) { + result := make([]recipemd.Ingredient, 0, len(groups)) + for _, g := range groups { + ingredients, err := flattenIngredients(p, g.Ingredients, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + result = append(result, ingredients...) + groupIngredients, err := flattenGroupIngredients(p, g.IngredientGroups, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenGroupIngredients: %w", err) + } + result = append(result, groupIngredients...) + } + return result, nil +} diff --git a/examples/parse/main.go b/examples/parse/main.go new file mode 100644 index 0000000..d63cb9f --- /dev/null +++ b/examples/parse/main.go @@ -0,0 +1,45 @@ +// Parse reads a RecipeMD file and writes compact JSON to stdout. +// +// Usage: parse +// +// The output can be piped to jq for further processing: +// +// parse recipe.md | jq .title +// parse recipe.md | jq -r '.tags[]' +// parse recipe.md | jq -r '[.. | .ingredients[]? | .name] | unique[]' +package main + +import ( + "fmt" + "os" + + recipemd "github.com/xcapaldi/recipemd-go" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: parse ") + os.Exit(1) + } + + f, err := os.Open(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer f.Close() + + p := recipemd.NewParser() + r, err := p.Parse(f) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + out, err := p.RenderJSON(r) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Stdout.Write(append(out, '\n')) +} diff --git a/examples/parse/main_test.go b/examples/parse/main_test.go new file mode 100644 index 0000000..501f9d4 --- /dev/null +++ b/examples/parse/main_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + recipemd "github.com/xcapaldi/recipemd-go" +) + +func TestParseProducesValidJSON(t *testing.T) { + data, err := os.ReadFile("../../testdata/golden/ing_simple.md") + if err != nil { + t.Fatal(err) + } + + p := recipemd.NewParser() + r, err := p.Parse(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + out, err := p.RenderJSON(r) + if err != nil { + t.Fatalf("RenderJSON: %v", err) + } + + var v map[string]any + if err := json.Unmarshal(out, &v); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out) + } + if v["title"] != "Recipe" { + t.Errorf("title = %q, want %q", v["title"], "Recipe") + } +} + +func TestParseInvalidRecipeFails(t *testing.T) { + p := recipemd.NewParser() + _, err := p.Parse(bytes.NewReader([]byte("no heading here"))) + if err == nil { + t.Error("expected parse error for invalid recipe") + } +} diff --git a/examples/renderhtml/main.go b/examples/renderhtml/main.go new file mode 100644 index 0000000..41f9426 --- /dev/null +++ b/examples/renderhtml/main.go @@ -0,0 +1,46 @@ +// Renderhtml reads a RecipeMD file, flattens linked ingredients, and writes +// an HTML
element to stdout. +// +// Usage: renderhtml +// +// Linked ingredients are resolved and inlined before rendering. Only local +// file links are resolved; HTTP(S) links are left as-is. +// +// renderhtml recipe.md > recipe.html +package main + +import ( + "fmt" + "os" + + recipemd "github.com/xcapaldi/recipemd-go" + "github.com/xcapaldi/recipemd-go/examples/helper" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: renderhtml ") + os.Exit(1) + } + + f, err := os.Open(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer f.Close() + + p := recipemd.NewParser() + r, err := p.Parse(f) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if err := helper.Flatten(p, r, os.Args[1]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Println(p.RenderHTML(r, 3)) +} diff --git a/examples/renderhtml/main_test.go b/examples/renderhtml/main_test.go new file mode 100644 index 0000000..b6534d4 --- /dev/null +++ b/examples/renderhtml/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "bytes" + "os" + "strings" + "testing" + + recipemd "github.com/xcapaldi/recipemd-go" +) + +func TestRenderHTMLProducesArticle(t *testing.T) { + data, err := os.ReadFile("../../testdata/golden/ing_simple.md") + if err != nil { + t.Fatal(err) + } + + p := recipemd.NewParser() + r, err := p.Parse(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + got := p.RenderHTML(r, 3) + + if !strings.Contains(got, `class="recipemd-recipe"`) { + t.Error("missing recipemd-recipe article element") + } + if !strings.Contains(got, "Recipe") { + t.Errorf("missing title in output:\n%s", got) + } + if !strings.Contains(got, "salt") { + t.Errorf("missing ingredient in output:\n%s", got) + } +} + +func TestRenderHTMLInvalidRecipeFails(t *testing.T) { + p := recipemd.NewParser() + _, err := p.Parse(bytes.NewReader([]byte("no heading here"))) + if err == nil { + t.Error("expected parse error for invalid recipe") + } +} diff --git a/examples/scale/main.go b/examples/scale/main.go new file mode 100644 index 0000000..31eb506 --- /dev/null +++ b/examples/scale/main.go @@ -0,0 +1,58 @@ +// Scale reads a RecipeMD file, scales it by the given amount, and writes +// markdown to stdout. +// +// Usage: scale +// +// is either a bare number to multiply all ingredients by that factor, +// or a quantity with a unit to scale for a specific yield: +// +// scale recipe.md 2 # double the recipe +// scale recipe.md 0.5 # halve the recipe +// scale recipe.md "6 servings" # scale to 6 servings +package main + +import ( + "fmt" + "os" + "strings" + + recipemd "github.com/xcapaldi/recipemd-go" +) + +func main() { + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: scale ") + os.Exit(1) + } + + f, err := os.Open(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer f.Close() + + amount, err := recipemd.ParseAmountString(strings.Join(os.Args[2:], " ")) + if err != nil || amount.Factor == 0 { + fmt.Fprintln(os.Stderr, "invalid amount: must be a number or a quantity with a unit (e.g. \"6 servings\")") + os.Exit(1) + } + + p := recipemd.NewParser() + r, err := p.Parse(f) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if amount.Unit != nil { + if err := r.ScaleForYield(amount); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } else { + r.Scale(amount.Factor) + } + + fmt.Print(p.RenderMarkdown(r, 2)) +} diff --git a/examples/scale/main_test.go b/examples/scale/main_test.go new file mode 100644 index 0000000..3e1ab2b --- /dev/null +++ b/examples/scale/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "bytes" + "os" + "strings" + "testing" + + recipemd "github.com/xcapaldi/recipemd-go" +) + +func TestScaleByFactor(t *testing.T) { + data, err := os.ReadFile("../../testdata/golden/amount_with_unit.md") + if err != nil { + t.Fatal(err) + } + + p := recipemd.NewParser() + r, err := p.Parse(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + + r.Scale(2) + + out := p.RenderMarkdown(r, 2) + if !strings.Contains(out, "2 cup") { + t.Errorf("expected scaled amount \"2 cup\" in output:\n%s", out) + } +} + +func TestScaleForYield(t *testing.T) { + data, err := os.ReadFile("../../testdata/golden/yields_single.md") + if err != nil { + t.Fatal(err) + } + + p := recipemd.NewParser() + r, err := p.Parse(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + + amount, err := recipemd.ParseAmountString("8 servings") + if err != nil || amount.Factor == 0 { + t.Fatal("failed to parse amount") + } + + if err := r.ScaleForYield(amount); err != nil { + t.Fatalf("ScaleForYield: %v", err) + } + + if len(r.Yields) == 0 { + t.Fatal("no yields after scaling") + } + if r.Yields[0].Factor != 8 { + t.Errorf("yield factor = %v, want 8", r.Yields[0].Factor) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e9a540c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/xcapaldi/recipemd-go + +go 1.26.0 + +require github.com/yuin/goldmark v1.7.16 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..63cb70d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= diff --git a/golden_test.go b/golden_test.go new file mode 100644 index 0000000..f124f9d --- /dev/null +++ b/golden_test.go @@ -0,0 +1,80 @@ +package recipemd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGolden(t *testing.T) { + suites := []struct { + name string + dir string + opts []Option + }{ + {"default", "testdata/golden", nil}, + {"gfm", "testdata/golden/gfm", []Option{WithGithubFormattedMarkdown()}}, + {"frontmatter", "testdata/golden/frontmatter", []Option{WithFrontmatter()}}, + } + + for _, suite := range suites { + t.Run(suite.name, func(t *testing.T) { + files, err := filepath.Glob(filepath.Join(suite.dir, "*.md")) + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatalf("no test files in %s", suite.dir) + } + + for _, mdFile := range files { + name := strings.TrimSuffix(filepath.Base(mdFile), ".md") + isInvalid := strings.HasSuffix(name, ".invalid") + + t.Run(name, func(t *testing.T) { + input, err := os.ReadFile(mdFile) + if err != nil { + t.Fatal(err) + } + + recipe, parseErr := NewParser(suite.opts...).Parse(bytes.NewReader(input)) + + if isInvalid { + if parseErr == nil { + t.Errorf("expected parse error for invalid case") + } + return + } + + if parseErr != nil { + t.Fatalf("Parse error: %v", parseErr) + } + + got, err := json.MarshalIndent(recipe, "", " ") + if err != nil { + t.Fatal(err) + } + jsonFile := strings.TrimSuffix(mdFile, ".md") + ".json" + expected, err := os.ReadFile(jsonFile) + if err != nil { + t.Fatal(err) + } + + var expectedMap, gotMap map[string]any + json.Unmarshal(expected, &expectedMap) + json.Unmarshal(got, &gotMap) + + expectedNorm, _ := json.Marshal(expectedMap) + gotNorm, _ := json.Marshal(gotMap) + + if string(expectedNorm) != string(gotNorm) { + t.Errorf("mismatch\nexpected:\n%s\ngot:\n%s", expected, got) + } + }) + } + }) + } +} diff --git a/inline_ingredients.go b/inline_ingredients.go new file mode 100644 index 0000000..aeddc5e --- /dev/null +++ b/inline_ingredients.go @@ -0,0 +1,451 @@ +package recipemd + +import ( + "html" + "regexp" + "sort" + "strings" + "unicode" +) + +// 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. +// +// 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. +// +// 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. +// +// - [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...) + } +} + +// 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() + + lookup := make(map[string]*inlineEntry, len(ingredients)*2) + var alts []string + + for _, ing := range ingredients { + if ing.Amount == nil { + continue + } + + 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(alts) == 0 { + return nil + } + + // 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]) }) + + pattern := `(?i)\b(?:` + strings.Join(alts, "|") + `)\b` + re := regexp.MustCompile(pattern) + + 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) + }) +} + +// 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 +} + +func isConsonant(r rune) bool { + return !strings.ContainsRune("aeiou", unicode.ToLower(r)) +} diff --git a/inline_ingredients_test.go b/inline_ingredients_test.go new file mode 100644 index 0000000..2bf1611 --- /dev/null +++ b/inline_ingredients_test.go @@ -0,0 +1,465 @@ +package recipemd + +import ( + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// wordForms (pluralization) +// --------------------------------------------------------------------------- + +func TestWordForms(t *testing.T) { + t.Parallel() + + 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{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "berry", Amount: &Amount{Factor: 200, Unit: new("g")}}}, + IngredientGroups: []IngredientGroup{}, + } + md := p.RenderMarkdown(r, 3) + if !strings.Contains(md, "200 g berries") { + t.Errorf("expected plural match in: %q", md) + } + }) + + t.Run("word boundary not partial", func(t *testing.T) { + t.Parallel() + instructions := "use salted butter" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "salt", Amount: &Amount{Factor: 1, Unit: new("tsp")}}}, + IngredientGroups: []IngredientGroup{}, + } + md := p.RenderMarkdown(r, 3) + if strings.Contains(md, "1 tsp") { + t.Errorf("'salt' should not match inside 'salted': %q", md) + } + }) + + t.Run("case-insensitive preserves original case", func(t *testing.T) { + t.Parallel() + 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{}, + } + 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("comma separator before format", func(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsBefore), + WithInlinePrepSeparators(","), + )) + instructions := "mince garlic and add to pan" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "garlic, minced", Amount: &Amount{Factor: 3, Unit: new("cloves")}}}, + IngredientGroups: []IngredientGroup{}, + } + 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("paren separator after format", func(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsAfter), + WithInlinePrepSeparators("("), + )) + instructions := "slice garlic thin and cook" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "garlic (sliced)", Amount: &Amount{Factor: 4, Unit: new("cloves")}}}, + IngredientGroups: []IngredientGroup{}, + } + 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("multi-word base with prep", func(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsBefore), + WithInlinePrepSeparators(","), + )) + instructions := "add brown sugar and mix" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "brown sugar, packed", Amount: &Amount{Factor: 1, Unit: new("cup")}}}, + IngredientGroups: []IngredientGroup{}, + } + 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) + } + }) +} + +// --------------------------------------------------------------------------- +// 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{}, + }, + { + Title: "Topping", + Ingredients: []Ingredient{ + {Name: "blueberry", Amount: &Amount{Factor: 100, Unit: new("g")}}, + }, + IngredientGroups: []IngredientGroup{}, + }, + }, + } + 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) + } +} + +// --------------------------------------------------------------------------- +// 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 new file mode 100644 index 0000000..e82486c --- /dev/null +++ b/parser.go @@ -0,0 +1,1135 @@ +package recipemd + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/url" + "strconv" + "strings" + "unicode" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + east "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" +) + +// Option is a functional option for configuring a [Parser]. +// Options are passed to [NewParser] and applied before the first parse. +type Option func(*Parser) + +// WithFrontmatter returns an [Option] that instructs the parser to strip +// YAML (---) or TOML (+++) front matter from the source before parsing. +// +// Front matter is identified by a fence of three dashes or plus signs on its +// own line at the very start of the document. The content up to and including +// the closing fence is removed before the RecipeMD document is parsed. +func WithFrontmatter() Option { return func(p *Parser) { p.Frontmatter = true } } + +// WithGithubFormattedMarkdown returns an [Option] that enables GitHub Flavored +// Markdown (GFM) extensions in the underlying markdown processor. +// +// This adds support for tables, strikethrough, autolinks, task lists, and +// other GFM features. Task-list checkboxes in ingredient items are +// transparently skipped so that ingredient parsing is unaffected. +func WithGithubFormattedMarkdown() Option { + return func(p *Parser) { + p.goldmarkExtensions = append(p.goldmarkExtensions, extension.GFM) + p.hasTaskList = true + } +} + +// Parser parses RecipeMD documents and renders [Recipe] values back to +// markdown or JSON. +// +// Create a Parser with [NewParser]. A single Parser instance is safe to reuse +// across multiple calls to [Parser.Parse] and the render methods. +// +// The exported Frontmatter field reflects whether the [WithFrontmatter] option +// was supplied at construction time. It should not be modified after the +// Parser is created. +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 + inlineIngredients bool + inlineIngredientsCfg inlineIngredientsConfig +} + +// NewParser creates a new Parser, applying any supplied options. +// +// Available options are [WithFrontmatter] and [WithGithubFormattedMarkdown]. +// If no options are provided a plain CommonMark parser is used. +// +// p := recipemd.NewParser(recipemd.WithFrontmatter()) +func NewParser(opts ...Option) (p *Parser) { + p = &Parser{} + for _, o := range opts { + o(p) + } + + if len(p.goldmarkExtensions) > 0 { + p.goldmarkProcessor = goldmark.New(goldmark.WithExtensions(p.goldmarkExtensions...)) + return + } + + p.goldmarkProcessor = goldmark.New() + return +} + +// Parse converts a RecipeMD document into a [Recipe]. +// +// r is an [io.Reader] providing a UTF-8-encoded RecipeMD document. The +// document structure that Parse expects is: +// +// 1. An H1 heading containing the recipe title (required). +// 2. An optional preamble: description paragraphs, an italic tags paragraph, +// and/or a bold yields paragraph, in any order. +// 3. A thematic break (---) separating the preamble from the ingredients. +// 4. An ingredient section: unordered lists of ingredients and optional +// sub-headings that introduce named [IngredientGroup] sections. +// 5. An optional second thematic break followed by free-form instructions. +// +// Parse collects all structural and value-level errors via [errors.Join], +// returning a non-nil error that may wrap one or more [*ParseError] values. +// Non-fatal errors are accumulated rather than halting the parse, so all +// problems are reported at once. A nil error means the document was valid. +// +// See https://recipemd.org/specification.html#parsing-a-recipe for the full +// specification. +func (p *Parser) Parse(r io.Reader) (*Recipe, error) { + source, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("io.ReadAll: %w", err) + } + + if p.Frontmatter { + source = stripFrontmatter(source) + } + + document := p.goldmarkProcessor.Parser().Parse(text.NewReader(source)) + + recipe := &Recipe{ + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{}, + IngredientGroups: []IngredientGroup{}, + } + + var errs error + + c := document.FirstChild() + if c == nil { + return nil, newParseError(source, 0, "recipe must have a title") + } + + // --- Preamble: title --- + h, ok := c.(*ast.Heading) + if !ok { + return nil, newParseError(source, nodeStartOffset(c), fmt.Sprintf("expected level 1 heading, got %T", c)) + } + if h.Level != 1 { + errs = errors.Join(errs, newParseError(source, nodeStartOffset(h), fmt.Sprintf("expected level 1 heading, got level %d", h.Level))) + } + title, err := extractPlainText(h, source) + if err != nil { + return nil, newParseError(source, nodeStartOffset(h), err.Error()) + } + recipe.Title = title + + _, titleLineEnd := getDirectLineBounds(h) + descStart := skipSetextUnderline(source, titleLineEnd) + c = c.NextSibling() + + // --- Preamble: description, tags, yields --- + var excludeRanges [][2]int + tagsFound, yieldsFound, tagsYieldsMode := false, false, false + + lastPreBreakEnd := descStart + for c != nil && c.Kind() != ast.KindThematicBreak { + para, isPara := c.(*ast.Paragraph) + if isPara { + if em, ok := isOnlyEmphasis(para, italic); ok { + if tagsFound { + errs = errors.Join(errs, newParseError(source, nodeStartOffset(c), "tags already set")) + c = c.NextSibling() + continue + } + tagsText, err := extractPlainText(em, source) + if err != nil { + return nil, newParseError(source, nodeStartOffset(c), err.Error()) + } + recipe.Tags = parseTags(tagsText) + tagsFound = true + tagsYieldsMode = true + if start, end := getDirectLineBounds(c); start >= 0 { + excludeRanges = append(excludeRanges, [2]int{start, end}) + } + c = c.NextSibling() + continue + } + if em, ok := isOnlyEmphasis(para, bold); ok { + if yieldsFound { + errs = errors.Join(errs, newParseError(source, nodeStartOffset(c), "yields already set")) + c = c.NextSibling() + continue + } + yieldsText, err := extractPlainText(em, source) + if err != nil { + return nil, newParseError(source, nodeStartOffset(c), err.Error()) + } + yields, yieldErrs := parseYields(yieldsText) + if yieldErrs != nil { + errs = errors.Join(errs, newParseError(source, nodeStartOffset(c), yieldErrs.Error())) + } + recipe.Yields = yields + yieldsFound = true + tagsYieldsMode = true + if start, end := getDirectLineBounds(c); start >= 0 { + excludeRanges = append(excludeRanges, [2]int{start, end}) + } + c = c.NextSibling() + continue + } + if tagsYieldsMode { + errs = errors.Join(errs, newParseError(source, nodeStartOffset(c), "unexpected content in tags/yields section")) + c = c.NextSibling() + continue + } + } + if _, end := getRecursiveSourceBounds(c, source); end > lastPreBreakEnd { + lastPreBreakEnd = end + } + c = c.NextSibling() + } + + // --- First thematic break --- + if c == nil || c.Kind() != ast.KindThematicBreak { + return nil, newParseError(source, lastPreBreakEnd, "missing thematic break divider") + } + firstBreakPos := findDashLine(source, lastPreBreakEnd) + + // Build description: source from after title to first break, minus tags/yields. + if firstBreakPos > descStart { + desc := excludeRangesFromSource(source[descStart:firstBreakPos], excludeRanges, descStart) + desc = strings.Trim(desc, "\n") + if desc != "" { + recipe.Description = &desc + } + } + + c = c.NextSibling() + + // --- 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")) + c = c.NextSibling() + continue + } + break + } + var listErrs, groupErrs error + c, listErrs = parseIngredientList(c, source, &recipe.Ingredients, p.hasTaskList) + c, groupErrs = parseIngredientGroup(c, source, &recipe.IngredientGroups, 0, p.hasTaskList) + errs = errors.Join(errs, listErrs, groupErrs) + + // --- Second thematic break (optional) → instructions --- + if c != nil && c.Kind() == ast.KindThematicBreak { + breakPos := findDashLine(source, firstBreakPos+1) + breakEnd := skipLine(source, breakPos) + instructions := strings.Trim(string(source[breakEnd:]), "\n") + if instructions != "" { + recipe.Instructions = &instructions + } + } + + if errs != nil { + return nil, errs + } + return recipe, nil +} + +// findDashLine finds the byte offset of the first line of 3+ dashes at or +// after minPos, aligning to line boundaries. +func findDashLine(source []byte, minPos int) int { + pos := minPos + if pos > 0 && pos < len(source) && source[pos-1] != '\n' { + for pos < len(source) && source[pos] != '\n' { + pos++ + } + if pos < len(source) { + pos++ + } + } + for pos < len(source) { + lineStart := pos + lineEnd := lineStart + for lineEnd < len(source) && source[lineEnd] != '\n' { + lineEnd++ + } + line := bytes.TrimSpace(source[lineStart:lineEnd]) + if len(line) >= 3 && len(bytes.Trim(line, "-")) == 0 { + return lineStart + } + pos = lineEnd + 1 + } + return -1 +} + +// skipLine returns the byte offset just past the newline at pos. +func skipLine(source []byte, pos int) int { + if pos < 0 { + return len(source) + } + for pos < len(source) && source[pos] != '\n' { + pos++ + } + if pos < len(source) { + pos++ + } + return pos +} + +// parseIngredientGroup parses headings and lists in the ingredient section. +// It accepts a block c, modifies groups to append ingredient groups found, +// and returns the current block. +// See: https://recipemd.org/specification.html#parsing-ingredient-groups +func parseIngredientGroup( + c ast.Node, + source []byte, + groups *[]IngredientGroup, + parentLevel int, + skipCheckbox bool, +) (ast.Node, error) { + var errs error + for { + h, ok := c.(*ast.Heading) + if !ok { + return c, errs + } + l := h.Level + if l <= parentLevel { + return c, errs + } + title, err := extractPlainText(h, source) + if err != nil { + return nil, errors.Join(errs, newParseError(source, nodeStartOffset(h), err.Error())) + } + g := IngredientGroup{ + Title: title, + Ingredients: []Ingredient{}, + IngredientGroups: []IngredientGroup{}, + } + c = c.NextSibling() + var listErrs, groupErrs error + if c != nil { + c, listErrs = parseIngredientList(c, source, &g.Ingredients, skipCheckbox) + c, groupErrs = parseIngredientGroup(c, source, &g.IngredientGroups, l, skipCheckbox) + } + errs = errors.Join(errs, listErrs, groupErrs) + *groups = append(*groups, g) + if c == nil { + return nil, errs + } + } +} + +// parseIngredientList parses a list of ingredients. +// It accepts a block c, modifies ingredients to append ingredients found, +// and returns the current block. +// See: https://recipemd.org/specification.html#parsing-an-ingredient-list +func parseIngredientList( + c ast.Node, + source []byte, + ingredients *[]Ingredient, + skipCheckbox bool, +) (ast.Node, error) { + var errs error + for { + // 1. Examine c + list, ok := c.(*ast.List) + if !ok { + return c, errs + } + // Enter c + c = list.FirstChild() + if c == nil { + c = list.NextSibling() + continue + } + + // 2. Collect ingredients + for { + ing, err := parseIngredient(c, source, skipCheckbox) + if err != nil { + offset, _ := getRecursiveSourceBounds(c, source) + if offset < 0 { + offset = 0 + } + errs = errors.Join(errs, newParseError(source, offset, err.Error())) + } else { + *ingredients = append(*ingredients, ing) + } + if c.NextSibling() != nil { + c = c.NextSibling() + } else { + c = list.NextSibling() + break + } + } + } +} + +// parseIngredient parses a block c into an ingredient. +// See: https://recipemd.org/specification.html#parsing-an-ingredient +func parseIngredient(c ast.Node, source []byte, skipCheckbox bool) (Ingredient, error) { + // 1. Examine c: If c is a list item, enter c + li, ok := c.(*ast.ListItem) + if !ok { + return Ingredient{}, fmt.Errorf("expected list item") + } + c = li.FirstChild() + + // 2. Let a be the amount, set to unset + var a *Amount + // 3. Let n be the name, set to empty string + n := "" + // 4. Let l be a link, set to unset + var l *string + + if c == nil { + return Ingredient{}, fmt.Errorf("ingredient must have a name") + } + + // 5. Examine c + // Note: goldmark uses TextBlock for tight lists, Paragraph for loose lists + var firstInline ast.Node + if para, ok := c.(*ast.Paragraph); ok { + firstInline = para.FirstChild() + } else if tb, ok := c.(*ast.TextBlock); ok { + firstInline = tb.FirstChild() + } + if skipCheckbox && firstInline != nil && firstInline.Kind() == east.KindTaskCheckBox { + firstInline = firstInline.NextSibling() + // skip whitespace text node after checkbox + if t, ok := firstInline.(*ast.Text); ok && strings.TrimSpace(string(t.Value(source))) == "" { + firstInline = firstInline.NextSibling() + } + } + if firstInline == nil { + // If c is not a paragraph, set n to verbatim contents of c + n = extractRawMarkdown(c, source) + } else { + // Parse the amount + var r string + afterAmount := firstInline + + if em, ok := firstInline.(*ast.Emphasis); ok && em.Level == 1 { + // If c's contents start with an emphasis inline + emText, err := extractPlainText(em, source) + if err != nil { + return Ingredient{}, err + } + amt, err := parseAmount(emText) + if err != nil { + return Ingredient{}, err + } + a = &amt + // Let r be the remaining contents of c after the emphasis + afterAmount = em.NextSibling() + r = extractInlineSequenceText(afterAmount, source) + } else { + // Let r be the verbatim contents of c + r = extractRawMarkdown(c, source) + } + + // Parse the link + isOnlyChild := c.NextSibling() == nil + link := findSingleLink(afterAmount, source) + + if isOnlyChild && link != nil { + dest := encodeURLPath(link.destination) + l = &dest + n = link.text + } else { + n = r + } + } + + // 6. Parse the following blocks of the list item + prevBlock := c + for c.NextSibling() != nil { + c = c.NextSibling() + // Append c's verbatim contents to n, preserving blank lines + sep := getBlockSeparator(prevBlock, c, source) + n += sep + extractRawMarkdown(c, source) + prevBlock = c + } + + // 7. Leave c (implicit) + + // 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") + } + + return Ingredient{Amount: a, Name: n, Link: l}, nil +} + +// extractInlineSequenceText extracts text from a sequence of sibling inline nodes, +// preserving some markdown syntax (emphasis markers, link syntax). +func extractInlineSequenceText(start ast.Node, source []byte) string { + var parts []string + for n := start; n != nil; n = n.NextSibling() { + parts = append(parts, convertInlineNodeToText(n, source)) + } + return strings.TrimSpace(strings.Join(parts, "")) +} + +// convertInlineNodeToText converts a single inline node to text, +// preserving markdown syntax for emphasis and links. +func convertInlineNodeToText(n ast.Node, source []byte) string { + if t, ok := n.(*ast.Text); ok { + return string(t.Value(source)) + } + if al, ok := n.(*ast.AutoLink); ok { + return string(al.URL(source)) + } + text, _ := extractPlainText(n, source) + if n.Kind() == ast.KindEmphasis { + return "*" + text + "*" + } + if link, ok := n.(*ast.Link); ok { + return "[" + text + "](" + string(link.Destination) + ")" + } + return text +} + +const listItemContinuationIndent = " " + +// getBlockSeparator returns the whitespace between two blocks, preserving blank lines +func getBlockSeparator(prev, curr ast.Node, source []byte) string { + prevLines := prev.Lines() + currLines := curr.Lines() + if prevLines.Len() == 0 || currLines.Len() == 0 { + return "\n" + } + prevEnd := prevLines.At(prevLines.Len() - 1).Stop + currStart := currLines.At(0).Start + between := source[prevEnd:currStart] + // Check for blank line (more than one newline) + newlineCount := bytes.Count(between, []byte{'\n'}) + if newlineCount <= 1 { + return "\n" + } + // Extract blank line content (everything between first and last newline). + // Trailing 2-space indent matches markdown list item continuation. + if _, rest, ok := bytes.Cut(between, []byte{'\n'}); ok { + if blankContent, _, ok := bytes.Cut(rest, []byte{'\n'}); ok { + return "\n" + string(blankContent) + "\n" + listItemContinuationIndent + } + } + return "\n\n" + listItemContinuationIndent +} + +type linkInfo struct { + destination string + text string +} + +// findSingleLink checks if nodes from start consist only of whitespace and a +// single link (explicit or autolink). Returns nil if no link or multiple links. +func findSingleLink(start ast.Node, source []byte) *linkInfo { + var found *linkInfo + for n := start; n != nil; n = n.NextSibling() { + if l, ok := n.(*ast.Link); ok { + if found != nil { + return nil + } + text, _ := extractPlainText(l, source) + found = &linkInfo{destination: string(l.Destination), text: text} + } else if al, ok := n.(*ast.AutoLink); ok { + if found != nil { + return nil + } + url := string(al.URL(source)) + found = &linkInfo{destination: url, text: url} + } else if t, ok := n.(*ast.Text); ok { + if strings.TrimSpace(string(t.Value(source))) != "" { + return nil + } + } else { + return nil + } + } + return found +} + +// ParseAmountString parses a human-readable amount string into an [Amount]. +// +// The following number formats are recognised (case-insensitive): +// - Mixed number: "1 1/2" or "1 ½" +// - Proper fraction: "1/2" +// - Vulgar fraction: "½", "¾", etc. (Unicode fraction characters) +// - Decimal: "1.5" or "1,5" +// - Integer: "3" +// +// An optional sign (- for negative) may precede the number. Any non-numeric +// text following the number is interpreted as the unit (e.g. "1.5 cups" → +// Factor=1.5, Unit="cups"). +// +// ParseAmountString returns an error if a unit is present without a numeric +// value, or if the input cannot be parsed as any recognised format. +func ParseAmountString(s string) (Amount, error) { + return parseAmount(s) +} + +// parseAmount parses an amount string into value and unit. +// See: https://recipemd.org/specification.html#parsing-an-amount +func parseAmount(s string) (Amount, error) { + // 1. Trim whitespace at beginning + s = strings.TrimLeftFunc(s, unicode.IsSpace) + + // 2. Check for negative + negative := false + if strings.HasPrefix(s, "-") { + negative = true + s = s[1:] + s = strings.TrimLeftFunc(s, unicode.IsSpace) + } + + // 3. Let v be a number, set to unset + var v *float64 + var remaining string + + // Try improper fraction: a w+ b w* / w* c + v, remaining = parseImproperFraction(s) + // Try improper with vulgar: a w+ b (vulgar) + if v == nil { + v, remaining = parseImproperVulgar(s) + } + // Try proper fraction: a w* / w* b + if v == nil { + v, remaining = parseProperFraction(s) + } + // Try vulgar fraction alone + if v == nil { + v, remaining = parseVulgarAlone(s) + } + // Try decimal: a [.,] b + if v == nil { + v, remaining = parseDecimalNumber(s) + } + // Try integer + if v == nil { + v, remaining = parseIntegerNumber(s) + } + + // 4. Let u be remainder, stripped of whitespace + u := strings.TrimSpace(remaining) + var unit *string + if u != "" { + unit = &u + } + + // 5. Return result + if v != nil { + val := *v + if negative { + val = -val + } + return Amount{Factor: val, Unit: unit}, nil + } else if unit != nil { + return Amount{}, fmt.Errorf("unit without value: %q", s) + } + return Amount{}, nil +} + +// parseImproperFraction parses "a b/c" (e.g., "1 1/2") +func parseImproperFraction(s string) (*float64, string) { + runes := []rune(s) + // Match: integer, whitespace+, integer, whitespace*, /, whitespace*, integer + i := 0 + // Parse whole part + start := i + for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { + i++ + } + if i == start { + return nil, s + } + whole := mustParseFloat(string(runes[start:i])) + + // Need at least one whitespace + if i >= len(runes) || !unicode.IsSpace(runes[i]) { + return nil, s + } + for i < len(runes) && unicode.IsSpace(runes[i]) { + i++ + } + + // Parse numerator + start = i + for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { + i++ + } + if i == start { + return nil, s + } + num := mustParseFloat(string(runes[start:i])) + + // Skip whitespace, expect / + for i < len(runes) && unicode.IsSpace(runes[i]) { + i++ + } + if i >= len(runes) || runes[i] != '/' { + return nil, s + } + i++ // skip / + for i < len(runes) && unicode.IsSpace(runes[i]) { + i++ + } + + // Parse denominator + start = i + for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { + i++ + } + if i == start { + return nil, s + } + denom := mustParseFloat(string(runes[start:i])) + + if denom == 0 { + return nil, s + } + v := whole + num/denom + return &v, string(runes[i:]) +} + +// parseImproperVulgar parses "a b" where b is a vulgar fraction (e.g., "1 ½") +func parseImproperVulgar(s string) (*float64, string) { + runes := []rune(s) + i := 0 + // Parse whole part + start := i + for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { + i++ + } + if i == start { + return nil, s + } + whole := mustParseFloat(string(runes[start:i])) + + // Need at least one whitespace + if i >= len(runes) || !unicode.IsSpace(runes[i]) { + return nil, s + } + for i < len(runes) && unicode.IsSpace(runes[i]) { + i++ + } + + // Check for vulgar fraction + if i >= len(runes) { + return nil, s + } + frac, ok := vulgarFractionMap[runes[i]] + if !ok { + return nil, s + } + i++ + + v := whole + frac + return &v, string(runes[i:]) +} + +// parseProperFraction parses "a/b" (e.g., "1/2") +func parseProperFraction(s string) (*float64, string) { + runes := []rune(s) + i := 0 + // Parse numerator + start := i + for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { + i++ + } + if i == start { + return nil, s + } + num := mustParseFloat(string(runes[start:i])) + + // Skip whitespace, expect / + for i < len(runes) && unicode.IsSpace(runes[i]) { + i++ + } + if i >= len(runes) || runes[i] != '/' { + return nil, s + } + i++ // skip / + for i < len(runes) && unicode.IsSpace(runes[i]) { + i++ + } + + // Parse denominator + start = i + for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { + i++ + } + if i == start { + return nil, s + } + denom := mustParseFloat(string(runes[start:i])) + + if denom == 0 { + return nil, s + } + v := num / denom + return &v, string(runes[i:]) +} + +// parseVulgarAlone parses a single vulgar fraction (e.g., "½") +func parseVulgarAlone(s string) (*float64, string) { + runes := []rune(s) + if len(runes) == 0 { + return nil, s + } + frac, ok := vulgarFractionMap[runes[0]] + if !ok { + return nil, s + } + return &frac, string(runes[1:]) +} + +// parseDecimalNumber parses "a.b", "a,b", or ".b" (e.g., "1.5" or ".5") +func parseDecimalNumber(s string) (*float64, string) { + runes := []rune(s) + i := 0 + start := i + + // Parse optional integer part + for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { + i++ + } + + // Need decimal point + if i >= len(runes) || (runes[i] != '.' && runes[i] != ',') { + return nil, s + } + i++ // skip decimal point + + // Parse fractional part (required) + fracStart := i + for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { + i++ + } + if i == fracStart { + return nil, s + } + + numStr := string(runes[start:i]) + numStr = strings.Replace(numStr, ",", ".", 1) + v := mustParseFloat(numStr) + return &v, string(runes[i:]) +} + +// parseIntegerNumber parses an integer (e.g., "5") +func parseIntegerNumber(s string) (*float64, string) { + runes := []rune(s) + i := 0 + for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { + i++ + } + if i == 0 { + return nil, s + } + v := mustParseFloat(string(runes[:i])) + return &v, string(runes[i:]) +} + +// mustParseFloat parses a string to float64, panicking on error. +// Only use when input is pre-validated to contain valid numeric characters. +func mustParseFloat(s string) float64 { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + panic(fmt.Sprintf("mustParseFloat: invalid input %q: %v", s, err)) + } + return v +} + +// encodeURLPath properly encodes special characters in a URL path. +func encodeURLPath(path string) string { + u, err := url.Parse(path) + if err != nil { + return path + } + return u.String() +} + +// getDirectLineBounds returns the byte range from a node's own Lines() property. +// Does not recurse into children. Returns (-1, -1) if node has no lines. +func getDirectLineBounds(n ast.Node) (start, end int) { + lines := n.Lines() + if lines.Len() == 0 { + return -1, -1 + } + return lines.At(0).Start, lines.At(lines.Len() - 1).Stop +} + +// extractPlainText recursively extracts plain text from a node, stripping all markdown. +func extractPlainText(node ast.Node, source []byte) (string, error) { + var buf bytes.Buffer + for child := node.FirstChild(); child != nil; child = child.NextSibling() { + if text, ok := child.(*ast.Text); ok { + if buf.Len() > 0 { + if _, err := buf.WriteRune(' '); err != nil { + return "", fmt.Errorf("buf.WriteRune: %w", err) + } + } + if _, err := buf.Write(bytes.TrimSpace(text.Value(source))); err != nil { + return "", fmt.Errorf("buf.Write: %w", err) + } + } else { + childText, err := extractPlainText(child, source) + if err != nil { + return "", err + } + buf.WriteString(childText) + } + } + return buf.String(), nil +} + +// extractRawMarkdown extracts raw source for a block node, preserving markdown syntax. +func extractRawMarkdown(node ast.Node, source []byte) string { + start, end := getRecursiveSourceBounds(node, source) + if start < 0 { + return "" + } + return strings.TrimRight(string(source[start:end]), "\n") +} + +// getRecursiveSourceBounds returns the byte range for a node's source, +// recursively including all children. Handles list markers specially. +func getRecursiveSourceBounds(node ast.Node, source []byte) (start, end int) { + if node.Type() == ast.TypeBlock { + if lines := node.Lines(); lines.Len() > 0 { + return lines.At(0).Start, lines.At(lines.Len() - 1).Stop + } + } + start, end = -1, -1 + for child := node.FirstChild(); child != nil; child = child.NextSibling() { + childStart, childEnd := getRecursiveSourceBounds(child, source) + if childStart < 0 { + continue + } + if node.Kind() == ast.KindList || node.Kind() == ast.KindListItem { + for childStart > 0 && source[childStart-1] != '\n' { + childStart-- + } + } + if start < 0 || childStart < start { + start = childStart + } + if childEnd > end { + end = childEnd + } + } + return start, end +} + +// offsetToLineCol converts a 0-based byte offset into 1-based line and column numbers. +func offsetToLineCol(source []byte, offset int) (line, col int) { + line, col = 1, 1 + for i := 0; i < offset && i < len(source); i++ { + if source[i] == '\n' { + line++ + col = 1 + } else { + col++ + } + } + return +} + +// newParseError constructs a *ParseError from a byte offset in source and a message. +func newParseError(source []byte, offset int, msg string) *ParseError { + line, col := offsetToLineCol(source, offset) + return &ParseError{Message: msg, Offset: offset, Line: line, Column: col} +} + +// nodeStartOffset returns the 0-based byte offset of the first byte of an AST node. +// Returns 0 if the node has no line info. +func nodeStartOffset(n ast.Node) int { + lines := n.Lines() + if lines.Len() > 0 { + return lines.At(0).Start + } + return 0 +} + +type emphasisLevel int + +const ( + italic emphasisLevel = iota + 1 + bold +) + +// isOnlyEmphasis returns the emphasis if paragraph has exactly one Emphasis +// child with given level (*italics* and **bold**) +func isOnlyEmphasis(p *ast.Paragraph, level emphasisLevel) (*ast.Emphasis, bool) { + first := p.FirstChild() + if first == nil || first.NextSibling() != nil || first.Kind() != ast.KindEmphasis { + return nil, false + } + em := first.(*ast.Emphasis) + if em.Level != int(level) { + return nil, false + } + return em, true +} + +// splitList splits on commas but not decimal commas (digit,digit). +func splitList(list string) []string { + parts := make([]string, 0, strings.Count(list, ",")+1) + var start, search int + for { + index := strings.IndexByte(list[search:], ',') + if index == -1 { + break + } + position := search + index + if position > 0 && + position < len(list)-1 && + unicode.IsDigit(rune(list[position-1])) && + unicode.IsDigit(rune(list[position+1])) { + search = position + 1 + continue + } + if t := strings.TrimSpace(list[start:position]); t != "" { + parts = append(parts, t) + } + start = position + 1 + search = start + } + if t := strings.TrimSpace(list[start:]); t != "" { + parts = append(parts, t) + } + return parts +} + +func parseTags(s string) []string { + return splitList(s) +} + +func parseYields(s string) (yields []Amount, errs error) { + for _, yield := range splitList(s) { + amount, err := parseAmount(yield) + if err != nil { + errs = errors.Join(errs, err) + continue + } + yields = append(yields, amount) + } + return yields, errs +} + +var vulgarFractionMap = map[rune]float64{ + '¼': 1.0 / 4, '½': 1.0 / 2, '¾': 3.0 / 4, + '⅐': 1.0 / 7, '⅑': 1.0 / 9, '⅒': 1.0 / 10, + '⅓': 1.0 / 3, '⅔': 2.0 / 3, + '⅕': 1.0 / 5, '⅖': 2.0 / 5, '⅗': 3.0 / 5, '⅘': 4.0 / 5, + '⅙': 1.0 / 6, '⅚': 5.0 / 6, + '⅛': 1.0 / 8, '⅜': 3.0 / 8, '⅝': 5.0 / 8, '⅞': 7.0 / 8, +} + +func skipSetextUnderline(source []byte, pos int) int { + if pos >= len(source) || source[pos] != '\n' { + return pos + } + next := pos + 1 + if next >= len(source) || source[next] != '=' { + return pos + } + for next < len(source) && source[next] != '\n' { + next++ + } + return next +} + +// stripFrontmatter removes YAML (---) or TOML (+++) frontmatter from the +// beginning of source. Returns source unchanged if no frontmatter is found. +func stripFrontmatter(source []byte) []byte { + if len(source) < 3 { + return source + } + var fence []byte + if bytes.HasPrefix(source, []byte("---")) { + fence = []byte("---") + } else if bytes.HasPrefix(source, []byte("+++")) { + fence = []byte("+++") + } else { + return source + } + + // Opening fence must be alone on the line (optional trailing whitespace) + firstNL := bytes.IndexByte(source, '\n') + if firstNL < 0 { + return source + } + if len(bytes.TrimSpace(source[:firstNL])) != len(fence) { + return source + } + + // Find closing fence + rest := source[firstNL+1:] + for len(rest) > 0 { + lineEnd := bytes.IndexByte(rest, '\n') + var line []byte + if lineEnd < 0 { + line = rest + } else { + line = rest[:lineEnd] + } + if bytes.Equal(bytes.TrimSpace(line), fence) { + if lineEnd < 0 { + return nil + } + return rest[lineEnd+1:] + } + if lineEnd < 0 { + break + } + rest = rest[lineEnd+1:] + } + return source +} + +func excludeRangesFromSource(src []byte, ranges [][2]int, offset int) string { + if len(ranges) == 0 { + return string(src) + } + var result strings.Builder + pos := 0 + for _, r := range ranges { + start := r[0] - offset + end := r[1] - offset + if start < 0 || end > len(src) { + continue + } + if start > pos { + result.Write(src[pos:start]) + } + pos = end + } + if pos < len(src) { + result.Write(src[pos:]) + } + return result.String() +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..ef39063 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,796 @@ +package recipemd + +import ( + "bytes" + "encoding/json" + "math" + "testing" +) + +func TestParser_WithFrontmatter_YAML(t *testing.T) { + input := []byte(`--- +title: ignored +--- +# Guacamole + +--- + +- avocado +`) + p := NewParser(WithFrontmatter()) + recipe, err := p.Parse(bytes.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if recipe.Title != "Guacamole" { + t.Errorf("Title = %q, want %q", recipe.Title, "Guacamole") + } +} + +func TestParser_WithFrontmatter_TOML(t *testing.T) { + input := []byte(`+++ +title = "ignored" ++++ +# Guacamole + +--- + +- avocado +`) + p := NewParser(WithFrontmatter()) + recipe, err := p.Parse(bytes.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if recipe.Title != "Guacamole" { + t.Errorf("Title = %q, want %q", recipe.Title, "Guacamole") + } +} + +func TestParser_FrontmatterWithoutOption_Fails(t *testing.T) { + input := []byte(`--- +title: ignored +--- +# Guacamole + +--- + +- avocado +`) + _, err := NewParser().Parse(bytes.NewReader(input)) + if err == nil { + t.Fatal("expected error parsing frontmatter without WithFrontmatter") + } +} + +func TestParser_WithGFM(t *testing.T) { + input := []byte(`# Guacamole + +Check out https://example.com for more info. + +--- + +- avocado +`) + p := NewParser(WithGithubFormattedMarkdown()) + recipe, err := p.Parse(bytes.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if recipe.Title != "Guacamole" { + t.Errorf("Title = %q, want %q", recipe.Title, "Guacamole") + } +} + +func TestParser_GFM_LinkifyIngredient(t *testing.T) { + input := []byte("# Test\n\n---\n\n- *1 cup* https://example.com/flour\n") + p := NewParser(WithGithubFormattedMarkdown()) + recipe, err := p.Parse(bytes.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + ing := recipe.Ingredients[0] + if ing.Link == nil { + t.Fatal("expected link from autolinked URL, got nil") + } + if *ing.Link != "https://example.com/flour" { + t.Errorf("link = %q, want %q", *ing.Link, "https://example.com/flour") + } + if ing.Amount == nil || ing.Amount.Factor != 1 { + t.Errorf("amount factor = %v, want 1", ing.Amount) + } +} + +func TestStripFrontmatter(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"yaml", "---\nfoo: bar\n---\ncontent", "content"}, + {"toml", "+++\nfoo = 1\n+++\ncontent", "content"}, + {"no frontmatter", "# Title\n\ncontent", "# Title\n\ncontent"}, + {"unclosed fence", "---\nfoo: bar\ncontent", "---\nfoo: bar\ncontent"}, + {"fence with trailing space", "--- \nfoo: bar\n---\ncontent", "content"}, + {"extra chars on opening", "--- extra\nfoo\n---\n", "--- extra\nfoo\n---\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := string(stripFrontmatter([]byte(tt.in))) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestParse_TitleAndDescription(t *testing.T) { + input := []byte(`# Guacamole + +Some people call it guac. + +It's delicious with chips. + +--- + +- avocado +`) + recipe, err := NewParser().Parse(bytes.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + b, _ := json.MarshalIndent(recipe, "", " ") + t.Logf("Parsed recipe:\n%s", b) + + if recipe.Title != "Guacamole" { + t.Errorf("Title = %q, want %q", recipe.Title, "Guacamole") + } + + wantDesc := "Some people call it guac.\n\nIt's delicious with chips." + if recipe.Description == nil { + t.Fatal("Description is nil") + } + if *recipe.Description != wantDesc { + t.Errorf("Description = %q, want %q", *recipe.Description, wantDesc) + } +} + +var sampleRecipe = []byte(`# Guacamole + +Some people call it guac. + +*sauce, vegan* + +**4 Servings, 200g** + +--- + +- *1* avocado +- *.5 teaspoon* salt +- *1 1/2 pinches* red pepper flakes +- lemon juice + +--- + +Remove flesh from avocado and roughly mash with fork. Season to taste +with salt, pepper and lemon juice. +`) + +func TestParse_FullRecipe(t *testing.T) { + recipe, err := NewParser().Parse(bytes.NewReader(sampleRecipe)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + b, _ := json.MarshalIndent(recipe, "", " ") + t.Logf("Parsed recipe:\n%s", b) +} + +func TestParseAmountString(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + wantF float64 + wantUnit *string + wantErr bool + }{ + {"integer", "5", 5, nil, false}, + {"integer with unit", "5 cups", 5, new("cups"), false}, + {"decimal dot", "1.5 ml", 1.5, new("ml"), false}, + {"decimal comma", "1,5 ml", 1.5, new("ml"), false}, + {"leading decimal", ".5", 0.5, nil, false}, + {"fraction", "1/2", 0.5, nil, false}, + {"fraction with unit", "1/2 cup", 0.5, new("cup"), false}, + {"improper fraction", "1 1/2", 1.5, nil, false}, + {"improper fraction with unit", "2 1/4 cups", 2.25, new("cups"), false}, + {"vulgar half", "½", 0.5, nil, false}, + {"vulgar quarter", "¼ cup", 0.25, new("cup"), false}, + {"improper vulgar", "1 ½", 1.5, nil, false}, + {"negative", "-3 oz", -3, new("oz"), false}, + {"negative fraction", "-1/2", -0.5, nil, false}, + {"whitespace trimmed", " 5 cups ", 5, new("cups"), false}, + {"unit only", "cups", 0, nil, true}, + {"empty string", "", 0, nil, false}, + {"fraction spaces", "1 / 2", 0.5, nil, false}, + {"zero denominator falls to integer", "1/0", 1, new("/0"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + amt, err := ParseAmountString(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if math.Abs(amt.Factor-tt.wantF) > 1e-9 { + t.Errorf("Factor = %v, want %v", amt.Factor, tt.wantF) + } + if tt.wantUnit == nil && amt.Unit != nil { + t.Errorf("Unit = %q, want nil", *amt.Unit) + } + if tt.wantUnit != nil { + if amt.Unit == nil { + t.Errorf("Unit = nil, want %q", *tt.wantUnit) + } else if *amt.Unit != *tt.wantUnit { + t.Errorf("Unit = %q, want %q", *amt.Unit, *tt.wantUnit) + } + } + }) + } +} + +func TestSplitList(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + want []string + }{ + {"simple", "a, b, c", []string{"a", "b", "c"}}, + {"decimal comma preserved", "1,5 cups, 2,5 oz", []string{"1,5 cups", "2,5 oz"}}, + {"empty parts skipped", "a,, b", []string{"a", "b"}}, + {"single item", "hello", []string{"hello"}}, + {"empty string", "", nil}, + {"whitespace only", " , , ", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := splitList(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Errorf("got[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestParseTags(t *testing.T) { + t.Parallel() + got := parseTags("sauce, vegan, easy") + want := []string{"sauce", "vegan", "easy"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("got[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestParseYields(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + wantLen int + wantErr bool + }{ + {"single yield", "4 servings", 1, false}, + {"multiple yields", "4 servings, 200g", 2, false}, + {"unitless", "4", 1, false}, + {"invalid", "cups", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + yields, err := parseYields(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(yields) != tt.wantLen { + t.Errorf("len = %d, want %d", len(yields), tt.wantLen) + } + }) + } +} + +func TestStripFrontmatter_Extended(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + want string + }{ + {"yaml", "---\nfoo: bar\n---\ncontent", "content"}, + {"toml", "+++\nfoo = 1\n+++\ncontent", "content"}, + {"no frontmatter", "# Title\n\ncontent", "# Title\n\ncontent"}, + {"unclosed fence", "---\nfoo: bar\ncontent", "---\nfoo: bar\ncontent"}, + {"trailing space on fence", "--- \nfoo: bar\n---\ncontent", "content"}, + {"extra chars on opening", "--- extra\nfoo\n---\n", "--- extra\nfoo\n---\n"}, + {"short input", "ab", "ab"}, + {"empty closing", "---\n---\n", ""}, + {"closing at eof", "---\nfoo\n---", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := string(stripFrontmatter([]byte(tt.in))) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestNewParser(t *testing.T) { + t.Parallel() + t.Run("default", func(t *testing.T) { + t.Parallel() + p := NewParser() + if p.Frontmatter { + t.Error("Frontmatter should be false by default") + } + if p.hasTaskList { + t.Error("hasTaskList should be false by default") + } + }) + t.Run("with frontmatter", func(t *testing.T) { + t.Parallel() + p := NewParser(WithFrontmatter()) + if !p.Frontmatter { + t.Error("Frontmatter should be true") + } + }) + t.Run("with GFM", func(t *testing.T) { + t.Parallel() + p := NewParser(WithGithubFormattedMarkdown()) + if !p.hasTaskList { + t.Error("hasTaskList should be true") + } + }) + t.Run("combined options", func(t *testing.T) { + t.Parallel() + p := NewParser(WithFrontmatter(), WithGithubFormattedMarkdown()) + if !p.Frontmatter || !p.hasTaskList { + t.Error("both options should be set") + } + }) +} + +func TestParse(t *testing.T) { + t.Parallel() + + t.Run("minimal recipe", func(t *testing.T) { + t.Parallel() + r, err := NewParser().Parse(bytes.NewReader([]byte("# Title\n\n---\n\n- salt\n"))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if r.Title != "Title" { + t.Errorf("Title = %q, want %q", r.Title, "Title") + } + if r.Description != nil { + t.Errorf("Description = %q, want nil", *r.Description) + } + if len(r.Ingredients) != 1 || r.Ingredients[0].Name != "salt" { + t.Errorf("Ingredients = %+v", r.Ingredients) + } + if r.Instructions != nil { + t.Errorf("Instructions should be nil") + } + }) + + t.Run("empty input", func(t *testing.T) { + t.Parallel() + _, err := NewParser().Parse(bytes.NewReader([]byte(""))) + if err == nil { + t.Fatal("expected error for empty input") + } + }) + + t.Run("no heading", func(t *testing.T) { + t.Parallel() + _, err := NewParser().Parse(bytes.NewReader([]byte("Not a heading\n\n---\n\n- x\n"))) + if err == nil { + t.Fatal("expected error for missing heading") + } + }) + + t.Run("wrong heading level", func(t *testing.T) { + t.Parallel() + _, err := NewParser().Parse(bytes.NewReader([]byte("## Level 2\n\n---\n\n- x\n"))) + if err == nil { + t.Fatal("expected error for level 2 heading") + } + }) + + t.Run("missing thematic break", func(t *testing.T) { + t.Parallel() + _, err := NewParser().Parse(bytes.NewReader([]byte("# Title\n\n- x\n"))) + if err == nil { + t.Fatal("expected error for missing thematic break") + } + }) + + t.Run("description", func(t *testing.T) { + t.Parallel() + r, err := NewParser().Parse(bytes.NewReader([]byte("# Title\n\nA description.\n\n---\n\n- x\n"))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if r.Description == nil || *r.Description != "A description." { + t.Errorf("Description = %v", r.Description) + } + }) + + t.Run("tags", func(t *testing.T) { + t.Parallel() + r, err := NewParser().Parse(bytes.NewReader([]byte("# Title\n\n*sauce, vegan*\n\n---\n\n- x\n"))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(r.Tags) != 2 || r.Tags[0] != "sauce" || r.Tags[1] != "vegan" { + t.Errorf("Tags = %v", r.Tags) + } + }) + + t.Run("yields", func(t *testing.T) { + t.Parallel() + r, err := NewParser().Parse(bytes.NewReader([]byte("# Title\n\n**4 servings**\n\n---\n\n- x\n"))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(r.Yields) != 1 || r.Yields[0].Factor != 4 || *r.Yields[0].Unit != "servings" { + t.Errorf("Yields = %+v", r.Yields) + } + }) + + t.Run("tags and yields", func(t *testing.T) { + t.Parallel() + input := "# Title\n\n*sauce*\n\n**4 servings**\n\n---\n\n- x\n" + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(r.Tags) != 1 { + t.Errorf("Tags = %v", r.Tags) + } + if len(r.Yields) != 1 { + t.Errorf("Yields = %+v", r.Yields) + } + }) + + t.Run("duplicate tags error", func(t *testing.T) { + t.Parallel() + input := "# Title\n\n*a*\n\n*b*\n\n---\n\n- x\n" + _, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err == nil { + t.Fatal("expected error for duplicate tags") + } + }) + + t.Run("duplicate yields error", func(t *testing.T) { + t.Parallel() + input := "# Title\n\n**4 servings**\n\n**8 servings**\n\n---\n\n- x\n" + _, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err == nil { + t.Fatal("expected error for duplicate yields") + } + }) + + t.Run("ingredient with amount", func(t *testing.T) { + t.Parallel() + r, err := NewParser().Parse(bytes.NewReader([]byte("# T\n\n---\n\n- *2 cups* flour\n"))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + ing := r.Ingredients[0] + if ing.Amount == nil || ing.Amount.Factor != 2 || *ing.Amount.Unit != "cups" { + t.Errorf("Amount = %+v", ing.Amount) + } + if ing.Name != "flour" { + t.Errorf("Name = %q", ing.Name) + } + }) + + t.Run("ingredient with link", func(t *testing.T) { + t.Parallel() + r, err := NewParser().Parse(bytes.NewReader([]byte("# T\n\n---\n\n- [flour](flour.md)\n"))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + ing := r.Ingredients[0] + if ing.Link == nil || *ing.Link != "flour.md" { + t.Errorf("Link = %v", ing.Link) + } + if ing.Name != "flour" { + t.Errorf("Name = %q", ing.Name) + } + }) + + t.Run("ingredient with amount and link", func(t *testing.T) { + t.Parallel() + r, err := NewParser().Parse(bytes.NewReader([]byte("# T\n\n---\n\n- *2 cups* [flour](flour.md)\n"))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + ing := r.Ingredients[0] + if ing.Amount == nil || ing.Amount.Factor != 2 { + t.Errorf("Amount = %+v", ing.Amount) + } + if ing.Link == nil || *ing.Link != "flour.md" { + t.Errorf("Link = %v", ing.Link) + } + }) + + t.Run("multiple ingredients", func(t *testing.T) { + t.Parallel() + input := "# T\n\n---\n\n- *1* a\n- *2* b\n- c\n" + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(r.Ingredients) != 3 { + t.Fatalf("len = %d, want 3", len(r.Ingredients)) + } + }) + + t.Run("ingredient groups", func(t *testing.T) { + t.Parallel() + input := "# T\n\n---\n\n- base\n\n## Sauce\n\n- tomato\n" + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(r.Ingredients) != 1 || r.Ingredients[0].Name != "base" { + t.Errorf("Ingredients = %+v", r.Ingredients) + } + if len(r.IngredientGroups) != 1 { + t.Fatalf("IngredientGroups len = %d", len(r.IngredientGroups)) + } + g := r.IngredientGroups[0] + if g.Title != "Sauce" { + t.Errorf("group title = %q", g.Title) + } + if len(g.Ingredients) != 1 || g.Ingredients[0].Name != "tomato" { + t.Errorf("group ingredients = %+v", g.Ingredients) + } + }) + + t.Run("nested ingredient groups", func(t *testing.T) { + t.Parallel() + input := "# T\n\n---\n\n## Dough\n\n- flour\n\n### Filling\n\n- cheese\n" + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(r.IngredientGroups) != 1 { + t.Fatalf("top groups = %d", len(r.IngredientGroups)) + } + if len(r.IngredientGroups[0].IngredientGroups) != 1 { + t.Fatalf("sub groups = %d", len(r.IngredientGroups[0].IngredientGroups)) + } + sub := r.IngredientGroups[0].IngredientGroups[0] + if sub.Title != "Filling" || sub.Ingredients[0].Name != "cheese" { + t.Errorf("sub group = %+v", sub) + } + }) + + t.Run("instructions", func(t *testing.T) { + t.Parallel() + input := "# T\n\n---\n\n- x\n\n---\n\nDo the thing.\n" + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if r.Instructions == nil || *r.Instructions != "Do the thing." { + t.Errorf("Instructions = %v", r.Instructions) + } + }) + + t.Run("no instructions", func(t *testing.T) { + t.Parallel() + r, err := NewParser().Parse(bytes.NewReader([]byte("# T\n\n---\n\n- x\n"))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if r.Instructions != nil { + t.Errorf("Instructions should be nil, got %q", *r.Instructions) + } + }) + + t.Run("description with tags and yields excluded", func(t *testing.T) { + t.Parallel() + input := "# Title\n\nHello world.\n\n*vegan*\n\n**4 servings**\n\n---\n\n- x\n" + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if r.Description == nil || *r.Description != "Hello world." { + t.Errorf("Description = %v", r.Description) + } + if len(r.Tags) != 1 || r.Tags[0] != "vegan" { + t.Errorf("Tags = %v", r.Tags) + } + }) + + t.Run("paragraph in ingredients errors", func(t *testing.T) { + t.Parallel() + input := "# T\n\n---\n\nNot a list\n" + _, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err == nil { + t.Fatal("expected error for paragraph in ingredients") + } + }) + + t.Run("setext heading title", func(t *testing.T) { + t.Parallel() + input := "Title\n=====\n\n---\n\n- x\n" + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if r.Title != "Title" { + t.Errorf("Title = %q", r.Title) + } + }) + + t.Run("frontmatter stripped", func(t *testing.T) { + t.Parallel() + input := "---\ntitle: meta\n---\n# Real Title\n\n---\n\n- x\n" + r, err := NewParser(WithFrontmatter()).Parse(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if r.Title != "Real Title" { + t.Errorf("Title = %q", r.Title) + } + }) + + t.Run("GFM task list with amounts", func(t *testing.T) { + t.Parallel() + input := "# T\n\n---\n\n- [ ] *1 cup* flour\n- [x] *2 cups* sugar\n" + r, err := NewParser(WithGithubFormattedMarkdown()).Parse(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(r.Ingredients) != 2 { + t.Fatalf("len = %d", len(r.Ingredients)) + } + if r.Ingredients[0].Name != "flour" { + t.Errorf("first = %q", r.Ingredients[0].Name) + } + if r.Ingredients[1].Name != "sugar" { + t.Errorf("second = %q", r.Ingredients[1].Name) + } + if r.Ingredients[0].Amount == nil || r.Ingredients[0].Amount.Factor != 1 { + t.Errorf("first amount = %+v", r.Ingredients[0].Amount) + } + }) +} + +func TestEncodeURLPath(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + want string + }{ + {"plain path", "recipe.md", "recipe.md"}, + {"spaces", "my recipe.md", "my%20recipe.md"}, + {"already encoded", "my%20recipe.md", "my%20recipe.md"}, + {"relative path", "../other/recipe.md", "../other/recipe.md"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := encodeURLPath(tt.in) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestExcludeRangesFromSource(t *testing.T) { + t.Parallel() + tests := []struct { + name string + src string + ranges [][2]int + offset int + want string + }{ + {"no ranges", "hello", nil, 0, "hello"}, + {"exclude middle", "abcdef", [][2]int{{2, 4}}, 0, "abef"}, + {"with offset", "abcdef", [][2]int{{12, 14}}, 10, "abef"}, + {"exclude start", "abcdef", [][2]int{{0, 2}}, 0, "cdef"}, + {"exclude end", "abcdef", [][2]int{{4, 6}}, 0, "abcd"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := excludeRangesFromSource([]byte(tt.src), tt.ranges, tt.offset) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFindDashLine(t *testing.T) { + t.Parallel() + tests := []struct { + name string + source string + minPos int + want int + }{ + {"at start", "---\ntext", 0, 0}, + {"after text", "text\n---\n", 0, 5}, + {"min skips first", "---\n---\n", 4, 4}, + {"no dashes", "text\nmore\n", 0, -1}, + {"requires 3 dashes", "--\n---\n", 0, 3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := findDashLine([]byte(tt.source), tt.minPos) + if got != tt.want { + t.Errorf("got %d, want %d", got, tt.want) + } + }) + } +} + +func TestSkipLine(t *testing.T) { + t.Parallel() + tests := []struct { + name string + source string + pos int + want int + }{ + {"normal", "abc\ndef\n", 0, 4}, + {"negative pos", "abc\n", -1, 4}, + {"at newline", "abc\n", 3, 4}, + {"no newline", "abc", 0, 3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := skipLine([]byte(tt.source), tt.pos) + if got != tt.want { + t.Errorf("got %d, want %d", got, tt.want) + } + }) + } +} diff --git a/recipe.go b/recipe.go new file mode 100644 index 0000000..1d58c1d --- /dev/null +++ b/recipe.go @@ -0,0 +1,239 @@ +package recipemd + +import ( + "errors" + "fmt" + "math" + "strconv" + "strings" +) + +type ( + // Recipe is the top-level representation of a parsed RecipeMD document. + // + // Title is always present. Description, Instructions, and individual + // Amount.Unit values are optional and represented as pointers; a nil + // pointer means the field was absent in the source document. + // Yields, Tags, Ingredients, and IngredientGroups are initialised to + // empty (non-nil) slices by [Parser.Parse]. + Recipe struct { + // Title is the recipe name, taken from the H1 heading. + Title string `json:"title"` + // Description is the optional free-form text between the title and + // the tags/yields lines, preserved as raw markdown. Nil when absent. + Description *string `json:"description"` + // Yields lists the recipe's yield amounts (e.g. "12 cookies", + // "1 loaf"). Multiple yields with different units are allowed. + Yields []Amount `json:"yields"` + // Tags is the comma-separated list of category tags from the italic + // paragraph in the preamble (e.g. "vegan, gluten-free"). + Tags []string `json:"tags"` + // Ingredients holds the flat, top-level ingredient list that appears + // directly after the first thematic break. + Ingredients []Ingredient `json:"ingredients"` + // IngredientGroups holds named sections of ingredients introduced by + // headings in the ingredient section. Groups may be nested. + IngredientGroups []IngredientGroup `json:"ingredient_groups"` + // Instructions is the optional free-form text after the second + // thematic break, preserved as raw markdown. Nil when absent. + Instructions *string `json:"instructions"` + } + + // Ingredient represents a single item in a recipe's ingredient list. + // + // Every ingredient must have a Name. Amount and Link are optional: Amount + // is present when the ingredient line starts with an italic quantity (e.g. + // "*200 g* flour"), and Link is present when the entire ingredient name is + // a hyperlink to another recipe file. + Ingredient struct { + // Name is the ingredient's display name (e.g. "flour", "olive oil"). + Name string `json:"name"` + // Amount is the optional quantity for this ingredient. Nil when the + // ingredient has no amount specified. + Amount *Amount `json:"amount"` + // Link is the optional URL or relative file path of a linked recipe. + // Nil when the ingredient is not a link. + Link *string `json:"link"` + } + + // IngredientGroup is a named section of ingredients within a recipe, + // introduced by a heading in the ingredient part of the document. + // + // Groups may contain both direct ingredients and nested sub-groups, + // mirroring the heading hierarchy in the source document. + IngredientGroup struct { + // Title is the heading text that names this group. + Title string `json:"title"` + // Ingredients is the flat list of ingredients directly inside this group. + Ingredients []Ingredient `json:"ingredients"` + // IngredientGroups holds any nested sub-groups whose headings are at a + // deeper level than this group's heading. + IngredientGroups []IngredientGroup `json:"ingredient_groups"` + } + + // Amount represents a measured quantity consisting of a numeric factor and + // an optional unit of measurement. + // + // The Factor is always set. Unit is nil when the amount is unitless + // (e.g. "3 eggs" has Factor=3 and Unit=nil). + Amount struct { + // Factor is the numeric value of the amount (e.g. 1.5 for "1.5 cups"). + Factor float64 `json:"factor"` + // Unit is the optional measurement unit (e.g. "cups", "g", "ml"). + // Nil when the amount has no unit. + Unit *string `json:"unit"` + } +) + +// MarshalJSON implements [encoding/json.Marshaler] for Amount. +// +// The numeric factor is encoded as a quoted string rounded to three decimal +// places with trailing zeros removed (e.g. "1.5"), so that JSON consumers +// receive a human-readable value rather than a raw float64. The unit field is +// always present, set to null when [Amount.Unit] is nil. +func (a Amount) MarshalJSON() ([]byte, error) { + s := a.FormatFactor(3) + if a.Unit != nil { + return fmt.Appendf([]byte{}, `{"factor":%q,"unit":%q}`, s, *a.Unit), nil + } + return fmt.Appendf([]byte{}, `{"factor":%q,"unit":null}`, s), nil +} + +// FormatFactor formats the numeric factor as a decimal string. +// +// When rounding is zero or positive the value is rounded to that many decimal +// places and trailing zeros (and a trailing decimal point) are removed. +// When rounding is negative the full precision of the underlying float64 is +// used. For example, FormatFactor(2) on a factor of 1.500 returns "1.5". +func (a Amount) FormatFactor(rounding int) string { + if rounding < 0 { + return strconv.FormatFloat(a.Factor, 'f', -1, 64) + } + rounded := math.Round(a.Factor*math.Pow(10, float64(rounding))) / math.Pow(10, float64(rounding)) + s := strconv.FormatFloat(rounded, 'f', rounding, 64) + if rounding > 0 { + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + } + return s +} + +// ScaleForYield scales the recipe so that its yield matches desiredYield. +// +// The method searches [Recipe.Yields] for an entry whose unit matches the unit +// of desiredYield, then calls [Recipe.Scale] with the derived ratio. Unit +// matching is case-sensitive and exact. +// +// If desiredYield is unitless (Unit == nil) the method first looks for a +// unitless entry in Yields. When none exists it falls back to treating +// desiredYield.Factor as a direct multiplier (i.e. it scales the whole recipe +// by that factor). +// +// 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") +} + +// 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) + } +} + +// Scale multiplies the amount's factor by factor. +func (a *Amount) Scale(factor float64) { + 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) + } +} + +// 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) + } +} + +// Serialize formats the amount as a human-readable string. +// +// The factor is formatted with [Amount.FormatFactor] using the given rounding. +// When a unit is present it is appended after a space (e.g. "1.5 cups"). +// When there is no unit only the formatted number is returned (e.g. "3"). +func (a Amount) Serialize(rounding int) string { + s := a.FormatFactor(rounding) + if a.Unit != nil { + return s + " " + *a.Unit + } + return s +} + +// Serialize formats the ingredient as a human-readable string. +// +// When an amount is present it is serialised (via [Amount.Serialize]) and +// prepended to the name, separated by a space (e.g. "200 g flour"). +// When there is no amount only the name is returned (e.g. "salt"). +func (i Ingredient) Serialize(rounding int) string { + if i.Amount != nil { + return i.Amount.Serialize(rounding) + " " + i.Name + } + return i.Name +} + +// LeafIngredients returns a flat list of every ingredient in the recipe, +// including those nested inside ingredient groups and sub-groups. The +// top-level [Recipe.Ingredients] slice is listed first, followed by the +// ingredients from each [Recipe.IngredientGroups] entry in order. +func (r *Recipe) LeafIngredients() []Ingredient { + var result []Ingredient + result = append(result, r.Ingredients...) + for _, g := range r.IngredientGroups { + result = append(result, g.LeafIngredients()...) + } + return result +} + +// LeafIngredients returns a flat list of every ingredient in the group, +// including those in nested sub-groups, in depth-first order. +func (g *IngredientGroup) LeafIngredients() []Ingredient { + var result []Ingredient + result = append(result, g.Ingredients...) + for _, sub := range g.IngredientGroups { + result = append(result, sub.LeafIngredients()...) + } + return result +} diff --git a/recipe_test.go b/recipe_test.go new file mode 100644 index 0000000..5c966a3 --- /dev/null +++ b/recipe_test.go @@ -0,0 +1,289 @@ +package recipemd + +import ( + "encoding/json" + "testing" +) + +func TestAmount_MarshalJSON(t *testing.T) { + t.Parallel() + tests := []struct { + name string + a Amount + want string + }{ + {"integer no unit", Amount{Factor: 3, Unit: nil}, `{"factor":"3","unit":null}`}, + {"decimal no unit", Amount{Factor: 1.5, Unit: nil}, `{"factor":"1.5","unit":null}`}, + {"with unit", Amount{Factor: 2, Unit: new("cups")}, `{"factor":"2","unit":"cups"}`}, + {"rounds to 3 decimals", Amount{Factor: 1.0 / 3.0, Unit: nil}, `{"factor":"0.333","unit":null}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := json.Marshal(tt.a) + if err != nil { + t.Fatalf("MarshalJSON error: %v", err) + } + if string(got) != tt.want { + t.Errorf("got %s, want %s", got, tt.want) + } + }) + } +} + +func TestAmount_FormatFactor(t *testing.T) { + t.Parallel() + tests := []struct { + name string + factor float64 + rounding int + want string + }{ + {"integer", 3, 3, "3"}, + {"one decimal", 1.5, 3, "1.5"}, + {"trailing zeros trimmed", 2.10, 3, "2.1"}, + {"no rounding", 1.123456, -1, "1.123456"}, + {"round to 0", 1.7, 0, "2"}, + {"round to 2", 1.555, 2, "1.56"}, + {"zero", 0, 3, "0"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + a := Amount{Factor: tt.factor} + got := a.FormatFactor(tt.rounding) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestAmount_Serialize(t *testing.T) { + t.Parallel() + tests := []struct { + name string + a Amount + rounding int + want string + }{ + {"no unit", Amount{Factor: 2, Unit: nil}, 3, "2"}, + {"with unit", Amount{Factor: 1.5, Unit: new("cups")}, 3, "1.5 cups"}, + {"integer with unit", Amount{Factor: 3, Unit: new("tsp")}, 0, "3 tsp"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.a.Serialize(tt.rounding) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestIngredient_Serialize(t *testing.T) { + t.Parallel() + tests := []struct { + name string + i Ingredient + 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"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.i.Serialize(3) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestAmount_Scale(t *testing.T) { + t.Parallel() + a := Amount{Factor: 2, Unit: new("cups")} + a.Scale(3) + if a.Factor != 6 { + t.Errorf("Factor = %v, want 6", a.Factor) + } + if *a.Unit != "cups" { + t.Errorf("Unit changed unexpectedly") + } +} + +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.Scale(0.5) + if i.Amount.Factor != 1 { + t.Errorf("Factor = %v, want 1", i.Amount.Factor) + } + }) + t.Run("nil amount", func(t *testing.T) { + t.Parallel() + i := Ingredient{Name: "salt"} + i.Scale(2) // should not panic + }) +} + +func TestIngredientGroup_Scale(t *testing.T) { + t.Parallel() + g := IngredientGroup{ + Title: "Sauce", + Ingredients: []Ingredient{ + {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")}}}, + }, + }, + } + g.Scale(3) + if g.Ingredients[0].Amount.Factor != 6 { + t.Errorf("tomato factor = %v, want 6", g.Ingredients[0].Amount.Factor) + } + if g.IngredientGroups[0].Ingredients[0].Amount.Factor != 3 { + t.Errorf("pepper factor = %v, want 3", g.IngredientGroups[0].Ingredients[0].Amount.Factor) + } +} + +func TestRecipe_Scale(t *testing.T) { + t.Parallel() + r := &Recipe{ + Yields: []Amount{ + {Factor: 4, Unit: new("servings")}, + }, + Ingredients: []Ingredient{ + {Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cups")}}, + }, + IngredientGroups: []IngredientGroup{ + { + Title: "Sauce", + Ingredients: []Ingredient{{Name: "tomato", Amount: &Amount{Factor: 1, Unit: nil}}}, + }, + }, + } + r.Scale(2) + if r.Yields[0].Factor != 8 { + t.Errorf("yield = %v, want 8", r.Yields[0].Factor) + } + if r.Ingredients[0].Amount.Factor != 4 { + t.Errorf("flour = %v, want 4", r.Ingredients[0].Amount.Factor) + } + if r.IngredientGroups[0].Ingredients[0].Amount.Factor != 2 { + t.Errorf("tomato = %v, want 2", r.IngredientGroups[0].Ingredients[0].Amount.Factor) + } +} + +func TestRecipe_ScaleForYield(t *testing.T) { + t.Parallel() + tests := []struct { + name string + yields []Amount + desired Amount + wantErr bool + wantFactor float64 + }{ + { + name: "match unit", + yields: []Amount{{Factor: 4, Unit: new("servings")}}, + desired: Amount{Factor: 8, Unit: new("servings")}, + wantFactor: 4, // 2*2 + }, + { + name: "match unitless", + yields: []Amount{{Factor: 2, Unit: nil}}, + desired: Amount{Factor: 6, Unit: nil}, + wantFactor: 6, // 2*(6/2) + }, + { + name: "unitless fallback to multiplier", + yields: []Amount{{Factor: 4, Unit: new("servings")}}, + desired: Amount{Factor: 3, Unit: nil}, + wantFactor: 6, // 2*3 + }, + { + name: "no matching unit", + yields: []Amount{{Factor: 4, Unit: new("servings")}}, + desired: Amount{Factor: 2, Unit: new("liters")}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Recipe{ + Yields: tt.yields, + Ingredients: []Ingredient{{Name: "x", Amount: &Amount{Factor: 2, Unit: nil}}}, + } + err := r.ScaleForYield(tt.desired) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.Ingredients[0].Amount.Factor != tt.wantFactor { + t.Errorf("factor = %v, want %v", r.Ingredients[0].Amount.Factor, tt.wantFactor) + } + }) + } +} + +func TestRecipe_LeafIngredients(t *testing.T) { + t.Parallel() + r := &Recipe{ + Ingredients: []Ingredient{{Name: "a"}, {Name: "b"}}, + IngredientGroups: []IngredientGroup{ + { + Title: "G1", + Ingredients: []Ingredient{{Name: "c"}}, + IngredientGroups: []IngredientGroup{ + {Title: "G2", Ingredients: []Ingredient{{Name: "d"}}}, + }, + }, + }, + } + leaves := r.LeafIngredients() + names := make([]string, len(leaves)) + for i, l := range leaves { + names[i] = l.Name + } + want := []string{"a", "b", "c", "d"} + if len(names) != len(want) { + t.Fatalf("got %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("names[%d] = %q, want %q", i, names[i], want[i]) + } + } +} + +func TestIngredientGroup_LeafIngredients(t *testing.T) { + t.Parallel() + g := &IngredientGroup{ + Title: "Top", + Ingredients: []Ingredient{{Name: "x"}}, + IngredientGroups: []IngredientGroup{ + {Title: "Sub", Ingredients: []Ingredient{{Name: "y"}, {Name: "z"}}}, + }, + } + leaves := g.LeafIngredients() + if len(leaves) != 3 { + t.Fatalf("got %d leaves, want 3", len(leaves)) + } +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..64fcaf1 --- /dev/null +++ b/render.go @@ -0,0 +1,31 @@ +package recipemd + +import ( + "strings" + "text/template" +) + +func renderFuncMap(rounding int) template.FuncMap { + return template.FuncMap{ + "join": strings.Join, + "deref": func(s *string) string { + if s == nil { + return "" + } + return *s + }, + "serializeAmount": func(a *Amount) string { + if a == nil { + return "" + } + return a.Serialize(rounding) + }, + "serializeYields": func(yields []Amount) string { + s := make([]string, len(yields)) + for i, y := range yields { + s[i] = y.Serialize(rounding) + } + return strings.Join(s, ", ") + }, + } +} diff --git a/render_html.go b/render_html.go new file mode 100644 index 0000000..75dc4cb --- /dev/null +++ b/render_html.go @@ -0,0 +1,167 @@ +package recipemd + +import ( + "bytes" + "fmt" + "html/template" + "strings" +) + +type htmlGroupCtx struct { + IngredientGroup + Level int +} + +func (g htmlGroupCtx) Subgroups() []htmlGroupCtx { + out := make([]htmlGroupCtx, len(g.IngredientGroups)) + for i, sg := range g.IngredientGroups { + out[i] = htmlGroupCtx{sg, g.Level + 1} + } + return out +} + +const htmlMainTmpl = `
+

{{ .Title }}

+{{- if or (deref .Description) .Tags .Yields }} +
+ {{- with deref .Description }} +
{{ renderMD . }}
+ {{- end }} + {{- if .Tags }} +

{{ join .Tags ", " }}

+ {{- end }} + {{- if .Yields }} +

{{ serializeYields .Yields }}

+ {{- end }} +
+{{- end }} +
+
+{{ template "ingredients" .Ingredients -}} +{{ template "groups" (topGroups .IngredientGroups) -}} +
+{{- with deref .Instructions }} +
+
{{ renderInstructionsMD . }}
+{{- end }} +
` + +const htmlIngredientsTmpl = `{{ if . -}} +
    +{{ range . -}} +
  • + {{- if .Amount }}{{ serializeAmount .Amount }} {{ end -}} + {{- if .Link }}{{ .Name }} + {{- else }}{{ .Name }}{{ end }} +
  • +{{ end -}} +
+{{ end }}` + +const htmlGroupsTmpl = `{{ range . -}} +
+{{ heading .Level .Title }} +{{ template "ingredients" .Ingredients -}} +{{ template "groups" .Subgroups }} +
+{{ end }}` + +// RenderHTML renders r as an HTML
element. +// +// Numeric amounts are rounded to rounding decimal places (trailing zeros are +// removed); pass a negative value to use full float64 precision. +// +// The Description and Instructions fields, which are stored as raw markdown +// strings, are converted to HTML using the same markdown processor that was +// configured on the [Parser]. Ingredient amounts are wrapped in and +// yields in , mirroring the emphasis encoding used in RecipeMD source. +// All elements carry CSS class attributes with the prefix "recipemd-" for +// styling. +// +// 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 +// 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. +// - 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 { + 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 { + out[i] = htmlGroupCtx{g, 2} + } + return out + } + + tmpl := template.Must(template.New("recipemd").Funcs(funcs).Parse(htmlMainTmpl)) + template.Must(tmpl.New("ingredients").Parse(htmlIngredientsTmpl)) + template.Must(tmpl.New("groups").Parse(htmlGroupsTmpl)) + + var buf bytes.Buffer + _ = tmpl.Execute(&buf, r) + return buf.String() +} + +func htmlFuncMap(p *Parser, rounding int, inj *inlineInjector) template.FuncMap { + return template.FuncMap{ + "join": strings.Join, + "deref": func(s *string) string { + if s == nil { + return "" + } + return *s + }, + "serializeAmount": func(a *Amount) string { + if a == nil { + return "" + } + return a.Serialize(rounding) + }, + "serializeYields": func(yields []Amount) string { + s := make([]string, len(yields)) + for i, y := range yields { + s[i] = y.Serialize(rounding) + } + return strings.Join(s, ", ") + }, + "renderMD": func(md string) template.HTML { + var buf bytes.Buffer + _ = 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_html_test.go b/render_html_test.go new file mode 100644 index 0000000..180072b --- /dev/null +++ b/render_html_test.go @@ -0,0 +1,214 @@ +package recipemd + +import ( + "strings" + "testing" +) + +func TestRenderHTML(t *testing.T) { + t.Parallel() + p := NewParser() + + t.Run("minimal", func(t *testing.T) { + t.Parallel() + r := &Recipe{ + Title: "Test", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "salt"}}, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTML(r, 3) + if got == "" { + t.Fatal("empty output") + } + if !strings.Contains(got, `class="recipemd-recipe"`) { + t.Error("missing recipe class") + } + if !strings.Contains(got, `class="recipemd-title"`) { + t.Error("missing title class") + } + if !strings.Contains(got, "Test") { + t.Error("missing title text") + } + if !strings.Contains(got, "salt") { + t.Error("missing ingredient") + } + if !strings.Contains(got, `class="recipemd-ingredient"`) { + t.Error("missing ingredient class") + } + }) + + t.Run("full recipe", func(t *testing.T) { + t.Parallel() + desc := "A great recipe." + instructions := "Mix well." + r := &Recipe{ + Title: "Guac", + Description: &desc, + Tags: []string{"sauce", "vegan"}, + Yields: []Amount{{Factor: 4, Unit: new("servings")}}, + Ingredients: []Ingredient{ + {Name: "avocado", Amount: &Amount{Factor: 2, Unit: new("cups")}}, + {Name: "salt"}, + }, + IngredientGroups: []IngredientGroup{ + { + Title: "Topping", + Ingredients: []Ingredient{{Name: "cilantro"}}, + IngredientGroups: []IngredientGroup{}, + }, + }, + Instructions: &instructions, + } + got := p.RenderHTML(r, 3) + if !strings.Contains(got, "Guac") { + t.Error("missing title") + } + if !strings.Contains(got, `class="recipemd-description"`) { + t.Error("missing description class") + } + if !strings.Contains(got, "A great recipe.") { + t.Error("missing description text") + } + if !strings.Contains(got, `class="recipemd-tags"`) { + t.Error("missing tags class") + } + if !strings.Contains(got, "sauce, vegan") { + t.Error("missing tags in em") + } + if !strings.Contains(got, `class="recipemd-yields"`) { + t.Error("missing yields class") + } + if !strings.Contains(got, "4 servings") { + t.Error("missing yields in strong") + } + if !strings.Contains(got, `class="recipemd-amount"`) { + t.Error("missing amount class") + } + if !strings.Contains(got, "Outer`) { + t.Errorf("missing h2 group title in: %s", got) + } + if !strings.Contains(got, `

Inner

`) { + t.Errorf("missing h3 nested group title in: %s", got) + } + }) + + t.Run("markdown in description and instructions", func(t *testing.T) { + t.Parallel() + desc := "A recipe with **bold** and *italic* text." + instructions := "## Step 1\n\nMix ingredients.\n\n## Step 2\n\nBake at 180°C." + r := &Recipe{ + Title: "Bake", + Description: &desc, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + got := p.RenderHTML(r, 3) + if !strings.Contains(got, "bold") { + t.Error("bold not rendered in description") + } + if !strings.Contains(got, "italic") { + t.Error("italic not rendered in description") + } + if !strings.Contains(got, "

Step 1

") { + t.Error("heading not rendered in instructions") + } + }) + + t.Run("html escaping in text fields", func(t *testing.T) { + t.Parallel() + r := &Recipe{ + Title: "Tom & Jerry's ", + Yields: []Amount{}, + Tags: []string{"sweet & sour"}, + Ingredients: []Ingredient{{Name: "sugar & spice"}}, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTML(r, 3) + if strings.Contains(got, "") { + t.Error("title should have < and > escaped") + } + if !strings.Contains(got, "Tom & Jerry") { + t.Error("& in title should be escaped") + } + }) + + t.Run("no preamble section when empty", func(t *testing.T) { + t.Parallel() + r := &Recipe{ + Title: "Plain", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "water"}}, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTML(r, 3) + if strings.Contains(got, `class="recipemd-preamble"`) { + t.Error("preamble should not be present when description/tags/yields are empty") + } + }) +} diff --git a/render_json.go b/render_json.go new file mode 100644 index 0000000..ec8b1a6 --- /dev/null +++ b/render_json.go @@ -0,0 +1,12 @@ +package recipemd + +import "encoding/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_json_test.go b/render_json_test.go new file mode 100644 index 0000000..6801b36 --- /dev/null +++ b/render_json_test.go @@ -0,0 +1,29 @@ +package recipemd + +import ( + "encoding/json" + "testing" +) + +func TestRenderJSON(t *testing.T) { + t.Parallel() + p := NewParser() + r := &Recipe{ + Title: "Test", + Yields: []Amount{{Factor: 4, Unit: new("servings")}}, + Tags: []string{"easy"}, + Ingredients: []Ingredient{{Name: "salt"}}, + IngredientGroups: []IngredientGroup{}, + } + got, err := p.RenderJSON(r) + if err != nil { + t.Fatal(err) + } + var parsed map[string]any + if err := json.Unmarshal(got, &parsed); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if parsed["title"] != "Test" { + t.Errorf("title = %v", parsed["title"]) + } +} diff --git a/render_markdown.go b/render_markdown.go new file mode 100644 index 0000000..ed0ac0c --- /dev/null +++ b/render_markdown.go @@ -0,0 +1,85 @@ +package recipemd + +import ( + "bytes" + "strings" + "text/template" +) + +type mdGroupCtx struct { + IngredientGroup + Level int +} + +func (g mdGroupCtx) Heading() string { + return strings.Repeat("#", g.Level) +} + +func (g mdGroupCtx) Subgroups() []mdGroupCtx { + out := make([]mdGroupCtx, len(g.IngredientGroups)) + for i, sg := range g.IngredientGroups { + out[i] = mdGroupCtx{sg, g.Level + 1} + } + return out +} + +const mdMainTmpl = `# {{ .Title }} +{{ with deref .Description }} +{{ . }} +{{ end }}{{ if .Tags }} +*{{ join .Tags ", " }}* +{{ end }}{{ if .Yields }} +**{{ serializeYields .Yields }}** +{{ end }} +--- +{{ template "ingredients" .Ingredients }}{{ template "groups" (topGroups .IngredientGroups) }}{{ with deref .Instructions }} +--- + +{{ . }} +{{ end }}` + +const mdIngredientsTmpl = `{{ if . }} +{{ range . }}- {{ if .Amount }}*{{ serializeAmount .Amount }}* {{ end }}{{ if .Link }}[{{ .Name }}]({{ deref .Link }}){{ else }}{{ .Name }}{{ end }} +{{ end }}{{ end }}` + +const mdGroupsTmpl = `{{ range . }} +{{ .Heading }} {{ .Title }} +{{ template "ingredients" .Ingredients }}{{ template "groups" (.Subgroups) }}{{ end }}` + +// 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]. +// +// 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)) + for i, g := range groups { + out[i] = mdGroupCtx{g, 2} + } + return out + } + + tmpl := template.Must(template.New("recipemd").Funcs(funcs).Parse(mdMainTmpl)) + template.Must(tmpl.New("ingredients").Parse(mdIngredientsTmpl)) + template.Must(tmpl.New("groups").Parse(mdGroupsTmpl)) + + var buf bytes.Buffer + _ = tmpl.Execute(&buf, r) + return buf.String() +} diff --git a/render_markdown_test.go b/render_markdown_test.go new file mode 100644 index 0000000..ef36e91 --- /dev/null +++ b/render_markdown_test.go @@ -0,0 +1,96 @@ +package recipemd + +import ( + "strings" + "testing" +) + +func TestRenderMarkdown(t *testing.T) { + t.Parallel() + p := NewParser() + + t.Run("minimal", func(t *testing.T) { + t.Parallel() + r := &Recipe{ + Title: "Test", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "salt"}}, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderMarkdown(r, 3) + if got == "" { + t.Fatal("empty output") + } + if !strings.Contains(got, "# Test") { + t.Error("missing title") + } + if !strings.Contains(got, "- salt") { + t.Error("missing ingredient") + } + }) + + t.Run("full recipe", func(t *testing.T) { + t.Parallel() + desc := "A great recipe." + instructions := "Mix well." + r := &Recipe{ + Title: "Guac", + Description: &desc, + Tags: []string{"sauce", "vegan"}, + Yields: []Amount{{Factor: 4, Unit: new("servings")}}, + Ingredients: []Ingredient{ + {Name: "avocado", Amount: &Amount{Factor: 1, Unit: nil}}, + {Name: "salt"}, + }, + IngredientGroups: []IngredientGroup{ + { + Title: "Topping", + Ingredients: []Ingredient{{Name: "cilantro"}}, + IngredientGroups: []IngredientGroup{}, + }, + }, + Instructions: &instructions, + } + got := p.RenderMarkdown(r, 3) + if !strings.Contains(got, "# Guac") { + t.Error("missing title") + } + if !strings.Contains(got, "A great recipe.") { + t.Error("missing description") + } + if !strings.Contains(got, "*sauce, vegan*") { + t.Error("missing tags") + } + if !strings.Contains(got, "**4 servings**") { + t.Error("missing yields") + } + if !strings.Contains(got, "- *1* avocado") { + t.Error("missing amount ingredient") + } + if !strings.Contains(got, "- salt") { + t.Error("missing plain ingredient") + } + if !strings.Contains(got, "## Topping") { + t.Error("missing ingredient group heading") + } + if !strings.Contains(got, "Mix well.") { + t.Error("missing instructions") + } + }) + + t.Run("ingredient with link", func(t *testing.T) { + t.Parallel() + r := &Recipe{ + Title: "T", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "sauce", Link: new("sauce.md")}}, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderMarkdown(r, 3) + if !strings.Contains(got, "[sauce](sauce.md)") { + t.Errorf("missing link rendering in: %s", got) + } + }) +} diff --git a/testdata/canonical/commonmark_fenced_code_blocks.json b/testdata/canonical/commonmark_fenced_code_blocks.json new file mode 100644 index 0000000..76b8c09 --- /dev/null +++ b/testdata/canonical/commonmark_fenced_code_blocks.json @@ -0,0 +1,15 @@ +{ + "ingredients": [ + { + "name": "foo", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "title": "Bug", + "description": " `````\n abc\n d\n `````", + "yields": [], + "tags": [], + "instructions": " ```\n abc\n d\n```" +} diff --git a/testdata/canonical/commonmark_fenced_code_blocks.md b/testdata/canonical/commonmark_fenced_code_blocks.md new file mode 100644 index 0000000..269f00e --- /dev/null +++ b/testdata/canonical/commonmark_fenced_code_blocks.md @@ -0,0 +1,17 @@ +# Bug + + ````` + abc + d + ````` + +--- + +- foo + +--- + + ``` + abc + d +``` \ No newline at end of file diff --git a/testdata/canonical/commonmark_reference_images.json b/testdata/canonical/commonmark_reference_images.json new file mode 100644 index 0000000..f5eaf82 --- /dev/null +++ b/testdata/canonical/commonmark_reference_images.json @@ -0,0 +1,18 @@ +{ + "title": "Title", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "Thing", + "amount": { + "factor": "1", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": "Image: ![Step 1][step_1].\n\n\n[step_1]: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TRZEWBzuoOGSoThakijhqFYpQIdQKrTqYXPoFTRqSFBdHwbXg4Mdi1cHFWVcHV0EQ/ABxdHJSdJES/5cUWsR4cNyPd/ced+8AoVFhmtU1AWi6baaTCTGbWxV7XiFgCGHEEZOZZcxJUgq+4+seAb7exXiW/7k/R1jNWwwIiMSzzDBt4g3i6U3b4LxPHGElWSU+Jx436YLEj1xXPH7jXHRZ4JkRM5OeJ44Qi8UOVjqYlUyNeIo4qmo65QtZj1XOW5y1So217slfGMrrK8tcpzmCJBaxBAkiFNRQRgU2YrTqpFhI037Cxz/s+iVyKeQqg5FjAVVokF0/+B/87tYqTMa9pFAC6H5xnI9RoGcXaNYd5/vYcZonQPAZuNLb/moDmPkkvd7WokdA/zZwcd3WlD3gcgcYfDJkU3alIE2hUADez+ibcsDALdC35vXW2sfpA5ChrlI3wMEhMFak7HWfd/d29vbvmVZ/P8BWcsbntpxwAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH5gIZDTQc/OsloQAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAASbSURBVHja7ZpfKHtvHMffB1FqirSy3MzFXAxT86fIHW4kl9wSteRK7dqtQruhVrsgLia18v8GSdLMNhNaoRWN5c+GHWG2c/b53Z2f2bCD7+9Pnk+dep49z+dzPu/X2fPvdDgiIvxiy8AvNwaAAWAAGAAGgAFgABgABoABYAAYAAagubkZHMeB4zjEYrH/rSg5Otg/gAFgAH63cTzPU15eXtoOoVAIBQUFKdvC4TB2dnbgcDhwcHCAvb09qNVq6HQ61NXVob6+HoWFhWndY3t7Gy6XC263G0dHR1CpVNBoNFCpVNBqtSgvL4darUZ2djYA4OHhAV/SwfM8AUj7CoVC9NZEUaT5+XnSaDQf+iqVSpqdnSVRFOk9W15eJqVSmVYuVqtV8vuqjm8DEASBhoeHZcUYHx9PKd5ut8uK8yMAXifQ1NQkdYhGo5SO2Wy2hCc8OTlJPp+PHh8fSRRFikQidHZ2RuPj4wkJuN3upFhdXV1S+8DAAHm9XuJ5ngRBIEEQKBwOUyAQIJfLRWazmebn51PmJEfHtwAEg0FSKBQEgCoqKsjn833Y3+l0SvF7enooHo9LbbFYTGpra2ujWCxGXzU5Or61Cqyvr+Ph4QEAYDKZUFJS8mH/qqoqDA4OAgAsFguurq7+no05DkqlEgCgVCqRlZX1318G19bWEsSlY7W1tVLZ7/dL5czMTPT390twZmZmEA6H/ziAL2OOxWIwm81SXavVSuVoNCqVn5+fpWXqrfE8n1Dv7OzE2toaVlZW0N7eDgDo7e1FbW0tSkpKoFarUVRUhIyMH9y+fHXs3N/fy5p1U12Li4sp405NTVF1dXVKn5aWFrLZbBQOh39kDvgygNvb228DWFhYeDd+NBoln89Hq6urNDo6Sq2trQm+DQ0NdHx8/O8BiEQiUl+NRvPh5uYnLB6P08XFBVksFum+9fX19Pj4+HOrgJyxlZOTg46ODgDA8fExAoHAn92zcxxUKhW6u7sxNDQEANja2sLh4WHyzC5DR0LP13tpURQ/dW5tbZXKc3Nz/9gBRqfTSeXr6+ukdjk6EgAUFRVJ5bu7u08TaWxshEKhAAD09fVhaWkJ6XxvcXZ2homJiYTfLi8vMT09nXK1eDNpw+l0SvXc3NykPrJ0vB4Pr8fX8PAwXV9fkyAIaW+FAZDRaCSHw0E3NzcUjUZJFEV6enqi8/Nz2tjYIKPRSADIYDAkxLm4uCAAVFxcTKOjo7S7u0vBYJBeXl5IEATieZ68Xi8NDg4m3M/v9yflJEdHAoD9/X3ZhyFRFGlsbEz2CvAeADmXxWJJKUqODrydaUdGRmQfh+PxOK2vr1NDQ0NaiZtMJrq8vEyKsbe3RwaD4VN/hUJBVqv13acqRwf39iOpeDwOj8eDzc1NOBwOeL1e7O/vp/VCJBKJwOPxwOPxwO124+TkBKFQCGVlZaiurkZlZSX0ej3y8/M/HOOnp6dwuVyw2+1wuVy4urpCaWkp9Ho99Ho9ampqpHPDe5auDo59JcZeijIADAADwAAwAAwAA8AAMAAMAAPAADAADAADwAAwAAwAA8AAMAAMAAPwS+wvNfSdDz8/sGoAAAAASUVORK5CYII=" +} \ No newline at end of file diff --git a/testdata/canonical/commonmark_reference_images.md b/testdata/canonical/commonmark_reference_images.md new file mode 100644 index 0000000..c3c94fe --- /dev/null +++ b/testdata/canonical/commonmark_reference_images.md @@ -0,0 +1,8 @@ +# Title +--- +- *1* Thing +--- +Image: ![Step 1][step_1]. + + +[step_1]: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TRZEWBzuoOGSoThakijhqFYpQIdQKrTqYXPoFTRqSFBdHwbXg4Mdi1cHFWVcHV0EQ/ABxdHJSdJES/5cUWsR4cNyPd/ced+8AoVFhmtU1AWi6baaTCTGbWxV7XiFgCGHEEZOZZcxJUgq+4+seAb7exXiW/7k/R1jNWwwIiMSzzDBt4g3i6U3b4LxPHGElWSU+Jx436YLEj1xXPH7jXHRZ4JkRM5OeJ44Qi8UOVjqYlUyNeIo4qmo65QtZj1XOW5y1So217slfGMrrK8tcpzmCJBaxBAkiFNRQRgU2YrTqpFhI037Cxz/s+iVyKeQqg5FjAVVokF0/+B/87tYqTMa9pFAC6H5xnI9RoGcXaNYd5/vYcZonQPAZuNLb/moDmPkkvd7WokdA/zZwcd3WlD3gcgcYfDJkU3alIE2hUADez+ibcsDALdC35vXW2sfpA5ChrlI3wMEhMFak7HWfd/d29vbvmVZ/P8BWcsbntpxwAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH5gIZDTQc/OsloQAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAASbSURBVHja7ZpfKHtvHMffB1FqirSy3MzFXAxT86fIHW4kl9wSteRK7dqtQruhVrsgLia18v8GSdLMNhNaoRWN5c+GHWG2c/b53Z2f2bCD7+9Pnk+dep49z+dzPu/X2fPvdDgiIvxiy8AvNwaAAWAAGAAGgAFgABgABoABYAAYAAagubkZHMeB4zjEYrH/rSg5Otg/gAFgAH63cTzPU15eXtoOoVAIBQUFKdvC4TB2dnbgcDhwcHCAvb09qNVq6HQ61NXVob6+HoWFhWndY3t7Gy6XC263G0dHR1CpVNBoNFCpVNBqtSgvL4darUZ2djYA4OHhAV/SwfM8AUj7CoVC9NZEUaT5+XnSaDQf+iqVSpqdnSVRFOk9W15eJqVSmVYuVqtV8vuqjm8DEASBhoeHZcUYHx9PKd5ut8uK8yMAXifQ1NQkdYhGo5SO2Wy2hCc8OTlJPp+PHh8fSRRFikQidHZ2RuPj4wkJuN3upFhdXV1S+8DAAHm9XuJ5ngRBIEEQKBwOUyAQIJfLRWazmebn51PmJEfHtwAEg0FSKBQEgCoqKsjn833Y3+l0SvF7enooHo9LbbFYTGpra2ujWCxGXzU5Or61Cqyvr+Ph4QEAYDKZUFJS8mH/qqoqDA4OAgAsFguurq7+no05DkqlEgCgVCqRlZX1318G19bWEsSlY7W1tVLZ7/dL5czMTPT390twZmZmEA6H/ziAL2OOxWIwm81SXavVSuVoNCqVn5+fpWXqrfE8n1Dv7OzE2toaVlZW0N7eDgDo7e1FbW0tSkpKoFarUVRUhIyMH9y+fHXs3N/fy5p1U12Li4sp405NTVF1dXVKn5aWFrLZbBQOh39kDvgygNvb228DWFhYeDd+NBoln89Hq6urNDo6Sq2trQm+DQ0NdHx8/O8BiEQiUl+NRvPh5uYnLB6P08XFBVksFum+9fX19Pj4+HOrgJyxlZOTg46ODgDA8fExAoHAn92zcxxUKhW6u7sxNDQEANja2sLh4WHyzC5DR0LP13tpURQ/dW5tbZXKc3Nz/9gBRqfTSeXr6+ukdjk6EgAUFRVJ5bu7u08TaWxshEKhAAD09fVhaWkJ6XxvcXZ2homJiYTfLi8vMT09nXK1eDNpw+l0SvXc3NykPrJ0vB4Pr8fX8PAwXV9fkyAIaW+FAZDRaCSHw0E3NzcUjUZJFEV6enqi8/Nz2tjYIKPRSADIYDAkxLm4uCAAVFxcTKOjo7S7u0vBYJBeXl5IEATieZ68Xi8NDg4m3M/v9yflJEdHAoD9/X3ZhyFRFGlsbEz2CvAeADmXxWJJKUqODrydaUdGRmQfh+PxOK2vr1NDQ0NaiZtMJrq8vEyKsbe3RwaD4VN/hUJBVqv13acqRwf39iOpeDwOj8eDzc1NOBwOeL1e7O/vp/VCJBKJwOPxwOPxwO124+TkBKFQCGVlZaiurkZlZSX0ej3y8/M/HOOnp6dwuVyw2+1wuVy4urpCaWkp9Ho99Ho9ampqpHPDe5auDo59JcZeijIADAADwAAwAAwAA8AAMAAMAAPAADAADAADwAAwAAwAA8AAMAAMAAPwS+wvNfSdDz8/sGoAAAAASUVORK5CYII= \ No newline at end of file diff --git a/testdata/canonical/commonmark_reference_links.json b/testdata/canonical/commonmark_reference_links.json new file mode 100644 index 0000000..7580e19 --- /dev/null +++ b/testdata/canonical/commonmark_reference_links.json @@ -0,0 +1,18 @@ +{ + "title": "Title", + "description": "[step_0]: https://example.org\n\nLinks: [Step 0][step_0]., [Step 1][step_1].\n\n[step_1]: https://example.org", + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "Thing", + "amount": { + "factor": "1", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": "[step_2]: https://example.org\n\nLinks: [Step 2][step_2]., [Step 3][step_3].\n\n[step_3]: https://example.org" +} \ No newline at end of file diff --git a/testdata/canonical/commonmark_reference_links.md b/testdata/canonical/commonmark_reference_links.md new file mode 100644 index 0000000..c26811f --- /dev/null +++ b/testdata/canonical/commonmark_reference_links.md @@ -0,0 +1,19 @@ +# Title + +[step_0]: https://example.org + +Links: [Step 0][step_0]., [Step 1][step_1]. + +[step_1]: https://example.org + +--- + +- *1* Thing + +--- + +[step_2]: https://example.org + +Links: [Step 2][step_2]., [Step 3][step_3]. + +[step_3]: https://example.org diff --git a/testdata/canonical/empty.invalid.md b/testdata/canonical/empty.invalid.md new file mode 100644 index 0000000..e69de29 diff --git a/testdata/canonical/ingredients.json b/testdata/canonical/ingredients.json new file mode 100644 index 0000000..a1f571a --- /dev/null +++ b/testdata/canonical/ingredients.json @@ -0,0 +1,52 @@ +{ + "title": "Recipe with ingredients", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "water", + "amount": { + "factor": "20", + "unit": "ml" + }, + "link": null + }, + { + "name": "earl grey, hot", + "amount": { + "factor": "1", + "unit": "cup" + }, + "link": null + }, + { + "name": "coffee", + "amount": { + "factor": "1.5", + "unit": "cup" + }, + "link": null + }, + { + "name": "cheese", + "amount": { + "factor": "0.25", + "unit": "kg" + }, + "link": null + }, + { + "name": "salt", + "amount": null, + "link": null + }, + { + "name": "ingredients may contain *markdown*", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/canonical/ingredients.md b/testdata/canonical/ingredients.md new file mode 100644 index 0000000..35a405a --- /dev/null +++ b/testdata/canonical/ingredients.md @@ -0,0 +1,10 @@ +# Recipe with ingredients + +--- + +- *20 ml* water +- *1 cup* earl grey, hot +- *1 1/2 cup* coffee +- *¼ kg* cheese +- salt +- ingredients may contain *markdown* \ No newline at end of file diff --git a/testdata/canonical/ingredients_amount_no_factor.invalid.md b/testdata/canonical/ingredients_amount_no_factor.invalid.md new file mode 100644 index 0000000..fec37e6 --- /dev/null +++ b/testdata/canonical/ingredients_amount_no_factor.invalid.md @@ -0,0 +1,5 @@ +# Recipe with ingredients + +--- + +- *amount* without factor is invalid \ No newline at end of file diff --git a/testdata/canonical/ingredients_empty.invalid.md b/testdata/canonical/ingredients_empty.invalid.md new file mode 100644 index 0000000..f810260 --- /dev/null +++ b/testdata/canonical/ingredients_empty.invalid.md @@ -0,0 +1,7 @@ +# The Empty Ingredient + +--- + +- + +--- \ No newline at end of file diff --git a/testdata/canonical/ingredients_groups.json b/testdata/canonical/ingredients_groups.json new file mode 100644 index 0000000..bd56b3f --- /dev/null +++ b/testdata/canonical/ingredients_groups.json @@ -0,0 +1,88 @@ +{ + "title": "Recipe with ingredient groups", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "ingredient 0", + "amount": null, + "link": null + } + ], + "ingredient_groups": [ + { + "title": "Group 1", + "ingredients": [ + { + "name": "ingredient 1", + "amount": null, + "link": null + }, + { + "name": "ingredient 2", + "amount": null, + "link": null + } + ], + "ingredient_groups": [ + { + "title": "Subgroup 1.1", + "ingredients": [ + { + "name": "ingredient 3", + "amount": null, + "link": null + }, + { + "name": "ingredient 4", + "amount": null, + "link": null + } + ], + "ingredient_groups": [ + { + "title": "Subgroup 1.1.1 (Two level deeper headline, still one lever deeper group)", + "ingredients": [ + { + "name": "ingredient 5", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + }, + { + "title": "Subgroup 1.1.2 (One level deeper headline)", + "ingredients": [ + { + "name": "ingredient 6", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + } + ] + } + ] + }, + { + "title": "Group 2", + "ingredients": [ + { + "name": "ingredient 7", + "amount": null, + "link": null + }, + { + "name": "ingredient 8", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + } + ], + "instructions": null +} diff --git a/testdata/canonical/ingredients_groups.md b/testdata/canonical/ingredients_groups.md new file mode 100644 index 0000000..9790acc --- /dev/null +++ b/testdata/canonical/ingredients_groups.md @@ -0,0 +1,28 @@ +# Recipe with ingredient groups + +--- + +- ingredient 0 + +## Group 1 + +- ingredient 1 +- ingredient 2 + +### Subgroup 1.1 + +- ingredient 3 +- ingredient 4 + +##### Subgroup 1.1.1 (Two level deeper headline, still one lever deeper group) + +- ingredient 5 + +#### Subgroup 1.1.2 (One level deeper headline) + +- ingredient 6 + +# Group 2 + +- ingredient 7 +- ingredient 8 \ No newline at end of file diff --git a/testdata/canonical/ingredients_groups_multiple_lists.json b/testdata/canonical/ingredients_groups_multiple_lists.json new file mode 100644 index 0000000..d630f0e --- /dev/null +++ b/testdata/canonical/ingredients_groups_multiple_lists.json @@ -0,0 +1,52 @@ +{ + "ingredients": [ + { + "name": "ingredient 0", + "amount": null, + "link": null + } + ], + "ingredient_groups": [ + { + "ingredients": [ + { + "name": "ingredient 1", + "amount": null, + "link": null + }, + { + "name": "ingredient 2", + "amount": null, + "link": null + }, + { + "name": "ingredient 3 (2nd list, ingredients go into same group)", + "amount": null, + "link": null + }, + { + "name": "ingredient 4", + "amount": null, + "link": null + }, + { + "name": "ingredient 5 (3nd list, ingredients go into same group)", + "amount": null, + "link": null + }, + { + "name": "ingredient 6", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "title": "Group 1" + } + ], + "title": "Recipe with ingredient groups with multiple lists", + "description": null, + "yields": [], + "tags": [], + "instructions": null +} \ No newline at end of file diff --git a/testdata/canonical/ingredients_groups_multiple_lists.md b/testdata/canonical/ingredients_groups_multiple_lists.md new file mode 100644 index 0000000..38b7b2f --- /dev/null +++ b/testdata/canonical/ingredients_groups_multiple_lists.md @@ -0,0 +1,16 @@ +# Recipe with ingredient groups with multiple lists + +--- + +- ingredient 0 + +## Group 1 + +- ingredient 1 +- ingredient 2 + +- ingredient 3 (2nd list, ingredients go into same group) +- ingredient 4 + +- ingredient 5 (3nd list, ingredients go into same group) +- ingredient 6 \ No newline at end of file diff --git a/testdata/canonical/ingredients_links.json b/testdata/canonical/ingredients_links.json new file mode 100644 index 0000000..98896b3 --- /dev/null +++ b/testdata/canonical/ingredients_links.json @@ -0,0 +1,48 @@ +{ + "title": "Ingredients with Links", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "link to something", + "amount": null, + "link": "./another_recipe.md" + }, + { + "name": "link to something else", + "amount": null, + "link": "http://example.org" + }, + { + "name": "link to something with amount", + "amount": { + "factor": "5", + "unit": "ml" + }, + "link": "http://example.org" + }, + { + "name": "link with spaces in target", + "amount": null, + "link": "./foo%20bar.md" + }, + { + "name": "link with title that is ignored", + "amount": null, + "link": "./foo.md" + }, + { + "name": "[not parsed as a link](http://example.org) due to additional text after it", + "amount": null, + "link": null + }, + { + "name": "[not parsed as a link](http://example.org)\n \n due to second paragraph", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} \ No newline at end of file diff --git a/testdata/canonical/ingredients_links.md b/testdata/canonical/ingredients_links.md new file mode 100644 index 0000000..b979f7a --- /dev/null +++ b/testdata/canonical/ingredients_links.md @@ -0,0 +1,19 @@ +# Ingredients with Links + +--- + +- [link to something](./another_recipe.md) + +- [link to something else](http://example.org) + +- *5 ml* [link to something with amount](http://example.org) + +- [link with spaces in target](<./foo bar.md>) + +- [link with title that is ignored](<./foo.md> "This is allowed but not represented in the parse result") + +- [not parsed as a link](http://example.org) due to additional text after it + +- [not parsed as a link](http://example.org) + + due to second paragraph \ No newline at end of file diff --git a/testdata/canonical/ingredients_multiline.json b/testdata/canonical/ingredients_multiline.json new file mode 100644 index 0000000..e369a96 --- /dev/null +++ b/testdata/canonical/ingredients_multiline.json @@ -0,0 +1,46 @@ +{ + "title": "Multiline Ingredients", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "This is a long ingredient\n\n it spans multiple paragraphs\n \n this may be unusual but is valid", + "amount": null, + "link": null + }, + { + "name": "this is a normal boring ingredient", + "amount": null, + "link": null + }, + { + "name": "is an amount that\n\n is the amount of *this* ingredient", + "amount": { + "factor": "5", + "unit": "ml" + }, + "link": null + }, + { + "name": "is an amount that stands alone on the first line", + "amount": { + "factor": "5", + "unit": "grams" + }, + "link": null + }, + { + "name": "this is a link", + "amount": null, + "link": "some_other_recipe" + }, + { + "name": "[this is not a link](some_other_recipe)\n\n since links must wrap the whole ingredient text", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/canonical/ingredients_multiline.md b/testdata/canonical/ingredients_multiline.md new file mode 100644 index 0000000..1f5515c --- /dev/null +++ b/testdata/canonical/ingredients_multiline.md @@ -0,0 +1,28 @@ +# Multiline Ingredients + +--- + +- This is a long ingredient + + it spans multiple paragraphs + + this may be unusual but is valid + + +- this is a normal boring ingredient + + +- *5ml* is an amount that + + is the amount of *this* ingredient + +- *5 grams* + + is an amount that stands alone on the first line + +- [this is a link](some_other_recipe) + + +- [this is not a link](some_other_recipe) + + since links must wrap the whole ingredient text \ No newline at end of file diff --git a/testdata/canonical/ingredients_no_divider.invalid.md b/testdata/canonical/ingredients_no_divider.invalid.md new file mode 100644 index 0000000..44fa776 --- /dev/null +++ b/testdata/canonical/ingredients_no_divider.invalid.md @@ -0,0 +1,3 @@ +# Title is okay + +This is a description and there is no ingredient divider \ No newline at end of file diff --git a/testdata/canonical/ingredients_no_name.invalid.md b/testdata/canonical/ingredients_no_name.invalid.md new file mode 100644 index 0000000..37d9313 --- /dev/null +++ b/testdata/canonical/ingredients_no_name.invalid.md @@ -0,0 +1,7 @@ +# The Nameless Ingredient + +--- + +- *5 nothings* + +--- \ No newline at end of file diff --git a/testdata/canonical/ingredients_numbered.json b/testdata/canonical/ingredients_numbered.json new file mode 100644 index 0000000..8c4fe11 --- /dev/null +++ b/testdata/canonical/ingredients_numbered.json @@ -0,0 +1,52 @@ +{ + "title": "Recipe with ingredients", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "water", + "amount": { + "factor": "20", + "unit": "ml" + }, + "link": null + }, + { + "name": "earl grey, hot", + "amount": { + "factor": "1", + "unit": "cup" + }, + "link": null + }, + { + "name": "coffee", + "amount": { + "factor": "1.5", + "unit": "cup" + }, + "link": null + }, + { + "name": "cheese", + "amount": { + "factor": "0.25", + "unit": "kg" + }, + "link": null + }, + { + "name": "salt", + "amount": null, + "link": null + }, + { + "name": "ingredients may contain *markdown*", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} \ No newline at end of file diff --git a/testdata/canonical/ingredients_numbered.md b/testdata/canonical/ingredients_numbered.md new file mode 100644 index 0000000..913d2ac --- /dev/null +++ b/testdata/canonical/ingredients_numbered.md @@ -0,0 +1,10 @@ +# Recipe with ingredients + +--- + +1. *20 ml* water +1. *1 cup* earl grey, hot +1. *1 1/2 cup* coffee +1. *¼ kg* cheese +1. salt +1. ingredients may contain *markdown* \ No newline at end of file diff --git a/testdata/canonical/ingredients_sublist.json b/testdata/canonical/ingredients_sublist.json new file mode 100644 index 0000000..b88e71f --- /dev/null +++ b/testdata/canonical/ingredients_sublist.json @@ -0,0 +1,34 @@ +{ + "title": "Recipe with ingredients", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "water", + "amount": { + "factor": "20", + "unit": "ml" + }, + "link": null + }, + { + "name": "earl grey, hot\n - nested sublist\n - these items are not new ingredients, but part of the contents \n of the top-level ingredient\n - same goes for deeper nesting", + "amount": { + "factor": "1", + "unit": "cup" + }, + "link": null + }, + { + "name": "coffee", + "amount": { + "factor": "1.5", + "unit": "cup" + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} \ No newline at end of file diff --git a/testdata/canonical/ingredients_sublist.md b/testdata/canonical/ingredients_sublist.md new file mode 100644 index 0000000..ed131bd --- /dev/null +++ b/testdata/canonical/ingredients_sublist.md @@ -0,0 +1,11 @@ +# Recipe with ingredients + +--- + +- *20 ml* water +- *1 cup* earl grey, hot + - nested sublist + - these items are not new ingredients, but part of the contents + of the top-level ingredient + - same goes for deeper nesting +- *1 1/2 cup* coffee \ No newline at end of file diff --git a/testdata/canonical/instructions.json b/testdata/canonical/instructions.json new file mode 100644 index 0000000..127a23f --- /dev/null +++ b/testdata/canonical/instructions.json @@ -0,0 +1,9 @@ +{ + "title": "Instructions", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": "The instructions are not processed in any way.\n\n## Can they contain markdown?\n\nYes, absolutely!" +} diff --git a/testdata/canonical/instructions.md b/testdata/canonical/instructions.md new file mode 100644 index 0000000..0c525ad --- /dev/null +++ b/testdata/canonical/instructions.md @@ -0,0 +1,11 @@ +# Instructions + +--- + +--- + +The instructions are not processed in any way. + +## Can they contain markdown? + +Yes, absolutely! \ No newline at end of file diff --git a/testdata/canonical/instructions_no_divider.invalid.md b/testdata/canonical/instructions_no_divider.invalid.md new file mode 100644 index 0000000..27121b7 --- /dev/null +++ b/testdata/canonical/instructions_no_divider.invalid.md @@ -0,0 +1,5 @@ +# Title is good + +--- + +A paragraph is not valid in the ingredients section, so we're missing the divider before instructions. \ No newline at end of file diff --git a/testdata/canonical/recipe.json b/testdata/canonical/recipe.json new file mode 100644 index 0000000..5718c29 --- /dev/null +++ b/testdata/canonical/recipe.json @@ -0,0 +1,97 @@ +{ + "title": "Title", + "description": "The description describes this recipe. It is delicious!\n\nIt can have multiple lines an may even include pictures.\n\n", + "yields": [ + { + "factor": "5", + "unit": "cups" + }, + { + "factor": "20", + "unit": "ml" + }, + { + "factor": "5.5", + "unit": "Tassen" + } + ], + "tags": [ + "vegetarian", + "vegan", + "not a real recipe" + ], + "ingredients": [ + { + "name": "ungrouped ingredient", + "amount": { + "factor": "5", + "unit": null + }, + "link": null + }, + { + "name": "grouped ingredient", + "amount": { + "factor": "5.2", + "unit": "ml" + }, + "link": null + } + ], + "ingredient_groups": [ + { + "title": "Group 1", + "ingredients": [ + { + "name": "link ingredient", + "amount": { + "factor": "1", + "unit": null + }, + "link": "./ingredients.md" + }, + { + "name": "unit is optional", + "amount": null, + "link": null + } + ], + "ingredient_groups": [ + { + "title": "Subgroup 1.1", + "ingredients": [ + { + "name": "ingredient", + "amount": { + "factor": "1.25", + "unit": "ml" + }, + "link": null + } + ], + "ingredient_groups": [] + } + ] + }, + { + "title": "Group 2", + "ingredients": [ + { + "name": "text isn't optional", + "amount": null, + "link": null + }, + { + "name": "amount is valid without unit", + "amount": { + "factor": "1", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [] + } + ], + "instructions": "Instructions are very instructive." +} diff --git a/testdata/canonical/recipe.md b/testdata/canonical/recipe.md new file mode 100644 index 0000000..83656a9 --- /dev/null +++ b/testdata/canonical/recipe.md @@ -0,0 +1,34 @@ +# Title + +The description describes this recipe. It is delicious! + +It can have multiple lines an may even include pictures. + + + +*vegetarian, vegan, not a real recipe* + +**5 cups, 20 ml, 5.5 Tassen** + +--- + +- *5* ungrouped ingredient +- *5.2 ml* grouped ingredient + +## Group 1 + +- *1* [link ingredient](./ingredients.md) +- unit is optional + +### Subgroup 1.1 + +- *1.25 ml* ingredient + +## Group 2 + +- text isn't optional +- *1* amount is valid without unit + +--- + +Instructions are very instructive. \ No newline at end of file diff --git a/testdata/canonical/tags.json b/testdata/canonical/tags.json new file mode 100644 index 0000000..46a3a2b --- /dev/null +++ b/testdata/canonical/tags.json @@ -0,0 +1,16 @@ +{ + "title": "Tags", + "description": null, + "yields": [], + "tags": [ + "tag1", + "tag2", + "tag3", + "tag4", + "tag with special! char", + "tag5" + ], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/canonical/tags.md b/testdata/canonical/tags.md new file mode 100644 index 0000000..0757553 --- /dev/null +++ b/testdata/canonical/tags.md @@ -0,0 +1,6 @@ +# Tags + +*tag1, tag2, tag3, tag4, tag with special! char, tag5* + +--- + diff --git a/testdata/canonical/tags_multiple.invalid.md b/testdata/canonical/tags_multiple.invalid.md new file mode 100644 index 0000000..7fd488b --- /dev/null +++ b/testdata/canonical/tags_multiple.invalid.md @@ -0,0 +1,10 @@ +# Multiple Tag Paragraphs + +A recipe may specify tags only once + +*tag1, tag2, tag3, tag4, tag5* + +*more tags!, how's that supposed to work?* + +--- + diff --git a/testdata/canonical/tags_no_partial.json b/testdata/canonical/tags_no_partial.json new file mode 100644 index 0000000..40414ed --- /dev/null +++ b/testdata/canonical/tags_no_partial.json @@ -0,0 +1,9 @@ +{ + "title": "Tags", + "description": "*tag1, tag2, tag3, tag4* some other stuff", + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} \ No newline at end of file diff --git a/testdata/canonical/tags_no_partial.md b/testdata/canonical/tags_no_partial.md new file mode 100644 index 0000000..3424ac4 --- /dev/null +++ b/testdata/canonical/tags_no_partial.md @@ -0,0 +1,6 @@ +# Tags + +*tag1, tag2, tag3, tag4* some other stuff + +--- + diff --git a/testdata/canonical/tags_splitting.json b/testdata/canonical/tags_splitting.json new file mode 100644 index 0000000..ee29151 --- /dev/null +++ b/testdata/canonical/tags_splitting.json @@ -0,0 +1,13 @@ +{ + "ingredients": [], + "ingredient_groups": [], + "title": "Tags splitting", + "description": "Commas are not a tag separator when between two numbers", + "yields": [], + "tags": [ + "tag1,1", + "tag1,2", + "tag1,3" + ], + "instructions": null +} diff --git a/testdata/canonical/tags_splitting.md b/testdata/canonical/tags_splitting.md new file mode 100644 index 0000000..e1fb137 --- /dev/null +++ b/testdata/canonical/tags_splitting.md @@ -0,0 +1,7 @@ +# Tags splitting + +Commas are not a tag separator when between two numbers + +*tag1,1, tag1,2, tag1,3* + +--- \ No newline at end of file diff --git a/testdata/canonical/tags_yields.json b/testdata/canonical/tags_yields.json new file mode 100644 index 0000000..f826d27 --- /dev/null +++ b/testdata/canonical/tags_yields.json @@ -0,0 +1,37 @@ +{ + "title": "Tags then Yields", + "description": null, + "yields": [ + { + "factor": "1.2", + "unit": "cups" + }, + { + "factor": "1.5", + "unit": "Tassen" + }, + { + "factor": "1.25", + "unit": "servings" + }, + { + "factor": "5", + "unit": "servings" + }, + { + "factor": "5", + "unit": null + } + ], + "tags": [ + "tag1", + "tag2", + "tag3", + "tag4", + "tag with special! char", + "tag5" + ], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/canonical/tags_yields.md b/testdata/canonical/tags_yields.md new file mode 100644 index 0000000..203d690 --- /dev/null +++ b/testdata/canonical/tags_yields.md @@ -0,0 +1,8 @@ +# Tags then Yields + +*tag1, tag2, tag3, tag4, tag with special! char, tag5* + +**1.2 cups, 1,5 Tassen, 1 1/4 servings, 5 servings, 5** + +--- + diff --git a/testdata/canonical/title.json b/testdata/canonical/title.json new file mode 100644 index 0000000..8a333b9 --- /dev/null +++ b/testdata/canonical/title.json @@ -0,0 +1,9 @@ +{ + "title": "The Most Useless Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/canonical/title.md b/testdata/canonical/title.md new file mode 100644 index 0000000..e07e420 --- /dev/null +++ b/testdata/canonical/title.md @@ -0,0 +1,3 @@ +# The Most Useless Recipe + +--- \ No newline at end of file diff --git a/testdata/canonical/title_second_level_heading.invalid.md b/testdata/canonical/title_second_level_heading.invalid.md new file mode 100644 index 0000000..61e0656 --- /dev/null +++ b/testdata/canonical/title_second_level_heading.invalid.md @@ -0,0 +1 @@ +## This is not a valid title \ No newline at end of file diff --git a/testdata/canonical/title_setext.json b/testdata/canonical/title_setext.json new file mode 100644 index 0000000..dfe9979 --- /dev/null +++ b/testdata/canonical/title_setext.json @@ -0,0 +1,9 @@ +{ + "title": "The Most Useless Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} \ No newline at end of file diff --git a/testdata/canonical/title_setext.md b/testdata/canonical/title_setext.md new file mode 100644 index 0000000..31bb66c --- /dev/null +++ b/testdata/canonical/title_setext.md @@ -0,0 +1,4 @@ +The Most Useless Recipe +=== + +--- \ No newline at end of file diff --git a/testdata/canonical/yields.json b/testdata/canonical/yields.json new file mode 100644 index 0000000..82b85f5 --- /dev/null +++ b/testdata/canonical/yields.json @@ -0,0 +1,30 @@ +{ + "title": "Yields", + "description": null, + "yields": [ + { + "factor": "1.2", + "unit": "cups" + }, + { + "factor": "1.5", + "unit": "Tassen" + }, + { + "factor": "1.25", + "unit": "servings" + }, + { + "factor": "5", + "unit": "servings" + }, + { + "factor": "5", + "unit": null + } + ], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/canonical/yields.md b/testdata/canonical/yields.md new file mode 100644 index 0000000..6868320 --- /dev/null +++ b/testdata/canonical/yields.md @@ -0,0 +1,5 @@ +# Yields + +**1.2 cups, 1,5 Tassen, 1 1/4 servings, 5 servings, 5** + +--- \ No newline at end of file diff --git a/testdata/canonical/yields_amount_not_factor.invalid.md b/testdata/canonical/yields_amount_not_factor.invalid.md new file mode 100644 index 0000000..bfc0f97 --- /dev/null +++ b/testdata/canonical/yields_amount_not_factor.invalid.md @@ -0,0 +1,8 @@ +# Yields without factor are invalid + +**factorless, 1.2 cups, 1,5 Tassen, 1 1/4 servings, 5 servings, 5** + +*tag1, tag2, tag3, tag4, tag with special! char, tag5* + +--- + diff --git a/testdata/canonical/yields_multiple.invalid.md b/testdata/canonical/yields_multiple.invalid.md new file mode 100644 index 0000000..51f50bf --- /dev/null +++ b/testdata/canonical/yields_multiple.invalid.md @@ -0,0 +1,7 @@ +# Multiple Yield Paragraphs + +**1,5 Tassen** + +**2 Portionen** + +--- \ No newline at end of file diff --git a/testdata/canonical/yields_tags.json b/testdata/canonical/yields_tags.json new file mode 100644 index 0000000..3456ff9 --- /dev/null +++ b/testdata/canonical/yields_tags.json @@ -0,0 +1,37 @@ +{ + "title": "Yields Then Tags", + "yields": [ + { + "factor": "1.2", + "unit": "cups" + }, + { + "factor": "1.5", + "unit": "Tassen" + }, + { + "factor": "1.25", + "unit": "servings" + }, + { + "factor": "5", + "unit": "servings" + }, + { + "factor": "5", + "unit": null + } + ], + "tags": [ + "tag1", + "tag2", + "tag3", + "tag4", + "tag with special! char", + "tag5" + ], + "ingredients": [], + "ingredient_groups": [], + "description": null, + "instructions": null +} diff --git a/testdata/canonical/yields_tags.md b/testdata/canonical/yields_tags.md new file mode 100644 index 0000000..a6761b1 --- /dev/null +++ b/testdata/canonical/yields_tags.md @@ -0,0 +1,8 @@ +# Yields Then Tags + +**1.2 cups, 1,5 Tassen, 1 1/4 servings, 5 servings, 5** + +*tag1, tag2, tag3, tag4, tag with special! char, tag5* + +--- + diff --git a/testdata/flatten/main.md b/testdata/flatten/main.md new file mode 100644 index 0000000..6c18172 --- /dev/null +++ b/testdata/flatten/main.md @@ -0,0 +1,8 @@ +# Pasta + +--- + +- *200g* pasta +- [Tomato Sauce](./sauce.md) + +Boil pasta and add sauce. diff --git a/testdata/flatten/sauce.md b/testdata/flatten/sauce.md new file mode 100644 index 0000000..769e8a9 --- /dev/null +++ b/testdata/flatten/sauce.md @@ -0,0 +1,10 @@ +# Tomato Sauce + +*2 servings* + +--- + +- *50ml* olive oil +- [Stock](./subdir/stock.md) + +Simmer together. diff --git a/testdata/flatten/subdir/stock.md b/testdata/flatten/subdir/stock.md new file mode 100644 index 0000000..06bc31e --- /dev/null +++ b/testdata/flatten/subdir/stock.md @@ -0,0 +1,8 @@ +# Vegetable Stock + +--- + +- *1l* water +- *1* bouillon cube + +Boil. diff --git a/testdata/golden/amount_decimal_comma.json b/testdata/golden/amount_decimal_comma.json new file mode 100644 index 0000000..9096880 --- /dev/null +++ b/testdata/golden/amount_decimal_comma.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "sugar", + "amount": { + "factor": "1.5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_decimal_comma.md b/testdata/golden/amount_decimal_comma.md new file mode 100644 index 0000000..7cf2639 --- /dev/null +++ b/testdata/golden/amount_decimal_comma.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1,5* sugar diff --git a/testdata/golden/amount_decimal_dot.json b/testdata/golden/amount_decimal_dot.json new file mode 100644 index 0000000..9096880 --- /dev/null +++ b/testdata/golden/amount_decimal_dot.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "sugar", + "amount": { + "factor": "1.5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_decimal_dot.md b/testdata/golden/amount_decimal_dot.md new file mode 100644 index 0000000..72838bb --- /dev/null +++ b/testdata/golden/amount_decimal_dot.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1.5* sugar diff --git a/testdata/golden/amount_fraction.json b/testdata/golden/amount_fraction.json new file mode 100644 index 0000000..000723e --- /dev/null +++ b/testdata/golden/amount_fraction.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "butter", + "amount": { + "factor": "0.5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_fraction.md b/testdata/golden/amount_fraction.md new file mode 100644 index 0000000..521ab32 --- /dev/null +++ b/testdata/golden/amount_fraction.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1/2* butter diff --git a/testdata/golden/amount_fraction_spaces.json b/testdata/golden/amount_fraction_spaces.json new file mode 100644 index 0000000..000723e --- /dev/null +++ b/testdata/golden/amount_fraction_spaces.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "butter", + "amount": { + "factor": "0.5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_fraction_spaces.md b/testdata/golden/amount_fraction_spaces.md new file mode 100644 index 0000000..274b6f4 --- /dev/null +++ b/testdata/golden/amount_fraction_spaces.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1 / 2* butter diff --git a/testdata/golden/amount_improper_fraction.json b/testdata/golden/amount_improper_fraction.json new file mode 100644 index 0000000..f739032 --- /dev/null +++ b/testdata/golden/amount_improper_fraction.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "butter", + "amount": { + "factor": "1.5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_improper_fraction.md b/testdata/golden/amount_improper_fraction.md new file mode 100644 index 0000000..9314f18 --- /dev/null +++ b/testdata/golden/amount_improper_fraction.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1 1/2* butter diff --git a/testdata/golden/amount_improper_vulgar.json b/testdata/golden/amount_improper_vulgar.json new file mode 100644 index 0000000..f739032 --- /dev/null +++ b/testdata/golden/amount_improper_vulgar.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "butter", + "amount": { + "factor": "1.5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_improper_vulgar.md b/testdata/golden/amount_improper_vulgar.md new file mode 100644 index 0000000..52c05dd --- /dev/null +++ b/testdata/golden/amount_improper_vulgar.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1 ½* butter diff --git a/testdata/golden/amount_integer.json b/testdata/golden/amount_integer.json new file mode 100644 index 0000000..5d81b0d --- /dev/null +++ b/testdata/golden/amount_integer.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": { + "factor": "5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_integer.md b/testdata/golden/amount_integer.md new file mode 100644 index 0000000..a66efba --- /dev/null +++ b/testdata/golden/amount_integer.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *5* salt diff --git a/testdata/golden/amount_leading_decimal.json b/testdata/golden/amount_leading_decimal.json new file mode 100644 index 0000000..71f187e --- /dev/null +++ b/testdata/golden/amount_leading_decimal.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "sugar", + "amount": { + "factor": "0.5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_leading_decimal.md b/testdata/golden/amount_leading_decimal.md new file mode 100644 index 0000000..022a1a2 --- /dev/null +++ b/testdata/golden/amount_leading_decimal.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *.5* sugar diff --git a/testdata/golden/amount_negative.json b/testdata/golden/amount_negative.json new file mode 100644 index 0000000..4997ed6 --- /dev/null +++ b/testdata/golden/amount_negative.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "adjustment", + "amount": { + "factor": "-5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_negative.md b/testdata/golden/amount_negative.md new file mode 100644 index 0000000..6a65c8d --- /dev/null +++ b/testdata/golden/amount_negative.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *-5* adjustment diff --git a/testdata/golden/amount_negative_fraction.json b/testdata/golden/amount_negative_fraction.json new file mode 100644 index 0000000..3572429 --- /dev/null +++ b/testdata/golden/amount_negative_fraction.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "adjustment", + "amount": { + "factor": "-0.5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_negative_fraction.md b/testdata/golden/amount_negative_fraction.md new file mode 100644 index 0000000..54504dc --- /dev/null +++ b/testdata/golden/amount_negative_fraction.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *-1/2* adjustment diff --git a/testdata/golden/amount_vulgar.json b/testdata/golden/amount_vulgar.json new file mode 100644 index 0000000..000723e --- /dev/null +++ b/testdata/golden/amount_vulgar.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "butter", + "amount": { + "factor": "0.5", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_vulgar.md b/testdata/golden/amount_vulgar.md new file mode 100644 index 0000000..9d2459a --- /dev/null +++ b/testdata/golden/amount_vulgar.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *½* butter diff --git a/testdata/golden/amount_with_unit.json b/testdata/golden/amount_with_unit.json new file mode 100644 index 0000000..46dafa1 --- /dev/null +++ b/testdata/golden/amount_with_unit.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "flour", + "amount": { + "factor": "1", + "unit": "cup" + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_with_unit.md b/testdata/golden/amount_with_unit.md new file mode 100644 index 0000000..652c4d3 --- /dev/null +++ b/testdata/golden/amount_with_unit.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1 cup* flour diff --git a/testdata/golden/amount_without_unit.json b/testdata/golden/amount_without_unit.json new file mode 100644 index 0000000..cc580bb --- /dev/null +++ b/testdata/golden/amount_without_unit.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "egg", + "amount": { + "factor": "1", + "unit": null + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_without_unit.md b/testdata/golden/amount_without_unit.md new file mode 100644 index 0000000..09d43fe --- /dev/null +++ b/testdata/golden/amount_without_unit.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1* egg diff --git a/testdata/golden/amount_zero_denom.json b/testdata/golden/amount_zero_denom.json new file mode 100644 index 0000000..3b63598 --- /dev/null +++ b/testdata/golden/amount_zero_denom.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "stuff", + "amount": { + "factor": "5", + "unit": "/0" + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/amount_zero_denom.md b/testdata/golden/amount_zero_denom.md new file mode 100644 index 0000000..a26ba56 --- /dev/null +++ b/testdata/golden/amount_zero_denom.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *5/0* stuff diff --git a/testdata/golden/break_many_dashes.json b/testdata/golden/break_many_dashes.json new file mode 100644 index 0000000..127bcbe --- /dev/null +++ b/testdata/golden/break_many_dashes.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/break_many_dashes.md b/testdata/golden/break_many_dashes.md new file mode 100644 index 0000000..c55805c --- /dev/null +++ b/testdata/golden/break_many_dashes.md @@ -0,0 +1,5 @@ +# Recipe + +---------- + +- salt diff --git a/testdata/golden/break_missing.invalid.md b/testdata/golden/break_missing.invalid.md new file mode 100644 index 0000000..8b8324f --- /dev/null +++ b/testdata/golden/break_missing.invalid.md @@ -0,0 +1,3 @@ +# Recipe + +- salt diff --git a/testdata/golden/desc_code_block.json b/testdata/golden/desc_code_block.json new file mode 100644 index 0000000..3ed8764 --- /dev/null +++ b/testdata/golden/desc_code_block.json @@ -0,0 +1,9 @@ +{ + "title": "Recipe", + "description": " indented code block", + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/desc_code_block.md b/testdata/golden/desc_code_block.md new file mode 100644 index 0000000..c1baac8 --- /dev/null +++ b/testdata/golden/desc_code_block.md @@ -0,0 +1,5 @@ +# Recipe + + indented code block + +--- diff --git a/testdata/golden/desc_fenced_code_with_dashes.json b/testdata/golden/desc_fenced_code_with_dashes.json new file mode 100644 index 0000000..d64109f --- /dev/null +++ b/testdata/golden/desc_fenced_code_with_dashes.json @@ -0,0 +1,9 @@ +{ + "title": "Recipe", + "description": "Some text.\n\n```\n---\nnot a break\n---\n```\n\nMore text.", + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/desc_fenced_code_with_dashes.md b/testdata/golden/desc_fenced_code_with_dashes.md new file mode 100644 index 0000000..b90852f --- /dev/null +++ b/testdata/golden/desc_fenced_code_with_dashes.md @@ -0,0 +1,13 @@ +# Recipe + +Some text. + +``` +--- +not a break +--- +``` + +More text. + +--- diff --git a/testdata/golden/desc_html.json b/testdata/golden/desc_html.json new file mode 100644 index 0000000..d5b4482 --- /dev/null +++ b/testdata/golden/desc_html.json @@ -0,0 +1,9 @@ +{ + "title": "Recipe", + "description": "Contains \u003cimg src=\"logo.png\" /\u003e HTML.", + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/desc_html.md b/testdata/golden/desc_html.md new file mode 100644 index 0000000..995484d --- /dev/null +++ b/testdata/golden/desc_html.md @@ -0,0 +1,5 @@ +# Recipe + +Contains HTML. + +--- diff --git a/testdata/golden/desc_multi.json b/testdata/golden/desc_multi.json new file mode 100644 index 0000000..f9ba92d --- /dev/null +++ b/testdata/golden/desc_multi.json @@ -0,0 +1,9 @@ +{ + "title": "Recipe", + "description": "First paragraph.\n\nSecond paragraph.", + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/desc_multi.md b/testdata/golden/desc_multi.md new file mode 100644 index 0000000..6e1f312 --- /dev/null +++ b/testdata/golden/desc_multi.md @@ -0,0 +1,7 @@ +# Recipe + +First paragraph. + +Second paragraph. + +--- diff --git a/testdata/golden/desc_none.json b/testdata/golden/desc_none.json new file mode 100644 index 0000000..0807576 --- /dev/null +++ b/testdata/golden/desc_none.json @@ -0,0 +1,17 @@ +{ + "title": "Recipe", + "description": null, + "yields": [ + { + "factor": "4", + "unit": "servings" + } + ], + "tags": [ + "tag1", + "tag2" + ], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/desc_none.md b/testdata/golden/desc_none.md new file mode 100644 index 0000000..9f5ca58 --- /dev/null +++ b/testdata/golden/desc_none.md @@ -0,0 +1,7 @@ +# Recipe + +*tag1, tag2* + +**4 servings** + +--- diff --git a/testdata/golden/desc_only.json b/testdata/golden/desc_only.json new file mode 100644 index 0000000..b490bb0 --- /dev/null +++ b/testdata/golden/desc_only.json @@ -0,0 +1,9 @@ +{ + "title": "Recipe", + "description": "Just a description.", + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/desc_only.md b/testdata/golden/desc_only.md new file mode 100644 index 0000000..3f1fd83 --- /dev/null +++ b/testdata/golden/desc_only.md @@ -0,0 +1,5 @@ +# Recipe + +Just a description. + +--- diff --git a/testdata/golden/desc_single.json b/testdata/golden/desc_single.json new file mode 100644 index 0000000..c05904b --- /dev/null +++ b/testdata/golden/desc_single.json @@ -0,0 +1,9 @@ +{ + "title": "Recipe", + "description": "A single description paragraph.", + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/desc_single.md b/testdata/golden/desc_single.md new file mode 100644 index 0000000..e7ac2af --- /dev/null +++ b/testdata/golden/desc_single.md @@ -0,0 +1,5 @@ +# Recipe + +A single description paragraph. + +--- diff --git a/testdata/golden/frontmatter/empty_frontmatter.json b/testdata/golden/frontmatter/empty_frontmatter.json new file mode 100644 index 0000000..127bcbe --- /dev/null +++ b/testdata/golden/frontmatter/empty_frontmatter.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/frontmatter/empty_frontmatter.md b/testdata/golden/frontmatter/empty_frontmatter.md new file mode 100644 index 0000000..0890a3f --- /dev/null +++ b/testdata/golden/frontmatter/empty_frontmatter.md @@ -0,0 +1,7 @@ +--- +--- +# Recipe + +--- + +- salt diff --git a/testdata/golden/frontmatter/mixed_fences.invalid.md b/testdata/golden/frontmatter/mixed_fences.invalid.md new file mode 100644 index 0000000..dd17437 --- /dev/null +++ b/testdata/golden/frontmatter/mixed_fences.invalid.md @@ -0,0 +1,3 @@ +--- +title: metadata ++++ diff --git a/testdata/golden/frontmatter/none.json b/testdata/golden/frontmatter/none.json new file mode 100644 index 0000000..127bcbe --- /dev/null +++ b/testdata/golden/frontmatter/none.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/frontmatter/none.md b/testdata/golden/frontmatter/none.md new file mode 100644 index 0000000..a923ced --- /dev/null +++ b/testdata/golden/frontmatter/none.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- salt diff --git a/testdata/golden/frontmatter/toml.json b/testdata/golden/frontmatter/toml.json new file mode 100644 index 0000000..127bcbe --- /dev/null +++ b/testdata/golden/frontmatter/toml.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/frontmatter/toml.md b/testdata/golden/frontmatter/toml.md new file mode 100644 index 0000000..ee04371 --- /dev/null +++ b/testdata/golden/frontmatter/toml.md @@ -0,0 +1,9 @@ ++++ +title = "metadata" +author = "test" ++++ +# Recipe + +--- + +- salt diff --git a/testdata/golden/frontmatter/unclosed.invalid.md b/testdata/golden/frontmatter/unclosed.invalid.md new file mode 100644 index 0000000..c31a744 --- /dev/null +++ b/testdata/golden/frontmatter/unclosed.invalid.md @@ -0,0 +1,7 @@ +--- +title: metadata +# Recipe + +--- + +- salt diff --git a/testdata/golden/frontmatter/yaml.json b/testdata/golden/frontmatter/yaml.json new file mode 100644 index 0000000..127bcbe --- /dev/null +++ b/testdata/golden/frontmatter/yaml.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/frontmatter/yaml.md b/testdata/golden/frontmatter/yaml.md new file mode 100644 index 0000000..8630d8c --- /dev/null +++ b/testdata/golden/frontmatter/yaml.md @@ -0,0 +1,9 @@ +--- +title: metadata +author: test +--- +# Recipe + +--- + +- salt diff --git a/testdata/golden/gfm/amount_autolink.json b/testdata/golden/gfm/amount_autolink.json new file mode 100644 index 0000000..7c5abf0 --- /dev/null +++ b/testdata/golden/gfm/amount_autolink.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "https://example.com/flour", + "amount": { + "factor": "1", + "unit": "cup" + }, + "link": "https://example.com/flour" + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/gfm/amount_autolink.md b/testdata/golden/gfm/amount_autolink.md new file mode 100644 index 0000000..9604d79 --- /dev/null +++ b/testdata/golden/gfm/amount_autolink.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1 cup* https://example.com/flour diff --git a/testdata/golden/gfm/autolink_description.json b/testdata/golden/gfm/autolink_description.json new file mode 100644 index 0000000..9bca3c5 --- /dev/null +++ b/testdata/golden/gfm/autolink_description.json @@ -0,0 +1,9 @@ +{ + "title": "Recipe", + "description": "Visit https://example.com for more.", + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/gfm/autolink_description.md b/testdata/golden/gfm/autolink_description.md new file mode 100644 index 0000000..c0eeff3 --- /dev/null +++ b/testdata/golden/gfm/autolink_description.md @@ -0,0 +1,5 @@ +# Recipe + +Visit https://example.com for more. + +--- diff --git a/testdata/golden/gfm/autolink_ingredient.json b/testdata/golden/gfm/autolink_ingredient.json new file mode 100644 index 0000000..a8f368d --- /dev/null +++ b/testdata/golden/gfm/autolink_ingredient.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "https://example.com", + "amount": null, + "link": "https://example.com" + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/gfm/autolink_ingredient.md b/testdata/golden/gfm/autolink_ingredient.md new file mode 100644 index 0000000..a7bd99d --- /dev/null +++ b/testdata/golden/gfm/autolink_ingredient.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- https://example.com diff --git a/testdata/golden/gfm/strikethrough_description.json b/testdata/golden/gfm/strikethrough_description.json new file mode 100644 index 0000000..a31e597 --- /dev/null +++ b/testdata/golden/gfm/strikethrough_description.json @@ -0,0 +1,9 @@ +{ + "title": "Recipe", + "description": "This is ~~deleted~~ text.", + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/gfm/strikethrough_description.md b/testdata/golden/gfm/strikethrough_description.md new file mode 100644 index 0000000..d1d25df --- /dev/null +++ b/testdata/golden/gfm/strikethrough_description.md @@ -0,0 +1,5 @@ +# Recipe + +This is ~~deleted~~ text. + +--- diff --git a/testdata/golden/gfm/table_instructions.json b/testdata/golden/gfm/table_instructions.json new file mode 100644 index 0000000..40f354e --- /dev/null +++ b/testdata/golden/gfm/table_instructions.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": "| Step | Action |\n|------|--------|\n| 1 | Mix |\n| 2 | Bake |" +} diff --git a/testdata/golden/gfm/table_instructions.md b/testdata/golden/gfm/table_instructions.md new file mode 100644 index 0000000..14bb848 --- /dev/null +++ b/testdata/golden/gfm/table_instructions.md @@ -0,0 +1,12 @@ +# Recipe + +--- + +- salt + +--- + +| Step | Action | +|------|--------| +| 1 | Mix | +| 2 | Bake | diff --git a/testdata/golden/gfm/tasklist_ingredients.json b/testdata/golden/gfm/tasklist_ingredients.json new file mode 100644 index 0000000..40d8b09 --- /dev/null +++ b/testdata/golden/gfm/tasklist_ingredients.json @@ -0,0 +1,26 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "flour", + "amount": { + "factor": "1", + "unit": "cup" + }, + "link": null + }, + { + "name": "sugar", + "amount": { + "factor": "2", + "unit": "cups" + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/gfm/tasklist_ingredients.md b/testdata/golden/gfm/tasklist_ingredients.md new file mode 100644 index 0000000..cf82ae3 --- /dev/null +++ b/testdata/golden/gfm/tasklist_ingredients.md @@ -0,0 +1,6 @@ +# Recipe + +--- + +- [ ] *1 cup* flour +- [x] *2 cups* sugar diff --git a/testdata/golden/group_back_to_lower.json b/testdata/golden/group_back_to_lower.json new file mode 100644 index 0000000..2cf4eeb --- /dev/null +++ b/testdata/golden/group_back_to_lower.json @@ -0,0 +1,38 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [ + { + "title": "Outer", + "ingredients": [], + "ingredient_groups": [ + { + "title": "Inner", + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + } + ] + }, + { + "title": "Another", + "ingredients": [ + { + "name": "pepper", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + } + ], + "instructions": null +} diff --git a/testdata/golden/group_back_to_lower.md b/testdata/golden/group_back_to_lower.md new file mode 100644 index 0000000..8220c43 --- /dev/null +++ b/testdata/golden/group_back_to_lower.md @@ -0,0 +1,13 @@ +# Recipe + +--- + +## Outer + +### Inner + +- salt + +## Another + +- pepper diff --git a/testdata/golden/group_empty.json b/testdata/golden/group_empty.json new file mode 100644 index 0000000..4950e88 --- /dev/null +++ b/testdata/golden/group_empty.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [ + { + "title": "Empty Group", + "ingredients": [], + "ingredient_groups": [] + } + ], + "instructions": null +} diff --git a/testdata/golden/group_empty.md b/testdata/golden/group_empty.md new file mode 100644 index 0000000..01f2554 --- /dev/null +++ b/testdata/golden/group_empty.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +## Empty Group diff --git a/testdata/golden/group_level_jump.json b/testdata/golden/group_level_jump.json new file mode 100644 index 0000000..4383009 --- /dev/null +++ b/testdata/golden/group_level_jump.json @@ -0,0 +1,33 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [ + { + "title": "Group", + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [ + { + "title": "Deep", + "ingredients": [ + { + "name": "pepper", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + } + ] + } + ], + "instructions": null +} diff --git a/testdata/golden/group_level_jump.md b/testdata/golden/group_level_jump.md new file mode 100644 index 0000000..c6b2baa --- /dev/null +++ b/testdata/golden/group_level_jump.md @@ -0,0 +1,11 @@ +# Recipe + +--- + +## Group + +- salt + +#### Deep + +- pepper diff --git a/testdata/golden/group_multiple_lists.json b/testdata/golden/group_multiple_lists.json new file mode 100644 index 0000000..12f3168 --- /dev/null +++ b/testdata/golden/group_multiple_lists.json @@ -0,0 +1,36 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [ + { + "title": "Group", + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + }, + { + "name": "pepper", + "amount": null, + "link": null + }, + { + "name": "garlic", + "amount": null, + "link": null + }, + { + "name": "onion", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + } + ], + "instructions": null +} diff --git a/testdata/golden/group_multiple_lists.md b/testdata/golden/group_multiple_lists.md new file mode 100644 index 0000000..7ff0c24 --- /dev/null +++ b/testdata/golden/group_multiple_lists.md @@ -0,0 +1,11 @@ +# Recipe + +--- + +## Group + +- salt +- pepper + +- garlic +- onion diff --git a/testdata/golden/group_nested.json b/testdata/golden/group_nested.json new file mode 100644 index 0000000..1410afd --- /dev/null +++ b/testdata/golden/group_nested.json @@ -0,0 +1,45 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [ + { + "title": "Group", + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [ + { + "title": "Subgroup", + "ingredients": [ + { + "name": "pepper", + "amount": null, + "link": null + } + ], + "ingredient_groups": [ + { + "title": "Sub-subgroup", + "ingredients": [ + { + "name": "cumin", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + } + ] + } + ] + } + ], + "instructions": null +} diff --git a/testdata/golden/group_nested.md b/testdata/golden/group_nested.md new file mode 100644 index 0000000..abacda6 --- /dev/null +++ b/testdata/golden/group_nested.md @@ -0,0 +1,15 @@ +# Recipe + +--- + +## Group + +- salt + +### Subgroup + +- pepper + +#### Sub-subgroup + +- cumin diff --git a/testdata/golden/group_same_level.json b/testdata/golden/group_same_level.json new file mode 100644 index 0000000..311b844 --- /dev/null +++ b/testdata/golden/group_same_level.json @@ -0,0 +1,32 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [ + { + "title": "First", + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + }, + { + "title": "Second", + "ingredients": [ + { + "name": "pepper", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + } + ], + "instructions": null +} diff --git a/testdata/golden/group_same_level.md b/testdata/golden/group_same_level.md new file mode 100644 index 0000000..aeec6a0 --- /dev/null +++ b/testdata/golden/group_same_level.md @@ -0,0 +1,11 @@ +# Recipe + +--- + +## First + +- salt + +## Second + +- pepper diff --git a/testdata/golden/group_single.json b/testdata/golden/group_single.json new file mode 100644 index 0000000..479df65 --- /dev/null +++ b/testdata/golden/group_single.json @@ -0,0 +1,21 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [ + { + "title": "Group", + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [] + } + ], + "instructions": null +} diff --git a/testdata/golden/group_single.md b/testdata/golden/group_single.md new file mode 100644 index 0000000..0dd0cef --- /dev/null +++ b/testdata/golden/group_single.md @@ -0,0 +1,7 @@ +# Recipe + +--- + +## Group + +- salt diff --git a/testdata/golden/ing_amount_and_link.json b/testdata/golden/ing_amount_and_link.json new file mode 100644 index 0000000..dee3b2e --- /dev/null +++ b/testdata/golden/ing_amount_and_link.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "flour", + "amount": { + "factor": "1", + "unit": "cup" + }, + "link": "./flour.md" + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/ing_amount_and_link.md b/testdata/golden/ing_amount_and_link.md new file mode 100644 index 0000000..c03fdbb --- /dev/null +++ b/testdata/golden/ing_amount_and_link.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1 cup* [flour](./flour.md) diff --git a/testdata/golden/ing_empty.invalid.md b/testdata/golden/ing_empty.invalid.md new file mode 100644 index 0000000..81f4867 --- /dev/null +++ b/testdata/golden/ing_empty.invalid.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- diff --git a/testdata/golden/ing_link_only.json b/testdata/golden/ing_link_only.json new file mode 100644 index 0000000..7aa86ed --- /dev/null +++ b/testdata/golden/ing_link_only.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "flour", + "amount": null, + "link": "./flour.md" + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/ing_link_only.md b/testdata/golden/ing_link_only.md new file mode 100644 index 0000000..1fb2c7a --- /dev/null +++ b/testdata/golden/ing_link_only.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- [flour](./flour.md) diff --git a/testdata/golden/ing_multiline.json b/testdata/golden/ing_multiline.json new file mode 100644 index 0000000..497a660 --- /dev/null +++ b/testdata/golden/ing_multiline.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt\n\n and pepper", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/ing_multiline.md b/testdata/golden/ing_multiline.md new file mode 100644 index 0000000..f5c6ba9 --- /dev/null +++ b/testdata/golden/ing_multiline.md @@ -0,0 +1,7 @@ +# Recipe + +--- + +- salt + + and pepper diff --git a/testdata/golden/ing_multiline_blank.json b/testdata/golden/ing_multiline_blank.json new file mode 100644 index 0000000..6b7dc81 --- /dev/null +++ b/testdata/golden/ing_multiline_blank.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "first paragraph\n\n second paragraph", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/ing_multiline_blank.md b/testdata/golden/ing_multiline_blank.md new file mode 100644 index 0000000..625a700 --- /dev/null +++ b/testdata/golden/ing_multiline_blank.md @@ -0,0 +1,7 @@ +# Recipe + +--- + +- first paragraph + + second paragraph diff --git a/testdata/golden/ing_nested_list.json b/testdata/golden/ing_nested_list.json new file mode 100644 index 0000000..1ae783c --- /dev/null +++ b/testdata/golden/ing_nested_list.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt\n - sub item", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/ing_nested_list.md b/testdata/golden/ing_nested_list.md new file mode 100644 index 0000000..8b994e3 --- /dev/null +++ b/testdata/golden/ing_nested_list.md @@ -0,0 +1,6 @@ +# Recipe + +--- + +- salt + - sub item diff --git a/testdata/golden/ing_numbered.json b/testdata/golden/ing_numbered.json new file mode 100644 index 0000000..40d8b09 --- /dev/null +++ b/testdata/golden/ing_numbered.json @@ -0,0 +1,26 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "flour", + "amount": { + "factor": "1", + "unit": "cup" + }, + "link": null + }, + { + "name": "sugar", + "amount": { + "factor": "2", + "unit": "cups" + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/ing_numbered.md b/testdata/golden/ing_numbered.md new file mode 100644 index 0000000..d7ae111 --- /dev/null +++ b/testdata/golden/ing_numbered.md @@ -0,0 +1,6 @@ +# Recipe + +--- + +1. *1 cup* flour +2. *2 cups* sugar diff --git a/testdata/golden/ing_simple.json b/testdata/golden/ing_simple.json new file mode 100644 index 0000000..127bcbe --- /dev/null +++ b/testdata/golden/ing_simple.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/ing_simple.md b/testdata/golden/ing_simple.md new file mode 100644 index 0000000..a923ced --- /dev/null +++ b/testdata/golden/ing_simple.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- salt diff --git a/testdata/golden/ing_whitespace_only.invalid.md b/testdata/golden/ing_whitespace_only.invalid.md new file mode 100644 index 0000000..81f4867 --- /dev/null +++ b/testdata/golden/ing_whitespace_only.invalid.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- diff --git a/testdata/golden/ing_with_amount.json b/testdata/golden/ing_with_amount.json new file mode 100644 index 0000000..46dafa1 --- /dev/null +++ b/testdata/golden/ing_with_amount.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "flour", + "amount": { + "factor": "1", + "unit": "cup" + }, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/ing_with_amount.md b/testdata/golden/ing_with_amount.md new file mode 100644 index 0000000..652c4d3 --- /dev/null +++ b/testdata/golden/ing_with_amount.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- *1 cup* flour diff --git a/testdata/golden/instr_code_block.json b/testdata/golden/instr_code_block.json new file mode 100644 index 0000000..0e58db6 --- /dev/null +++ b/testdata/golden/instr_code_block.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": "```bash\necho \"hello\"\n```" +} diff --git a/testdata/golden/instr_code_block.md b/testdata/golden/instr_code_block.md new file mode 100644 index 0000000..3274543 --- /dev/null +++ b/testdata/golden/instr_code_block.md @@ -0,0 +1,11 @@ +# Recipe + +--- + +- salt + +--- + +```bash +echo "hello" +``` diff --git a/testdata/golden/instr_fenced_dashes.json b/testdata/golden/instr_fenced_dashes.json new file mode 100644 index 0000000..1135572 --- /dev/null +++ b/testdata/golden/instr_fenced_dashes.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": "Instructions here.\n\n```\n---\nnot a break\n---\n```\n\nMore instructions." +} diff --git a/testdata/golden/instr_fenced_dashes.md b/testdata/golden/instr_fenced_dashes.md new file mode 100644 index 0000000..eca9621 --- /dev/null +++ b/testdata/golden/instr_fenced_dashes.md @@ -0,0 +1,17 @@ +# Recipe + +--- + +- salt + +--- + +Instructions here. + +``` +--- +not a break +--- +``` + +More instructions. diff --git a/testdata/golden/instr_headings.json b/testdata/golden/instr_headings.json new file mode 100644 index 0000000..c9ea531 --- /dev/null +++ b/testdata/golden/instr_headings.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": "## Step 1\n\nDo this.\n\n## Step 2\n\nDo that." +} diff --git a/testdata/golden/instr_headings.md b/testdata/golden/instr_headings.md new file mode 100644 index 0000000..f1886d2 --- /dev/null +++ b/testdata/golden/instr_headings.md @@ -0,0 +1,15 @@ +# Recipe + +--- + +- salt + +--- + +## Step 1 + +Do this. + +## Step 2 + +Do that. diff --git a/testdata/golden/instr_multi.json b/testdata/golden/instr_multi.json new file mode 100644 index 0000000..27128a5 --- /dev/null +++ b/testdata/golden/instr_multi.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": "First step.\n\nSecond step with **bold**." +} diff --git a/testdata/golden/instr_multi.md b/testdata/golden/instr_multi.md new file mode 100644 index 0000000..4f2c83f --- /dev/null +++ b/testdata/golden/instr_multi.md @@ -0,0 +1,11 @@ +# Recipe + +--- + +- salt + +--- + +First step. + +Second step with **bold**. diff --git a/testdata/golden/instr_none.json b/testdata/golden/instr_none.json new file mode 100644 index 0000000..127bcbe --- /dev/null +++ b/testdata/golden/instr_none.json @@ -0,0 +1,15 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [], + "ingredients": [ + { + "name": "salt", + "amount": null, + "link": null + } + ], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/instr_none.md b/testdata/golden/instr_none.md new file mode 100644 index 0000000..a923ced --- /dev/null +++ b/testdata/golden/instr_none.md @@ -0,0 +1,5 @@ +# Recipe + +--- + +- salt diff --git a/testdata/golden/preamble_non_matching.invalid.md b/testdata/golden/preamble_non_matching.invalid.md new file mode 100644 index 0000000..959bdec --- /dev/null +++ b/testdata/golden/preamble_non_matching.invalid.md @@ -0,0 +1,7 @@ +# Recipe + +*tag* + +Plain paragraph after tags. + +--- diff --git a/testdata/golden/tags_after_yields.json b/testdata/golden/tags_after_yields.json new file mode 100644 index 0000000..3e57050 --- /dev/null +++ b/testdata/golden/tags_after_yields.json @@ -0,0 +1,16 @@ +{ + "title": "Recipe", + "description": null, + "yields": [ + { + "factor": "4", + "unit": "servings" + } + ], + "tags": [ + "vegetarian" + ], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/tags_after_yields.md b/testdata/golden/tags_after_yields.md new file mode 100644 index 0000000..54c57e1 --- /dev/null +++ b/testdata/golden/tags_after_yields.md @@ -0,0 +1,7 @@ +# Recipe + +**4 servings** + +*vegetarian* + +--- diff --git a/testdata/golden/tags_decimal_comma.json b/testdata/golden/tags_decimal_comma.json new file mode 100644 index 0000000..62cf676 --- /dev/null +++ b/testdata/golden/tags_decimal_comma.json @@ -0,0 +1,12 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [ + "tag1,1", + "tag2" + ], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/tags_decimal_comma.md b/testdata/golden/tags_decimal_comma.md new file mode 100644 index 0000000..e409358 --- /dev/null +++ b/testdata/golden/tags_decimal_comma.md @@ -0,0 +1,5 @@ +# Recipe + +*tag1,1, tag2* + +--- diff --git a/testdata/golden/tags_duplicate.invalid.md b/testdata/golden/tags_duplicate.invalid.md new file mode 100644 index 0000000..5a2dcb2 --- /dev/null +++ b/testdata/golden/tags_duplicate.invalid.md @@ -0,0 +1,7 @@ +# Recipe + +*first* + +*second* + +--- diff --git a/testdata/golden/tags_multiple.json b/testdata/golden/tags_multiple.json new file mode 100644 index 0000000..fc694f5 --- /dev/null +++ b/testdata/golden/tags_multiple.json @@ -0,0 +1,13 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [ + "a", + "b", + "c" + ], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/tags_multiple.md b/testdata/golden/tags_multiple.md new file mode 100644 index 0000000..f86f7ec --- /dev/null +++ b/testdata/golden/tags_multiple.md @@ -0,0 +1,5 @@ +# Recipe + +*a, b, c* + +--- diff --git a/testdata/golden/tags_single.json b/testdata/golden/tags_single.json new file mode 100644 index 0000000..78aecd8 --- /dev/null +++ b/testdata/golden/tags_single.json @@ -0,0 +1,11 @@ +{ + "title": "Recipe", + "description": null, + "yields": [], + "tags": [ + "vegetarian" + ], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/tags_single.md b/testdata/golden/tags_single.md new file mode 100644 index 0000000..3f46a47 --- /dev/null +++ b/testdata/golden/tags_single.md @@ -0,0 +1,5 @@ +# Recipe + +*vegetarian* + +--- diff --git a/testdata/golden/title_atx.json b/testdata/golden/title_atx.json new file mode 100644 index 0000000..b23dfbf --- /dev/null +++ b/testdata/golden/title_atx.json @@ -0,0 +1,9 @@ +{ + "title": "Simple Title", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/title_atx.md b/testdata/golden/title_atx.md new file mode 100644 index 0000000..ca474ed --- /dev/null +++ b/testdata/golden/title_atx.md @@ -0,0 +1,3 @@ +# Simple Title + +--- diff --git a/testdata/golden/title_empty.invalid.md b/testdata/golden/title_empty.invalid.md new file mode 100644 index 0000000..e69de29 diff --git a/testdata/golden/title_inline_formatting.json b/testdata/golden/title_inline_formatting.json new file mode 100644 index 0000000..45ae2bb --- /dev/null +++ b/testdata/golden/title_inline_formatting.json @@ -0,0 +1,9 @@ +{ + "title": "Title withemphasis andcode", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/title_inline_formatting.md b/testdata/golden/title_inline_formatting.md new file mode 100644 index 0000000..a7eab60 --- /dev/null +++ b/testdata/golden/title_inline_formatting.md @@ -0,0 +1,3 @@ +# Title with *emphasis* and `code` + +--- diff --git a/testdata/golden/title_level2.invalid.md b/testdata/golden/title_level2.invalid.md new file mode 100644 index 0000000..b1c2b10 --- /dev/null +++ b/testdata/golden/title_level2.invalid.md @@ -0,0 +1,3 @@ +## Not A Title + +--- diff --git a/testdata/golden/title_none.invalid.md b/testdata/golden/title_none.invalid.md new file mode 100644 index 0000000..6d93687 --- /dev/null +++ b/testdata/golden/title_none.invalid.md @@ -0,0 +1,3 @@ +Just a paragraph. + +--- diff --git a/testdata/golden/title_setext.json b/testdata/golden/title_setext.json new file mode 100644 index 0000000..b23dfbf --- /dev/null +++ b/testdata/golden/title_setext.json @@ -0,0 +1,9 @@ +{ + "title": "Simple Title", + "description": null, + "yields": [], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/title_setext.md b/testdata/golden/title_setext.md new file mode 100644 index 0000000..bdc7172 --- /dev/null +++ b/testdata/golden/title_setext.md @@ -0,0 +1,4 @@ +Simple Title +============ + +--- diff --git a/testdata/golden/yields_decimal_comma.json b/testdata/golden/yields_decimal_comma.json new file mode 100644 index 0000000..22b99c9 --- /dev/null +++ b/testdata/golden/yields_decimal_comma.json @@ -0,0 +1,14 @@ +{ + "title": "Recipe", + "description": null, + "yields": [ + { + "factor": "1.5", + "unit": "cups" + } + ], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/yields_decimal_comma.md b/testdata/golden/yields_decimal_comma.md new file mode 100644 index 0000000..7352f23 --- /dev/null +++ b/testdata/golden/yields_decimal_comma.md @@ -0,0 +1,5 @@ +# Recipe + +**1,5 cups** + +--- diff --git a/testdata/golden/yields_duplicate.invalid.md b/testdata/golden/yields_duplicate.invalid.md new file mode 100644 index 0000000..9eac9ac --- /dev/null +++ b/testdata/golden/yields_duplicate.invalid.md @@ -0,0 +1,7 @@ +# Recipe + +**first** + +**second** + +--- diff --git a/testdata/golden/yields_multiple.json b/testdata/golden/yields_multiple.json new file mode 100644 index 0000000..5c27a53 --- /dev/null +++ b/testdata/golden/yields_multiple.json @@ -0,0 +1,18 @@ +{ + "title": "Recipe", + "description": null, + "yields": [ + { + "factor": "4", + "unit": "servings" + }, + { + "factor": "200", + "unit": "g" + } + ], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/yields_multiple.md b/testdata/golden/yields_multiple.md new file mode 100644 index 0000000..67acd56 --- /dev/null +++ b/testdata/golden/yields_multiple.md @@ -0,0 +1,5 @@ +# Recipe + +**4 servings, 200 g** + +--- diff --git a/testdata/golden/yields_single.json b/testdata/golden/yields_single.json new file mode 100644 index 0000000..08b36c0 --- /dev/null +++ b/testdata/golden/yields_single.json @@ -0,0 +1,14 @@ +{ + "title": "Recipe", + "description": null, + "yields": [ + { + "factor": "4", + "unit": "servings" + } + ], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/yields_single.md b/testdata/golden/yields_single.md new file mode 100644 index 0000000..efeedea --- /dev/null +++ b/testdata/golden/yields_single.md @@ -0,0 +1,5 @@ +# Recipe + +**4 servings** + +--- diff --git a/testdata/golden/yields_unit_no_factor.invalid.md b/testdata/golden/yields_unit_no_factor.invalid.md new file mode 100644 index 0000000..3c9ecfb --- /dev/null +++ b/testdata/golden/yields_unit_no_factor.invalid.md @@ -0,0 +1,5 @@ +# Recipe + +**cups** + +--- diff --git a/testdata/golden/yields_unitless.json b/testdata/golden/yields_unitless.json new file mode 100644 index 0000000..047a038 --- /dev/null +++ b/testdata/golden/yields_unitless.json @@ -0,0 +1,14 @@ +{ + "title": "Recipe", + "description": null, + "yields": [ + { + "factor": "4", + "unit": null + } + ], + "tags": [], + "ingredients": [], + "ingredient_groups": [], + "instructions": null +} diff --git a/testdata/golden/yields_unitless.md b/testdata/golden/yields_unitless.md new file mode 100644 index 0000000..2ac3a20 --- /dev/null +++ b/testdata/golden/yields_unitless.md @@ -0,0 +1,5 @@ +# Recipe + +**4** + +---