From 77ca3624f97934eedf7d27bb716f64736fab92be Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:54:34 -0500 Subject: [PATCH 01/29] feat: init Go module with goldmark dependency --- go.mod | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 go.mod diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eb0d98d --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/xcapaldi/recipemd-go + +go 1.25.5 + +require github.com/yuin/goldmark v1.7.16 From 503fef00d55bfe62c4e6a5731be77c8c8e66759c Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Tue, 3 Mar 2026 02:31:27 -0500 Subject: [PATCH 02/29] feat: parse from markdown to goldmark ast --- go.sum | 2 ++ parser.go | 13 +++++++++++++ parser_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 go.sum create mode 100644 parser.go create mode 100644 parser_test.go 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/parser.go b/parser.go new file mode 100644 index 0000000..33bea2c --- /dev/null +++ b/parser.go @@ -0,0 +1,13 @@ +package recipemd + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +func parseToAST(source []byte) ast.Node { + reader := text.NewReader(source) + parser := goldmark.DefaultParser() + return parser.Parse(reader) +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..89b6da3 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,51 @@ +package recipemd + +import ( + "fmt" + "testing" + + "github.com/yuin/goldmark/ast" +) + +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 TestParseAST(t *testing.T) { + node := parseToAST(sampleRecipe) + dumpAST(node, sampleRecipe, 0) +} + +func dumpAST(n ast.Node, source []byte, depth int) { + indent := "" + for i := 0; i < depth; i++ { + indent += " " + } + fmt.Printf("%s%s", indent, n.Kind()) + if n.Type() == ast.TypeInline || n.Type() == ast.TypeBlock { + if text := n.Text(source); len(text) > 0 { + fmt.Printf(" %q", text) + } + } + fmt.Println() + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + dumpAST(c, source, depth+1) + } +} From cfe325b0a9f4df1cd1c48358f98ef1e8c8595ebe Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:53:08 -0500 Subject: [PATCH 03/29] feat: define recipe and component structs --- recipe.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 recipe.go diff --git a/recipe.go b/recipe.go new file mode 100644 index 0000000..cf7b597 --- /dev/null +++ b/recipe.go @@ -0,0 +1,30 @@ +package recipemd + +type ( + Recipe struct { + Title string `json:"title"` + Description *string `json:"description"` + Yields []Amount `json:"yields"` + Tags []string `json:"tags"` + Ingredients []Ingredient `json:"ingredients"` + IngredientGroups []IngredientGroup `json:"ingredient_groups"` + Instructions *string `json:"instructions"` + } + + Ingredient struct { + Name string `json:"name"` + Amount *Amount `json:"amount"` + Link *string `json:"link"` + } + + IngredientGroup struct { + Title string `json:"title"` + Ingredients []Ingredient `json:"ingredients"` + IngredientGroups []IngredientGroup `json:"ingredient_groups"` + } + + Amount struct { + Factor string `json:"factor"` + Unit *string `json:"unit"` + } +) From f097803b893bfafa24d9186711f067c6a1c85e87 Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:41:50 -0500 Subject: [PATCH 04/29] feat: implement RecipeMD parser with canonical test suite Parse RecipeMD format: title, description, tags, yields, ingredients, ingredient groups, and instructions. Includes amount parsing with fractions, decimals, and unicode vulgar fractions. Validation: empty titles, missing dividers, invalid amounts, multiple tags/yields, empty ingredients, paragraphs in ingredients section. Extract description and instructions from raw source bytes instead of reconstructing from AST nodes. This preserves CommonMark reference-style links and their definitions exactly as written. Also fixes setext heading underlines (===) being included in description. All 31 canonical tests pass. --- canonical_test.go | 64 ++ parser.go | 987 +++++++++++++++++- parser_test.go | 62 +- .../commonmark_fenced_code_blocks.json | 15 + .../commonmark_fenced_code_blocks.md | 17 + .../commonmark_reference_images.json | 18 + .../canonical/commonmark_reference_images.md | 8 + .../canonical/commonmark_reference_links.json | 18 + .../canonical/commonmark_reference_links.md | 19 + testdata/canonical/empty.invalid.md | 0 testdata/canonical/ingredients.json | 52 + testdata/canonical/ingredients.md | 10 + .../ingredients_amount_no_factor.invalid.md | 5 + .../canonical/ingredients_empty.invalid.md | 7 + testdata/canonical/ingredients_groups.json | 88 ++ testdata/canonical/ingredients_groups.md | 28 + .../ingredients_groups_multiple_lists.json | 52 + .../ingredients_groups_multiple_lists.md | 16 + testdata/canonical/ingredients_links.json | 48 + testdata/canonical/ingredients_links.md | 19 + testdata/canonical/ingredients_multiline.json | 46 + testdata/canonical/ingredients_multiline.md | 28 + .../ingredients_no_divider.invalid.md | 3 + .../canonical/ingredients_no_name.invalid.md | 7 + testdata/canonical/ingredients_numbered.json | 52 + testdata/canonical/ingredients_numbered.md | 10 + testdata/canonical/ingredients_sublist.json | 34 + testdata/canonical/ingredients_sublist.md | 11 + testdata/canonical/instructions.json | 9 + testdata/canonical/instructions.md | 11 + .../instructions_no_divider.invalid.md | 5 + testdata/canonical/recipe.json | 97 ++ testdata/canonical/recipe.md | 34 + testdata/canonical/tags.json | 16 + testdata/canonical/tags.md | 6 + testdata/canonical/tags_multiple.invalid.md | 10 + testdata/canonical/tags_no_partial.json | 9 + testdata/canonical/tags_no_partial.md | 6 + testdata/canonical/tags_splitting.json | 13 + testdata/canonical/tags_splitting.md | 7 + testdata/canonical/tags_yields.json | 37 + testdata/canonical/tags_yields.md | 8 + testdata/canonical/title.json | 9 + testdata/canonical/title.md | 3 + .../title_second_level_heading.invalid.md | 1 + testdata/canonical/title_setext.json | 9 + testdata/canonical/title_setext.md | 4 + testdata/canonical/yields.json | 30 + testdata/canonical/yields.md | 5 + .../yields_amount_not_factor.invalid.md | 8 + testdata/canonical/yields_multiple.invalid.md | 7 + testdata/canonical/yields_tags.json | 37 + testdata/canonical/yields_tags.md | 8 + 53 files changed, 2086 insertions(+), 27 deletions(-) create mode 100644 canonical_test.go create mode 100644 testdata/canonical/commonmark_fenced_code_blocks.json create mode 100644 testdata/canonical/commonmark_fenced_code_blocks.md create mode 100644 testdata/canonical/commonmark_reference_images.json create mode 100644 testdata/canonical/commonmark_reference_images.md create mode 100644 testdata/canonical/commonmark_reference_links.json create mode 100644 testdata/canonical/commonmark_reference_links.md create mode 100644 testdata/canonical/empty.invalid.md create mode 100644 testdata/canonical/ingredients.json create mode 100644 testdata/canonical/ingredients.md create mode 100644 testdata/canonical/ingredients_amount_no_factor.invalid.md create mode 100644 testdata/canonical/ingredients_empty.invalid.md create mode 100644 testdata/canonical/ingredients_groups.json create mode 100644 testdata/canonical/ingredients_groups.md create mode 100644 testdata/canonical/ingredients_groups_multiple_lists.json create mode 100644 testdata/canonical/ingredients_groups_multiple_lists.md create mode 100644 testdata/canonical/ingredients_links.json create mode 100644 testdata/canonical/ingredients_links.md create mode 100644 testdata/canonical/ingredients_multiline.json create mode 100644 testdata/canonical/ingredients_multiline.md create mode 100644 testdata/canonical/ingredients_no_divider.invalid.md create mode 100644 testdata/canonical/ingredients_no_name.invalid.md create mode 100644 testdata/canonical/ingredients_numbered.json create mode 100644 testdata/canonical/ingredients_numbered.md create mode 100644 testdata/canonical/ingredients_sublist.json create mode 100644 testdata/canonical/ingredients_sublist.md create mode 100644 testdata/canonical/instructions.json create mode 100644 testdata/canonical/instructions.md create mode 100644 testdata/canonical/instructions_no_divider.invalid.md create mode 100644 testdata/canonical/recipe.json create mode 100644 testdata/canonical/recipe.md create mode 100644 testdata/canonical/tags.json create mode 100644 testdata/canonical/tags.md create mode 100644 testdata/canonical/tags_multiple.invalid.md create mode 100644 testdata/canonical/tags_no_partial.json create mode 100644 testdata/canonical/tags_no_partial.md create mode 100644 testdata/canonical/tags_splitting.json create mode 100644 testdata/canonical/tags_splitting.md create mode 100644 testdata/canonical/tags_yields.json create mode 100644 testdata/canonical/tags_yields.md create mode 100644 testdata/canonical/title.json create mode 100644 testdata/canonical/title.md create mode 100644 testdata/canonical/title_second_level_heading.invalid.md create mode 100644 testdata/canonical/title_setext.json create mode 100644 testdata/canonical/title_setext.md create mode 100644 testdata/canonical/yields.json create mode 100644 testdata/canonical/yields.md create mode 100644 testdata/canonical/yields_amount_not_factor.invalid.md create mode 100644 testdata/canonical/yields_multiple.invalid.md create mode 100644 testdata/canonical/yields_tags.json create mode 100644 testdata/canonical/yields_tags.md diff --git a/canonical_test.go b/canonical_test.go new file mode 100644 index 0000000..e102166 --- /dev/null +++ b/canonical_test.go @@ -0,0 +1,64 @@ +package recipemd + +import ( + "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 := ParseRecipe(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/parser.go b/parser.go index 33bea2c..d151c0e 100644 --- a/parser.go +++ b/parser.go @@ -1,13 +1,990 @@ package recipemd import ( - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/text" + "bytes" + "fmt" + "net/url" + "strconv" + "strings" + "unicode" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +// Due to the nature of walking the AST with a goldmark ast.Walker, we cannot +// chain function calls as proposed in the RecipeMD parsing strategy: +// https://recipemd.org/specification.html#recipemd-parsing-strategy +// Instead we need a state machine to track what we have parsed and what could +// be the next parse-able element. +type parserState int + +const ( + stateStart parserState = iota + stateDescription + stateTagsYields + stateIngredients + stateInstructions ) -func parseToAST(source []byte) ast.Node { +// ParseRecipe converts a RecipeMD document into a Recipe struct. +// See: https://recipemd.org/specification.html#parsing-a-recipe +func ParseRecipe(source []byte) (*Recipe, error) { reader := text.NewReader(source) parser := goldmark.DefaultParser() - return parser.Parse(reader) + document := parser.Parse(reader) + thematicBreaks := findThematicBreaks(source) + + recipe := &Recipe{ + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{}, + IngredientGroups: []IngredientGroup{}, + } + + // State machine variables + state := stateStart + ingredientsParsed := false + var descriptionStart int + var excludeRanges [][2]int + var firstBreakPos, secondBreakPos int + breakIdx := 0 + + excludeNodeRange := func(n ast.Node) { + start, end := getDirectLineBounds(n) + if start >= 0 { + excludeRanges = append(excludeRanges, [2]int{start, end}) + } + } + + extractRecipe := func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch state { + // 2. Parse title + case stateStart: + h, ok := n.(*ast.Heading) + if !ok { + return ast.WalkContinue, nil + } + if h.Level != 1 { + return ast.WalkStop, fmt.Errorf("expected level 1 heading, got level %d", h.Level) + } + title, err := extractPlainText(h, source) + if err != nil { + return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) + } + recipe.Title = title + // 3. Let descriptionStart be the index of the starting line of c (after title) + _, end := getDirectLineBounds(n) + descriptionStart = skipSetextUnderline(source, end) + state = stateDescription + return ast.WalkSkipChildren, nil + + // 4. Parse the description + case stateDescription: + // If c is a thematic break, go to 7 (stateIngredients) + if n.Kind() == ast.KindThematicBreak { + if breakIdx < len(thematicBreaks) { + firstBreakPos = thematicBreaks[breakIdx] + breakIdx++ + } + state = stateIngredients + return ast.WalkSkipChildren, nil + } + p, ok := n.(*ast.Paragraph) + if !ok { + // Not a paragraph - include in description (handled later using firstBreakPos) + return ast.WalkSkipChildren, nil + } + // If c is a paragraph whose contents are a single emphasis, go to 5 (stateTagsYields) + if em, ok := isOnlyEmphasis(p, italic); ok { + // 6. Parse tags + tagsText, err := extractPlainText(em, source) + if err != nil { + return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) + } + recipe.Tags = parseTags(tagsText) + excludeNodeRange(n) + state = stateTagsYields + return ast.WalkSkipChildren, nil + } + // If c is a paragraph whose contents are a single strong emphasis, go to 5 (stateTagsYields) + if em, ok := isOnlyEmphasis(p, bold); ok { + // 6. Parse yields + yieldsText, err := extractPlainText(em, source) + if err != nil { + return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) + } + yields, err := parseYields(yieldsText) + if err != nil { + return ast.WalkStop, fmt.Errorf("parseYields: %w", err) + } + recipe.Yields = yields + excludeNodeRange(n) + state = stateTagsYields + return ast.WalkSkipChildren, nil + } + // Regular paragraph - include in description (handled later using firstBreakPos) + return ast.WalkSkipChildren, nil + + // 6. Parse tags and yields (continued) + case stateTagsYields: + // If c is a thematic break, go to 7 (stateIngredients) + if n.Kind() == ast.KindThematicBreak { + if breakIdx < len(thematicBreaks) { + firstBreakPos = thematicBreaks[breakIdx] + breakIdx++ + } + state = stateIngredients + return ast.WalkSkipChildren, nil + } + p, ok := n.(*ast.Paragraph) + if !ok { + return ast.WalkSkipChildren, nil + } + if em, ok := isOnlyEmphasis(p, italic); ok { + if len(recipe.Tags) > 0 { + return ast.WalkStop, fmt.Errorf("tags already set") + } + tagsText, err := extractPlainText(em, source) + if err != nil { + return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) + } + recipe.Tags = parseTags(tagsText) + excludeNodeRange(n) + return ast.WalkSkipChildren, nil + } + if em, ok := isOnlyEmphasis(p, bold); ok { + if len(recipe.Yields) > 0 { + return ast.WalkStop, fmt.Errorf("yields already set") + } + yieldsText, err := extractPlainText(em, source) + if err != nil { + return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) + } + yields, err := parseYields(yieldsText) + if err != nil { + return ast.WalkStop, fmt.Errorf("parseYields: %w", err) + } + recipe.Yields = yields + excludeNodeRange(n) + return ast.WalkSkipChildren, nil + } + return ast.WalkStop, fmt.Errorf("unexpected content in tags/yields section") + + // 8. Parse ingredients and ingredient groups + case stateIngredients: + // 9. Find instruction divider + if n.Kind() == ast.KindThematicBreak { + if breakIdx < len(thematicBreaks) { + secondBreakPos = thematicBreaks[breakIdx] + breakIdx++ + } + state = stateInstructions + return ast.WalkSkipChildren, nil + } + if ingredientsParsed { + return ast.WalkSkipChildren, nil + } + // Paragraphs are not valid in ingredients section + if _, ok := n.(*ast.Paragraph); ok { + return ast.WalkStop, fmt.Errorf("paragraph not valid in ingredients section") + } + // Run parsing ingredient list and groups + c, err := parseIngredientList(n, source, &recipe.Ingredients) + if err != nil { + return ast.WalkStop, err + } + _, err = parseIngredientGroup(c, source, &recipe.IngredientGroups, 0) + if err != nil { + return ast.WalkStop, err + } + ingredientsParsed = true + return ast.WalkSkipChildren, nil + + // 10. Instructions handled after walk + case stateInstructions: + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + } + + if err := ast.Walk(document, extractRecipe); err != nil { + return nil, fmt.Errorf("ast.Walk: %w", err) + } + + // Validate + if recipe.Title == "" { + return nil, fmt.Errorf("recipe must have a title") + } + if state != stateIngredients && state != stateInstructions { + return nil, fmt.Errorf("missing thematic break divider") + } + + // 5. Set the description (from title end to first thematic break) + if firstBreakPos > descriptionStart { + descBytes := source[descriptionStart:firstBreakPos] + desc := excludeRangesFromSource(descBytes, excludeRanges, descriptionStart) + desc = strings.Trim(desc, "\n") + if desc != "" { + recipe.Description = &desc + } + } + + // 10. Set the recipe's instructions to the remainder of the document + if secondBreakPos > 0 { + // Skip past the thematic break line + instrPos := secondBreakPos + for instrPos < len(source) && source[instrPos] != '\n' { + instrPos++ + } + if instrPos < len(source) { + instrPos++ + } + instr := strings.Trim(string(source[instrPos:]), "\n") + if instr != "" { + recipe.Instructions = &instr + } + } + + return recipe, nil +} + +// 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, +) (ast.Node, error) { + for { + h, ok := c.(*ast.Heading) + if !ok { + return c, nil + } + l := h.Level + if l <= parentLevel { + return c, nil + } + title, err := extractPlainText(h, source) + if err != nil { + return nil, fmt.Errorf("extractPlainText: %w", err) + } + g := IngredientGroup{ + Title: title, + Ingredients: []Ingredient{}, + IngredientGroups: []IngredientGroup{}, + } + c = c.NextSibling() + if c == nil { + *groups = append(*groups, g) + return nil, nil + } + c, err = parseIngredientList(c, source, &g.Ingredients) + if err != nil { + return nil, err + } + c, err = parseIngredientGroup(c, source, &g.IngredientGroups, l) + if err != nil { + return nil, err + } + *groups = append(*groups, g) + } +} + +// 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, +) (ast.Node, error) { + for { + // 1. Examine c + list, ok := c.(*ast.List) + if !ok { + return c, nil + } + // Enter c + c = list.FirstChild() + if c == nil { + c = list.NextSibling() + continue + } + + // 2. Collect ingredients + for { + ing, err := parseIngredient(c, source) + if err != nil { + return nil, fmt.Errorf("parseIngredient: %w", err) + } + if ing.Name == "" { + return nil, fmt.Errorf("ingredient must have a name") + } + *ingredients = append(*ingredients, ing) + // Go to next item + if c.NextSibling() != nil { + c = c.NextSibling() + } else { + // Leave c and go to 1 + 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) (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{Name: n}, nil + } + + // 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 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 { + // Set l to the link's destination + dest := encodeURLPath(string(link.Destination)) + l = &dest + // Set n to the link's text + linkText, _ := extractPlainText(link, source) + n = linkText + } 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) + 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)) + } + 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 +} + +// findSingleLink checks if nodes from start consist only of whitespace and a single link +func findSingleLink(start ast.Node, source []byte) *ast.Link { + var link *ast.Link + for n := start; n != nil; n = n.NextSibling() { + if l, ok := n.(*ast.Link); ok { + if link != nil { + return nil // multiple links + } + link = l + } else if t, ok := n.(*ast.Text); ok { + if strings.TrimSpace(string(t.Value(source))) != "" { + return nil // non-whitespace text + } + } else { + return nil // other inline element + } + } + return link +} + +// 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: formatDecimal(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() +} + +func findThematicBreaks(source []byte) []int { + var positions []int + lines := bytes.Split(source, []byte("\n")) + pos := 0 + for _, line := range lines { + trimmed := bytes.TrimSpace(line) + if len(trimmed) >= 3 && len(bytes.Trim(trimmed, "-")) == 0 { + positions = append(positions, pos) + } + pos += len(line) + 1 + } + return positions +} + +// 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 +} + +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, err error) { + for _, yield := range splitList(s) { + amount, err := parseAmount(yield) + if err != nil { + return nil, fmt.Errorf("parseAmount: %w", err) + } + yields = append(yields, amount) + } + return yields, nil +} + +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 formatDecimal(f float64) string { + s := fmt.Sprintf("%.3f", f) + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + return s +} + +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 +} + +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 index 89b6da3..89f18c2 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,12 +1,42 @@ package recipemd import ( - "fmt" + "encoding/json" "testing" - - "github.com/yuin/goldmark/ast" ) +func TestParse_TitleAndDescription(t *testing.T) { + input := []byte(`# Guacamole + +Some people call it guac. + +It's delicious with chips. + +--- + +- avocado +`) + recipe, err := ParseRecipe(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. @@ -28,24 +58,12 @@ Remove flesh from avocado and roughly mash with fork. Season to taste with salt, pepper and lemon juice. `) -func TestParseAST(t *testing.T) { - node := parseToAST(sampleRecipe) - dumpAST(node, sampleRecipe, 0) -} - -func dumpAST(n ast.Node, source []byte, depth int) { - indent := "" - for i := 0; i < depth; i++ { - indent += " " - } - fmt.Printf("%s%s", indent, n.Kind()) - if n.Type() == ast.TypeInline || n.Type() == ast.TypeBlock { - if text := n.Text(source); len(text) > 0 { - fmt.Printf(" %q", text) - } - } - fmt.Println() - for c := n.FirstChild(); c != nil; c = c.NextSibling() { - dumpAST(c, source, depth+1) +func TestParse_FullRecipe(t *testing.T) { + recipe, err := ParseRecipe(sampleRecipe) + if err != nil { + t.Fatalf("Parse error: %v", err) } + b, _ := json.MarshalIndent(recipe, "", " ") + t.Logf("Parsed recipe:\n%s", b) } + 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* + +--- + From bfdec47d1adfa986c6ac643604450008f544bb04 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 16:09:09 +0000 Subject: [PATCH 05/29] fix: invert recipe parsing strategy -- split first Replace the single-pass state machine with a cleaner three-phase approach: 1. splitSections: uses goldmark to count real thematic breaks (correctly ignoring --- inside fenced code blocks or setext H2 headers), then a raw line scan for exact byte positions. This preserves invisible content (link reference definitions, setext underlines, fenced code block delimiters) that goldmark's AST does not expose as nodes. 2. parsePreamble: parses only the preamble section for title, description, tags, and yields. Eliminates the state machine and the excludeRanges mechanism; uses skipSetextUnderline for correct descStart on setext headings. 3. parseIngredientsSection: parses only the ingredients section, reusing the existing parseIngredientList/parseIngredientGroup helpers. The refactor also fixes a correctness bug in the original findThematicBreaks: it would previously match --- inside fenced code blocks. Removes: parserState type, stateStart/Description/TagsYields/Ingredients/ Instructions constants, the ast.Walk state machine closure, skipSetextUnderline (restored), and the excludeRanges accumulator pattern. --- go.mod | 2 +- parser.go | 423 +++++++++++++++++++++++++++--------------------------- 2 files changed, 211 insertions(+), 214 deletions(-) diff --git a/go.mod b/go.mod index eb0d98d..c34e667 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/xcapaldi/recipemd-go -go 1.25.5 +go 1.24.7 require github.com/yuin/goldmark v1.7.16 diff --git a/parser.go b/parser.go index d151c0e..2133af8 100644 --- a/parser.go +++ b/parser.go @@ -13,28 +13,17 @@ import ( "github.com/yuin/goldmark/text" ) -// Due to the nature of walking the AST with a goldmark ast.Walker, we cannot -// chain function calls as proposed in the RecipeMD parsing strategy: -// https://recipemd.org/specification.html#recipemd-parsing-strategy -// Instead we need a state machine to track what we have parsed and what could -// be the next parse-able element. -type parserState int - -const ( - stateStart parserState = iota - stateDescription - stateTagsYields - stateIngredients - stateInstructions -) +// Commonmark compliant parser +var markdownParser = goldmark.DefaultParser() // ParseRecipe converts a RecipeMD document into a Recipe struct. // See: https://recipemd.org/specification.html#parsing-a-recipe +// +// The document is split into sections at thematic breaks (---), then each +// section is parsed independently: preamble (title, description, tags, yields), +// ingredients, and instructions. func ParseRecipe(source []byte) (*Recipe, error) { - reader := text.NewReader(source) - parser := goldmark.DefaultParser() - document := parser.Parse(reader) - thematicBreaks := findThematicBreaks(source) + preamble, ingredients, remaining := splitSections(source) recipe := &Recipe{ Yields: []Amount{}, @@ -43,215 +32,237 @@ func ParseRecipe(source []byte) (*Recipe, error) { IngredientGroups: []IngredientGroup{}, } - // State machine variables - state := stateStart - ingredientsParsed := false - var descriptionStart int - var excludeRanges [][2]int - var firstBreakPos, secondBreakPos int - breakIdx := 0 + if err := parsePreamble(preamble, recipe); err != nil { + return nil, fmt.Errorf("parsePreamble: %w", err) + } - excludeNodeRange := func(n ast.Node) { - start, end := getDirectLineBounds(n) - if start >= 0 { - excludeRanges = append(excludeRanges, [2]int{start, end}) - } + if ingredients == nil { + return nil, fmt.Errorf("missing thematic break divider") + } + + if err := parseIngredientsSection(ingredients, recipe); err != nil { + return nil, err } - extractRecipe := func(n ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering { - return ast.WalkContinue, nil + if remaining != nil { + instructions := strings.Trim(string(remaining), "\n") + if instructions != "" { + recipe.Instructions = &instructions } + } - switch state { - // 2. Parse title - case stateStart: - h, ok := n.(*ast.Heading) - if !ok { - return ast.WalkContinue, nil - } - if h.Level != 1 { - return ast.WalkStop, fmt.Errorf("expected level 1 heading, got level %d", h.Level) - } - title, err := extractPlainText(h, source) - if err != nil { - return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) - } - recipe.Title = title - // 3. Let descriptionStart be the index of the starting line of c (after title) - _, end := getDirectLineBounds(n) - descriptionStart = skipSetextUnderline(source, end) - state = stateDescription - return ast.WalkSkipChildren, nil - - // 4. Parse the description - case stateDescription: - // If c is a thematic break, go to 7 (stateIngredients) - if n.Kind() == ast.KindThematicBreak { - if breakIdx < len(thematicBreaks) { - firstBreakPos = thematicBreaks[breakIdx] - breakIdx++ - } - state = stateIngredients - return ast.WalkSkipChildren, nil - } - p, ok := n.(*ast.Paragraph) - if !ok { - // Not a paragraph - include in description (handled later using firstBreakPos) - return ast.WalkSkipChildren, nil - } - // If c is a paragraph whose contents are a single emphasis, go to 5 (stateTagsYields) - if em, ok := isOnlyEmphasis(p, italic); ok { - // 6. Parse tags - tagsText, err := extractPlainText(em, source) - if err != nil { - return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) - } - recipe.Tags = parseTags(tagsText) - excludeNodeRange(n) - state = stateTagsYields - return ast.WalkSkipChildren, nil - } - // If c is a paragraph whose contents are a single strong emphasis, go to 5 (stateTagsYields) - if em, ok := isOnlyEmphasis(p, bold); ok { - // 6. Parse yields - yieldsText, err := extractPlainText(em, source) - if err != nil { - return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) - } - yields, err := parseYields(yieldsText) - if err != nil { - return ast.WalkStop, fmt.Errorf("parseYields: %w", err) + return recipe, nil +} + +// splitSections splits a RecipeMD source at thematic breaks (---) identified +// by goldmark (which correctly ignores --- inside fenced code blocks, setext +// H2 underlines, etc.). Returns up to three sections: preamble, ingredients, +// instructions. Sections that don't exist are returned as nil. +func splitSections(source []byte) (preamble, ingredients, instructions []byte) { + document := markdownParser.Parse(text.NewReader(source)) + + // Collect positions of real ThematicBreak nodes as identified by goldmark. + // ThematicBreak nodes don't have Lines(), so we find position by scanning + // forward. Track minPos to handle nodes with no valid bounds. + var breakPositions []int + minPos := 0 + for c := document.FirstChild(); c != nil; c = c.NextSibling() { + if c.Kind() == ast.KindThematicBreak { + pos := findThematicBreakAfter(minPos, source) + if pos >= 0 { + breakPositions = append(breakPositions, pos) + // Next search starts after this break's line + minPos = pos + for minPos < len(source) && source[minPos] != '\n' { + minPos++ } - recipe.Yields = yields - excludeNodeRange(n) - state = stateTagsYields - return ast.WalkSkipChildren, nil - } - // Regular paragraph - include in description (handled later using firstBreakPos) - return ast.WalkSkipChildren, nil - - // 6. Parse tags and yields (continued) - case stateTagsYields: - // If c is a thematic break, go to 7 (stateIngredients) - if n.Kind() == ast.KindThematicBreak { - if breakIdx < len(thematicBreaks) { - firstBreakPos = thematicBreaks[breakIdx] - breakIdx++ + if minPos < len(source) { + minPos++ } - state = stateIngredients - return ast.WalkSkipChildren, nil } - p, ok := n.(*ast.Paragraph) - if !ok { - return ast.WalkSkipChildren, nil - } - if em, ok := isOnlyEmphasis(p, italic); ok { - if len(recipe.Tags) > 0 { - return ast.WalkStop, fmt.Errorf("tags already set") - } - tagsText, err := extractPlainText(em, source) - if err != nil { - return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) - } - recipe.Tags = parseTags(tagsText) - excludeNodeRange(n) - return ast.WalkSkipChildren, nil + } else { + // Update minPos based on this node's bounds + _, end := getRecursiveSourceBounds(c, source) + if end > minPos { + minPos = end } - if em, ok := isOnlyEmphasis(p, bold); ok { - if len(recipe.Yields) > 0 { - return ast.WalkStop, fmt.Errorf("yields already set") - } - yieldsText, err := extractPlainText(em, source) - if err != nil { - return ast.WalkStop, fmt.Errorf("extractPlainText: %w", err) - } - yields, err := parseYields(yieldsText) - if err != nil { - return ast.WalkStop, fmt.Errorf("parseYields: %w", err) - } - recipe.Yields = yields - excludeNodeRange(n) - return ast.WalkSkipChildren, nil + } + } + if len(breakPositions) == 0 { + return source, nil, nil + } + + // Compute the byte just past each break's newline (start of next section). + breakEnds := make([]int, len(breakPositions)) + for i, pos := range breakPositions { + j := pos + for j < len(source) && source[j] != '\n' { + j++ + } + if j < len(source) { + j++ // include the newline + } + breakEnds[i] = j + } + + switch len(breakPositions) { + case 0: + return source, nil, nil + case 1: + return source[:breakPositions[0]], source[breakEnds[0]:], nil + default: + return source[:breakPositions[0]], + source[breakEnds[0]:breakPositions[1]], + source[breakEnds[1]:] + } +} + +// findThematicBreakAfter finds the byte offset of the first thematic break +// line (3+ dashes) at or after minPos. +func findThematicBreakAfter(minPos int, source []byte) int { + pos := minPos + // Advance to line start if mid-line + 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 +} + +// parsePreamble extracts title, description, tags, and yields from the preamble +// section (everything before the first thematic break). +// See: https://recipemd.org/specification.html#parsing-a-recipe steps 2–6 +func parsePreamble(section []byte, recipe *Recipe) error { + document := markdownParser.Parse(text.NewReader(section)) + + // 2. Parse title: first block must be a level-1 heading. + c := document.FirstChild() + if c == nil { + return fmt.Errorf("recipe must have a title") + } + h, ok := c.(*ast.Heading) + if !ok { + return fmt.Errorf("expected level 1 heading, got %T", c) + } + if h.Level != 1 { + return fmt.Errorf("expected level 1 heading, got level %d", h.Level) + } + title, err := extractPlainText(h, section) + if err != nil { + return fmt.Errorf("extractPlainText: %w", err) + } + recipe.Title = title + + // 3. Description starts after the title line (plus setext underline if present). + // We use the heading's own line end rather than the next sibling's start + // because nodes like FencedCodeBlock have Lines() covering only their + // interior content, not the fence delimiters. + _, titleLineEnd := getDirectLineBounds(h) + descStart := skipSetextUnderline(section, titleLineEnd) + + // 4–6. Walk remaining blocks to extract tags and yields. + // Once either is seen, any subsequent non-tags/yields paragraph is an error. + var excludeRanges [][2]int + tagsFound, yieldsFound, tagsYieldsMode := false, false, false + + for c = c.NextSibling(); c != nil; c = c.NextSibling() { + p, ok := c.(*ast.Paragraph) + if !ok { + // Non-paragraph blocks (headings, code, etc.) are part of the description. + continue + } + + if em, ok := isOnlyEmphasis(p, italic); ok { + // 6. Italic-only paragraph → tags. + if tagsFound { + return fmt.Errorf("tags already set") } - return ast.WalkStop, fmt.Errorf("unexpected content in tags/yields section") - - // 8. Parse ingredients and ingredient groups - case stateIngredients: - // 9. Find instruction divider - if n.Kind() == ast.KindThematicBreak { - if breakIdx < len(thematicBreaks) { - secondBreakPos = thematicBreaks[breakIdx] - breakIdx++ - } - state = stateInstructions - return ast.WalkSkipChildren, nil + tagsText, err := extractPlainText(em, section) + if err != nil { + return fmt.Errorf("extractPlainText: %w", err) } - if ingredientsParsed { - return ast.WalkSkipChildren, nil + recipe.Tags = parseTags(tagsText) + tagsFound = true + tagsYieldsMode = true + if start, end := getDirectLineBounds(c); start >= 0 { + excludeRanges = append(excludeRanges, [2]int{start, end}) } - // Paragraphs are not valid in ingredients section - if _, ok := n.(*ast.Paragraph); ok { - return ast.WalkStop, fmt.Errorf("paragraph not valid in ingredients section") + } else if em, ok := isOnlyEmphasis(p, bold); ok { + // 6. Bold-only paragraph → yields. + if yieldsFound { + return fmt.Errorf("yields already set") } - // Run parsing ingredient list and groups - c, err := parseIngredientList(n, source, &recipe.Ingredients) + yieldsText, err := extractPlainText(em, section) if err != nil { - return ast.WalkStop, err + return fmt.Errorf("extractPlainText: %w", err) } - _, err = parseIngredientGroup(c, source, &recipe.IngredientGroups, 0) + yields, err := parseYields(yieldsText) if err != nil { - return ast.WalkStop, err + return fmt.Errorf("parseYields: %w", err) } - ingredientsParsed = true - return ast.WalkSkipChildren, nil - - // 10. Instructions handled after walk - case stateInstructions: - return ast.WalkSkipChildren, nil + recipe.Yields = yields + yieldsFound = true + tagsYieldsMode = true + if start, end := getDirectLineBounds(c); start >= 0 { + excludeRanges = append(excludeRanges, [2]int{start, end}) + } + } else if tagsYieldsMode { + // Any other paragraph after tags/yields is invalid. + return fmt.Errorf("unexpected content in tags/yields section") } - return ast.WalkContinue, nil - } - - if err := ast.Walk(document, extractRecipe); err != nil { - return nil, fmt.Errorf("ast.Walk: %w", err) - } - - // Validate - if recipe.Title == "" { - return nil, fmt.Errorf("recipe must have a title") - } - if state != stateIngredients && state != stateInstructions { - return nil, fmt.Errorf("missing thematic break divider") + // Otherwise: a regular description paragraph; included via exclusion logic below. } - // 5. Set the description (from title end to first thematic break) - if firstBreakPos > descriptionStart { - descBytes := source[descriptionStart:firstBreakPos] - desc := excludeRangesFromSource(descBytes, excludeRanges, descriptionStart) + // 5. Build description: preamble from after title to end, minus tags/yields. + if descStart < len(section) { + desc := excludeRangesFromSource(section[descStart:], excludeRanges, descStart) desc = strings.Trim(desc, "\n") if desc != "" { recipe.Description = &desc } } - // 10. Set the recipe's instructions to the remainder of the document - if secondBreakPos > 0 { - // Skip past the thematic break line - instrPos := secondBreakPos - for instrPos < len(source) && source[instrPos] != '\n' { - instrPos++ - } - if instrPos < len(source) { - instrPos++ - } - instr := strings.Trim(string(source[instrPos:]), "\n") - if instr != "" { - recipe.Instructions = &instr - } + return nil +} + +// parseIngredientsSection extracts ingredients and ingredient groups from the +// section between the two thematic breaks. +// See: https://recipemd.org/specification.html#parsing-an-ingredient-list +func parseIngredientsSection(section []byte, recipe *Recipe) error { + document := markdownParser.Parse(text.NewReader(section)) + + c := document.FirstChild() + + // Paragraphs are not valid in the ingredients section. + if _, ok := c.(*ast.Paragraph); ok { + return fmt.Errorf("paragraph not valid in ingredients section") } - return recipe, nil + c, err := parseIngredientList(c, section, &recipe.Ingredients) + if err != nil { + return err + } + _, err = parseIngredientGroup(c, section, &recipe.IngredientGroups, 0) + return err } // parseIngredientGroup parses headings and lists in the ingredient section. @@ -785,20 +796,6 @@ func encodeURLPath(path string) string { return u.String() } -func findThematicBreaks(source []byte) []int { - var positions []int - lines := bytes.Split(source, []byte("\n")) - pos := 0 - for _, line := range lines { - trimmed := bytes.TrimSpace(line) - if len(trimmed) >= 3 && len(bytes.Trim(trimmed, "-")) == 0 { - positions = append(positions, pos) - } - pos += len(line) + 1 - } - return positions -} - // 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) { From baa00bc3506015c88e7fde0b83c032d201ca1f8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 11:48:08 +0000 Subject: [PATCH 06/29] feat: implement crude recipemd and recipemd-find CLI tools Add two CLI commands matching the RecipeMD CLI specification: - recipemd: parse, display, scale, and serialize recipes with support for title-only, ingredients-only, JSON output, rounding, multiply, and yield-based scaling - recipemd-find: search recipe collections with filter expressions, list recipes/tags/ingredients/units with multi-column output --- cmd/recipemd-find/main.go | 784 ++++++++++++++++++++++++++++++++++++++ cmd/recipemd/main.go | 189 +++++++++ parser.go | 22 +- recipe.go | 334 +++++++++++++++- 4 files changed, 1317 insertions(+), 12 deletions(-) create mode 100644 cmd/recipemd-find/main.go create mode 100644 cmd/recipemd/main.go diff --git a/cmd/recipemd-find/main.go b/cmd/recipemd-find/main.go new file mode 100644 index 0000000..2d42456 --- /dev/null +++ b/cmd/recipemd-find/main.go @@ -0,0 +1,784 @@ +package main + +import ( + "bufio" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "syscall" + "unicode" + "unsafe" + + recipemd "github.com/xcapaldi/recipemd-go" +) + +const version = "0.1.0" + +func main() { + args, err := parseArgs(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if args.version { + fmt.Printf("recipemd-find %s\n", version) + os.Exit(0) + } + + if args.action == "" { + fmt.Fprintf(os.Stderr, "Error: an action is required (recipes, tags, ingredients, units)\n") + os.Exit(1) + } + + switch args.action { + case "recipes": + listRecipes(args) + case "tags": + listTags(args) + case "ingredients": + listIngredients(args) + case "units": + listUnits(args) + default: + fmt.Fprintf(os.Stderr, "Error: unknown action %q\n", args.action) + os.Exit(1) + } +} + +type cliArgs struct { + version bool + expression string + noMessages bool + outputMulti string // "no", "columns", "rows", or "" + action string + folder string + count bool + searcher string // external search program (e.g. "grep", "rg") +} + +func parseArgs(raw []string) (cliArgs, error) { + var args cliArgs + args.folder = "." + + i := 0 + for i < len(raw) { + arg := raw[i] + switch arg { + case "-v", "--version": + args.version = true + case "-h", "--help": + printUsage() + os.Exit(0) + case "-e", "--expression": + i++ + if i >= len(raw) { + return args, fmt.Errorf("missing value for %s", arg) + } + args.expression = raw[i] + case "-s", "--no-messages": + args.noMessages = true + case "-1": + args.outputMulti = "no" + case "-C": + args.outputMulti = "columns" + case "-x": + args.outputMulti = "rows" + case "-c", "--count": + args.count = true + case "--searcher": + i++ + if i >= len(raw) { + return args, fmt.Errorf("missing value for %s", arg) + } + args.searcher = raw[i] + default: + if strings.HasPrefix(arg, "-") { + return args, fmt.Errorf("unknown option %q", arg) + } + if args.action == "" { + args.action = arg + } else { + args.folder = arg + } + } + i++ + } + return args, nil +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `Usage: recipemd-find [options] [folder] + +Find recipes, ingredients and units by filter expression + +Actions: + recipes list recipe paths + tags list used tags + ingredients list used ingredients + units list used units + +Options: + -v, --version show version + -h, --help show help + -e, --expression E filter expression, e.g. "cake and vegan or ingr:cheese" + -s, --no-messages suppress error messages + -1 force output to be one entry per line + -C force multi-column output + -x multi-column output sorted across columns + -c, --count count number of uses (tags, ingredients, units only) + --searcher PROG use external search program (grep, rg) to pre-filter + files. The filter expression is translated to the + program's syntax. Candidate files are then parsed and + verified with the RecipeMD-aware filter. +`) +} + +type parsedRecipe struct { + recipe *recipemd.Recipe + path string +} + +func getFilteredRecipes(args cliArgs) []parsedRecipe { + // When a searcher is specified with an expression, use it to pre-filter + // candidate files before doing full RecipeMD parsing. + if args.searcher != "" && args.expression != "" { + return getFilteredRecipesWithSearcher(args) + } + return getFilteredRecipesBuiltin(args) +} + +func getFilteredRecipesBuiltin(args cliArgs) []parsedRecipe { + var results []parsedRecipe + + folder := args.folder + err := filepath.Walk(folder, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".md") { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + if !args.noMessages { + relPath, _ := filepath.Rel(folder, path) + fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", relPath, err) + } + return nil + } + + recipe, err := recipemd.ParseRecipe(data) + if err != nil { + if !args.noMessages { + relPath, _ := filepath.Rel(folder, path) + fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", relPath, err) + } + return nil + } + + if args.expression != "" && !matchesFilter(recipe, args.expression) { + return nil + } + + relPath, _ := filepath.Rel(folder, path) + results = append(results, parsedRecipe{recipe: recipe, path: relPath}) + return nil + }) + + if err != nil && !args.noMessages { + fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err) + } + + return results +} + +func getFilteredRecipesWithSearcher(args cliArgs) []parsedRecipe { + folder := args.folder + + // Parse expression into AST, translate to external searcher, get candidate files + ast := parseExprAST(tokenize(args.expression)) + candidates, err := evalSearcherAST(ast, args.searcher, folder, args.noMessages) + if err != nil { + if !args.noMessages { + fmt.Fprintf(os.Stderr, "Searcher error: %v\n", err) + } + return nil + } + + // Parse candidates and verify with exact RecipeMD-aware filter + var results []parsedRecipe + for _, path := range candidates { + fullPath := filepath.Join(folder, path) + data, err := os.ReadFile(fullPath) + if err != nil { + if !args.noMessages { + fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", path, err) + } + continue + } + + recipe, err := recipemd.ParseRecipe(data) + if err != nil { + if !args.noMessages { + fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", path, err) + } + continue + } + + // Exact filter verification — the external searcher is a text-level + // pre-filter that doesn't understand RecipeMD structure, so we + // confirm with the real filter. + if !matchesFilter(recipe, args.expression) { + continue + } + + results = append(results, parsedRecipe{recipe: recipe, path: path}) + } + return results +} + +// --- Expression AST --- + +type exprNodeKind int + +const ( + nodeLeaf exprNodeKind = iota + nodeAnd + nodeOr + nodeNot +) + +type exprNode struct { + kind exprNodeKind + term string // for nodeLeaf: raw term like "cake", "ingr:cheese", "tag:vegan" + children []*exprNode +} + +// parseExprAST parses a tokenized filter expression into an AST. +func parseExprAST(tokens []string) *exprNode { + node, _ := parseOrAST(tokens) + return node +} + +func parseOrAST(tokens []string) (*exprNode, []string) { + left, tokens := parseAndAST(tokens) + var orChildren []*exprNode + orChildren = append(orChildren, left) + for len(tokens) > 0 && strings.EqualFold(tokens[0], "or") { + tokens = tokens[1:] + right, rest := parseAndAST(tokens) + orChildren = append(orChildren, right) + tokens = rest + } + if len(orChildren) == 1 { + return orChildren[0], tokens + } + return &exprNode{kind: nodeOr, children: orChildren}, tokens +} + +func parseAndAST(tokens []string) (*exprNode, []string) { + left, tokens := parsePrimaryAST(tokens) + var andChildren []*exprNode + andChildren = append(andChildren, left) + for len(tokens) > 0 && strings.EqualFold(tokens[0], "and") { + tokens = tokens[1:] + right, rest := parsePrimaryAST(tokens) + andChildren = append(andChildren, right) + tokens = rest + } + if len(andChildren) == 1 { + return andChildren[0], tokens + } + return &exprNode{kind: nodeAnd, children: andChildren}, tokens +} + +func parsePrimaryAST(tokens []string) (*exprNode, []string) { + if len(tokens) == 0 { + return &exprNode{kind: nodeLeaf, term: ""}, tokens + } + token := tokens[0] + tokens = tokens[1:] + if strings.EqualFold(token, "not") { + child, rest := parsePrimaryAST(tokens) + return &exprNode{kind: nodeNot, children: []*exprNode{child}}, rest + } + return &exprNode{kind: nodeLeaf, term: token}, tokens +} + +// --- External searcher evaluation --- + +// evalSearcherAST evaluates an expression AST using the external search program. +// Returns relative paths of matching .md files. +func evalSearcherAST(node *exprNode, searcher, folder string, noMessages bool) ([]string, error) { + switch node.kind { + case nodeLeaf: + if node.term == "" { + return nil, nil + } + return searcherGrep(searcher, folder, extractSearchTerm(node.term), false, noMessages) + + case nodeNot: + return searcherGrep(searcher, folder, extractSearchTerm(node.children[0].term), true, noMessages) + + case nodeAnd: + // Intersection: start with first child's results, narrow down + result, err := evalSearcherAST(node.children[0], searcher, folder, noMessages) + if err != nil { + return nil, err + } + resultSet := stringSet(result) + for _, child := range node.children[1:] { + childFiles, err := evalSearcherAST(child, searcher, folder, noMessages) + if err != nil { + return nil, err + } + resultSet = intersect(resultSet, stringSet(childFiles)) + } + return setToSlice(resultSet), nil + + case nodeOr: + // Union: combine all children's results + resultSet := make(map[string]bool) + for _, child := range node.children { + childFiles, err := evalSearcherAST(child, searcher, folder, noMessages) + if err != nil { + return nil, err + } + for _, f := range childFiles { + resultSet[f] = true + } + } + return setToSlice(resultSet), nil + } + return nil, nil +} + +// extractSearchTerm strips the ingr:/tag: prefix since the external tool +// searches raw text and can't distinguish RecipeMD sections structurally. +func extractSearchTerm(term string) string { + lower := strings.ToLower(term) + if strings.HasPrefix(lower, "ingr:") { + return term[5:] + } + if strings.HasPrefix(lower, "tag:") { + return term[4:] + } + return term +} + +// searcherGrep runs the external search program and returns matching relative paths. +// If invert is true, returns files that do NOT match the pattern. +func searcherGrep(searcher, folder, pattern string, invert bool, noMessages bool) ([]string, error) { + prog := filepath.Base(searcher) + var cmdArgs []string + + switch prog { + case "rg", "ripgrep": + // rg -li pattern -g "*.md" folder + // rg --files-without-match -i pattern -g "*.md" folder + cmdArgs = append(cmdArgs, "-i") + if invert { + cmdArgs = append(cmdArgs, "--files-without-match") + } else { + cmdArgs = append(cmdArgs, "-l") + } + cmdArgs = append(cmdArgs, "-g", "*.md", "--", pattern, folder) + + case "grep": + // grep -rli pattern --include="*.md" folder + // grep -rLi pattern --include="*.md" folder + cmdArgs = append(cmdArgs, "-r", "-i") + if invert { + cmdArgs = append(cmdArgs, "-L") + } else { + cmdArgs = append(cmdArgs, "-l") + } + cmdArgs = append(cmdArgs, "--include=*.md", "--", pattern, folder) + + default: + // Generic: assume grep-compatible interface + cmdArgs = append(cmdArgs, "-r", "-i") + if invert { + cmdArgs = append(cmdArgs, "-L") + } else { + cmdArgs = append(cmdArgs, "-l") + } + cmdArgs = append(cmdArgs, "--include=*.md", "--", pattern, folder) + } + + cmd := exec.Command(searcher, cmdArgs...) + cmd.Stderr = os.Stderr + if noMessages { + cmd.Stderr = nil + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to create pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start %s: %w", searcher, err) + } + + var files []string + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + // Convert to relative path + rel, err := filepath.Rel(folder, line) + if err != nil { + rel = line + } + files = append(files, rel) + } + + // grep exits 1 when no matches found — that's not an error + if err := cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return files, nil + } + return files, nil // be lenient with searcher exit codes + } + + return files, nil +} + +func stringSet(items []string) map[string]bool { + s := make(map[string]bool, len(items)) + for _, item := range items { + s[item] = true + } + return s +} + +func intersect(a, b map[string]bool) map[string]bool { + result := make(map[string]bool) + for k := range a { + if b[k] { + result[k] = true + } + } + return result +} + +func setToSlice(s map[string]bool) []string { + result := make([]string, 0, len(s)) + for k := range s { + result = append(result, k) + } + sort.Strings(result) + return result +} + +// matchesFilter evaluates a simple boolean filter expression against a recipe. +// Supports: word matching (in title/tags), "ingr:word" for ingredient matching, +// "and", "or", "not" operators. +func matchesFilter(r *recipemd.Recipe, expr string) bool { + tokens := tokenize(expr) + result, _ := parseOr(tokens, r) + return result +} + +func tokenize(expr string) []string { + var tokens []string + current := strings.Builder{} + for _, ch := range expr { + if unicode.IsSpace(ch) { + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + } else { + current.WriteRune(ch) + } + } + if current.Len() > 0 { + tokens = append(tokens, current.String()) + } + return tokens +} + +func parseOr(tokens []string, r *recipemd.Recipe) (bool, []string) { + left, tokens := parseAnd(tokens, r) + for len(tokens) > 0 && strings.EqualFold(tokens[0], "or") { + tokens = tokens[1:] + right, rest := parseAnd(tokens, r) + left = left || right + tokens = rest + } + return left, tokens +} + +func parseAnd(tokens []string, r *recipemd.Recipe) (bool, []string) { + left, tokens := parsePrimary(tokens, r) + for len(tokens) > 0 && strings.EqualFold(tokens[0], "and") { + tokens = tokens[1:] + right, rest := parsePrimary(tokens, r) + left = left && right + tokens = rest + } + return left, tokens +} + +func parsePrimary(tokens []string, r *recipemd.Recipe) (bool, []string) { + if len(tokens) == 0 { + return false, tokens + } + + token := tokens[0] + tokens = tokens[1:] + + if strings.EqualFold(token, "not") { + result, rest := parsePrimary(tokens, r) + return !result, rest + } + + if strings.HasPrefix(strings.ToLower(token), "ingr:") { + needle := strings.ToLower(token[5:]) + for _, ing := range r.LeafIngredients() { + if strings.Contains(strings.ToLower(ing.Name), needle) { + return true, tokens + } + } + return false, tokens + } + + if strings.HasPrefix(strings.ToLower(token), "tag:") { + needle := strings.ToLower(token[4:]) + for _, tag := range r.Tags { + if strings.Contains(strings.ToLower(tag), needle) { + return true, tokens + } + } + return false, tokens + } + + // Default: match against title and tags + needle := strings.ToLower(token) + if strings.Contains(strings.ToLower(r.Title), needle) { + return true, tokens + } + for _, tag := range r.Tags { + if strings.Contains(strings.ToLower(tag), needle) { + return true, tokens + } + } + return false, tokens +} + +func listRecipes(args cliArgs) { + recipes := getFilteredRecipes(args) + items := make([]string, len(recipes)) + for i, r := range recipes { + items[i] = r.path + } + sort.Strings(items) + printResult(items, args.outputMulti) +} + +func listTags(args cliArgs) { + listElements(args, func(r *recipemd.Recipe) []string { + return r.Tags + }) +} + +func listIngredients(args cliArgs) { + listElements(args, func(r *recipemd.Recipe) []string { + ings := r.LeafIngredients() + names := make([]string, len(ings)) + for i, ing := range ings { + names[i] = ing.Name + } + return names + }) +} + +func listUnits(args cliArgs) { + listElements(args, func(r *recipemd.Recipe) []string { + var units []string + for _, ing := range r.LeafIngredients() { + if ing.Amount != nil && ing.Amount.Unit != nil { + units = append(units, *ing.Amount.Unit) + } + } + for _, y := range r.Yields { + if y.Unit != nil { + units = append(units, *y.Unit) + } + } + return units + }) +} + +func listElements(args cliArgs, extractor func(*recipemd.Recipe) []string) { + recipes := getFilteredRecipes(args) + counter := make(map[string]int) + + for _, pr := range recipes { + seen := make(map[string]bool) + for _, item := range extractor(pr.recipe) { + if !seen[item] { + counter[item]++ + seen[item] = true + } + } + } + + if args.count { + type pair struct { + name string + count int + } + pairs := make([]pair, 0, len(counter)) + maxCount := 0 + for name, count := range counter { + pairs = append(pairs, pair{name, count}) + if count > maxCount { + maxCount = count + } + } + sort.Slice(pairs, func(i, j int) bool { + return pairs[i].count > pairs[j].count + }) + maxWidth := len(fmt.Sprintf("%d", maxCount)) + items := make([]string, len(pairs)) + for i, p := range pairs { + items[i] = fmt.Sprintf("%*d %s", maxWidth, p.count, p.name) + } + printResult(items, args.outputMulti) + } else { + items := make([]string, 0, len(counter)) + for name := range counter { + items = append(items, name) + } + sort.Slice(items, func(i, j int) bool { + return strings.ToLower(items[i]) < strings.ToLower(items[j]) + }) + printResult(items, args.outputMulti) + } +} + +func printResult(items []string, outputMulti string) { + if outputMulti == "" { + if isTerminal() { + outputMulti = "columns" + } else { + outputMulti = "no" + } + } + + switch outputMulti { + case "columns": + printColumns(items, false) + case "rows": + printColumns(items, true) + default: + for _, item := range items { + fmt.Println(item) + } + } +} + +func isTerminal() bool { + _, _, err := getTerminalSize(int(os.Stdout.Fd())) + return err == nil +} + +func printColumns(items []string, across bool) { + if len(items) == 0 { + return + } + + maxWidth := 0 + for _, item := range items { + if len(item) > maxWidth { + maxWidth = len(item) + } + } + colWidth := maxWidth + 2 + lineWidth := getTerminalWidth() + + colCount := lineWidth / colWidth + if colCount == 0 { + colCount = 1 + } + rowCount := int(math.Ceil(float64(len(items)) / float64(colCount))) + + if across { + for r := 0; r < rowCount; r++ { + var row []string + for c := 0; c < colCount; c++ { + idx := r*colCount + c + if idx < len(items) { + row = append(row, items[idx]) + } + } + printRow(row, colWidth) + } + } else { + for r := 0; r < rowCount; r++ { + var row []string + for c := 0; c < colCount; c++ { + idx := c*rowCount + r + if idx < len(items) { + row = append(row, items[idx]) + } + } + printRow(row, colWidth) + } + } +} + +func printRow(row []string, colWidth int) { + if len(row) == 0 { + return + } + var parts []string + for i, val := range row { + if i < len(row)-1 { + parts = append(parts, fmt.Sprintf("%-*s", colWidth, val)) + } else { + parts = append(parts, val) + } + } + fmt.Println(strings.Join(parts, "")) +} + +type winsize struct { + Row uint16 + Col uint16 + Xpixel uint16 + Ypixel uint16 +} + +func getTerminalSize(fd int) (int, int, error) { + var ws winsize + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(fd), + uintptr(syscall.TIOCGWINSZ), + uintptr(unsafe.Pointer(&ws)), + ) + if errno != 0 { + return 0, 0, errno + } + return int(ws.Col), int(ws.Row), nil +} + +func getTerminalWidth() int { + width, _, err := getTerminalSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + return 80 + } + return width +} diff --git a/cmd/recipemd/main.go b/cmd/recipemd/main.go new file mode 100644 index 0000000..079b2e6 --- /dev/null +++ b/cmd/recipemd/main.go @@ -0,0 +1,189 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strconv" + "strings" + + recipemd "github.com/xcapaldi/recipemd-go" +) + +const version = "0.1.0" + +func main() { + // Custom usage + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: recipemd [options] \n\nRead and process recipemd recipes\n\nOptions:\n") + flag.PrintDefaults() + } + + showVersion := flag.Bool("v", false, "show version") + showVersionLong := flag.Bool("version", false, "show version") + showTitle := flag.Bool("t", false, "display recipe title") + showTitleLong := flag.Bool("title", false, "display recipe title") + showIngredients := flag.Bool("i", false, "display recipe ingredients") + showIngredientsLong := flag.Bool("ingredients", false, "display recipe ingredients") + showJSON := flag.Bool("j", false, "display recipe as JSON") + showJSONLong := flag.Bool("json", false, "display recipe as JSON") + roundStr := flag.String("r", "2", "round amount to n digits after decimal point. Default is \"2\", use \"no\" to disable rounding") + roundStrLong := flag.String("round", "2", "round amount to n digits after decimal point. Default is \"2\", use \"no\" to disable rounding") + multiply := flag.String("m", "", "multiply recipe by N") + multiplyLong := flag.String("multiply", "", "multiply recipe by N") + yield := flag.String("y", "", "scale the recipe for yield Y, e.g. \"5 servings\"") + yieldLong := flag.String("yield", "", "scale the recipe for yield Y, e.g. \"5 servings\"") + flatten := flag.Bool("f", false, "flatten ingredients and instructions of linked recipes into main recipe") + flattenLong := flag.Bool("flatten", false, "flatten ingredients and instructions of linked recipes into main recipe") + + flag.Parse() + + if *showVersion || *showVersionLong { + fmt.Printf("recipemd %s\n", version) + os.Exit(0) + } + + title := *showTitle || *showTitleLong + ingredients := *showIngredients || *showIngredientsLong + jsonOut := *showJSON || *showJSONLong + flat := *flatten || *flattenLong + + // Mutual exclusivity check for display options + displayCount := 0 + if title { + displayCount++ + } + if ingredients { + displayCount++ + } + if jsonOut { + displayCount++ + } + if displayCount > 1 { + fmt.Fprintf(os.Stderr, "Error: -t/--title, -i/--ingredients, and -j/--json are mutually exclusive\n") + os.Exit(1) + } + + // Resolve round value (prefer short flag if set) + rStr := *roundStr + if isFlagSet("round") { + rStr = *roundStrLong + } + rounding := 2 + if strings.EqualFold(rStr, "no") { + rounding = -1 + } else { + n, err := strconv.Atoi(rStr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid rounding value %q\n", rStr) + os.Exit(1) + } + rounding = n + } + + // Resolve multiply/yield (prefer short flag if set) + multiplyVal := *multiply + if multiplyVal == "" { + multiplyVal = *multiplyLong + } + yieldVal := *yield + if yieldVal == "" { + yieldVal = *yieldLong + } + + if multiplyVal != "" && yieldVal != "" { + fmt.Fprintf(os.Stderr, "Error: -m/--multiply and -y/--yield are mutually exclusive\n") + os.Exit(1) + } + + // Need a file argument + if flag.NArg() < 1 { + fmt.Fprintf(os.Stderr, "Error: a recipe file is required\n") + flag.Usage() + os.Exit(1) + } + filePath := flag.Arg(0) + + // Read file + data, err := os.ReadFile(filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) + os.Exit(1) + } + + // Parse recipe + recipe, err := recipemd.ParseRecipe(data) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing recipe: %v\n", err) + os.Exit(1) + } + + // Flatten linked recipes + if flat { + if err := recipe.Flatten(filePath); err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + } + } + + // Scale recipe + if yieldVal != "" { + requiredYield, err := recipemd.ParseAmountString(yieldVal) + if err != nil || requiredYield.Factor == 0 { + fmt.Fprintf(os.Stderr, "Error: given yield is not valid\n") + os.Exit(1) + } + if err := recipe.ScaleForYield(requiredYield); err != nil { + fmt.Fprintf(os.Stderr, "Error: recipe does not have a yield with matching unit\n") + units := make([]string, 0) + for _, y := range recipe.Yields { + if y.Unit != nil { + units = append(units, fmt.Sprintf("%q", *y.Unit)) + } + } + if len(units) > 0 { + fmt.Fprintf(os.Stderr, "Available units: %s\n", strings.Join(units, ", ")) + } + os.Exit(1) + } + } else if multiplyVal != "" { + mult, err := recipemd.ParseAmountString(multiplyVal) + if err != nil || mult.Factor == 0 { + fmt.Fprintf(os.Stderr, "Error: given multiplier is not valid\n") + os.Exit(1) + } + if mult.Unit != nil { + fmt.Fprintf(os.Stderr, "Error: a recipe can only be multiplied with a unitless amount\n") + os.Exit(1) + } + recipe.Scale(mult.Factor) + } + + // Output + if title { + fmt.Println(recipe.Title) + } else if ingredients { + for _, ing := range recipe.LeafIngredients() { + fmt.Println(ing.Serialize(rounding)) + } + } else if jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(recipe); err != nil { + fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err) + os.Exit(1) + } + } else { + fmt.Print(recipe.RenderMarkdown(rounding)) + } +} + +func isFlagSet(name string) bool { + found := false + flag.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} diff --git a/parser.go b/parser.go index 2133af8..6a35705 100644 --- a/parser.go +++ b/parser.go @@ -338,9 +338,6 @@ func parseIngredientList( if err != nil { return nil, fmt.Errorf("parseIngredient: %w", err) } - if ing.Name == "" { - return nil, fmt.Errorf("ingredient must have a name") - } *ingredients = append(*ingredients, ing) // Go to next item if c.NextSibling() != nil { @@ -440,6 +437,10 @@ func parseIngredient(c ast.Node, source []byte) (Ingredient, error) { // 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 } @@ -516,6 +517,12 @@ func findSingleLink(start ast.Node, source []byte) *ast.Link { return link } +// ParseAmountString parses an amount string into value and unit. +// This is the exported version of parseAmount for CLI use. +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) { @@ -570,7 +577,7 @@ func parseAmount(s string) (Amount, error) { if negative { val = -val } - return Amount{Factor: formatDecimal(val), Unit: unit}, nil + return Amount{Factor: val, Unit: unit}, nil } else if unit != nil { return Amount{}, fmt.Errorf("unit without value: %q", s) } @@ -942,13 +949,6 @@ var vulgarFractionMap = map[rune]float64{ '⅛': 1.0 / 8, '⅜': 3.0 / 8, '⅝': 5.0 / 8, '⅞': 7.0 / 8, } -func formatDecimal(f float64) string { - s := fmt.Sprintf("%.3f", f) - s = strings.TrimRight(s, "0") - s = strings.TrimRight(s, ".") - return s -} - func skipSetextUnderline(source []byte, pos int) int { if pos >= len(source) || source[pos] != '\n' { return pos diff --git a/recipe.go b/recipe.go index cf7b597..bc66c52 100644 --- a/recipe.go +++ b/recipe.go @@ -1,5 +1,15 @@ package recipemd +import ( + "errors" + "fmt" + "math" + "os" + "path/filepath" + "strconv" + "strings" +) + type ( Recipe struct { Title string `json:"title"` @@ -24,7 +34,329 @@ type ( } Amount struct { - Factor string `json:"factor"` + Factor float64 `json:"factor"` Unit *string `json:"unit"` } ) + +// MarshalJSON is a custom marshaler for an Amount. +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 factor as a string. rounding < 0 means no rounding. +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 tries to find a matching yield in the recipe and uses that to +// find the overall scaling factor. If the desired yield is unitless, first try +// to match a recipe yield that also has no unit. If there is none, assume the +// scaling factor is for the implicit 1x recipe yield. For example, scale the +// whole recipe by 2x. +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 Recipe by factor +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 Amount by factor +func (a *Amount) Scale(factor float64) { + a.Factor *= factor +} + +// Scale Ingredient by factor +func (i *Ingredient) Scale(factor float64) { + if i.Amount != nil { + i.Amount.Scale(factor) + } +} + +// Scale IngredientGroup 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 an Amount as a string. +func (a Amount) Serialize(rounding int) string { + s := a.FormatFactor(rounding) + if a.Unit != nil { + return s + " " + *a.Unit + } + return s +} + +// Serialize formats an Ingredient as a string. +func (i Ingredient) Serialize(rounding int) string { + if i.Amount != nil { + return i.Amount.Serialize(rounding) + " " + i.Name + } + return i.Name +} + +// LeafIngredients returns all ingredients including those in groups. +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 all ingredients in the group and subgroups. +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 +} + +// RenderMarkdown formats a Recipe as RecipeMD markdown. +func (r *Recipe) RenderMarkdown(rounding int) string { + var sb strings.Builder + + sb.WriteString("# ") + sb.WriteString(r.Title) + sb.WriteString("\n") + + if r.Description != nil && *r.Description != "" { + sb.WriteString("\n") + sb.WriteString(*r.Description) + sb.WriteString("\n") + } + + if len(r.Tags) > 0 { + sb.WriteString("\n*") + sb.WriteString(strings.Join(r.Tags, ", ")) + sb.WriteString("*\n") + } + + if len(r.Yields) > 0 { + sb.WriteString("\n**") + yields := make([]string, len(r.Yields)) + for i, y := range r.Yields { + yields[i] = y.Serialize(rounding) + } + sb.WriteString(strings.Join(yields, ", ")) + sb.WriteString("**\n") + } + + sb.WriteString("\n---\n") + + renderMarkdownIngredientList(&sb, r.Ingredients, rounding) + renderMarkdownIngredientGroups(&sb, r.IngredientGroups, 2, rounding) + + if r.Instructions != nil && *r.Instructions != "" { + sb.WriteString("\n---\n\n") + sb.WriteString(*r.Instructions) + sb.WriteString("\n") + } + + return sb.String() +} + +func renderMarkdownIngredientList(sb *strings.Builder, ingredients []Ingredient, rounding int) { + if len(ingredients) == 0 { + return + } + sb.WriteString("\n") + for _, ing := range ingredients { + sb.WriteString("- ") + if ing.Amount != nil { + sb.WriteString("*") + sb.WriteString(ing.Amount.Serialize(rounding)) + sb.WriteString("* ") + } + if ing.Link != nil { + sb.WriteString("[") + sb.WriteString(ing.Name) + sb.WriteString("](") + sb.WriteString(*ing.Link) + sb.WriteString(")") + } else { + sb.WriteString(ing.Name) + } + sb.WriteString("\n") + } +} + +func renderMarkdownIngredientGroups(sb *strings.Builder, groups []IngredientGroup, level int, rounding int) { + for _, g := range groups { + sb.WriteString("\n") + sb.WriteString(strings.Repeat("#", level)) + sb.WriteString(" ") + sb.WriteString(g.Title) + sb.WriteString("\n") + renderMarkdownIngredientList(sb, g.Ingredients, rounding) + renderMarkdownIngredientGroups(sb, g.IngredientGroups, level+1, rounding) + } +} + +// Flatten resolves linked ingredients by parsing referenced recipe files +// and inlining their ingredients. Links resolved relative to recipeFile dir. +func (r *Recipe) Flatten(recipeFile string) error { + baseDir := filepath.Dir(recipeFile) + ingredients, err := flattenIngredients(r.Ingredients, baseDir) + if err != nil { + return fmt.Errorf("flattenIngredients: %w", err) + } + r.Ingredients = ingredients + groups, err := flattenIngredientGroups(r.IngredientGroups, baseDir) + if err != nil { + return fmt.Errorf("flattenIngredientGroups: %w", err) + } + r.IngredientGroups = groups + + return nil +} + +func flattenIngredients(ingredients []Ingredient, baseDir string) ([]Ingredient, error) { + result := make([]Ingredient, 0, len(ingredients)) + for _, ing := range ingredients { + if ing.Link != nil { + resolved, err := resolveLinkedRecipe(*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(groups []IngredientGroup, baseDir string) ([]IngredientGroup, error) { + result := make([]IngredientGroup, 0, len(groups)) + for _, g := range groups { + ingredients, err := flattenIngredients(g.Ingredients, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients:%w", err) + } + groups, err := flattenIngredientGroups(g.IngredientGroups, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredientGroups: %w" , err) + } + flat := IngredientGroup{ + Title: g.Title, + Ingredients: ingredients, + IngredientGroups: groups, + } + result = append(result, flat) + } + + return result, nil +} + +func resolveLinkedRecipe(link string, baseDir string, parent *Ingredient) ([]Ingredient, error) { + if strings.Contains(link, "://") { + return []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 := ParseRecipe(data) + if err != nil { + return nil, fmt.Errorf("ParseRecipe: %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(linked.Ingredients, linkedDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + for _, g := range linked.IngredientGroups { + ingredients, err := flattenIngredients(g.Ingredients, linkedDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + flatIngredients = append(flatIngredients, ingredients...) + groupIngredients, err := flattenGroupIngredients(g.IngredientGroups, linkedDir) + if err != nil { + return nil, fmt.Errorf("flattenGroupIngredients: %w", err) + } + flatIngredients = append(flatIngredients, groupIngredients...) + } + + if len(flatIngredients) == 0 { + return []Ingredient{*parent}, nil + } + return flatIngredients, nil +} + +func flattenGroupIngredients(groups []IngredientGroup, baseDir string) ([]Ingredient, error) { + result := make([]Ingredient, 0, len(groups)) + for _, g := range groups { + ingredients, err := flattenIngredients(g.Ingredients, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + result = append(result, ingredients...) + groupIngredients, err := flattenGroupIngredients(g.IngredientGroups, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenGroupIngredients: %w", err) + } + result = append(result, groupIngredients...) + } + return result, nil +} From 17055f331a996443cc9739fd7d0ab874240327b2 Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:27:06 -0400 Subject: [PATCH 07/29] feat: reduce to 1 goldmark parser pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walk the full-document AST linearly (preamble → thematic break → ingredients → thematic break → instructions) instead of splitting into byte slices and re-parsing each section separately. ~43% faster, ~45% less memory, ~35% fewer allocs vs the 3-parse original (17.5μs vs 30.9μs per recipe). Explored storing and threading goldmark AST nodes through the Recipe struct so renderers could derive text on demand. Not worth it: AST nodes are read-only source-offset references that can't survive scaling, serialization, or caching. Pure data structs (eagerly extracted strings) stay decoupled from goldmark and are trivially cacheable in SQLite. Also fixes ingredients_empty.invalid — empty list items now correctly error instead of producing a nameless ingredient. --- parser.go | 301 ++++++++++++++++--------------------------- parser_bench_test.go | 25 ++++ 2 files changed, 137 insertions(+), 189 deletions(-) create mode 100644 parser_bench_test.go diff --git a/parser.go b/parser.go index 6a35705..031f4d1 100644 --- a/parser.go +++ b/parser.go @@ -16,14 +16,11 @@ import ( // Commonmark compliant parser var markdownParser = goldmark.DefaultParser() -// ParseRecipe converts a RecipeMD document into a Recipe struct. +// ParseRecipe converts a RecipeMD document into a Recipe struct via a single +// goldmark parse and linear AST walk. // See: https://recipemd.org/specification.html#parsing-a-recipe -// -// The document is split into sections at thematic breaks (---), then each -// section is parsed independently: preamble (title, description, tags, yields), -// ingredients, and instructions. func ParseRecipe(source []byte) (*Recipe, error) { - preamble, ingredients, remaining := splitSections(source) + document := markdownParser.Parse(text.NewReader(source)) recipe := &Recipe{ Yields: []Amount{}, @@ -32,96 +29,130 @@ func ParseRecipe(source []byte) (*Recipe, error) { IngredientGroups: []IngredientGroup{}, } - if err := parsePreamble(preamble, recipe); err != nil { - return nil, fmt.Errorf("parsePreamble: %w", err) + c := document.FirstChild() + if c == nil { + return nil, fmt.Errorf("recipe must have a title") } - if ingredients == nil { - return nil, fmt.Errorf("missing thematic break divider") + // --- Preamble: title --- + h, ok := c.(*ast.Heading) + if !ok { + return nil, fmt.Errorf("expected level 1 heading, got %T", c) } - - if err := parseIngredientsSection(ingredients, recipe); err != nil { - return nil, err + if h.Level != 1 { + return nil, fmt.Errorf("expected level 1 heading, got level %d", h.Level) } - - if remaining != nil { - instructions := strings.Trim(string(remaining), "\n") - if instructions != "" { - recipe.Instructions = &instructions - } + title, err := extractPlainText(h, source) + if err != nil { + return nil, fmt.Errorf("extractPlainText: %w", err) } + recipe.Title = title - return recipe, nil -} + _, titleLineEnd := getDirectLineBounds(h) + descStart := skipSetextUnderline(source, titleLineEnd) + c = c.NextSibling() -// splitSections splits a RecipeMD source at thematic breaks (---) identified -// by goldmark (which correctly ignores --- inside fenced code blocks, setext -// H2 underlines, etc.). Returns up to three sections: preamble, ingredients, -// instructions. Sections that don't exist are returned as nil. -func splitSections(source []byte) (preamble, ingredients, instructions []byte) { - document := markdownParser.Parse(text.NewReader(source)) + // --- Preamble: description, tags, yields --- + var excludeRanges [][2]int + tagsFound, yieldsFound, tagsYieldsMode := false, false, false - // Collect positions of real ThematicBreak nodes as identified by goldmark. - // ThematicBreak nodes don't have Lines(), so we find position by scanning - // forward. Track minPos to handle nodes with no valid bounds. - var breakPositions []int - minPos := 0 - for c := document.FirstChild(); c != nil; c = c.NextSibling() { - if c.Kind() == ast.KindThematicBreak { - pos := findThematicBreakAfter(minPos, source) - if pos >= 0 { - breakPositions = append(breakPositions, pos) - // Next search starts after this break's line - minPos = pos - for minPos < len(source) && source[minPos] != '\n' { - minPos++ + for c != nil && c.Kind() != ast.KindThematicBreak { + p, isPara := c.(*ast.Paragraph) + if isPara { + if em, ok := isOnlyEmphasis(p, italic); ok { + if tagsFound { + return nil, fmt.Errorf("tags already set") + } + tagsText, err := extractPlainText(em, source) + if err != nil { + return nil, fmt.Errorf("extractPlainText: %w", err) } - if minPos < len(source) { - minPos++ + 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 } - } else { - // Update minPos based on this node's bounds - _, end := getRecursiveSourceBounds(c, source) - if end > minPos { - minPos = end + if em, ok := isOnlyEmphasis(p, bold); ok { + if yieldsFound { + return nil, fmt.Errorf("yields already set") + } + yieldsText, err := extractPlainText(em, source) + if err != nil { + return nil, fmt.Errorf("extractPlainText: %w", err) + } + yields, err := parseYields(yieldsText) + if err != nil { + return nil, fmt.Errorf("parseYields: %w", err) + } + 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 { + return nil, fmt.Errorf("unexpected content in tags/yields section") } } + c = c.NextSibling() } - if len(breakPositions) == 0 { - return source, nil, nil + + // --- First thematic break --- + if c == nil || c.Kind() != ast.KindThematicBreak { + return nil, fmt.Errorf("missing thematic break divider") } + firstBreakPos := findThematicBreakAfter(descStart, source) - // Compute the byte just past each break's newline (start of next section). - breakEnds := make([]int, len(breakPositions)) - for i, pos := range breakPositions { - j := pos - for j < len(source) && source[j] != '\n' { - j++ - } - if j < len(source) { - j++ // include the newline + // 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 } - breakEnds[i] = j } - switch len(breakPositions) { - case 0: - return source, nil, nil - case 1: - return source[:breakPositions[0]], source[breakEnds[0]:], nil - default: - return source[:breakPositions[0]], - source[breakEnds[0]:breakPositions[1]], - source[breakEnds[1]:] + c = c.NextSibling() + + // --- Ingredients --- + if _, ok := c.(*ast.Paragraph); ok { + return nil, fmt.Errorf("paragraph not valid in ingredients section") } + c, err = parseIngredientList(c, source, &recipe.Ingredients) + if err != nil { + return nil, err + } + c, err = parseIngredientGroup(c, source, &recipe.IngredientGroups, 0) + if err != nil { + return nil, err + } + + // --- Second thematic break (optional) → instructions --- + if c != nil && c.Kind() == ast.KindThematicBreak { + breakPos := findThematicBreakAfter(firstBreakPos+1, source) + if breakPos >= 0 { + instrStart := breakLineEnd(breakPos, source) + instructions := strings.Trim(string(source[instrStart:]), "\n") + if instructions != "" { + recipe.Instructions = &instructions + } + } + } + + return recipe, nil } -// findThematicBreakAfter finds the byte offset of the first thematic break -// line (3+ dashes) at or after minPos. +// findThematicBreakAfter finds the byte offset of the first line of 3+ dashes +// at or after minPos. func findThematicBreakAfter(minPos int, source []byte) int { pos := minPos - // Advance to line start if mid-line if pos > 0 && pos < len(source) && source[pos-1] != '\n' { for pos < len(source) && source[pos] != '\n' { pos++ @@ -130,139 +161,31 @@ func findThematicBreakAfter(minPos int, source []byte) int { 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 } -// parsePreamble extracts title, description, tags, and yields from the preamble -// section (everything before the first thematic break). -// See: https://recipemd.org/specification.html#parsing-a-recipe steps 2–6 -func parsePreamble(section []byte, recipe *Recipe) error { - document := markdownParser.Parse(text.NewReader(section)) - - // 2. Parse title: first block must be a level-1 heading. - c := document.FirstChild() - if c == nil { - return fmt.Errorf("recipe must have a title") - } - h, ok := c.(*ast.Heading) - if !ok { - return fmt.Errorf("expected level 1 heading, got %T", c) +// breakLineEnd returns the byte offset just past the newline of a break line. +func breakLineEnd(breakPos int, source []byte) int { + j := breakPos + for j < len(source) && source[j] != '\n' { + j++ } - if h.Level != 1 { - return fmt.Errorf("expected level 1 heading, got level %d", h.Level) - } - title, err := extractPlainText(h, section) - if err != nil { - return fmt.Errorf("extractPlainText: %w", err) - } - recipe.Title = title - - // 3. Description starts after the title line (plus setext underline if present). - // We use the heading's own line end rather than the next sibling's start - // because nodes like FencedCodeBlock have Lines() covering only their - // interior content, not the fence delimiters. - _, titleLineEnd := getDirectLineBounds(h) - descStart := skipSetextUnderline(section, titleLineEnd) - - // 4–6. Walk remaining blocks to extract tags and yields. - // Once either is seen, any subsequent non-tags/yields paragraph is an error. - var excludeRanges [][2]int - tagsFound, yieldsFound, tagsYieldsMode := false, false, false - - for c = c.NextSibling(); c != nil; c = c.NextSibling() { - p, ok := c.(*ast.Paragraph) - if !ok { - // Non-paragraph blocks (headings, code, etc.) are part of the description. - continue - } - - if em, ok := isOnlyEmphasis(p, italic); ok { - // 6. Italic-only paragraph → tags. - if tagsFound { - return fmt.Errorf("tags already set") - } - tagsText, err := extractPlainText(em, section) - if err != nil { - return fmt.Errorf("extractPlainText: %w", err) - } - recipe.Tags = parseTags(tagsText) - tagsFound = true - tagsYieldsMode = true - if start, end := getDirectLineBounds(c); start >= 0 { - excludeRanges = append(excludeRanges, [2]int{start, end}) - } - } else if em, ok := isOnlyEmphasis(p, bold); ok { - // 6. Bold-only paragraph → yields. - if yieldsFound { - return fmt.Errorf("yields already set") - } - yieldsText, err := extractPlainText(em, section) - if err != nil { - return fmt.Errorf("extractPlainText: %w", err) - } - yields, err := parseYields(yieldsText) - if err != nil { - return fmt.Errorf("parseYields: %w", err) - } - recipe.Yields = yields - yieldsFound = true - tagsYieldsMode = true - if start, end := getDirectLineBounds(c); start >= 0 { - excludeRanges = append(excludeRanges, [2]int{start, end}) - } - } else if tagsYieldsMode { - // Any other paragraph after tags/yields is invalid. - return fmt.Errorf("unexpected content in tags/yields section") - } - // Otherwise: a regular description paragraph; included via exclusion logic below. - } - - // 5. Build description: preamble from after title to end, minus tags/yields. - if descStart < len(section) { - desc := excludeRangesFromSource(section[descStart:], excludeRanges, descStart) - desc = strings.Trim(desc, "\n") - if desc != "" { - recipe.Description = &desc - } - } - - return nil -} - -// parseIngredientsSection extracts ingredients and ingredient groups from the -// section between the two thematic breaks. -// See: https://recipemd.org/specification.html#parsing-an-ingredient-list -func parseIngredientsSection(section []byte, recipe *Recipe) error { - document := markdownParser.Parse(text.NewReader(section)) - - c := document.FirstChild() - - // Paragraphs are not valid in the ingredients section. - if _, ok := c.(*ast.Paragraph); ok { - return fmt.Errorf("paragraph not valid in ingredients section") - } - - c, err := parseIngredientList(c, section, &recipe.Ingredients) - if err != nil { - return err + if j < len(source) { + j++ } - _, err = parseIngredientGroup(c, section, &recipe.IngredientGroups, 0) - return err + return j } // parseIngredientGroup parses headings and lists in the ingredient section. @@ -369,7 +292,7 @@ func parseIngredient(c ast.Node, source []byte) (Ingredient, error) { var l *string if c == nil { - return Ingredient{Name: n}, nil + return Ingredient{}, fmt.Errorf("ingredient must have a name") } // 5. Examine c diff --git a/parser_bench_test.go b/parser_bench_test.go new file mode 100644 index 0000000..e6041e6 --- /dev/null +++ b/parser_bench_test.go @@ -0,0 +1,25 @@ +package recipemd + +import ( + "os" + "testing" +) + +var benchSource []byte + +func init() { + var err error + benchSource, err = os.ReadFile("testdata/canonical/recipe.md") + if err != nil { + panic(err) + } +} + +func BenchmarkParseRecipe(b *testing.B) { + for b.Loop() { + _, err := ParseRecipe(benchSource) + if err != nil { + b.Fatal(err) + } + } +} From 80d5d999f72505e66b765e513ab6e13e40817df1 Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:12:16 -0400 Subject: [PATCH 08/29] feat: refactor rendering and support simple extensions Migrate to a `Parser` struct with functional options. This struct contains the underlying Goldmark parser/renderer for future HTML rendering. A few functional options are exposed: - a simple frontmatter extension which ignores YAML or TOML frontmatter - a Github-formatted Markdown extension which supports GFM The frontmatter extension simply allows RecipeMD files to pass if they have some frontmatter (maybe as part of another note system like Prot's Denote). The frontmatter is not currently parsed or rendered to keep dependencies minimal. The GFM extension is almost purely for rendering purposes EXCEPT for AutoLink support which infers links from plain text. With that extension, bare URLs in ingredients will correctly populate the link field. Recipe struct is now pure data and transforms on that data. No IO. --- canonical_test.go | 2 +- cmd/recipemd-find/main.go | 4 +- cmd/recipemd/main.go | 7 +- parser.go | 328 +++++++++++++++++++++++++++++++++++--- parser_bench_test.go | 3 +- parser_test.go | 121 +++++++++++++- recipe.go | 201 ----------------------- 7 files changed, 437 insertions(+), 229 deletions(-) diff --git a/canonical_test.go b/canonical_test.go index e102166..07b30f4 100644 --- a/canonical_test.go +++ b/canonical_test.go @@ -24,7 +24,7 @@ func TestCanonical(t *testing.T) { t.Fatal(err) } - recipe, parseErr := ParseRecipe(input) + recipe, parseErr := NewParser().Parse(input) if isInvalid { if parseErr == nil { diff --git a/cmd/recipemd-find/main.go b/cmd/recipemd-find/main.go index 2d42456..6044eb6 100644 --- a/cmd/recipemd-find/main.go +++ b/cmd/recipemd-find/main.go @@ -173,7 +173,7 @@ func getFilteredRecipesBuiltin(args cliArgs) []parsedRecipe { return nil } - recipe, err := recipemd.ParseRecipe(data) + recipe, err := recipemd.NewParser().Parse(data) if err != nil { if !args.noMessages { relPath, _ := filepath.Rel(folder, path) @@ -223,7 +223,7 @@ func getFilteredRecipesWithSearcher(args cliArgs) []parsedRecipe { continue } - recipe, err := recipemd.ParseRecipe(data) + recipe, err := recipemd.NewParser().Parse(data) if err != nil { if !args.noMessages { fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", path, err) diff --git a/cmd/recipemd/main.go b/cmd/recipemd/main.go index 079b2e6..f0fae73 100644 --- a/cmd/recipemd/main.go +++ b/cmd/recipemd/main.go @@ -113,7 +113,8 @@ func main() { } // Parse recipe - recipe, err := recipemd.ParseRecipe(data) + p := recipemd.NewParser() + recipe, err := p.Parse(data) if err != nil { fmt.Fprintf(os.Stderr, "Error parsing recipe: %v\n", err) os.Exit(1) @@ -121,7 +122,7 @@ func main() { // Flatten linked recipes if flat { - if err := recipe.Flatten(filePath); err != nil { + if err := p.Flatten(recipe, filePath); err != nil { fmt.Fprintf(os.Stderr, "Warning: %v\n", err) } } @@ -174,7 +175,7 @@ func main() { os.Exit(1) } } else { - fmt.Print(recipe.RenderMarkdown(rounding)) + fmt.Print(p.RenderMarkdown(recipe, rounding)) } } diff --git a/parser.go b/parser.go index 031f4d1..03b753b 100644 --- a/parser.go +++ b/parser.go @@ -4,23 +4,252 @@ import ( "bytes" "fmt" "net/url" + "os" + "path/filepath" "strconv" "strings" "unicode" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/text" ) -// Commonmark compliant parser -var markdownParser = goldmark.DefaultParser() +type Option func(*Parser) -// ParseRecipe converts a RecipeMD document into a Recipe struct via a single +func WithFrontmatter() Option { return func(p *Parser) { p.Frontmatter = true } } +func WithGithubFormattedMarkdown() Option { return func(p *Parser) { + p.goldmarkExtensions = append(p.goldmarkExtensions, extension.GFM) +} } + +type Parser struct { + Frontmatter bool + goldmarkProcessor goldmark.Markdown + goldmarkExtensions []goldmark.Extender +} + +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 +} + +// RenderMarkdown formats a Recipe as RecipeMD markdown. +func (p *Parser) RenderMarkdown(r *Recipe, rounding int) string { + var sb strings.Builder + + sb.WriteString("# ") + sb.WriteString(r.Title) + sb.WriteString("\n") + + if r.Description != nil && *r.Description != "" { + sb.WriteString("\n") + sb.WriteString(*r.Description) + sb.WriteString("\n") + } + + if len(r.Tags) > 0 { + sb.WriteString("\n*") + sb.WriteString(strings.Join(r.Tags, ", ")) + sb.WriteString("*\n") + } + + if len(r.Yields) > 0 { + sb.WriteString("\n**") + yields := make([]string, len(r.Yields)) + for i, y := range r.Yields { + yields[i] = y.Serialize(rounding) + } + sb.WriteString(strings.Join(yields, ", ")) + sb.WriteString("**\n") + } + + sb.WriteString("\n---\n") + + renderMarkdownIngredientList(&sb, r.Ingredients, rounding) + renderMarkdownIngredientGroups(&sb, r.IngredientGroups, 2, rounding) + + if r.Instructions != nil && *r.Instructions != "" { + sb.WriteString("\n---\n\n") + sb.WriteString(*r.Instructions) + sb.WriteString("\n") + } + + return sb.String() +} + +func renderMarkdownIngredientList(sb *strings.Builder, ingredients []Ingredient, rounding int) { + if len(ingredients) == 0 { + return + } + sb.WriteString("\n") + for _, ing := range ingredients { + sb.WriteString("- ") + if ing.Amount != nil { + sb.WriteString("*") + sb.WriteString(ing.Amount.Serialize(rounding)) + sb.WriteString("* ") + } + if ing.Link != nil { + sb.WriteString("[") + sb.WriteString(ing.Name) + sb.WriteString("](") + sb.WriteString(*ing.Link) + sb.WriteString(")") + } else { + sb.WriteString(ing.Name) + } + sb.WriteString("\n") + } +} + +func renderMarkdownIngredientGroups(sb *strings.Builder, groups []IngredientGroup, level int, rounding int) { + for _, g := range groups { + sb.WriteString("\n") + sb.WriteString(strings.Repeat("#", level)) + sb.WriteString(" ") + sb.WriteString(g.Title) + sb.WriteString("\n") + renderMarkdownIngredientList(sb, g.Ingredients, rounding) + renderMarkdownIngredientGroups(sb, g.IngredientGroups, level+1, rounding) + } +} + +// Flatten resolves linked ingredients by parsing referenced recipe files +// and inlining their ingredients. Links resolved relative to recipeFile dir. +func (p *Parser) Flatten(r *Recipe, recipeFile string) error { + baseDir := filepath.Dir(recipeFile) + ingredients, err := p.flattenIngredients(r.Ingredients, baseDir) + if err != nil { + return fmt.Errorf("flattenIngredients: %w", err) + } + r.Ingredients = ingredients + groups, err := p.flattenIngredientGroups(r.IngredientGroups, baseDir) + if err != nil { + return fmt.Errorf("flattenIngredientGroups: %w", err) + } + r.IngredientGroups = groups + return nil +} + +func (p *Parser) flattenIngredients(ingredients []Ingredient, baseDir string) ([]Ingredient, error) { + result := make([]Ingredient, 0, len(ingredients)) + for _, ing := range ingredients { + if ing.Link != nil { + resolved, err := p.resolveLinkedRecipe(*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 (p *Parser) flattenIngredientGroups(groups []IngredientGroup, baseDir string) ([]IngredientGroup, error) { + result := make([]IngredientGroup, 0, len(groups)) + for _, g := range groups { + ingredients, err := p.flattenIngredients(g.Ingredients, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + groups, err := p.flattenIngredientGroups(g.IngredientGroups, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredientGroups: %w", err) + } + result = append(result, IngredientGroup{ + Title: g.Title, + Ingredients: ingredients, + IngredientGroups: groups, + }) + } + return result, nil +} + +func (p *Parser) resolveLinkedRecipe(link string, baseDir string, parent *Ingredient) ([]Ingredient, error) { + if strings.Contains(link, "://") { + return []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(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 := p.flattenIngredients(linked.Ingredients, linkedDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + for _, g := range linked.IngredientGroups { + ingredients, err := p.flattenIngredients(g.Ingredients, linkedDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + flatIngredients = append(flatIngredients, ingredients...) + groupIngredients, err := p.flattenGroupIngredients(g.IngredientGroups, linkedDir) + if err != nil { + return nil, fmt.Errorf("flattenGroupIngredients: %w", err) + } + flatIngredients = append(flatIngredients, groupIngredients...) + } + + if len(flatIngredients) == 0 { + return []Ingredient{*parent}, nil + } + return flatIngredients, nil +} + +func (p *Parser) flattenGroupIngredients(groups []IngredientGroup, baseDir string) ([]Ingredient, error) { + result := make([]Ingredient, 0, len(groups)) + for _, g := range groups { + ingredients, err := p.flattenIngredients(g.Ingredients, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenIngredients: %w", err) + } + result = append(result, ingredients...) + groupIngredients, err := p.flattenGroupIngredients(g.IngredientGroups, baseDir) + if err != nil { + return nil, fmt.Errorf("flattenGroupIngredients: %w", err) + } + result = append(result, groupIngredients...) + } + return result, nil +} + +// Parse converts a RecipeMD document into a Recipe struct via a single // goldmark parse and linear AST walk. // See: https://recipemd.org/specification.html#parsing-a-recipe -func ParseRecipe(source []byte) (*Recipe, error) { - document := markdownParser.Parse(text.NewReader(source)) +func (p *Parser) Parse(source []byte) (*Recipe, error) { + if p.Frontmatter { + source = stripFrontmatter(source) + } + + document := p.goldmarkProcessor.Parser().Parse(text.NewReader(source)) recipe := &Recipe{ Yields: []Amount{}, @@ -335,12 +564,9 @@ func parseIngredient(c ast.Node, source []byte) (Ingredient, error) { link := findSingleLink(afterAmount, source) if isOnlyChild && link != nil { - // Set l to the link's destination - dest := encodeURLPath(string(link.Destination)) + dest := encodeURLPath(link.destination) l = &dest - // Set n to the link's text - linkText, _ := extractPlainText(link, source) - n = linkText + n = link.text } else { n = r } @@ -383,6 +609,9 @@ 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 + "*" @@ -420,24 +649,37 @@ func getBlockSeparator(prev, curr ast.Node, source []byte) string { return "\n\n" + listItemContinuationIndent } -// findSingleLink checks if nodes from start consist only of whitespace and a single link -func findSingleLink(start ast.Node, source []byte) *ast.Link { - var link *ast.Link +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 link != nil { - return nil // multiple links + 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 } - link = l + 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 // non-whitespace text + return nil } } else { - return nil // other inline element + return nil } } - return link + return found } // ParseAmountString parses an amount string into value and unit. @@ -886,6 +1128,54 @@ func skipSetextUnderline(source []byte, pos int) int { 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) diff --git a/parser_bench_test.go b/parser_bench_test.go index e6041e6..477fa89 100644 --- a/parser_bench_test.go +++ b/parser_bench_test.go @@ -16,8 +16,9 @@ func init() { } func BenchmarkParseRecipe(b *testing.B) { + p := NewParser() for b.Loop() { - _, err := ParseRecipe(benchSource) + _, err := p.Parse(benchSource) if err != nil { b.Fatal(err) } diff --git a/parser_test.go b/parser_test.go index 89f18c2..cb92e0f 100644 --- a/parser_test.go +++ b/parser_test.go @@ -5,6 +5,123 @@ import ( "testing" ) +func TestParser_WithFrontmatter_YAML(t *testing.T) { + input := []byte(`--- +title: ignored +--- +# Guacamole + +--- + +- avocado +`) + p := NewParser(WithFrontmatter()) + recipe, err := p.Parse(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(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(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(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(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 @@ -16,7 +133,7 @@ It's delicious with chips. - avocado `) - recipe, err := ParseRecipe(input) + recipe, err := NewParser().Parse(input) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -59,7 +176,7 @@ with salt, pepper and lemon juice. `) func TestParse_FullRecipe(t *testing.T) { - recipe, err := ParseRecipe(sampleRecipe) + recipe, err := NewParser().Parse(sampleRecipe) if err != nil { t.Fatalf("Parse error: %v", err) } diff --git a/recipe.go b/recipe.go index bc66c52..41eea56 100644 --- a/recipe.go +++ b/recipe.go @@ -4,8 +4,6 @@ import ( "errors" "fmt" "math" - "os" - "path/filepath" "strconv" "strings" ) @@ -160,203 +158,4 @@ func (g *IngredientGroup) LeafIngredients() []Ingredient { return result } -// RenderMarkdown formats a Recipe as RecipeMD markdown. -func (r *Recipe) RenderMarkdown(rounding int) string { - var sb strings.Builder - sb.WriteString("# ") - sb.WriteString(r.Title) - sb.WriteString("\n") - - if r.Description != nil && *r.Description != "" { - sb.WriteString("\n") - sb.WriteString(*r.Description) - sb.WriteString("\n") - } - - if len(r.Tags) > 0 { - sb.WriteString("\n*") - sb.WriteString(strings.Join(r.Tags, ", ")) - sb.WriteString("*\n") - } - - if len(r.Yields) > 0 { - sb.WriteString("\n**") - yields := make([]string, len(r.Yields)) - for i, y := range r.Yields { - yields[i] = y.Serialize(rounding) - } - sb.WriteString(strings.Join(yields, ", ")) - sb.WriteString("**\n") - } - - sb.WriteString("\n---\n") - - renderMarkdownIngredientList(&sb, r.Ingredients, rounding) - renderMarkdownIngredientGroups(&sb, r.IngredientGroups, 2, rounding) - - if r.Instructions != nil && *r.Instructions != "" { - sb.WriteString("\n---\n\n") - sb.WriteString(*r.Instructions) - sb.WriteString("\n") - } - - return sb.String() -} - -func renderMarkdownIngredientList(sb *strings.Builder, ingredients []Ingredient, rounding int) { - if len(ingredients) == 0 { - return - } - sb.WriteString("\n") - for _, ing := range ingredients { - sb.WriteString("- ") - if ing.Amount != nil { - sb.WriteString("*") - sb.WriteString(ing.Amount.Serialize(rounding)) - sb.WriteString("* ") - } - if ing.Link != nil { - sb.WriteString("[") - sb.WriteString(ing.Name) - sb.WriteString("](") - sb.WriteString(*ing.Link) - sb.WriteString(")") - } else { - sb.WriteString(ing.Name) - } - sb.WriteString("\n") - } -} - -func renderMarkdownIngredientGroups(sb *strings.Builder, groups []IngredientGroup, level int, rounding int) { - for _, g := range groups { - sb.WriteString("\n") - sb.WriteString(strings.Repeat("#", level)) - sb.WriteString(" ") - sb.WriteString(g.Title) - sb.WriteString("\n") - renderMarkdownIngredientList(sb, g.Ingredients, rounding) - renderMarkdownIngredientGroups(sb, g.IngredientGroups, level+1, rounding) - } -} - -// Flatten resolves linked ingredients by parsing referenced recipe files -// and inlining their ingredients. Links resolved relative to recipeFile dir. -func (r *Recipe) Flatten(recipeFile string) error { - baseDir := filepath.Dir(recipeFile) - ingredients, err := flattenIngredients(r.Ingredients, baseDir) - if err != nil { - return fmt.Errorf("flattenIngredients: %w", err) - } - r.Ingredients = ingredients - groups, err := flattenIngredientGroups(r.IngredientGroups, baseDir) - if err != nil { - return fmt.Errorf("flattenIngredientGroups: %w", err) - } - r.IngredientGroups = groups - - return nil -} - -func flattenIngredients(ingredients []Ingredient, baseDir string) ([]Ingredient, error) { - result := make([]Ingredient, 0, len(ingredients)) - for _, ing := range ingredients { - if ing.Link != nil { - resolved, err := resolveLinkedRecipe(*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(groups []IngredientGroup, baseDir string) ([]IngredientGroup, error) { - result := make([]IngredientGroup, 0, len(groups)) - for _, g := range groups { - ingredients, err := flattenIngredients(g.Ingredients, baseDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredients:%w", err) - } - groups, err := flattenIngredientGroups(g.IngredientGroups, baseDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredientGroups: %w" , err) - } - flat := IngredientGroup{ - Title: g.Title, - Ingredients: ingredients, - IngredientGroups: groups, - } - result = append(result, flat) - } - - return result, nil -} - -func resolveLinkedRecipe(link string, baseDir string, parent *Ingredient) ([]Ingredient, error) { - if strings.Contains(link, "://") { - return []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 := ParseRecipe(data) - if err != nil { - return nil, fmt.Errorf("ParseRecipe: %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(linked.Ingredients, linkedDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredients: %w", err) - } - for _, g := range linked.IngredientGroups { - ingredients, err := flattenIngredients(g.Ingredients, linkedDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredients: %w", err) - } - flatIngredients = append(flatIngredients, ingredients...) - groupIngredients, err := flattenGroupIngredients(g.IngredientGroups, linkedDir) - if err != nil { - return nil, fmt.Errorf("flattenGroupIngredients: %w", err) - } - flatIngredients = append(flatIngredients, groupIngredients...) - } - - if len(flatIngredients) == 0 { - return []Ingredient{*parent}, nil - } - return flatIngredients, nil -} - -func flattenGroupIngredients(groups []IngredientGroup, baseDir string) ([]Ingredient, error) { - result := make([]Ingredient, 0, len(groups)) - for _, g := range groups { - ingredients, err := flattenIngredients(g.Ingredients, baseDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredients: %w", err) - } - result = append(result, ingredients...) - groupIngredients, err := flattenGroupIngredients(g.IngredientGroups, baseDir) - if err != nil { - return nil, fmt.Errorf("flattenGroupIngredients: %w", err) - } - result = append(result, groupIngredients...) - } - return result, nil -} From 72f7af5def80699969f7c866b084a23adb1e3f6f Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:41:54 -0400 Subject: [PATCH 09/29] fix: rewrite rendering and use template for Markdown rendering --- cmd/recipemd/main.go | 7 +- parser.go | 81 ---------- regression_test.go | 32 ++++ render.go | 150 ++++++++++++++++++ testdata/regression/empty_ingredient.md | 7 + .../empty_ingredient_trailing_space.md | 7 + 6 files changed, 199 insertions(+), 85 deletions(-) create mode 100644 regression_test.go create mode 100644 render.go create mode 100644 testdata/regression/empty_ingredient.md create mode 100644 testdata/regression/empty_ingredient_trailing_space.md diff --git a/cmd/recipemd/main.go b/cmd/recipemd/main.go index f0fae73..64aafe4 100644 --- a/cmd/recipemd/main.go +++ b/cmd/recipemd/main.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "flag" "fmt" "os" @@ -168,12 +167,12 @@ func main() { fmt.Println(ing.Serialize(rounding)) } } else if jsonOut { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(recipe); err != nil { + data, err := p.RenderJSON(recipe) + if err != nil { fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err) os.Exit(1) } + fmt.Println(string(data)) } else { fmt.Print(p.RenderMarkdown(recipe, rounding)) } diff --git a/parser.go b/parser.go index 03b753b..a4ae25f 100644 --- a/parser.go +++ b/parser.go @@ -44,87 +44,6 @@ func NewParser(opts ...Option) (p *Parser) { return } -// RenderMarkdown formats a Recipe as RecipeMD markdown. -func (p *Parser) RenderMarkdown(r *Recipe, rounding int) string { - var sb strings.Builder - - sb.WriteString("# ") - sb.WriteString(r.Title) - sb.WriteString("\n") - - if r.Description != nil && *r.Description != "" { - sb.WriteString("\n") - sb.WriteString(*r.Description) - sb.WriteString("\n") - } - - if len(r.Tags) > 0 { - sb.WriteString("\n*") - sb.WriteString(strings.Join(r.Tags, ", ")) - sb.WriteString("*\n") - } - - if len(r.Yields) > 0 { - sb.WriteString("\n**") - yields := make([]string, len(r.Yields)) - for i, y := range r.Yields { - yields[i] = y.Serialize(rounding) - } - sb.WriteString(strings.Join(yields, ", ")) - sb.WriteString("**\n") - } - - sb.WriteString("\n---\n") - - renderMarkdownIngredientList(&sb, r.Ingredients, rounding) - renderMarkdownIngredientGroups(&sb, r.IngredientGroups, 2, rounding) - - if r.Instructions != nil && *r.Instructions != "" { - sb.WriteString("\n---\n\n") - sb.WriteString(*r.Instructions) - sb.WriteString("\n") - } - - return sb.String() -} - -func renderMarkdownIngredientList(sb *strings.Builder, ingredients []Ingredient, rounding int) { - if len(ingredients) == 0 { - return - } - sb.WriteString("\n") - for _, ing := range ingredients { - sb.WriteString("- ") - if ing.Amount != nil { - sb.WriteString("*") - sb.WriteString(ing.Amount.Serialize(rounding)) - sb.WriteString("* ") - } - if ing.Link != nil { - sb.WriteString("[") - sb.WriteString(ing.Name) - sb.WriteString("](") - sb.WriteString(*ing.Link) - sb.WriteString(")") - } else { - sb.WriteString(ing.Name) - } - sb.WriteString("\n") - } -} - -func renderMarkdownIngredientGroups(sb *strings.Builder, groups []IngredientGroup, level int, rounding int) { - for _, g := range groups { - sb.WriteString("\n") - sb.WriteString(strings.Repeat("#", level)) - sb.WriteString(" ") - sb.WriteString(g.Title) - sb.WriteString("\n") - renderMarkdownIngredientList(sb, g.Ingredients, rounding) - renderMarkdownIngredientGroups(sb, g.IngredientGroups, level+1, rounding) - } -} - // Flatten resolves linked ingredients by parsing referenced recipe files // and inlining their ingredients. Links resolved relative to recipeFile dir. func (p *Parser) Flatten(r *Recipe, recipeFile string) error { diff --git a/regression_test.go b/regression_test.go new file mode 100644 index 0000000..d03b6a9 --- /dev/null +++ b/regression_test.go @@ -0,0 +1,32 @@ +package recipemd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRegression_EmptyIngredient(t *testing.T) { + files, err := filepath.Glob("testdata/regression/empty_ingredient*.md") + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no test files found") + } + + p := NewParser() + for _, f := range files { + name := filepath.Base(f) + t.Run(name, func(t *testing.T) { + data, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + _, err = p.Parse(data) + if err == nil { + t.Error("expected parse error for empty ingredient") + } + }) + } +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..f752969 --- /dev/null +++ b/render.go @@ -0,0 +1,150 @@ +package recipemd + +import ( + "bytes" + "encoding/json" + "strings" + "text/template" +) + +var markdownTmpl = template.Must(template.New("recipemd").Funcs(template.FuncMap{ + "join": strings.Join, + "hashes": func(n int) string { return strings.Repeat("#", n) }, +}).Parse(`# {{ .Recipe.Title }} +{{ if .HasDescription }} +{{ .Description }} +{{ end }}{{ if .Recipe.Tags }} +*{{ join .Recipe.Tags ", " }}* +{{ end }}{{ if .Recipe.Yields }} +**{{ join .SerializedYields ", " }}** +{{ end }} +--- +{{ template "ingredients" . }}{{ template "groups" . }}{{ if .HasInstructions }} +--- + +{{ .Instructions }} +{{ end }}`)) + +var ingredientsTmpl = template.Must(markdownTmpl.New("ingredients").Parse( + `{{ if .Ingredients }} +{{ range .Ingredients }}- {{ if .Amount }}*{{ .Amount }}* {{ end }}{{ if .Link }}[{{ .Name }}]({{ .Link }}){{ else }}{{ .Name }}{{ end }} +{{ end }}{{ end }}`)) + +var _ = template.Must(markdownTmpl.New("groups").Parse( + `{{ range .Groups }} +{{ .Heading }} {{ .Title }} +{{ template "ingredients" .IngredientData }}{{ template "groups" .SubgroupData }}{{ end }}`)) + +type renderData struct { + Recipe *Recipe + rounding int + level int +} + +func (d renderData) HasDescription() bool { + return d.Recipe.Description != nil && *d.Recipe.Description != "" +} + +func (d renderData) Description() string { + if d.Recipe.Description == nil { + return "" + } + return *d.Recipe.Description +} + +func (d renderData) HasInstructions() bool { + return d.Recipe.Instructions != nil && *d.Recipe.Instructions != "" +} + +func (d renderData) Instructions() string { + if d.Recipe.Instructions == nil { + return "" + } + return *d.Recipe.Instructions +} + +func (d renderData) SerializedYields() []string { + yields := make([]string, len(d.Recipe.Yields)) + for i, y := range d.Recipe.Yields { + yields[i] = y.Serialize(d.rounding) + } + return yields +} + +func (d renderData) Ingredients() []ingredientData { + return makeIngredientData(d.Recipe.Ingredients, d.rounding) +} + +func (d renderData) Groups() []groupData { + return makeGroupData(d.Recipe.IngredientGroups, d.rounding, d.level) +} + +type ingredientData struct { + Name string + Amount string + Link string +} + +func makeIngredientData(ingredients []Ingredient, rounding int) []ingredientData { + result := make([]ingredientData, len(ingredients)) + for i, ing := range ingredients { + d := ingredientData{Name: ing.Name} + if ing.Amount != nil { + d.Amount = ing.Amount.Serialize(rounding) + } + if ing.Link != nil { + d.Link = *ing.Link + } + result[i] = d + } + return result +} + +type groupData struct { + Title string + rounding int + level int + group IngredientGroup +} + +func (g groupData) Heading() string { + return strings.Repeat("#", g.level) +} + +func (g groupData) IngredientData() struct{ Ingredients []ingredientData } { + return struct{ Ingredients []ingredientData }{ + Ingredients: makeIngredientData(g.group.Ingredients, g.rounding), + } +} + +func (g groupData) SubgroupData() struct{ Groups []groupData } { + return struct{ Groups []groupData }{ + Groups: makeGroupData(g.group.IngredientGroups, g.rounding, g.level+1), + } +} + +func makeGroupData(groups []IngredientGroup, rounding int, level int) []groupData { + result := make([]groupData, len(groups)) + for i, g := range groups { + result[i] = groupData{ + Title: g.Title, + rounding: rounding, + level: level, + group: g, + } + } + return result +} + +// RenderMarkdown formats a Recipe as RecipeMD markdown. +func (p *Parser) RenderMarkdown(r *Recipe, rounding int) string { + var buf bytes.Buffer + data := renderData{Recipe: r, rounding: rounding, level: 2} + _ = markdownTmpl.Execute(&buf, data) + return buf.String() +} + +// RenderJSON serializes a Recipe as indented JSON. +func (p *Parser) RenderJSON(r *Recipe) ([]byte, error) { + return json.MarshalIndent(r, "", " ") +} diff --git a/testdata/regression/empty_ingredient.md b/testdata/regression/empty_ingredient.md new file mode 100644 index 0000000..160338d --- /dev/null +++ b/testdata/regression/empty_ingredient.md @@ -0,0 +1,7 @@ +# Empty Ingredient + +--- + +- + +--- diff --git a/testdata/regression/empty_ingredient_trailing_space.md b/testdata/regression/empty_ingredient_trailing_space.md new file mode 100644 index 0000000..baeb1a8 --- /dev/null +++ b/testdata/regression/empty_ingredient_trailing_space.md @@ -0,0 +1,7 @@ +# Empty Ingredient With Trailing Space + +--- + +- + +--- From 3e7ed909114bb7a01cf8907bd15ca37edd33101c Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:46:10 -0400 Subject: [PATCH 10/29] feat: expose flags for the GFM and frontmatter parser options in CLI --- cmd/recipemd-find/main.go | 23 +++++++++++++++++++++-- cmd/recipemd/main.go | 13 +++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/cmd/recipemd-find/main.go b/cmd/recipemd-find/main.go index 6044eb6..377421b 100644 --- a/cmd/recipemd-find/main.go +++ b/cmd/recipemd-find/main.go @@ -59,6 +59,8 @@ type cliArgs struct { folder string count bool searcher string // external search program (e.g. "grep", "rg") + gfm bool + frontmatter bool } func parseArgs(raw []string) (cliArgs, error) { @@ -96,6 +98,10 @@ func parseArgs(raw []string) (cliArgs, error) { return args, fmt.Errorf("missing value for %s", arg) } args.searcher = raw[i] + case "--gfm": + args.gfm = true + case "--frontmatter": + args.frontmatter = true default: if strings.HasPrefix(arg, "-") { return args, fmt.Errorf("unknown option %q", arg) @@ -111,6 +117,17 @@ func parseArgs(raw []string) (cliArgs, error) { return args, nil } +func parserOpts(args cliArgs) []recipemd.Option { + var opts []recipemd.Option + if args.gfm { + opts = append(opts, recipemd.WithGithubFormattedMarkdown()) + } + if args.frontmatter { + opts = append(opts, recipemd.WithFrontmatter()) + } + return opts +} + func printUsage() { fmt.Fprintf(os.Stderr, `Usage: recipemd-find [options] [folder] @@ -135,6 +152,8 @@ Options: files. The filter expression is translated to the program's syntax. Candidate files are then parsed and verified with the RecipeMD-aware filter. + --gfm enable GitHub Flavored Markdown extensions + --frontmatter strip YAML/TOML frontmatter before parsing `) } @@ -173,7 +192,7 @@ func getFilteredRecipesBuiltin(args cliArgs) []parsedRecipe { return nil } - recipe, err := recipemd.NewParser().Parse(data) + recipe, err := recipemd.NewParser(parserOpts(args)...).Parse(data) if err != nil { if !args.noMessages { relPath, _ := filepath.Rel(folder, path) @@ -223,7 +242,7 @@ func getFilteredRecipesWithSearcher(args cliArgs) []parsedRecipe { continue } - recipe, err := recipemd.NewParser().Parse(data) + recipe, err := recipemd.NewParser(parserOpts(args)...).Parse(data) if err != nil { if !args.noMessages { fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", path, err) diff --git a/cmd/recipemd/main.go b/cmd/recipemd/main.go index 64aafe4..bd4890f 100644 --- a/cmd/recipemd/main.go +++ b/cmd/recipemd/main.go @@ -35,6 +35,8 @@ func main() { yieldLong := flag.String("yield", "", "scale the recipe for yield Y, e.g. \"5 servings\"") flatten := flag.Bool("f", false, "flatten ingredients and instructions of linked recipes into main recipe") flattenLong := flag.Bool("flatten", false, "flatten ingredients and instructions of linked recipes into main recipe") + gfm := flag.Bool("gfm", false, "enable GitHub Flavored Markdown extensions") + frontmatter := flag.Bool("frontmatter", false, "strip YAML/TOML frontmatter before parsing") flag.Parse() @@ -111,8 +113,15 @@ func main() { os.Exit(1) } - // Parse recipe - p := recipemd.NewParser() + // Build parser + var opts []recipemd.Option + if *gfm { + opts = append(opts, recipemd.WithGithubFormattedMarkdown()) + } + if *frontmatter { + opts = append(opts, recipemd.WithFrontmatter()) + } + p := recipemd.NewParser(opts...) recipe, err := p.Parse(data) if err != nil { fmt.Fprintf(os.Stderr, "Error parsing recipe: %v\n", err) From c8120c539b872430023c7f6c345280f4c24f3aaf Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:07:57 -0400 Subject: [PATCH 11/29] fix: fix fenced code and GFM task list bugs and add golden tests Fixed a bug where fenced code --- was treated as a thematic break. Added support for GFM task lists which look like normal Commonmark lists except they have the "checkbox": - [ ] an unchecked element - [x] a checked element When used as an ingredient list, it breaks the recipemd spec which expects the first element to be the unit with emphasis. It would still parse but you would get the entire `[x] *1 cup* flour` treated as the text field. With this commit, we properly skip the ast.TaskCheckBox before parsing. Finally, these were found because we added a massive golden test suite in the vein of the canonical tests but extended to cover many more cases. --- golden_test.go | 79 +++++++++++++++++++ parser.go | 70 +++++++++------- testdata/golden/amount_decimal_comma.json | 18 +++++ testdata/golden/amount_decimal_comma.md | 5 ++ testdata/golden/amount_decimal_dot.json | 18 +++++ testdata/golden/amount_decimal_dot.md | 5 ++ testdata/golden/amount_fraction.json | 18 +++++ testdata/golden/amount_fraction.md | 5 ++ testdata/golden/amount_fraction_spaces.json | 18 +++++ testdata/golden/amount_fraction_spaces.md | 5 ++ testdata/golden/amount_improper_fraction.json | 18 +++++ testdata/golden/amount_improper_fraction.md | 5 ++ testdata/golden/amount_improper_vulgar.json | 18 +++++ testdata/golden/amount_improper_vulgar.md | 5 ++ testdata/golden/amount_integer.json | 18 +++++ testdata/golden/amount_integer.md | 5 ++ testdata/golden/amount_leading_decimal.json | 18 +++++ testdata/golden/amount_leading_decimal.md | 5 ++ testdata/golden/amount_negative.json | 18 +++++ testdata/golden/amount_negative.md | 5 ++ testdata/golden/amount_negative_fraction.json | 18 +++++ testdata/golden/amount_negative_fraction.md | 5 ++ testdata/golden/amount_vulgar.json | 18 +++++ testdata/golden/amount_vulgar.md | 5 ++ testdata/golden/amount_with_unit.json | 18 +++++ testdata/golden/amount_with_unit.md | 5 ++ testdata/golden/amount_without_unit.json | 18 +++++ testdata/golden/amount_without_unit.md | 5 ++ testdata/golden/amount_zero_denom.json | 18 +++++ testdata/golden/amount_zero_denom.md | 5 ++ testdata/golden/break_many_dashes.json | 15 ++++ testdata/golden/break_many_dashes.md | 5 ++ testdata/golden/break_missing.invalid.md | 3 + testdata/golden/desc_code_block.json | 9 +++ testdata/golden/desc_code_block.md | 5 ++ .../golden/desc_fenced_code_with_dashes.json | 9 +++ .../golden/desc_fenced_code_with_dashes.md | 13 +++ testdata/golden/desc_html.json | 9 +++ testdata/golden/desc_html.md | 5 ++ testdata/golden/desc_multi.json | 9 +++ testdata/golden/desc_multi.md | 7 ++ testdata/golden/desc_none.json | 17 ++++ testdata/golden/desc_none.md | 7 ++ testdata/golden/desc_only.json | 9 +++ testdata/golden/desc_only.md | 5 ++ testdata/golden/desc_single.json | 9 +++ testdata/golden/desc_single.md | 5 ++ .../golden/frontmatter/empty_frontmatter.json | 15 ++++ .../golden/frontmatter/empty_frontmatter.md | 7 ++ .../frontmatter/mixed_fences.invalid.md | 3 + testdata/golden/frontmatter/none.json | 15 ++++ testdata/golden/frontmatter/none.md | 5 ++ testdata/golden/frontmatter/toml.json | 15 ++++ testdata/golden/frontmatter/toml.md | 9 +++ .../golden/frontmatter/unclosed.invalid.md | 7 ++ testdata/golden/frontmatter/yaml.json | 15 ++++ testdata/golden/frontmatter/yaml.md | 9 +++ testdata/golden/gfm/amount_autolink.json | 18 +++++ testdata/golden/gfm/amount_autolink.md | 5 ++ testdata/golden/gfm/autolink_description.json | 9 +++ testdata/golden/gfm/autolink_description.md | 5 ++ testdata/golden/gfm/autolink_ingredient.json | 15 ++++ testdata/golden/gfm/autolink_ingredient.md | 5 ++ .../golden/gfm/strikethrough_description.json | 9 +++ .../golden/gfm/strikethrough_description.md | 5 ++ testdata/golden/gfm/table_instructions.json | 15 ++++ testdata/golden/gfm/table_instructions.md | 12 +++ testdata/golden/gfm/tasklist_ingredients.json | 26 ++++++ testdata/golden/gfm/tasklist_ingredients.md | 6 ++ testdata/golden/group_back_to_lower.json | 38 +++++++++ testdata/golden/group_back_to_lower.md | 13 +++ testdata/golden/group_empty.json | 15 ++++ testdata/golden/group_empty.md | 5 ++ testdata/golden/group_level_jump.json | 33 ++++++++ testdata/golden/group_level_jump.md | 11 +++ testdata/golden/group_multiple_lists.json | 36 +++++++++ testdata/golden/group_multiple_lists.md | 11 +++ testdata/golden/group_nested.json | 45 +++++++++++ testdata/golden/group_nested.md | 15 ++++ testdata/golden/group_same_level.json | 32 ++++++++ testdata/golden/group_same_level.md | 11 +++ testdata/golden/group_single.json | 21 +++++ testdata/golden/group_single.md | 7 ++ testdata/golden/ing_amount_and_link.json | 18 +++++ testdata/golden/ing_amount_and_link.md | 5 ++ testdata/golden/ing_empty.invalid.md | 5 ++ testdata/golden/ing_link_only.json | 15 ++++ testdata/golden/ing_link_only.md | 5 ++ testdata/golden/ing_multiline.json | 15 ++++ testdata/golden/ing_multiline.md | 7 ++ testdata/golden/ing_multiline_blank.json | 15 ++++ testdata/golden/ing_multiline_blank.md | 7 ++ testdata/golden/ing_nested_list.json | 15 ++++ testdata/golden/ing_nested_list.md | 6 ++ testdata/golden/ing_numbered.json | 26 ++++++ testdata/golden/ing_numbered.md | 6 ++ testdata/golden/ing_simple.json | 15 ++++ testdata/golden/ing_simple.md | 5 ++ .../golden/ing_whitespace_only.invalid.md | 5 ++ testdata/golden/ing_with_amount.json | 18 +++++ testdata/golden/ing_with_amount.md | 5 ++ testdata/golden/instr_code_block.json | 15 ++++ testdata/golden/instr_code_block.md | 11 +++ testdata/golden/instr_fenced_dashes.json | 15 ++++ testdata/golden/instr_fenced_dashes.md | 17 ++++ testdata/golden/instr_headings.json | 15 ++++ testdata/golden/instr_headings.md | 15 ++++ testdata/golden/instr_multi.json | 15 ++++ testdata/golden/instr_multi.md | 11 +++ testdata/golden/instr_none.json | 15 ++++ testdata/golden/instr_none.md | 5 ++ .../golden/preamble_non_matching.invalid.md | 7 ++ testdata/golden/tags_after_yields.json | 16 ++++ testdata/golden/tags_after_yields.md | 7 ++ testdata/golden/tags_decimal_comma.json | 12 +++ testdata/golden/tags_decimal_comma.md | 5 ++ testdata/golden/tags_duplicate.invalid.md | 7 ++ testdata/golden/tags_multiple.json | 13 +++ testdata/golden/tags_multiple.md | 5 ++ testdata/golden/tags_single.json | 11 +++ testdata/golden/tags_single.md | 5 ++ testdata/golden/title_atx.json | 9 +++ testdata/golden/title_atx.md | 3 + testdata/golden/title_empty.invalid.md | 0 testdata/golden/title_inline_formatting.json | 9 +++ testdata/golden/title_inline_formatting.md | 3 + testdata/golden/title_level2.invalid.md | 3 + testdata/golden/title_none.invalid.md | 3 + testdata/golden/title_setext.json | 9 +++ testdata/golden/title_setext.md | 4 + testdata/golden/yields_decimal_comma.json | 14 ++++ testdata/golden/yields_decimal_comma.md | 5 ++ testdata/golden/yields_duplicate.invalid.md | 7 ++ testdata/golden/yields_multiple.json | 18 +++++ testdata/golden/yields_multiple.md | 5 ++ testdata/golden/yields_single.json | 14 ++++ testdata/golden/yields_single.md | 5 ++ .../golden/yields_unit_no_factor.invalid.md | 5 ++ testdata/golden/yields_unitless.json | 14 ++++ testdata/golden/yields_unitless.md | 5 ++ 140 files changed, 1658 insertions(+), 27 deletions(-) create mode 100644 golden_test.go create mode 100644 testdata/golden/amount_decimal_comma.json create mode 100644 testdata/golden/amount_decimal_comma.md create mode 100644 testdata/golden/amount_decimal_dot.json create mode 100644 testdata/golden/amount_decimal_dot.md create mode 100644 testdata/golden/amount_fraction.json create mode 100644 testdata/golden/amount_fraction.md create mode 100644 testdata/golden/amount_fraction_spaces.json create mode 100644 testdata/golden/amount_fraction_spaces.md create mode 100644 testdata/golden/amount_improper_fraction.json create mode 100644 testdata/golden/amount_improper_fraction.md create mode 100644 testdata/golden/amount_improper_vulgar.json create mode 100644 testdata/golden/amount_improper_vulgar.md create mode 100644 testdata/golden/amount_integer.json create mode 100644 testdata/golden/amount_integer.md create mode 100644 testdata/golden/amount_leading_decimal.json create mode 100644 testdata/golden/amount_leading_decimal.md create mode 100644 testdata/golden/amount_negative.json create mode 100644 testdata/golden/amount_negative.md create mode 100644 testdata/golden/amount_negative_fraction.json create mode 100644 testdata/golden/amount_negative_fraction.md create mode 100644 testdata/golden/amount_vulgar.json create mode 100644 testdata/golden/amount_vulgar.md create mode 100644 testdata/golden/amount_with_unit.json create mode 100644 testdata/golden/amount_with_unit.md create mode 100644 testdata/golden/amount_without_unit.json create mode 100644 testdata/golden/amount_without_unit.md create mode 100644 testdata/golden/amount_zero_denom.json create mode 100644 testdata/golden/amount_zero_denom.md create mode 100644 testdata/golden/break_many_dashes.json create mode 100644 testdata/golden/break_many_dashes.md create mode 100644 testdata/golden/break_missing.invalid.md create mode 100644 testdata/golden/desc_code_block.json create mode 100644 testdata/golden/desc_code_block.md create mode 100644 testdata/golden/desc_fenced_code_with_dashes.json create mode 100644 testdata/golden/desc_fenced_code_with_dashes.md create mode 100644 testdata/golden/desc_html.json create mode 100644 testdata/golden/desc_html.md create mode 100644 testdata/golden/desc_multi.json create mode 100644 testdata/golden/desc_multi.md create mode 100644 testdata/golden/desc_none.json create mode 100644 testdata/golden/desc_none.md create mode 100644 testdata/golden/desc_only.json create mode 100644 testdata/golden/desc_only.md create mode 100644 testdata/golden/desc_single.json create mode 100644 testdata/golden/desc_single.md create mode 100644 testdata/golden/frontmatter/empty_frontmatter.json create mode 100644 testdata/golden/frontmatter/empty_frontmatter.md create mode 100644 testdata/golden/frontmatter/mixed_fences.invalid.md create mode 100644 testdata/golden/frontmatter/none.json create mode 100644 testdata/golden/frontmatter/none.md create mode 100644 testdata/golden/frontmatter/toml.json create mode 100644 testdata/golden/frontmatter/toml.md create mode 100644 testdata/golden/frontmatter/unclosed.invalid.md create mode 100644 testdata/golden/frontmatter/yaml.json create mode 100644 testdata/golden/frontmatter/yaml.md create mode 100644 testdata/golden/gfm/amount_autolink.json create mode 100644 testdata/golden/gfm/amount_autolink.md create mode 100644 testdata/golden/gfm/autolink_description.json create mode 100644 testdata/golden/gfm/autolink_description.md create mode 100644 testdata/golden/gfm/autolink_ingredient.json create mode 100644 testdata/golden/gfm/autolink_ingredient.md create mode 100644 testdata/golden/gfm/strikethrough_description.json create mode 100644 testdata/golden/gfm/strikethrough_description.md create mode 100644 testdata/golden/gfm/table_instructions.json create mode 100644 testdata/golden/gfm/table_instructions.md create mode 100644 testdata/golden/gfm/tasklist_ingredients.json create mode 100644 testdata/golden/gfm/tasklist_ingredients.md create mode 100644 testdata/golden/group_back_to_lower.json create mode 100644 testdata/golden/group_back_to_lower.md create mode 100644 testdata/golden/group_empty.json create mode 100644 testdata/golden/group_empty.md create mode 100644 testdata/golden/group_level_jump.json create mode 100644 testdata/golden/group_level_jump.md create mode 100644 testdata/golden/group_multiple_lists.json create mode 100644 testdata/golden/group_multiple_lists.md create mode 100644 testdata/golden/group_nested.json create mode 100644 testdata/golden/group_nested.md create mode 100644 testdata/golden/group_same_level.json create mode 100644 testdata/golden/group_same_level.md create mode 100644 testdata/golden/group_single.json create mode 100644 testdata/golden/group_single.md create mode 100644 testdata/golden/ing_amount_and_link.json create mode 100644 testdata/golden/ing_amount_and_link.md create mode 100644 testdata/golden/ing_empty.invalid.md create mode 100644 testdata/golden/ing_link_only.json create mode 100644 testdata/golden/ing_link_only.md create mode 100644 testdata/golden/ing_multiline.json create mode 100644 testdata/golden/ing_multiline.md create mode 100644 testdata/golden/ing_multiline_blank.json create mode 100644 testdata/golden/ing_multiline_blank.md create mode 100644 testdata/golden/ing_nested_list.json create mode 100644 testdata/golden/ing_nested_list.md create mode 100644 testdata/golden/ing_numbered.json create mode 100644 testdata/golden/ing_numbered.md create mode 100644 testdata/golden/ing_simple.json create mode 100644 testdata/golden/ing_simple.md create mode 100644 testdata/golden/ing_whitespace_only.invalid.md create mode 100644 testdata/golden/ing_with_amount.json create mode 100644 testdata/golden/ing_with_amount.md create mode 100644 testdata/golden/instr_code_block.json create mode 100644 testdata/golden/instr_code_block.md create mode 100644 testdata/golden/instr_fenced_dashes.json create mode 100644 testdata/golden/instr_fenced_dashes.md create mode 100644 testdata/golden/instr_headings.json create mode 100644 testdata/golden/instr_headings.md create mode 100644 testdata/golden/instr_multi.json create mode 100644 testdata/golden/instr_multi.md create mode 100644 testdata/golden/instr_none.json create mode 100644 testdata/golden/instr_none.md create mode 100644 testdata/golden/preamble_non_matching.invalid.md create mode 100644 testdata/golden/tags_after_yields.json create mode 100644 testdata/golden/tags_after_yields.md create mode 100644 testdata/golden/tags_decimal_comma.json create mode 100644 testdata/golden/tags_decimal_comma.md create mode 100644 testdata/golden/tags_duplicate.invalid.md create mode 100644 testdata/golden/tags_multiple.json create mode 100644 testdata/golden/tags_multiple.md create mode 100644 testdata/golden/tags_single.json create mode 100644 testdata/golden/tags_single.md create mode 100644 testdata/golden/title_atx.json create mode 100644 testdata/golden/title_atx.md create mode 100644 testdata/golden/title_empty.invalid.md create mode 100644 testdata/golden/title_inline_formatting.json create mode 100644 testdata/golden/title_inline_formatting.md create mode 100644 testdata/golden/title_level2.invalid.md create mode 100644 testdata/golden/title_none.invalid.md create mode 100644 testdata/golden/title_setext.json create mode 100644 testdata/golden/title_setext.md create mode 100644 testdata/golden/yields_decimal_comma.json create mode 100644 testdata/golden/yields_decimal_comma.md create mode 100644 testdata/golden/yields_duplicate.invalid.md create mode 100644 testdata/golden/yields_multiple.json create mode 100644 testdata/golden/yields_multiple.md create mode 100644 testdata/golden/yields_single.json create mode 100644 testdata/golden/yields_single.md create mode 100644 testdata/golden/yields_unit_no_factor.invalid.md create mode 100644 testdata/golden/yields_unitless.json create mode 100644 testdata/golden/yields_unitless.md diff --git a/golden_test.go b/golden_test.go new file mode 100644 index 0000000..d096528 --- /dev/null +++ b/golden_test.go @@ -0,0 +1,79 @@ +package recipemd + +import ( + "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(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/parser.go b/parser.go index a4ae25f..e1e8090 100644 --- a/parser.go +++ b/parser.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -21,11 +22,13 @@ type Option func(*Parser) func WithFrontmatter() Option { return func(p *Parser) { p.Frontmatter = true } } func WithGithubFormattedMarkdown() Option { return func(p *Parser) { p.goldmarkExtensions = append(p.goldmarkExtensions, extension.GFM) + p.hasTaskList = true } } type Parser struct { - Frontmatter bool - goldmarkProcessor goldmark.Markdown + Frontmatter bool + hasTaskList bool + goldmarkProcessor goldmark.Markdown goldmarkExtensions []goldmark.Extender } @@ -204,6 +207,7 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { var excludeRanges [][2]int tagsFound, yieldsFound, tagsYieldsMode := false, false, false + lastPreBreakEnd := descStart for c != nil && c.Kind() != ast.KindThematicBreak { p, isPara := c.(*ast.Paragraph) if isPara { @@ -249,6 +253,9 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { return nil, fmt.Errorf("unexpected content in tags/yields section") } } + if _, end := getRecursiveSourceBounds(c, source); end > lastPreBreakEnd { + lastPreBreakEnd = end + } c = c.NextSibling() } @@ -256,7 +263,7 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { if c == nil || c.Kind() != ast.KindThematicBreak { return nil, fmt.Errorf("missing thematic break divider") } - firstBreakPos := findThematicBreakAfter(descStart, source) + firstBreakPos := findDashLine(source, lastPreBreakEnd) // Build description: source from after title to first break, minus tags/yields. if firstBreakPos > descStart { @@ -273,33 +280,31 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { if _, ok := c.(*ast.Paragraph); ok { return nil, fmt.Errorf("paragraph not valid in ingredients section") } - c, err = parseIngredientList(c, source, &recipe.Ingredients) + c, err = parseIngredientList(c, source, &recipe.Ingredients, p.hasTaskList) if err != nil { return nil, err } - c, err = parseIngredientGroup(c, source, &recipe.IngredientGroups, 0) + c, err = parseIngredientGroup(c, source, &recipe.IngredientGroups, 0, p.hasTaskList) if err != nil { return nil, err } // --- Second thematic break (optional) → instructions --- if c != nil && c.Kind() == ast.KindThematicBreak { - breakPos := findThematicBreakAfter(firstBreakPos+1, source) - if breakPos >= 0 { - instrStart := breakLineEnd(breakPos, source) - instructions := strings.Trim(string(source[instrStart:]), "\n") - if instructions != "" { - recipe.Instructions = &instructions - } + breakPos := findDashLine(source, firstBreakPos+1) + breakEnd := skipLine(source, breakPos) + instructions := strings.Trim(string(source[breakEnd:]), "\n") + if instructions != "" { + recipe.Instructions = &instructions } } return recipe, nil } -// findThematicBreakAfter finds the byte offset of the first line of 3+ dashes -// at or after minPos. -func findThematicBreakAfter(minPos int, source []byte) int { +// 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' { @@ -324,16 +329,18 @@ func findThematicBreakAfter(minPos int, source []byte) int { return -1 } -// breakLineEnd returns the byte offset just past the newline of a break line. -func breakLineEnd(breakPos int, source []byte) int { - j := breakPos - for j < len(source) && source[j] != '\n' { - j++ +// 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 j < len(source) { - j++ + if pos < len(source) { + pos++ } - return j + return pos } // parseIngredientGroup parses headings and lists in the ingredient section. @@ -345,6 +352,7 @@ func parseIngredientGroup( source []byte, groups *[]IngredientGroup, parentLevel int, + skipCheckbox bool, ) (ast.Node, error) { for { h, ok := c.(*ast.Heading) @@ -369,11 +377,11 @@ func parseIngredientGroup( *groups = append(*groups, g) return nil, nil } - c, err = parseIngredientList(c, source, &g.Ingredients) + c, err = parseIngredientList(c, source, &g.Ingredients, skipCheckbox) if err != nil { return nil, err } - c, err = parseIngredientGroup(c, source, &g.IngredientGroups, l) + c, err = parseIngredientGroup(c, source, &g.IngredientGroups, l, skipCheckbox) if err != nil { return nil, err } @@ -389,6 +397,7 @@ func parseIngredientList( c ast.Node, source []byte, ingredients *[]Ingredient, + skipCheckbox bool, ) (ast.Node, error) { for { // 1. Examine c @@ -405,7 +414,7 @@ func parseIngredientList( // 2. Collect ingredients for { - ing, err := parseIngredient(c, source) + ing, err := parseIngredient(c, source, skipCheckbox) if err != nil { return nil, fmt.Errorf("parseIngredient: %w", err) } @@ -424,7 +433,7 @@ func parseIngredientList( // parseIngredient parses a block c into an ingredient. // See: https://recipemd.org/specification.html#parsing-an-ingredient -func parseIngredient(c ast.Node, source []byte) (Ingredient, error) { +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 { @@ -451,6 +460,13 @@ func parseIngredient(c ast.Node, source []byte) (Ingredient, error) { } 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) 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** + +--- From 6714cd42aaa99fa29c5202e0800a410e63d7267f Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:19:46 -0400 Subject: [PATCH 12/29] fix: drop duplicate regression tests --- regression_test.go | 32 ------------------- testdata/regression/empty_ingredient.md | 7 ---- .../empty_ingredient_trailing_space.md | 7 ---- 3 files changed, 46 deletions(-) delete mode 100644 regression_test.go delete mode 100644 testdata/regression/empty_ingredient.md delete mode 100644 testdata/regression/empty_ingredient_trailing_space.md diff --git a/regression_test.go b/regression_test.go deleted file mode 100644 index d03b6a9..0000000 --- a/regression_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package recipemd - -import ( - "os" - "path/filepath" - "testing" -) - -func TestRegression_EmptyIngredient(t *testing.T) { - files, err := filepath.Glob("testdata/regression/empty_ingredient*.md") - if err != nil { - t.Fatal(err) - } - if len(files) == 0 { - t.Fatal("no test files found") - } - - p := NewParser() - for _, f := range files { - name := filepath.Base(f) - t.Run(name, func(t *testing.T) { - data, err := os.ReadFile(f) - if err != nil { - t.Fatal(err) - } - _, err = p.Parse(data) - if err == nil { - t.Error("expected parse error for empty ingredient") - } - }) - } -} diff --git a/testdata/regression/empty_ingredient.md b/testdata/regression/empty_ingredient.md deleted file mode 100644 index 160338d..0000000 --- a/testdata/regression/empty_ingredient.md +++ /dev/null @@ -1,7 +0,0 @@ -# Empty Ingredient - ---- - -- - ---- diff --git a/testdata/regression/empty_ingredient_trailing_space.md b/testdata/regression/empty_ingredient_trailing_space.md deleted file mode 100644 index baeb1a8..0000000 --- a/testdata/regression/empty_ingredient_trailing_space.md +++ /dev/null @@ -1,7 +0,0 @@ -# Empty Ingredient With Trailing Space - ---- - -- - ---- From 3471d8585d24afaecd4b5b53be356c3d064ee958 Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:55:50 -0400 Subject: [PATCH 13/29] fix: refactor rendering logic Clean it up and split into files. --- render.go | 163 ++++++--------------------------------------- render_json.go | 8 +++ render_markdown.go | 67 +++++++++++++++++++ 3 files changed, 97 insertions(+), 141 deletions(-) create mode 100644 render_json.go create mode 100644 render_markdown.go diff --git a/render.go b/render.go index f752969..64fcaf1 100644 --- a/render.go +++ b/render.go @@ -1,150 +1,31 @@ package recipemd import ( - "bytes" - "encoding/json" "strings" "text/template" ) -var markdownTmpl = template.Must(template.New("recipemd").Funcs(template.FuncMap{ - "join": strings.Join, - "hashes": func(n int) string { return strings.Repeat("#", n) }, -}).Parse(`# {{ .Recipe.Title }} -{{ if .HasDescription }} -{{ .Description }} -{{ end }}{{ if .Recipe.Tags }} -*{{ join .Recipe.Tags ", " }}* -{{ end }}{{ if .Recipe.Yields }} -**{{ join .SerializedYields ", " }}** -{{ end }} ---- -{{ template "ingredients" . }}{{ template "groups" . }}{{ if .HasInstructions }} ---- - -{{ .Instructions }} -{{ end }}`)) - -var ingredientsTmpl = template.Must(markdownTmpl.New("ingredients").Parse( - `{{ if .Ingredients }} -{{ range .Ingredients }}- {{ if .Amount }}*{{ .Amount }}* {{ end }}{{ if .Link }}[{{ .Name }}]({{ .Link }}){{ else }}{{ .Name }}{{ end }} -{{ end }}{{ end }}`)) - -var _ = template.Must(markdownTmpl.New("groups").Parse( - `{{ range .Groups }} -{{ .Heading }} {{ .Title }} -{{ template "ingredients" .IngredientData }}{{ template "groups" .SubgroupData }}{{ end }}`)) - -type renderData struct { - Recipe *Recipe - rounding int - level int -} - -func (d renderData) HasDescription() bool { - return d.Recipe.Description != nil && *d.Recipe.Description != "" -} - -func (d renderData) Description() string { - if d.Recipe.Description == nil { - return "" - } - return *d.Recipe.Description -} - -func (d renderData) HasInstructions() bool { - return d.Recipe.Instructions != nil && *d.Recipe.Instructions != "" -} - -func (d renderData) Instructions() string { - if d.Recipe.Instructions == nil { - return "" - } - return *d.Recipe.Instructions -} - -func (d renderData) SerializedYields() []string { - yields := make([]string, len(d.Recipe.Yields)) - for i, y := range d.Recipe.Yields { - yields[i] = y.Serialize(d.rounding) - } - return yields -} - -func (d renderData) Ingredients() []ingredientData { - return makeIngredientData(d.Recipe.Ingredients, d.rounding) -} - -func (d renderData) Groups() []groupData { - return makeGroupData(d.Recipe.IngredientGroups, d.rounding, d.level) -} - -type ingredientData struct { - Name string - Amount string - Link string -} - -func makeIngredientData(ingredients []Ingredient, rounding int) []ingredientData { - result := make([]ingredientData, len(ingredients)) - for i, ing := range ingredients { - d := ingredientData{Name: ing.Name} - if ing.Amount != nil { - d.Amount = ing.Amount.Serialize(rounding) - } - if ing.Link != nil { - d.Link = *ing.Link - } - result[i] = d - } - return result -} - -type groupData struct { - Title string - rounding int - level int - group IngredientGroup -} - -func (g groupData) Heading() string { - return strings.Repeat("#", g.level) -} - -func (g groupData) IngredientData() struct{ Ingredients []ingredientData } { - return struct{ Ingredients []ingredientData }{ - Ingredients: makeIngredientData(g.group.Ingredients, g.rounding), +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, ", ") + }, } } - -func (g groupData) SubgroupData() struct{ Groups []groupData } { - return struct{ Groups []groupData }{ - Groups: makeGroupData(g.group.IngredientGroups, g.rounding, g.level+1), - } -} - -func makeGroupData(groups []IngredientGroup, rounding int, level int) []groupData { - result := make([]groupData, len(groups)) - for i, g := range groups { - result[i] = groupData{ - Title: g.Title, - rounding: rounding, - level: level, - group: g, - } - } - return result -} - -// RenderMarkdown formats a Recipe as RecipeMD markdown. -func (p *Parser) RenderMarkdown(r *Recipe, rounding int) string { - var buf bytes.Buffer - data := renderData{Recipe: r, rounding: rounding, level: 2} - _ = markdownTmpl.Execute(&buf, data) - return buf.String() -} - -// RenderJSON serializes a Recipe as indented JSON. -func (p *Parser) RenderJSON(r *Recipe) ([]byte, error) { - return json.MarshalIndent(r, "", " ") -} diff --git a/render_json.go b/render_json.go new file mode 100644 index 0000000..36f4666 --- /dev/null +++ b/render_json.go @@ -0,0 +1,8 @@ +package recipemd + +import "encoding/json" + +// RenderJSON serializes a Recipe as indented JSON. +func (p *Parser) RenderJSON(r *Recipe) ([]byte, error) { + return json.MarshalIndent(r, "", " ") +} diff --git a/render_markdown.go b/render_markdown.go new file mode 100644 index 0000000..49b8be2 --- /dev/null +++ b/render_markdown.go @@ -0,0 +1,67 @@ +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 formats a Recipe as RecipeMD markdown. +func (p *Parser) RenderMarkdown(r *Recipe, rounding int) string { + 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() +} From f71e213a547e4891d787bffc54659666f57246ba Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:26:50 -0400 Subject: [PATCH 14/29] feat: bump to Go v1.26.0 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index c34e667..e9a540c 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/xcapaldi/recipemd-go -go 1.24.7 +go 1.26.0 require github.com/yuin/goldmark v1.7.16 From 2cc8b140bbc0dce43e837242df263552b06c9f2a Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:27:07 -0400 Subject: [PATCH 15/29] fix: add unit tests --- parser_test.go | 680 ++++++++++++++++++++++++++++++++++++++++ recipe_test.go | 289 +++++++++++++++++ render_json_test.go | 29 ++ render_markdown_test.go | 96 ++++++ 4 files changed, 1094 insertions(+) create mode 100644 recipe_test.go create mode 100644 render_json_test.go create mode 100644 render_markdown_test.go diff --git a/parser_test.go b/parser_test.go index cb92e0f..73538b3 100644 --- a/parser_test.go +++ b/parser_test.go @@ -2,6 +2,9 @@ package recipemd import ( "encoding/json" + "math" + "os" + "path/filepath" "testing" ) @@ -184,3 +187,680 @@ func TestParse_FullRecipe(t *testing.T) { 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([]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([]byte("")) + if err == nil { + t.Fatal("expected error for empty input") + } + }) + + t.Run("no heading", func(t *testing.T) { + t.Parallel() + _, err := NewParser().Parse([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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([]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 TestFlatten(t *testing.T) { + t.Parallel() + + t.Run("no links unchanged", func(t *testing.T) { + t.Parallel() + p := NewParser() + r := &Recipe{ + Ingredients: []Ingredient{{Name: "salt"}}, + IngredientGroups: []IngredientGroup{}, + } + if err := p.Flatten(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 := NewParser() + r := &Recipe{ + Ingredients: []Ingredient{{Name: "sauce", Link: new("https://example.com/sauce.md")}}, + IngredientGroups: []IngredientGroup{}, + } + if err := p.Flatten(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 := NewParser() + r := &Recipe{ + Ingredients: []Ingredient{{Name: "sauce", Link: new("sauce.md"), Amount: &Amount{Factor: 2, Unit: new("cups")}}}, + IngredientGroups: []IngredientGroup{}, + } + if err := p.Flatten(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 := NewParser() + r := &Recipe{ + Ingredients: []Ingredient{{Name: "x", Link: new("nonexistent.md")}}, + IngredientGroups: []IngredientGroup{}, + } + if err := p.Flatten(r, "/fake/recipe.md"); err == nil { + t.Fatal("expected error for missing linked file") + } + }) +} + +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_test.go b/recipe_test.go new file mode 100644 index 0000000..129caa6 --- /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_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_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) + } + }) +} From 93a4a87ca7083298534dd149bc40b551ea4a5d10 Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:09:22 -0400 Subject: [PATCH 16/29] feat: refactor recipemd and recipemd-find CLIs Match Python reference where possible, accept Go flag package defaults. Differences from Python: - Flags must precede positional args (Go flag behavior). For recipemd-find this means: `recipemd-find [flags] [folder]`, not `recipemd-find [folder] [flags]`. - -h/--help exits 2 (Go default), not 0 - --export-links always requires DIR argument - Error/help message formatting differs Go-only extensions: --gfm, --frontmatter --- cmd/recipemd-find/main.go | 444 +++++--------------------------------- cmd/recipemd/main.go | 79 ++++++- 2 files changed, 128 insertions(+), 395 deletions(-) diff --git a/cmd/recipemd-find/main.go b/cmd/recipemd-find/main.go index 377421b..8ac4980 100644 --- a/cmd/recipemd-find/main.go +++ b/cmd/recipemd-find/main.go @@ -1,11 +1,11 @@ package main import ( - "bufio" + "flag" "fmt" + "io/fs" "math" "os" - "os/exec" "path/filepath" "sort" "strings" @@ -19,21 +19,58 @@ import ( const version = "0.1.0" func main() { - args, err := parseArgs(os.Args[1:]) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if args.version { + showVersion := flag.Bool("v", false, "show version") + showVersionLong := flag.Bool("version", false, "show version") + expression := flag.String("e", "", "filter expression") + expressionLong := flag.String("expression", "", "filter expression") + noMessages := flag.Bool("s", false, "suppress error messages") + noMessagesLong := flag.Bool("no-messages", false, "suppress error messages") + outputOne := flag.Bool("1", false, "force output one entry per line") + outputCols := flag.Bool("C", false, "force multi-column output") + outputRows := flag.Bool("x", false, "multi-column output sorted across columns") + count := flag.Bool("c", false, "count number of uses") + countLong := flag.Bool("count", false, "count number of uses") + gfm := flag.Bool("gfm", false, "enable GitHub Flavored Markdown extensions") + frontmatter := flag.Bool("frontmatter", false, "strip YAML/TOML frontmatter before parsing") + + flag.Parse() + + if *showVersion || *showVersionLong { fmt.Printf("recipemd-find %s\n", version) os.Exit(0) } - if args.action == "" { + args := cliArgs{ + expression: coalesce(*expression, *expressionLong), + noMessages: *noMessages || *noMessagesLong, + count: *count || *countLong, + gfm: *gfm, + frontmatter: *frontmatter, + folder: ".", + } + + if *outputOne { + args.outputMulti = "no" + } else if *outputRows { + args.outputMulti = "rows" + } else if *outputCols { + args.outputMulti = "columns" + } + + positional := flag.Args() + if len(positional) < 1 { fmt.Fprintf(os.Stderr, "Error: an action is required (recipes, tags, ingredients, units)\n") os.Exit(1) } + args.action = positional[0] + if len(positional) > 1 { + args.folder = positional[1] + } + + if args.count && args.action == "recipes" { + fmt.Fprintf(os.Stderr, "Error: -c/--count cannot be used with recipes action\n") + os.Exit(1) + } switch args.action { case "recipes": @@ -50,73 +87,24 @@ func main() { } } +func coalesce(a, b string) string { + if a != "" { + return a + } + return b +} + type cliArgs struct { - version bool expression string noMessages bool outputMulti string // "no", "columns", "rows", or "" action string folder string count bool - searcher string // external search program (e.g. "grep", "rg") gfm bool frontmatter bool } -func parseArgs(raw []string) (cliArgs, error) { - var args cliArgs - args.folder = "." - - i := 0 - for i < len(raw) { - arg := raw[i] - switch arg { - case "-v", "--version": - args.version = true - case "-h", "--help": - printUsage() - os.Exit(0) - case "-e", "--expression": - i++ - if i >= len(raw) { - return args, fmt.Errorf("missing value for %s", arg) - } - args.expression = raw[i] - case "-s", "--no-messages": - args.noMessages = true - case "-1": - args.outputMulti = "no" - case "-C": - args.outputMulti = "columns" - case "-x": - args.outputMulti = "rows" - case "-c", "--count": - args.count = true - case "--searcher": - i++ - if i >= len(raw) { - return args, fmt.Errorf("missing value for %s", arg) - } - args.searcher = raw[i] - case "--gfm": - args.gfm = true - case "--frontmatter": - args.frontmatter = true - default: - if strings.HasPrefix(arg, "-") { - return args, fmt.Errorf("unknown option %q", arg) - } - if args.action == "" { - args.action = arg - } else { - args.folder = arg - } - } - i++ - } - return args, nil -} - func parserOpts(args cliArgs) []recipemd.Option { var opts []recipemd.Option if args.gfm { @@ -128,58 +116,20 @@ func parserOpts(args cliArgs) []recipemd.Option { return opts } -func printUsage() { - fmt.Fprintf(os.Stderr, `Usage: recipemd-find [options] [folder] - -Find recipes, ingredients and units by filter expression - -Actions: - recipes list recipe paths - tags list used tags - ingredients list used ingredients - units list used units - -Options: - -v, --version show version - -h, --help show help - -e, --expression E filter expression, e.g. "cake and vegan or ingr:cheese" - -s, --no-messages suppress error messages - -1 force output to be one entry per line - -C force multi-column output - -x multi-column output sorted across columns - -c, --count count number of uses (tags, ingredients, units only) - --searcher PROG use external search program (grep, rg) to pre-filter - files. The filter expression is translated to the - program's syntax. Candidate files are then parsed and - verified with the RecipeMD-aware filter. - --gfm enable GitHub Flavored Markdown extensions - --frontmatter strip YAML/TOML frontmatter before parsing -`) -} - type parsedRecipe struct { recipe *recipemd.Recipe path string } func getFilteredRecipes(args cliArgs) []parsedRecipe { - // When a searcher is specified with an expression, use it to pre-filter - // candidate files before doing full RecipeMD parsing. - if args.searcher != "" && args.expression != "" { - return getFilteredRecipesWithSearcher(args) - } - return getFilteredRecipesBuiltin(args) -} - -func getFilteredRecipesBuiltin(args cliArgs) []parsedRecipe { var results []parsedRecipe folder := args.folder - err := filepath.Walk(folder, func(path string, info os.FileInfo, err error) error { + err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } - if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".md") { + if d.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".md") { return nil } @@ -217,288 +167,6 @@ func getFilteredRecipesBuiltin(args cliArgs) []parsedRecipe { return results } -func getFilteredRecipesWithSearcher(args cliArgs) []parsedRecipe { - folder := args.folder - - // Parse expression into AST, translate to external searcher, get candidate files - ast := parseExprAST(tokenize(args.expression)) - candidates, err := evalSearcherAST(ast, args.searcher, folder, args.noMessages) - if err != nil { - if !args.noMessages { - fmt.Fprintf(os.Stderr, "Searcher error: %v\n", err) - } - return nil - } - - // Parse candidates and verify with exact RecipeMD-aware filter - var results []parsedRecipe - for _, path := range candidates { - fullPath := filepath.Join(folder, path) - data, err := os.ReadFile(fullPath) - if err != nil { - if !args.noMessages { - fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", path, err) - } - continue - } - - recipe, err := recipemd.NewParser(parserOpts(args)...).Parse(data) - if err != nil { - if !args.noMessages { - fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", path, err) - } - continue - } - - // Exact filter verification — the external searcher is a text-level - // pre-filter that doesn't understand RecipeMD structure, so we - // confirm with the real filter. - if !matchesFilter(recipe, args.expression) { - continue - } - - results = append(results, parsedRecipe{recipe: recipe, path: path}) - } - return results -} - -// --- Expression AST --- - -type exprNodeKind int - -const ( - nodeLeaf exprNodeKind = iota - nodeAnd - nodeOr - nodeNot -) - -type exprNode struct { - kind exprNodeKind - term string // for nodeLeaf: raw term like "cake", "ingr:cheese", "tag:vegan" - children []*exprNode -} - -// parseExprAST parses a tokenized filter expression into an AST. -func parseExprAST(tokens []string) *exprNode { - node, _ := parseOrAST(tokens) - return node -} - -func parseOrAST(tokens []string) (*exprNode, []string) { - left, tokens := parseAndAST(tokens) - var orChildren []*exprNode - orChildren = append(orChildren, left) - for len(tokens) > 0 && strings.EqualFold(tokens[0], "or") { - tokens = tokens[1:] - right, rest := parseAndAST(tokens) - orChildren = append(orChildren, right) - tokens = rest - } - if len(orChildren) == 1 { - return orChildren[0], tokens - } - return &exprNode{kind: nodeOr, children: orChildren}, tokens -} - -func parseAndAST(tokens []string) (*exprNode, []string) { - left, tokens := parsePrimaryAST(tokens) - var andChildren []*exprNode - andChildren = append(andChildren, left) - for len(tokens) > 0 && strings.EqualFold(tokens[0], "and") { - tokens = tokens[1:] - right, rest := parsePrimaryAST(tokens) - andChildren = append(andChildren, right) - tokens = rest - } - if len(andChildren) == 1 { - return andChildren[0], tokens - } - return &exprNode{kind: nodeAnd, children: andChildren}, tokens -} - -func parsePrimaryAST(tokens []string) (*exprNode, []string) { - if len(tokens) == 0 { - return &exprNode{kind: nodeLeaf, term: ""}, tokens - } - token := tokens[0] - tokens = tokens[1:] - if strings.EqualFold(token, "not") { - child, rest := parsePrimaryAST(tokens) - return &exprNode{kind: nodeNot, children: []*exprNode{child}}, rest - } - return &exprNode{kind: nodeLeaf, term: token}, tokens -} - -// --- External searcher evaluation --- - -// evalSearcherAST evaluates an expression AST using the external search program. -// Returns relative paths of matching .md files. -func evalSearcherAST(node *exprNode, searcher, folder string, noMessages bool) ([]string, error) { - switch node.kind { - case nodeLeaf: - if node.term == "" { - return nil, nil - } - return searcherGrep(searcher, folder, extractSearchTerm(node.term), false, noMessages) - - case nodeNot: - return searcherGrep(searcher, folder, extractSearchTerm(node.children[0].term), true, noMessages) - - case nodeAnd: - // Intersection: start with first child's results, narrow down - result, err := evalSearcherAST(node.children[0], searcher, folder, noMessages) - if err != nil { - return nil, err - } - resultSet := stringSet(result) - for _, child := range node.children[1:] { - childFiles, err := evalSearcherAST(child, searcher, folder, noMessages) - if err != nil { - return nil, err - } - resultSet = intersect(resultSet, stringSet(childFiles)) - } - return setToSlice(resultSet), nil - - case nodeOr: - // Union: combine all children's results - resultSet := make(map[string]bool) - for _, child := range node.children { - childFiles, err := evalSearcherAST(child, searcher, folder, noMessages) - if err != nil { - return nil, err - } - for _, f := range childFiles { - resultSet[f] = true - } - } - return setToSlice(resultSet), nil - } - return nil, nil -} - -// extractSearchTerm strips the ingr:/tag: prefix since the external tool -// searches raw text and can't distinguish RecipeMD sections structurally. -func extractSearchTerm(term string) string { - lower := strings.ToLower(term) - if strings.HasPrefix(lower, "ingr:") { - return term[5:] - } - if strings.HasPrefix(lower, "tag:") { - return term[4:] - } - return term -} - -// searcherGrep runs the external search program and returns matching relative paths. -// If invert is true, returns files that do NOT match the pattern. -func searcherGrep(searcher, folder, pattern string, invert bool, noMessages bool) ([]string, error) { - prog := filepath.Base(searcher) - var cmdArgs []string - - switch prog { - case "rg", "ripgrep": - // rg -li pattern -g "*.md" folder - // rg --files-without-match -i pattern -g "*.md" folder - cmdArgs = append(cmdArgs, "-i") - if invert { - cmdArgs = append(cmdArgs, "--files-without-match") - } else { - cmdArgs = append(cmdArgs, "-l") - } - cmdArgs = append(cmdArgs, "-g", "*.md", "--", pattern, folder) - - case "grep": - // grep -rli pattern --include="*.md" folder - // grep -rLi pattern --include="*.md" folder - cmdArgs = append(cmdArgs, "-r", "-i") - if invert { - cmdArgs = append(cmdArgs, "-L") - } else { - cmdArgs = append(cmdArgs, "-l") - } - cmdArgs = append(cmdArgs, "--include=*.md", "--", pattern, folder) - - default: - // Generic: assume grep-compatible interface - cmdArgs = append(cmdArgs, "-r", "-i") - if invert { - cmdArgs = append(cmdArgs, "-L") - } else { - cmdArgs = append(cmdArgs, "-l") - } - cmdArgs = append(cmdArgs, "--include=*.md", "--", pattern, folder) - } - - cmd := exec.Command(searcher, cmdArgs...) - cmd.Stderr = os.Stderr - if noMessages { - cmd.Stderr = nil - } - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("failed to create pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("failed to start %s: %w", searcher, err) - } - - var files []string - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - // Convert to relative path - rel, err := filepath.Rel(folder, line) - if err != nil { - rel = line - } - files = append(files, rel) - } - - // grep exits 1 when no matches found — that's not an error - if err := cmd.Wait(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return files, nil - } - return files, nil // be lenient with searcher exit codes - } - - return files, nil -} - -func stringSet(items []string) map[string]bool { - s := make(map[string]bool, len(items)) - for _, item := range items { - s[item] = true - } - return s -} - -func intersect(a, b map[string]bool) map[string]bool { - result := make(map[string]bool) - for k := range a { - if b[k] { - result[k] = true - } - } - return result -} - -func setToSlice(s map[string]bool) []string { - result := make([]string, 0, len(s)) - for k := range s { - result = append(result, k) - } - sort.Strings(result) - return result -} - // matchesFilter evaluates a simple boolean filter expression against a recipe. // Supports: word matching (in title/tags), "ingr:word" for ingredient matching, // "and", "or", "not" operators. diff --git a/cmd/recipemd/main.go b/cmd/recipemd/main.go index bd4890f..49afbb0 100644 --- a/cmd/recipemd/main.go +++ b/cmd/recipemd/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "path/filepath" "strconv" "strings" @@ -13,12 +14,6 @@ import ( const version = "0.1.0" func main() { - // Custom usage - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: recipemd [options] \n\nRead and process recipemd recipes\n\nOptions:\n") - flag.PrintDefaults() - } - showVersion := flag.Bool("v", false, "show version") showVersionLong := flag.Bool("version", false, "show version") showTitle := flag.Bool("t", false, "display recipe title") @@ -35,6 +30,7 @@ func main() { yieldLong := flag.String("yield", "", "scale the recipe for yield Y, e.g. \"5 servings\"") flatten := flag.Bool("f", false, "flatten ingredients and instructions of linked recipes into main recipe") flattenLong := flag.Bool("flatten", false, "flatten ingredients and instructions of linked recipes into main recipe") + exportLinks := flag.String("export-links", "", "export linked recipes to DIR") gfm := flag.Bool("gfm", false, "enable GitHub Flavored Markdown extensions") frontmatter := flag.Bool("frontmatter", false, "strip YAML/TOML frontmatter before parsing") @@ -66,6 +62,12 @@ func main() { os.Exit(1) } + // --export-links is mutually exclusive with -t, -i, -j + if *exportLinks != "" && displayCount > 0 { + fmt.Fprintf(os.Stderr, "Error: --export-links cannot be used with -t, -i, or -j\n") + os.Exit(1) + } + // Resolve round value (prefer short flag if set) rStr := *roundStr if isFlagSet("round") { @@ -101,7 +103,6 @@ func main() { // Need a file argument if flag.NArg() < 1 { fmt.Fprintf(os.Stderr, "Error: a recipe file is required\n") - flag.Usage() os.Exit(1) } filePath := flag.Arg(0) @@ -135,6 +136,12 @@ func main() { } } + // Export linked recipes + if *exportLinks != "" { + exportLinkedRecipes(p, recipe, filePath, *exportLinks, rounding) + return + } + // Scale recipe if yieldVal != "" { requiredYield, err := recipemd.ParseAmountString(yieldVal) @@ -187,6 +194,64 @@ func main() { } } +func exportLinkedRecipes(p *recipemd.Parser, recipe *recipemd.Recipe, recipeFile, dir string, rounding int) { + if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Error creating export directory: %v\n", err) + os.Exit(1) + } + + recipeDir := filepath.Dir(recipeFile) + seen := make(map[string]bool) + + var links []string + collectLinks(recipe.Ingredients, recipe.IngredientGroups, &links) + + for _, link := range links { + if strings.Contains(link, "://") { + continue + } + if seen[link] { + continue + } + seen[link] = true + + resolved := filepath.Join(recipeDir, link) + data, err := os.ReadFile(resolved) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: cannot read linked recipe %s: %v\n", link, err) + continue + } + + linked, err := p.Parse(data) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: cannot parse linked recipe %s: %v\n", link, err) + continue + } + + if err := p.Flatten(linked, resolved); err != nil { + fmt.Fprintf(os.Stderr, "Warning: flatten %s: %v\n", link, err) + } + + output := p.RenderMarkdown(linked, rounding) + outPath := filepath.Join(dir, filepath.Base(link)) + if err := os.WriteFile(outPath, []byte(output), 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", outPath, err) + os.Exit(1) + } + } +} + +func collectLinks(ingredients []recipemd.Ingredient, groups []recipemd.IngredientGroup, links *[]string) { + for _, ing := range ingredients { + if ing.Link != nil { + *links = append(*links, *ing.Link) + } + } + for _, g := range groups { + collectLinks(g.Ingredients, g.IngredientGroups, links) + } +} + func isFlagSet(name string) bool { found := false flag.Visit(func(f *flag.Flag) { From 32cc59d8d111cf5bdfcb41e5d0ad7acf1a9bccc3 Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:05:27 -0400 Subject: [PATCH 17/29] fix: remove unused benchmark --- parser_bench_test.go | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 parser_bench_test.go diff --git a/parser_bench_test.go b/parser_bench_test.go deleted file mode 100644 index 477fa89..0000000 --- a/parser_bench_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package recipemd - -import ( - "os" - "testing" -) - -var benchSource []byte - -func init() { - var err error - benchSource, err = os.ReadFile("testdata/canonical/recipe.md") - if err != nil { - panic(err) - } -} - -func BenchmarkParseRecipe(b *testing.B) { - p := NewParser() - for b.Loop() { - _, err := p.Parse(benchSource) - if err != nil { - b.Fatal(err) - } - } -} From 27482f4add31630744156639b514529c4320fbe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 21:05:58 +0000 Subject: [PATCH 18/29] feat: drop CLI tools in favour of focused examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace cmd/recipemd and cmd/recipemd-find with a set of simple, self-contained examples in examples/ that demonstrate library usage: - examples/parse/ – parse a .md file, output compact JSON (pipe to jq) - examples/scale/ – scale a recipe by factor or yield, output markdown - examples/flatten/ – inline linked recipes, output markdown Also have some minimal tests for the examples to ensure future changes don't break the examples. --- cmd/recipemd-find/main.go | 471 ------------------------------- cmd/recipemd/main.go | 263 ----------------- examples/flatten/main.go | 41 +++ examples/flatten/main_test.go | 75 +++++ examples/parse/main.go | 44 +++ examples/parse/main_test.go | 43 +++ examples/scale/main.go | 57 ++++ examples/scale/main_test.go | 58 ++++ render_json.go | 4 +- testdata/flatten/main.md | 8 + testdata/flatten/sauce.md | 10 + testdata/flatten/subdir/stock.md | 8 + 12 files changed, 346 insertions(+), 736 deletions(-) delete mode 100644 cmd/recipemd-find/main.go delete mode 100644 cmd/recipemd/main.go create mode 100644 examples/flatten/main.go create mode 100644 examples/flatten/main_test.go create mode 100644 examples/parse/main.go create mode 100644 examples/parse/main_test.go create mode 100644 examples/scale/main.go create mode 100644 examples/scale/main_test.go create mode 100644 testdata/flatten/main.md create mode 100644 testdata/flatten/sauce.md create mode 100644 testdata/flatten/subdir/stock.md diff --git a/cmd/recipemd-find/main.go b/cmd/recipemd-find/main.go deleted file mode 100644 index 8ac4980..0000000 --- a/cmd/recipemd-find/main.go +++ /dev/null @@ -1,471 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "io/fs" - "math" - "os" - "path/filepath" - "sort" - "strings" - "syscall" - "unicode" - "unsafe" - - recipemd "github.com/xcapaldi/recipemd-go" -) - -const version = "0.1.0" - -func main() { - showVersion := flag.Bool("v", false, "show version") - showVersionLong := flag.Bool("version", false, "show version") - expression := flag.String("e", "", "filter expression") - expressionLong := flag.String("expression", "", "filter expression") - noMessages := flag.Bool("s", false, "suppress error messages") - noMessagesLong := flag.Bool("no-messages", false, "suppress error messages") - outputOne := flag.Bool("1", false, "force output one entry per line") - outputCols := flag.Bool("C", false, "force multi-column output") - outputRows := flag.Bool("x", false, "multi-column output sorted across columns") - count := flag.Bool("c", false, "count number of uses") - countLong := flag.Bool("count", false, "count number of uses") - gfm := flag.Bool("gfm", false, "enable GitHub Flavored Markdown extensions") - frontmatter := flag.Bool("frontmatter", false, "strip YAML/TOML frontmatter before parsing") - - flag.Parse() - - if *showVersion || *showVersionLong { - fmt.Printf("recipemd-find %s\n", version) - os.Exit(0) - } - - args := cliArgs{ - expression: coalesce(*expression, *expressionLong), - noMessages: *noMessages || *noMessagesLong, - count: *count || *countLong, - gfm: *gfm, - frontmatter: *frontmatter, - folder: ".", - } - - if *outputOne { - args.outputMulti = "no" - } else if *outputRows { - args.outputMulti = "rows" - } else if *outputCols { - args.outputMulti = "columns" - } - - positional := flag.Args() - if len(positional) < 1 { - fmt.Fprintf(os.Stderr, "Error: an action is required (recipes, tags, ingredients, units)\n") - os.Exit(1) - } - args.action = positional[0] - if len(positional) > 1 { - args.folder = positional[1] - } - - if args.count && args.action == "recipes" { - fmt.Fprintf(os.Stderr, "Error: -c/--count cannot be used with recipes action\n") - os.Exit(1) - } - - switch args.action { - case "recipes": - listRecipes(args) - case "tags": - listTags(args) - case "ingredients": - listIngredients(args) - case "units": - listUnits(args) - default: - fmt.Fprintf(os.Stderr, "Error: unknown action %q\n", args.action) - os.Exit(1) - } -} - -func coalesce(a, b string) string { - if a != "" { - return a - } - return b -} - -type cliArgs struct { - expression string - noMessages bool - outputMulti string // "no", "columns", "rows", or "" - action string - folder string - count bool - gfm bool - frontmatter bool -} - -func parserOpts(args cliArgs) []recipemd.Option { - var opts []recipemd.Option - if args.gfm { - opts = append(opts, recipemd.WithGithubFormattedMarkdown()) - } - if args.frontmatter { - opts = append(opts, recipemd.WithFrontmatter()) - } - return opts -} - -type parsedRecipe struct { - recipe *recipemd.Recipe - path string -} - -func getFilteredRecipes(args cliArgs) []parsedRecipe { - var results []parsedRecipe - - folder := args.folder - err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".md") { - return nil - } - - data, err := os.ReadFile(path) - if err != nil { - if !args.noMessages { - relPath, _ := filepath.Rel(folder, path) - fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", relPath, err) - } - return nil - } - - recipe, err := recipemd.NewParser(parserOpts(args)...).Parse(data) - if err != nil { - if !args.noMessages { - relPath, _ := filepath.Rel(folder, path) - fmt.Fprintf(os.Stderr, "An error occurred, skipping %s: %v\n", relPath, err) - } - return nil - } - - if args.expression != "" && !matchesFilter(recipe, args.expression) { - return nil - } - - relPath, _ := filepath.Rel(folder, path) - results = append(results, parsedRecipe{recipe: recipe, path: relPath}) - return nil - }) - - if err != nil && !args.noMessages { - fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err) - } - - return results -} - -// matchesFilter evaluates a simple boolean filter expression against a recipe. -// Supports: word matching (in title/tags), "ingr:word" for ingredient matching, -// "and", "or", "not" operators. -func matchesFilter(r *recipemd.Recipe, expr string) bool { - tokens := tokenize(expr) - result, _ := parseOr(tokens, r) - return result -} - -func tokenize(expr string) []string { - var tokens []string - current := strings.Builder{} - for _, ch := range expr { - if unicode.IsSpace(ch) { - if current.Len() > 0 { - tokens = append(tokens, current.String()) - current.Reset() - } - } else { - current.WriteRune(ch) - } - } - if current.Len() > 0 { - tokens = append(tokens, current.String()) - } - return tokens -} - -func parseOr(tokens []string, r *recipemd.Recipe) (bool, []string) { - left, tokens := parseAnd(tokens, r) - for len(tokens) > 0 && strings.EqualFold(tokens[0], "or") { - tokens = tokens[1:] - right, rest := parseAnd(tokens, r) - left = left || right - tokens = rest - } - return left, tokens -} - -func parseAnd(tokens []string, r *recipemd.Recipe) (bool, []string) { - left, tokens := parsePrimary(tokens, r) - for len(tokens) > 0 && strings.EqualFold(tokens[0], "and") { - tokens = tokens[1:] - right, rest := parsePrimary(tokens, r) - left = left && right - tokens = rest - } - return left, tokens -} - -func parsePrimary(tokens []string, r *recipemd.Recipe) (bool, []string) { - if len(tokens) == 0 { - return false, tokens - } - - token := tokens[0] - tokens = tokens[1:] - - if strings.EqualFold(token, "not") { - result, rest := parsePrimary(tokens, r) - return !result, rest - } - - if strings.HasPrefix(strings.ToLower(token), "ingr:") { - needle := strings.ToLower(token[5:]) - for _, ing := range r.LeafIngredients() { - if strings.Contains(strings.ToLower(ing.Name), needle) { - return true, tokens - } - } - return false, tokens - } - - if strings.HasPrefix(strings.ToLower(token), "tag:") { - needle := strings.ToLower(token[4:]) - for _, tag := range r.Tags { - if strings.Contains(strings.ToLower(tag), needle) { - return true, tokens - } - } - return false, tokens - } - - // Default: match against title and tags - needle := strings.ToLower(token) - if strings.Contains(strings.ToLower(r.Title), needle) { - return true, tokens - } - for _, tag := range r.Tags { - if strings.Contains(strings.ToLower(tag), needle) { - return true, tokens - } - } - return false, tokens -} - -func listRecipes(args cliArgs) { - recipes := getFilteredRecipes(args) - items := make([]string, len(recipes)) - for i, r := range recipes { - items[i] = r.path - } - sort.Strings(items) - printResult(items, args.outputMulti) -} - -func listTags(args cliArgs) { - listElements(args, func(r *recipemd.Recipe) []string { - return r.Tags - }) -} - -func listIngredients(args cliArgs) { - listElements(args, func(r *recipemd.Recipe) []string { - ings := r.LeafIngredients() - names := make([]string, len(ings)) - for i, ing := range ings { - names[i] = ing.Name - } - return names - }) -} - -func listUnits(args cliArgs) { - listElements(args, func(r *recipemd.Recipe) []string { - var units []string - for _, ing := range r.LeafIngredients() { - if ing.Amount != nil && ing.Amount.Unit != nil { - units = append(units, *ing.Amount.Unit) - } - } - for _, y := range r.Yields { - if y.Unit != nil { - units = append(units, *y.Unit) - } - } - return units - }) -} - -func listElements(args cliArgs, extractor func(*recipemd.Recipe) []string) { - recipes := getFilteredRecipes(args) - counter := make(map[string]int) - - for _, pr := range recipes { - seen := make(map[string]bool) - for _, item := range extractor(pr.recipe) { - if !seen[item] { - counter[item]++ - seen[item] = true - } - } - } - - if args.count { - type pair struct { - name string - count int - } - pairs := make([]pair, 0, len(counter)) - maxCount := 0 - for name, count := range counter { - pairs = append(pairs, pair{name, count}) - if count > maxCount { - maxCount = count - } - } - sort.Slice(pairs, func(i, j int) bool { - return pairs[i].count > pairs[j].count - }) - maxWidth := len(fmt.Sprintf("%d", maxCount)) - items := make([]string, len(pairs)) - for i, p := range pairs { - items[i] = fmt.Sprintf("%*d %s", maxWidth, p.count, p.name) - } - printResult(items, args.outputMulti) - } else { - items := make([]string, 0, len(counter)) - for name := range counter { - items = append(items, name) - } - sort.Slice(items, func(i, j int) bool { - return strings.ToLower(items[i]) < strings.ToLower(items[j]) - }) - printResult(items, args.outputMulti) - } -} - -func printResult(items []string, outputMulti string) { - if outputMulti == "" { - if isTerminal() { - outputMulti = "columns" - } else { - outputMulti = "no" - } - } - - switch outputMulti { - case "columns": - printColumns(items, false) - case "rows": - printColumns(items, true) - default: - for _, item := range items { - fmt.Println(item) - } - } -} - -func isTerminal() bool { - _, _, err := getTerminalSize(int(os.Stdout.Fd())) - return err == nil -} - -func printColumns(items []string, across bool) { - if len(items) == 0 { - return - } - - maxWidth := 0 - for _, item := range items { - if len(item) > maxWidth { - maxWidth = len(item) - } - } - colWidth := maxWidth + 2 - lineWidth := getTerminalWidth() - - colCount := lineWidth / colWidth - if colCount == 0 { - colCount = 1 - } - rowCount := int(math.Ceil(float64(len(items)) / float64(colCount))) - - if across { - for r := 0; r < rowCount; r++ { - var row []string - for c := 0; c < colCount; c++ { - idx := r*colCount + c - if idx < len(items) { - row = append(row, items[idx]) - } - } - printRow(row, colWidth) - } - } else { - for r := 0; r < rowCount; r++ { - var row []string - for c := 0; c < colCount; c++ { - idx := c*rowCount + r - if idx < len(items) { - row = append(row, items[idx]) - } - } - printRow(row, colWidth) - } - } -} - -func printRow(row []string, colWidth int) { - if len(row) == 0 { - return - } - var parts []string - for i, val := range row { - if i < len(row)-1 { - parts = append(parts, fmt.Sprintf("%-*s", colWidth, val)) - } else { - parts = append(parts, val) - } - } - fmt.Println(strings.Join(parts, "")) -} - -type winsize struct { - Row uint16 - Col uint16 - Xpixel uint16 - Ypixel uint16 -} - -func getTerminalSize(fd int) (int, int, error) { - var ws winsize - _, _, errno := syscall.Syscall( - syscall.SYS_IOCTL, - uintptr(fd), - uintptr(syscall.TIOCGWINSZ), - uintptr(unsafe.Pointer(&ws)), - ) - if errno != 0 { - return 0, 0, errno - } - return int(ws.Col), int(ws.Row), nil -} - -func getTerminalWidth() int { - width, _, err := getTerminalSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - return 80 - } - return width -} diff --git a/cmd/recipemd/main.go b/cmd/recipemd/main.go deleted file mode 100644 index 49afbb0..0000000 --- a/cmd/recipemd/main.go +++ /dev/null @@ -1,263 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - recipemd "github.com/xcapaldi/recipemd-go" -) - -const version = "0.1.0" - -func main() { - showVersion := flag.Bool("v", false, "show version") - showVersionLong := flag.Bool("version", false, "show version") - showTitle := flag.Bool("t", false, "display recipe title") - showTitleLong := flag.Bool("title", false, "display recipe title") - showIngredients := flag.Bool("i", false, "display recipe ingredients") - showIngredientsLong := flag.Bool("ingredients", false, "display recipe ingredients") - showJSON := flag.Bool("j", false, "display recipe as JSON") - showJSONLong := flag.Bool("json", false, "display recipe as JSON") - roundStr := flag.String("r", "2", "round amount to n digits after decimal point. Default is \"2\", use \"no\" to disable rounding") - roundStrLong := flag.String("round", "2", "round amount to n digits after decimal point. Default is \"2\", use \"no\" to disable rounding") - multiply := flag.String("m", "", "multiply recipe by N") - multiplyLong := flag.String("multiply", "", "multiply recipe by N") - yield := flag.String("y", "", "scale the recipe for yield Y, e.g. \"5 servings\"") - yieldLong := flag.String("yield", "", "scale the recipe for yield Y, e.g. \"5 servings\"") - flatten := flag.Bool("f", false, "flatten ingredients and instructions of linked recipes into main recipe") - flattenLong := flag.Bool("flatten", false, "flatten ingredients and instructions of linked recipes into main recipe") - exportLinks := flag.String("export-links", "", "export linked recipes to DIR") - gfm := flag.Bool("gfm", false, "enable GitHub Flavored Markdown extensions") - frontmatter := flag.Bool("frontmatter", false, "strip YAML/TOML frontmatter before parsing") - - flag.Parse() - - if *showVersion || *showVersionLong { - fmt.Printf("recipemd %s\n", version) - os.Exit(0) - } - - title := *showTitle || *showTitleLong - ingredients := *showIngredients || *showIngredientsLong - jsonOut := *showJSON || *showJSONLong - flat := *flatten || *flattenLong - - // Mutual exclusivity check for display options - displayCount := 0 - if title { - displayCount++ - } - if ingredients { - displayCount++ - } - if jsonOut { - displayCount++ - } - if displayCount > 1 { - fmt.Fprintf(os.Stderr, "Error: -t/--title, -i/--ingredients, and -j/--json are mutually exclusive\n") - os.Exit(1) - } - - // --export-links is mutually exclusive with -t, -i, -j - if *exportLinks != "" && displayCount > 0 { - fmt.Fprintf(os.Stderr, "Error: --export-links cannot be used with -t, -i, or -j\n") - os.Exit(1) - } - - // Resolve round value (prefer short flag if set) - rStr := *roundStr - if isFlagSet("round") { - rStr = *roundStrLong - } - rounding := 2 - if strings.EqualFold(rStr, "no") { - rounding = -1 - } else { - n, err := strconv.Atoi(rStr) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: invalid rounding value %q\n", rStr) - os.Exit(1) - } - rounding = n - } - - // Resolve multiply/yield (prefer short flag if set) - multiplyVal := *multiply - if multiplyVal == "" { - multiplyVal = *multiplyLong - } - yieldVal := *yield - if yieldVal == "" { - yieldVal = *yieldLong - } - - if multiplyVal != "" && yieldVal != "" { - fmt.Fprintf(os.Stderr, "Error: -m/--multiply and -y/--yield are mutually exclusive\n") - os.Exit(1) - } - - // Need a file argument - if flag.NArg() < 1 { - fmt.Fprintf(os.Stderr, "Error: a recipe file is required\n") - os.Exit(1) - } - filePath := flag.Arg(0) - - // Read file - data, err := os.ReadFile(filePath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) - os.Exit(1) - } - - // Build parser - var opts []recipemd.Option - if *gfm { - opts = append(opts, recipemd.WithGithubFormattedMarkdown()) - } - if *frontmatter { - opts = append(opts, recipemd.WithFrontmatter()) - } - p := recipemd.NewParser(opts...) - recipe, err := p.Parse(data) - if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing recipe: %v\n", err) - os.Exit(1) - } - - // Flatten linked recipes - if flat { - if err := p.Flatten(recipe, filePath); err != nil { - fmt.Fprintf(os.Stderr, "Warning: %v\n", err) - } - } - - // Export linked recipes - if *exportLinks != "" { - exportLinkedRecipes(p, recipe, filePath, *exportLinks, rounding) - return - } - - // Scale recipe - if yieldVal != "" { - requiredYield, err := recipemd.ParseAmountString(yieldVal) - if err != nil || requiredYield.Factor == 0 { - fmt.Fprintf(os.Stderr, "Error: given yield is not valid\n") - os.Exit(1) - } - if err := recipe.ScaleForYield(requiredYield); err != nil { - fmt.Fprintf(os.Stderr, "Error: recipe does not have a yield with matching unit\n") - units := make([]string, 0) - for _, y := range recipe.Yields { - if y.Unit != nil { - units = append(units, fmt.Sprintf("%q", *y.Unit)) - } - } - if len(units) > 0 { - fmt.Fprintf(os.Stderr, "Available units: %s\n", strings.Join(units, ", ")) - } - os.Exit(1) - } - } else if multiplyVal != "" { - mult, err := recipemd.ParseAmountString(multiplyVal) - if err != nil || mult.Factor == 0 { - fmt.Fprintf(os.Stderr, "Error: given multiplier is not valid\n") - os.Exit(1) - } - if mult.Unit != nil { - fmt.Fprintf(os.Stderr, "Error: a recipe can only be multiplied with a unitless amount\n") - os.Exit(1) - } - recipe.Scale(mult.Factor) - } - - // Output - if title { - fmt.Println(recipe.Title) - } else if ingredients { - for _, ing := range recipe.LeafIngredients() { - fmt.Println(ing.Serialize(rounding)) - } - } else if jsonOut { - data, err := p.RenderJSON(recipe) - if err != nil { - fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err) - os.Exit(1) - } - fmt.Println(string(data)) - } else { - fmt.Print(p.RenderMarkdown(recipe, rounding)) - } -} - -func exportLinkedRecipes(p *recipemd.Parser, recipe *recipemd.Recipe, recipeFile, dir string, rounding int) { - if err := os.MkdirAll(dir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "Error creating export directory: %v\n", err) - os.Exit(1) - } - - recipeDir := filepath.Dir(recipeFile) - seen := make(map[string]bool) - - var links []string - collectLinks(recipe.Ingredients, recipe.IngredientGroups, &links) - - for _, link := range links { - if strings.Contains(link, "://") { - continue - } - if seen[link] { - continue - } - seen[link] = true - - resolved := filepath.Join(recipeDir, link) - data, err := os.ReadFile(resolved) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: cannot read linked recipe %s: %v\n", link, err) - continue - } - - linked, err := p.Parse(data) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: cannot parse linked recipe %s: %v\n", link, err) - continue - } - - if err := p.Flatten(linked, resolved); err != nil { - fmt.Fprintf(os.Stderr, "Warning: flatten %s: %v\n", link, err) - } - - output := p.RenderMarkdown(linked, rounding) - outPath := filepath.Join(dir, filepath.Base(link)) - if err := os.WriteFile(outPath, []byte(output), 0644); err != nil { - fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", outPath, err) - os.Exit(1) - } - } -} - -func collectLinks(ingredients []recipemd.Ingredient, groups []recipemd.IngredientGroup, links *[]string) { - for _, ing := range ingredients { - if ing.Link != nil { - *links = append(*links, *ing.Link) - } - } - for _, g := range groups { - collectLinks(g.Ingredients, g.IngredientGroups, links) - } -} - -func isFlagSet(name string) bool { - found := false - flag.Visit(func(f *flag.Flag) { - if f.Name == name { - found = true - } - }) - return found -} diff --git a/examples/flatten/main.go b/examples/flatten/main.go new file mode 100644 index 0000000..9fa1f07 --- /dev/null +++ b/examples/flatten/main.go @@ -0,0 +1,41 @@ +// 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" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: flatten ") + os.Exit(1) + } + + data, err := os.ReadFile(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + p := recipemd.NewParser() + r, err := p.Parse(data) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if err := p.Flatten(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..347b5bc --- /dev/null +++ b/examples/flatten/main_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + recipemd "github.com/xcapaldi/recipemd-go" +) + +// 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(data) + if err != nil { + t.Fatal(err) + } + + if err := p.Flatten(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]) + } + } +} + +// 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(input) + if err != nil { + t.Fatal(err) + } + + if err := p.Flatten(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/parse/main.go b/examples/parse/main.go new file mode 100644 index 0000000..a715bb8 --- /dev/null +++ b/examples/parse/main.go @@ -0,0 +1,44 @@ +// 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) + } + + data, err := os.ReadFile(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + p := recipemd.NewParser() + r, err := p.Parse(data) + 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..658ee79 --- /dev/null +++ b/examples/parse/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "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(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([]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..7f6afa1 --- /dev/null +++ b/examples/scale/main.go @@ -0,0 +1,57 @@ +// 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) + } + + data, err := os.ReadFile(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + 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(data) + 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..c229fa3 --- /dev/null +++ b/examples/scale/main_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "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(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(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/render_json.go b/render_json.go index 36f4666..77bc7fd 100644 --- a/render_json.go +++ b/render_json.go @@ -2,7 +2,7 @@ package recipemd import "encoding/json" -// RenderJSON serializes a Recipe as indented JSON. +// RenderJSON serializes a Recipe as compact JSON. func (p *Parser) RenderJSON(r *Recipe) ([]byte, error) { - return json.MarshalIndent(r, "", " ") + return json.Marshal(r) } 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. From 0cbb4e2551b2619915e472e65abf2d7c62cc7379 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 12:31:24 +0000 Subject: [PATCH 19/29] feat: collect all parse errors with position info Instead of returning on the first error, Parse() now collects all recoverable errors and returns them together via errors.Join. Each error is wrapped in a *ParseError that includes byte offset, 1-based line, and 1-based column. This is enough info for a future LSP diagnostic. --- errors.go | 16 +++ examples/flatten/main.go | 118 ++++++++++++++- examples/flatten/main_test.go | 76 +++++++++- parser.go | 263 +++++++++++++--------------------- parser_test.go | 69 --------- 5 files changed, 302 insertions(+), 240 deletions(-) create mode 100644 errors.go diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..52d3a92 --- /dev/null +++ b/errors.go @@ -0,0 +1,16 @@ +package recipemd + +import "fmt" + +// ParseError represents a single parse error with source position information, +// suitable for use as an LSP diagnostic in the future. +type ParseError struct { + Message string // human-readable error description + Offset int // byte offset in source (0-based) + Line int // 1-based line number + Column int // 1-based column number +} + +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 index 9fa1f07..1627328 100644 --- a/examples/flatten/main.go +++ b/examples/flatten/main.go @@ -9,6 +9,8 @@ package main import ( "fmt" "os" + "path/filepath" + "strings" recipemd "github.com/xcapaldi/recipemd-go" ) @@ -32,10 +34,124 @@ func main() { os.Exit(1) } - if err := p.Flatten(r, os.Args[1]); err != nil { + if err := flatten(p, r, os.Args[1]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } fmt.Print(p.RenderMarkdown(r, 2)) } + +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(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/flatten/main_test.go b/examples/flatten/main_test.go index 347b5bc..67b13ea 100644 --- a/examples/flatten/main_test.go +++ b/examples/flatten/main_test.go @@ -27,8 +27,8 @@ func TestFlattenInlinesLinksRecursively(t *testing.T) { t.Fatal(err) } - if err := p.Flatten(r, recipeFile); err != nil { - t.Fatalf("Flatten: %v", err) + if err := flatten(p, r, recipeFile); err != nil { + t.Fatalf("flatten: %v", err) } want := []string{"pasta", "olive oil", "water", "bouillon cube"} @@ -46,6 +46,74 @@ func TestFlattenInlinesLinksRecursively(t *testing.T) { } } +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 := 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 := 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 := 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 := 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") @@ -62,8 +130,8 @@ func TestFlattenHTTPLinksPreserved(t *testing.T) { t.Fatal(err) } - if err := p.Flatten(r, recipeFile); err != nil { - t.Fatalf("Flatten: %v", err) + if err := flatten(p, r, recipeFile); err != nil { + t.Fatalf("flatten: %v", err) } if len(r.Ingredients) != 1 { diff --git a/parser.go b/parser.go index e1e8090..26b363b 100644 --- a/parser.go +++ b/parser.go @@ -2,10 +2,9 @@ package recipemd import ( "bytes" + "errors" "fmt" "net/url" - "os" - "path/filepath" "strconv" "strings" "unicode" @@ -47,125 +46,12 @@ func NewParser(opts ...Option) (p *Parser) { return } -// Flatten resolves linked ingredients by parsing referenced recipe files -// and inlining their ingredients. Links resolved relative to recipeFile dir. -func (p *Parser) Flatten(r *Recipe, recipeFile string) error { - baseDir := filepath.Dir(recipeFile) - ingredients, err := p.flattenIngredients(r.Ingredients, baseDir) - if err != nil { - return fmt.Errorf("flattenIngredients: %w", err) - } - r.Ingredients = ingredients - groups, err := p.flattenIngredientGroups(r.IngredientGroups, baseDir) - if err != nil { - return fmt.Errorf("flattenIngredientGroups: %w", err) - } - r.IngredientGroups = groups - return nil -} - -func (p *Parser) flattenIngredients(ingredients []Ingredient, baseDir string) ([]Ingredient, error) { - result := make([]Ingredient, 0, len(ingredients)) - for _, ing := range ingredients { - if ing.Link != nil { - resolved, err := p.resolveLinkedRecipe(*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 (p *Parser) flattenIngredientGroups(groups []IngredientGroup, baseDir string) ([]IngredientGroup, error) { - result := make([]IngredientGroup, 0, len(groups)) - for _, g := range groups { - ingredients, err := p.flattenIngredients(g.Ingredients, baseDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredients: %w", err) - } - groups, err := p.flattenIngredientGroups(g.IngredientGroups, baseDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredientGroups: %w", err) - } - result = append(result, IngredientGroup{ - Title: g.Title, - Ingredients: ingredients, - IngredientGroups: groups, - }) - } - return result, nil -} - -func (p *Parser) resolveLinkedRecipe(link string, baseDir string, parent *Ingredient) ([]Ingredient, error) { - if strings.Contains(link, "://") { - return []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(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 := p.flattenIngredients(linked.Ingredients, linkedDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredients: %w", err) - } - for _, g := range linked.IngredientGroups { - ingredients, err := p.flattenIngredients(g.Ingredients, linkedDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredients: %w", err) - } - flatIngredients = append(flatIngredients, ingredients...) - groupIngredients, err := p.flattenGroupIngredients(g.IngredientGroups, linkedDir) - if err != nil { - return nil, fmt.Errorf("flattenGroupIngredients: %w", err) - } - flatIngredients = append(flatIngredients, groupIngredients...) - } - - if len(flatIngredients) == 0 { - return []Ingredient{*parent}, nil - } - return flatIngredients, nil -} - -func (p *Parser) flattenGroupIngredients(groups []IngredientGroup, baseDir string) ([]Ingredient, error) { - result := make([]Ingredient, 0, len(groups)) - for _, g := range groups { - ingredients, err := p.flattenIngredients(g.Ingredients, baseDir) - if err != nil { - return nil, fmt.Errorf("flattenIngredients: %w", err) - } - result = append(result, ingredients...) - groupIngredients, err := p.flattenGroupIngredients(g.IngredientGroups, baseDir) - if err != nil { - return nil, fmt.Errorf("flattenGroupIngredients: %w", err) - } - result = append(result, groupIngredients...) - } - return result, nil -} - // Parse converts a RecipeMD document into a Recipe struct via a single // goldmark parse and linear AST walk. // See: https://recipemd.org/specification.html#parsing-a-recipe +// +// All errors are collected and returned together via errors.Join. +// Any error results in a nil *Recipe. func (p *Parser) Parse(source []byte) (*Recipe, error) { if p.Frontmatter { source = stripFrontmatter(source) @@ -180,22 +66,24 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { IngredientGroups: []IngredientGroup{}, } + var errs error + c := document.FirstChild() if c == nil { - return nil, fmt.Errorf("recipe must have a title") + return nil, newParseError(source, 0, "recipe must have a title") } // --- Preamble: title --- h, ok := c.(*ast.Heading) if !ok { - return nil, fmt.Errorf("expected level 1 heading, got %T", c) + return nil, newParseError(source, nodeStartOffset(c), fmt.Sprintf("expected level 1 heading, got %T", c)) } if h.Level != 1 { - return nil, fmt.Errorf("expected level 1 heading, got level %d", h.Level) + 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, fmt.Errorf("extractPlainText: %w", err) + return nil, newParseError(source, nodeStartOffset(h), err.Error()) } recipe.Title = title @@ -209,15 +97,17 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { lastPreBreakEnd := descStart for c != nil && c.Kind() != ast.KindThematicBreak { - p, isPara := c.(*ast.Paragraph) + para, isPara := c.(*ast.Paragraph) if isPara { - if em, ok := isOnlyEmphasis(p, italic); ok { + if em, ok := isOnlyEmphasis(para, italic); ok { if tagsFound { - return nil, fmt.Errorf("tags already set") + 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, fmt.Errorf("extractPlainText: %w", err) + return nil, newParseError(source, nodeStartOffset(c), err.Error()) } recipe.Tags = parseTags(tagsText) tagsFound = true @@ -228,17 +118,19 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { c = c.NextSibling() continue } - if em, ok := isOnlyEmphasis(p, bold); ok { + if em, ok := isOnlyEmphasis(para, bold); ok { if yieldsFound { - return nil, fmt.Errorf("yields already set") + 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, fmt.Errorf("extractPlainText: %w", err) + return nil, newParseError(source, nodeStartOffset(c), err.Error()) } - yields, err := parseYields(yieldsText) - if err != nil { - return nil, fmt.Errorf("parseYields: %w", err) + yields, yieldErrs := parseYields(yieldsText) + if yieldErrs != nil { + errs = errors.Join(errs, newParseError(source, nodeStartOffset(c), yieldErrs.Error())) } recipe.Yields = yields yieldsFound = true @@ -250,7 +142,9 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { continue } if tagsYieldsMode { - return nil, fmt.Errorf("unexpected content in tags/yields section") + 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 { @@ -261,7 +155,7 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { // --- First thematic break --- if c == nil || c.Kind() != ast.KindThematicBreak { - return nil, fmt.Errorf("missing thematic break divider") + return nil, newParseError(source, lastPreBreakEnd, "missing thematic break divider") } firstBreakPos := findDashLine(source, lastPreBreakEnd) @@ -277,17 +171,18 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { c = c.NextSibling() // --- Ingredients --- - if _, ok := c.(*ast.Paragraph); ok { - return nil, fmt.Errorf("paragraph not valid in ingredients section") - } - c, err = parseIngredientList(c, source, &recipe.Ingredients, p.hasTaskList) - if err != nil { - return nil, err - } - c, err = parseIngredientGroup(c, source, &recipe.IngredientGroups, 0, p.hasTaskList) - if err != nil { - return nil, err + 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 { @@ -299,6 +194,9 @@ func (p *Parser) Parse(source []byte) (*Recipe, error) { } } + if errs != nil { + return nil, errs + } return recipe, nil } @@ -354,18 +252,19 @@ func parseIngredientGroup( parentLevel int, skipCheckbox bool, ) (ast.Node, error) { + var errs error for { h, ok := c.(*ast.Heading) if !ok { - return c, nil + return c, errs } l := h.Level if l <= parentLevel { - return c, nil + return c, errs } title, err := extractPlainText(h, source) if err != nil { - return nil, fmt.Errorf("extractPlainText: %w", err) + return nil, errors.Join(errs, newParseError(source, nodeStartOffset(h), err.Error())) } g := IngredientGroup{ Title: title, @@ -373,19 +272,16 @@ func parseIngredientGroup( IngredientGroups: []IngredientGroup{}, } c = c.NextSibling() - if c == nil { - *groups = append(*groups, g) - return nil, nil - } - c, err = parseIngredientList(c, source, &g.Ingredients, skipCheckbox) - if err != nil { - return nil, err - } - c, err = parseIngredientGroup(c, source, &g.IngredientGroups, l, skipCheckbox) - if err != nil { - return nil, err + 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 + } } } @@ -399,11 +295,12 @@ func parseIngredientList( ingredients *[]Ingredient, skipCheckbox bool, ) (ast.Node, error) { + var errs error for { // 1. Examine c list, ok := c.(*ast.List) if !ok { - return c, nil + return c, errs } // Enter c c = list.FirstChild() @@ -416,14 +313,17 @@ func parseIngredientList( for { ing, err := parseIngredient(c, source, skipCheckbox) if err != nil { - return nil, fmt.Errorf("parseIngredient: %w", err) + offset, _ := getRecursiveSourceBounds(c, source) + if offset < 0 { + offset = 0 + } + errs = errors.Join(errs, newParseError(source, offset, err.Error())) + } else { + *ingredients = append(*ingredients, ing) } - *ingredients = append(*ingredients, ing) - // Go to next item if c.NextSibling() != nil { c = c.NextSibling() } else { - // Leave c and go to 1 c = list.NextSibling() break } @@ -975,6 +875,36 @@ func getRecursiveSourceBounds(node ast.Node, source []byte) (start, end int) { 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 ( @@ -1029,15 +959,16 @@ func parseTags(s string) []string { return splitList(s) } -func parseYields(s string) (yields []Amount, err error) { +func parseYields(s string) (yields []Amount, errs error) { for _, yield := range splitList(s) { amount, err := parseAmount(yield) if err != nil { - return nil, fmt.Errorf("parseAmount: %w", err) + errs = errors.Join(errs, err) + continue } yields = append(yields, amount) } - return yields, nil + return yields, errs } var vulgarFractionMap = map[rune]float64{ diff --git a/parser_test.go b/parser_test.go index 73538b3..5324b98 100644 --- a/parser_test.go +++ b/parser_test.go @@ -3,8 +3,6 @@ package recipemd import ( "encoding/json" "math" - "os" - "path/filepath" "testing" ) @@ -698,73 +696,6 @@ func TestParse(t *testing.T) { }) } -func TestFlatten(t *testing.T) { - t.Parallel() - - t.Run("no links unchanged", func(t *testing.T) { - t.Parallel() - p := NewParser() - r := &Recipe{ - Ingredients: []Ingredient{{Name: "salt"}}, - IngredientGroups: []IngredientGroup{}, - } - if err := p.Flatten(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 := NewParser() - r := &Recipe{ - Ingredients: []Ingredient{{Name: "sauce", Link: new("https://example.com/sauce.md")}}, - IngredientGroups: []IngredientGroup{}, - } - if err := p.Flatten(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 := NewParser() - r := &Recipe{ - Ingredients: []Ingredient{{Name: "sauce", Link: new("sauce.md"), Amount: &Amount{Factor: 2, Unit: new("cups")}}}, - IngredientGroups: []IngredientGroup{}, - } - if err := p.Flatten(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 := NewParser() - r := &Recipe{ - Ingredients: []Ingredient{{Name: "x", Link: new("nonexistent.md")}}, - IngredientGroups: []IngredientGroup{}, - } - if err := p.Flatten(r, "/fake/recipe.md"); err == nil { - t.Fatal("expected error for missing linked file") - } - }) -} func TestEncodeURLPath(t *testing.T) { t.Parallel() From af19e84ab75d64a66c941c41b542dba1c829e816 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 06:58:37 +0000 Subject: [PATCH 20/29] feat: change Parse to accept io.Reader instead of []byte Accepting io.Reader is idiomatic Go for byte-stream consumption. Callers can now pass *os.File, http.Response.Body, strings.NewReader, etc. directly. Internally io.ReadAll buffers the bytes for goldmark, which is identical overhead to what callers did before with os.ReadFile. --- canonical_test.go | 3 +- examples/flatten/main.go | 8 +++-- examples/flatten/main_test.go | 5 +-- examples/parse/main.go | 5 +-- examples/parse/main_test.go | 5 +-- examples/scale/main.go | 5 +-- examples/scale/main_test.go | 5 +-- golden_test.go | 3 +- parser.go | 11 +++--- parser_test.go | 63 ++++++++++++++++++----------------- 10 files changed, 63 insertions(+), 50 deletions(-) diff --git a/canonical_test.go b/canonical_test.go index 07b30f4..e407cba 100644 --- a/canonical_test.go +++ b/canonical_test.go @@ -1,6 +1,7 @@ package recipemd import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -24,7 +25,7 @@ func TestCanonical(t *testing.T) { t.Fatal(err) } - recipe, parseErr := NewParser().Parse(input) + recipe, parseErr := NewParser().Parse(bytes.NewReader(input)) if isInvalid { if parseErr == nil { diff --git a/examples/flatten/main.go b/examples/flatten/main.go index 1627328..75c7206 100644 --- a/examples/flatten/main.go +++ b/examples/flatten/main.go @@ -7,6 +7,7 @@ package main import ( + "bytes" "fmt" "os" "path/filepath" @@ -21,14 +22,15 @@ func main() { os.Exit(1) } - data, err := os.ReadFile(os.Args[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(data) + r, err := p.Parse(f) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -104,7 +106,7 @@ func resolveLinkedRecipe(p *recipemd.Parser, link string, baseDir string, parent return nil, fmt.Errorf("os.ReadFile: %w", err) } - linked, err := p.Parse(data) + linked, err := p.Parse(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("Parse: %w", err) } diff --git a/examples/flatten/main_test.go b/examples/flatten/main_test.go index 67b13ea..848f800 100644 --- a/examples/flatten/main_test.go +++ b/examples/flatten/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "os" "path/filepath" "testing" @@ -22,7 +23,7 @@ func TestFlattenInlinesLinksRecursively(t *testing.T) { } p := recipemd.NewParser() - r, err := p.Parse(data) + r, err := p.Parse(bytes.NewReader(data)) if err != nil { t.Fatal(err) } @@ -125,7 +126,7 @@ func TestFlattenHTTPLinksPreserved(t *testing.T) { } p := recipemd.NewParser() - r, err := p.Parse(input) + r, err := p.Parse(bytes.NewReader(input)) if err != nil { t.Fatal(err) } diff --git a/examples/parse/main.go b/examples/parse/main.go index a715bb8..d63cb9f 100644 --- a/examples/parse/main.go +++ b/examples/parse/main.go @@ -22,14 +22,15 @@ func main() { os.Exit(1) } - data, err := os.ReadFile(os.Args[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(data) + r, err := p.Parse(f) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/examples/parse/main_test.go b/examples/parse/main_test.go index 658ee79..501f9d4 100644 --- a/examples/parse/main_test.go +++ b/examples/parse/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/json" "os" "testing" @@ -15,7 +16,7 @@ func TestParseProducesValidJSON(t *testing.T) { } p := recipemd.NewParser() - r, err := p.Parse(data) + r, err := p.Parse(bytes.NewReader(data)) if err != nil { t.Fatalf("Parse: %v", err) } @@ -36,7 +37,7 @@ func TestParseProducesValidJSON(t *testing.T) { func TestParseInvalidRecipeFails(t *testing.T) { p := recipemd.NewParser() - _, err := p.Parse([]byte("no heading here")) + _, 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 index 7f6afa1..31eb506 100644 --- a/examples/scale/main.go +++ b/examples/scale/main.go @@ -25,11 +25,12 @@ func main() { os.Exit(1) } - data, err := os.ReadFile(os.Args[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 { @@ -38,7 +39,7 @@ func main() { } p := recipemd.NewParser() - r, err := p.Parse(data) + r, err := p.Parse(f) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/examples/scale/main_test.go b/examples/scale/main_test.go index c229fa3..3e1ab2b 100644 --- a/examples/scale/main_test.go +++ b/examples/scale/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "os" "strings" "testing" @@ -15,7 +16,7 @@ func TestScaleByFactor(t *testing.T) { } p := recipemd.NewParser() - r, err := p.Parse(data) + r, err := p.Parse(bytes.NewReader(data)) if err != nil { t.Fatal(err) } @@ -35,7 +36,7 @@ func TestScaleForYield(t *testing.T) { } p := recipemd.NewParser() - r, err := p.Parse(data) + r, err := p.Parse(bytes.NewReader(data)) if err != nil { t.Fatal(err) } diff --git a/golden_test.go b/golden_test.go index d096528..f124f9d 100644 --- a/golden_test.go +++ b/golden_test.go @@ -1,6 +1,7 @@ package recipemd import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -39,7 +40,7 @@ func TestGolden(t *testing.T) { t.Fatal(err) } - recipe, parseErr := NewParser(suite.opts...).Parse(input) + recipe, parseErr := NewParser(suite.opts...).Parse(bytes.NewReader(input)) if isInvalid { if parseErr == nil { diff --git a/parser.go b/parser.go index 26b363b..1b0e4ba 100644 --- a/parser.go +++ b/parser.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "io" "net/url" "strconv" "strings" @@ -49,10 +50,12 @@ func NewParser(opts ...Option) (p *Parser) { // Parse converts a RecipeMD document into a Recipe struct via a single // goldmark parse and linear AST walk. // See: https://recipemd.org/specification.html#parsing-a-recipe -// -// All errors are collected and returned together via errors.Join. -// Any error results in a nil *Recipe. -func (p *Parser) Parse(source []byte) (*Recipe, error) { +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) } diff --git a/parser_test.go b/parser_test.go index 5324b98..86b1ad2 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,6 +1,7 @@ package recipemd import ( + "bytes" "encoding/json" "math" "testing" @@ -17,7 +18,7 @@ title: ignored - avocado `) p := NewParser(WithFrontmatter()) - recipe, err := p.Parse(input) + recipe, err := p.Parse(bytes.NewReader(input)) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -37,7 +38,7 @@ title = "ignored" - avocado `) p := NewParser(WithFrontmatter()) - recipe, err := p.Parse(input) + recipe, err := p.Parse(bytes.NewReader(input)) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -56,7 +57,7 @@ title: ignored - avocado `) - _, err := NewParser().Parse(input) + _, err := NewParser().Parse(bytes.NewReader(input)) if err == nil { t.Fatal("expected error parsing frontmatter without WithFrontmatter") } @@ -72,7 +73,7 @@ Check out https://example.com for more info. - avocado `) p := NewParser(WithGithubFormattedMarkdown()) - recipe, err := p.Parse(input) + recipe, err := p.Parse(bytes.NewReader(input)) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -84,7 +85,7 @@ Check out https://example.com for more info. 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(input) + recipe, err := p.Parse(bytes.NewReader(input)) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -134,7 +135,7 @@ It's delicious with chips. - avocado `) - recipe, err := NewParser().Parse(input) + recipe, err := NewParser().Parse(bytes.NewReader(input)) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -177,7 +178,7 @@ with salt, pepper and lemon juice. `) func TestParse_FullRecipe(t *testing.T) { - recipe, err := NewParser().Parse(sampleRecipe) + recipe, err := NewParser().Parse(bytes.NewReader(sampleRecipe)) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -389,7 +390,7 @@ func TestParse(t *testing.T) { t.Run("minimal recipe", func(t *testing.T) { t.Parallel() - r, err := NewParser().Parse([]byte("# Title\n\n---\n\n- salt\n")) + r, err := NewParser().Parse(bytes.NewReader([]byte("# Title\n\n---\n\n- salt\n"))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -409,7 +410,7 @@ func TestParse(t *testing.T) { t.Run("empty input", func(t *testing.T) { t.Parallel() - _, err := NewParser().Parse([]byte("")) + _, err := NewParser().Parse(bytes.NewReader([]byte(""))) if err == nil { t.Fatal("expected error for empty input") } @@ -417,7 +418,7 @@ func TestParse(t *testing.T) { t.Run("no heading", func(t *testing.T) { t.Parallel() - _, err := NewParser().Parse([]byte("Not a heading\n\n---\n\n- x\n")) + _, 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") } @@ -425,7 +426,7 @@ func TestParse(t *testing.T) { t.Run("wrong heading level", func(t *testing.T) { t.Parallel() - _, err := NewParser().Parse([]byte("## Level 2\n\n---\n\n- x\n")) + _, 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") } @@ -433,7 +434,7 @@ func TestParse(t *testing.T) { t.Run("missing thematic break", func(t *testing.T) { t.Parallel() - _, err := NewParser().Parse([]byte("# Title\n\n- x\n")) + _, err := NewParser().Parse(bytes.NewReader([]byte("# Title\n\n- x\n"))) if err == nil { t.Fatal("expected error for missing thematic break") } @@ -441,7 +442,7 @@ func TestParse(t *testing.T) { t.Run("description", func(t *testing.T) { t.Parallel() - r, err := NewParser().Parse([]byte("# Title\n\nA description.\n\n---\n\n- x\n")) + 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) } @@ -452,7 +453,7 @@ func TestParse(t *testing.T) { t.Run("tags", func(t *testing.T) { t.Parallel() - r, err := NewParser().Parse([]byte("# Title\n\n*sauce, vegan*\n\n---\n\n- x\n")) + 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) } @@ -463,7 +464,7 @@ func TestParse(t *testing.T) { t.Run("yields", func(t *testing.T) { t.Parallel() - r, err := NewParser().Parse([]byte("# Title\n\n**4 servings**\n\n---\n\n- x\n")) + 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) } @@ -475,7 +476,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -490,7 +491,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + _, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err == nil { t.Fatal("expected error for duplicate tags") } @@ -499,7 +500,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + _, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err == nil { t.Fatal("expected error for duplicate yields") } @@ -507,7 +508,7 @@ func TestParse(t *testing.T) { t.Run("ingredient with amount", func(t *testing.T) { t.Parallel() - r, err := NewParser().Parse([]byte("# T\n\n---\n\n- *2 cups* flour\n")) + 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) } @@ -522,7 +523,7 @@ func TestParse(t *testing.T) { t.Run("ingredient with link", func(t *testing.T) { t.Parallel() - r, err := NewParser().Parse([]byte("# T\n\n---\n\n- [flour](flour.md)\n")) + 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) } @@ -537,7 +538,7 @@ func TestParse(t *testing.T) { t.Run("ingredient with amount and link", func(t *testing.T) { t.Parallel() - r, err := NewParser().Parse([]byte("# T\n\n---\n\n- *2 cups* [flour](flour.md)\n")) + 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) } @@ -553,7 +554,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -565,7 +566,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -587,7 +588,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -606,7 +607,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -617,7 +618,7 @@ func TestParse(t *testing.T) { t.Run("no instructions", func(t *testing.T) { t.Parallel() - r, err := NewParser().Parse([]byte("# T\n\n---\n\n- x\n")) + r, err := NewParser().Parse(bytes.NewReader([]byte("# T\n\n---\n\n- x\n"))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -629,7 +630,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -644,7 +645,7 @@ func TestParse(t *testing.T) { t.Run("paragraph in ingredients errors", func(t *testing.T) { t.Parallel() input := "# T\n\n---\n\nNot a list\n" - _, err := NewParser().Parse([]byte(input)) + _, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err == nil { t.Fatal("expected error for paragraph in ingredients") } @@ -653,7 +654,7 @@ func TestParse(t *testing.T) { t.Run("setext heading title", func(t *testing.T) { t.Parallel() input := "Title\n=====\n\n---\n\n- x\n" - r, err := NewParser().Parse([]byte(input)) + r, err := NewParser().Parse(bytes.NewReader([]byte(input))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -665,7 +666,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + r, err := NewParser(WithFrontmatter()).Parse(bytes.NewReader([]byte(input))) if err != nil { t.Fatalf("Parse error: %v", err) } @@ -677,7 +678,7 @@ func TestParse(t *testing.T) { 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([]byte(input)) + r, err := NewParser(WithGithubFormattedMarkdown()).Parse(bytes.NewReader([]byte(input))) if err != nil { t.Fatalf("Parse error: %v", err) } From 5f751d51390ec76bf75e449d5293c139ba5d4097 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 04:18:17 +0000 Subject: [PATCH 21/29] feat: Add RenderHTML method for HTML output Implements Parser.RenderHTML that converts a Recipe to an HTML
element. Markdown fields (Description, Instructions) are rendered to HTML via the parser's goldmark instance. Ingredient amounts are wrapped in , yields in , and tags in to restore the emphasis conveyed by the original RecipeMD markdown formatting. All elements carry class attributes matching RecipeMD types (recipemd-recipe, recipemd-title, recipemd-preamble, recipemd-description, recipemd-tags, recipemd-yields, recipemd-separator, recipemd-ingredients, recipemd-ingredient-list, recipemd-ingredient, recipemd-amount, recipemd-ingredient-name, recipemd-ingredient-link, recipemd-ingredient-group, recipemd-group-title, recipemd-instructions) for CSS styling. Ingredient group headings use h2-h6 matching nesting depth. Nested groups are supported recursively. --- examples/flatten/main.go | 120 +---------------- examples/flatten/main_test.go | 13 +- examples/helper/flatten.go | 128 ++++++++++++++++++ examples/renderhtml/main.go | 46 +++++++ examples/renderhtml/main_test.go | 43 +++++++ render_html.go | 129 +++++++++++++++++++ render_html_test.go | 214 +++++++++++++++++++++++++++++++ 7 files changed, 569 insertions(+), 124 deletions(-) create mode 100644 examples/helper/flatten.go create mode 100644 examples/renderhtml/main.go create mode 100644 examples/renderhtml/main_test.go create mode 100644 render_html.go create mode 100644 render_html_test.go diff --git a/examples/flatten/main.go b/examples/flatten/main.go index 75c7206..3752a2c 100644 --- a/examples/flatten/main.go +++ b/examples/flatten/main.go @@ -7,13 +7,11 @@ package main import ( - "bytes" "fmt" "os" - "path/filepath" - "strings" recipemd "github.com/xcapaldi/recipemd-go" + "github.com/xcapaldi/recipemd-go/examples/helper" ) func main() { @@ -36,124 +34,10 @@ func main() { os.Exit(1) } - if err := flatten(p, r, os.Args[1]); err != nil { + 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)) } - -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/flatten/main_test.go b/examples/flatten/main_test.go index 848f800..e3f9efa 100644 --- a/examples/flatten/main_test.go +++ b/examples/flatten/main_test.go @@ -7,6 +7,7 @@ import ( "testing" recipemd "github.com/xcapaldi/recipemd-go" + "github.com/xcapaldi/recipemd-go/examples/helper" ) // TestFlattenInlinesLinksRecursively verifies that linked ingredients are @@ -28,7 +29,7 @@ func TestFlattenInlinesLinksRecursively(t *testing.T) { t.Fatal(err) } - if err := flatten(p, r, recipeFile); err != nil { + if err := helper.Flatten(p, r, recipeFile); err != nil { t.Fatalf("flatten: %v", err) } @@ -57,7 +58,7 @@ func TestFlatten(t *testing.T) { Ingredients: []recipemd.Ingredient{{Name: "salt"}}, IngredientGroups: []recipemd.IngredientGroup{}, } - if err := flatten(p, r, "/fake/recipe.md"); err != nil { + if err := helper.Flatten(p, r, "/fake/recipe.md"); err != nil { t.Fatal(err) } if len(r.Ingredients) != 1 || r.Ingredients[0].Name != "salt" { @@ -72,7 +73,7 @@ func TestFlatten(t *testing.T) { Ingredients: []recipemd.Ingredient{{Name: "sauce", Link: new("https://example.com/sauce.md")}}, IngredientGroups: []recipemd.IngredientGroup{}, } - if err := flatten(p, r, "/fake/recipe.md"); err != nil { + if err := helper.Flatten(p, r, "/fake/recipe.md"); err != nil { t.Fatal(err) } if r.Ingredients[0].Link == nil { @@ -94,7 +95,7 @@ func TestFlatten(t *testing.T) { Ingredients: []recipemd.Ingredient{{Name: "sauce", Link: new("sauce.md"), Amount: &recipemd.Amount{Factor: 2, Unit: new("cups")}}}, IngredientGroups: []recipemd.IngredientGroup{}, } - if err := flatten(p, r, main); err != nil { + if err := helper.Flatten(p, r, main); err != nil { t.Fatal(err) } if len(r.Ingredients) < 1 { @@ -109,7 +110,7 @@ func TestFlatten(t *testing.T) { Ingredients: []recipemd.Ingredient{{Name: "x", Link: new("nonexistent.md")}}, IngredientGroups: []recipemd.IngredientGroup{}, } - if err := flatten(p, r, "/fake/recipe.md"); err == nil { + if err := helper.Flatten(p, r, "/fake/recipe.md"); err == nil { t.Fatal("expected error for missing linked file") } }) @@ -131,7 +132,7 @@ func TestFlattenHTTPLinksPreserved(t *testing.T) { t.Fatal(err) } - if err := flatten(p, r, recipeFile); err != nil { + if err := helper.Flatten(p, r, recipeFile); err != nil { t.Fatalf("flatten: %v", err) } 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/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/render_html.go b/render_html.go new file mode 100644 index 0000000..99635ca --- /dev/null +++ b/render_html.go @@ -0,0 +1,129 @@ +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 }} +
+
{{ renderMD . }}
+{{- 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 }}` + +// WARNING: WIP — not ready for production use. +// Known issues: +// - Ingredient links point to raw .md file paths with no resolution or conversion. +// +// RenderHTML formats a Recipe as an HTML
element. +// Fields containing raw markdown (Description, Instructions) are parsed and +// rendered to HTML. Ingredient amounts and yields are wrapped in / +// to restore the emphasis that markdown formatting conveys. All elements carry +// class attributes matching the RecipeMD types so they can be styled with CSS. +func (p *Parser) RenderHTML(r *Recipe, rounding int) string { + funcs := htmlFuncMap(p, rounding) + 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) 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()) + }, + "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") + } + }) +} From 8118028f25e3993d20dd5e86d2260a92fd88635f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 22:31:09 +0000 Subject: [PATCH 22/29] Add thorough godoc documentation to all exported symbols - Add doc.go with package-level overview, usage examples for parsing, scaling, rendering, and flattening linked ingredients. - Document all exported types (Recipe, Ingredient, IngredientGroup, Amount) with field-level comments explaining optionality and semantics. - Document all exported methods: Scale, ScaleForYield, LeafIngredients, FormatFactor, Serialize, MarshalJSON. - Document Parser, Option, NewParser, WithFrontmatter, WithGithubFormattedMarkdown, Parse, Flatten, ParseAmountString, RenderMarkdown, and RenderJSON following godoc conventions (comments begin with the symbol name, full sentences, doc-links). https://claude.ai/code/session_01MzFkhNfhVMPGFeoB6nYAVv --- doc.go | 52 ++++++++++++++++++ errors.go | 29 ++++++++-- parser.go | 88 +++++++++++++++++++++++++---- recipe.go | 134 ++++++++++++++++++++++++++++++++++++--------- render_html.go | 25 ++++++--- render_json.go | 6 +- render_markdown.go | 8 ++- 7 files changed, 287 insertions(+), 55 deletions(-) create mode 100644 doc.go 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 index 52d3a92..91a8a11 100644 --- a/errors.go +++ b/errors.go @@ -2,15 +2,32 @@ package recipemd import "fmt" -// ParseError represents a single parse error with source position information, -// suitable for use as an LSP diagnostic in the future. +// 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 string // human-readable error description - Offset int // byte offset in source (0-based) - Line int // 1-based line number - Column int // 1-based column number + // 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/parser.go b/parser.go index 1b0e4ba..be8032b 100644 --- a/parser.go +++ b/parser.go @@ -17,21 +17,55 @@ import ( "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) -func WithFrontmatter() Option { return func(p *Parser) { p.Frontmatter = true } } -func WithGithubFormattedMarkdown() Option { return func(p *Parser) { - p.goldmarkExtensions = append(p.goldmarkExtensions, extension.GFM) - p.hasTaskList = true -} } +// 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 bool - hasTaskList bool + // 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 } +// 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 { @@ -47,9 +81,26 @@ func NewParser(opts ...Option) (p *Parser) { return } -// Parse converts a RecipeMD document into a Recipe struct via a single -// goldmark parse and linear AST walk. -// See: https://recipemd.org/specification.html#parsing-a-recipe +// 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 { @@ -520,8 +571,21 @@ func findSingleLink(start ast.Node, source []byte) *linkInfo { return found } -// ParseAmountString parses an amount string into value and unit. -// This is the exported version of parseAmount for CLI use. +// 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) } diff --git a/recipe.go b/recipe.go index 41eea56..a0a33f1 100644 --- a/recipe.go +++ b/recipe.go @@ -9,35 +9,88 @@ import ( ) 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 string `json:"title"` - Description *string `json:"description"` - Yields []Amount `json:"yields"` - Tags []string `json:"tags"` - Ingredients []Ingredient `json:"ingredients"` + // 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 *string `json:"instructions"` + // 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 string `json:"name"` + // 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 *string `json:"link"` + // 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 string `json:"title"` - Ingredients []Ingredient `json:"ingredients"` + // 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 float64 `json:"factor"` - Unit *string `json:"unit"` + // 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 is a custom marshaler for an Amount. +// 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 { @@ -46,7 +99,12 @@ func (a Amount) MarshalJSON() ([]byte, error) { return fmt.Appendf([]byte{}, `{"factor":%q,"unit":null}`, s), nil } -// FormatFactor formats the factor as a string. rounding < 0 means no rounding. +// 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) @@ -60,11 +118,19 @@ func (a Amount) FormatFactor(rounding int) string { return s } -// ScaleForYield tries to find a matching yield in the recipe and uses that to -// find the overall scaling factor. If the desired yield is unitless, first try -// to match a recipe yield that also has no unit. If there is none, assume the -// scaling factor is for the implicit 1x recipe yield. For example, scale the -// whole recipe by 2x. +// 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 { @@ -86,7 +152,8 @@ func (r *Recipe) ScaleForYield(desiredYield Amount) error { return errors.New("no matching yield unit found") } -// Scale Recipe by factor +// 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) @@ -99,19 +166,20 @@ func (r *Recipe) Scale(factor float64) { } } -// Scale Amount by factor +// Scale multiplies the amount's factor by factor. func (a *Amount) Scale(factor float64) { a.Factor *= factor } -// Scale Ingredient by 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 IngredientGroup by 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) @@ -121,7 +189,11 @@ func (g *IngredientGroup) Scale(factor float64) { } } -// Serialize formats an Amount as a string. +// 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 { @@ -130,7 +202,11 @@ func (a Amount) Serialize(rounding int) string { return s } -// Serialize formats an Ingredient as a string. +// 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 @@ -138,7 +214,10 @@ func (i Ingredient) Serialize(rounding int) string { return i.Name } -// LeafIngredients returns all ingredients including those in groups. +// 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...) @@ -148,7 +227,8 @@ func (r *Recipe) LeafIngredients() []Ingredient { return result } -// LeafIngredients returns all ingredients in the group and subgroups. +// 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...) diff --git a/render_html.go b/render_html.go index 99635ca..b5344f3 100644 --- a/render_html.go +++ b/render_html.go @@ -66,15 +66,24 @@ const htmlGroupsTmpl = `{{ range . -}} {{ end }}` -// WARNING: WIP — not ready for production use. -// Known issues: -// - Ingredient links point to raw .md file paths with no resolution or conversion. +// RenderHTML renders r as an HTML
element. // -// RenderHTML formats a Recipe as an HTML
element. -// Fields containing raw markdown (Description, Instructions) are parsed and -// rendered to HTML. Ingredient amounts and yields are wrapped in / -// to restore the emphasis that markdown formatting conveys. All elements carry -// class attributes matching the RecipeMD types so they can be styled with CSS. +// 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. +// +// WARNING: This method is a work-in-progress and not yet ready for production +// use. Known limitation: ingredient links reference raw .md file paths without +// any resolution or conversion to HTML-friendly URLs. func (p *Parser) RenderHTML(r *Recipe, rounding int) string { funcs := htmlFuncMap(p, rounding) funcs["topGroups"] = func(groups []IngredientGroup) []htmlGroupCtx { diff --git a/render_json.go b/render_json.go index 77bc7fd..ec8b1a6 100644 --- a/render_json.go +++ b/render_json.go @@ -2,7 +2,11 @@ package recipemd import "encoding/json" -// RenderJSON serializes a Recipe as compact JSON. +// RenderJSON serialises r as compact JSON. +// +// Amount factors are encoded as quoted strings (via [Amount.MarshalJSON]) to +// preserve human-readable precision. All other fields use their standard JSON +// representations. func (p *Parser) RenderJSON(r *Recipe) ([]byte, error) { return json.Marshal(r) } diff --git a/render_markdown.go b/render_markdown.go index 49b8be2..289a25b 100644 --- a/render_markdown.go +++ b/render_markdown.go @@ -46,7 +46,13 @@ const mdGroupsTmpl = `{{ range . }} {{ .Heading }} {{ .Title }} {{ template "ingredients" .Ingredients }}{{ template "groups" (.Subgroups) }}{{ end }}` -// RenderMarkdown formats a Recipe as RecipeMD markdown. +// RenderMarkdown renders r as a RecipeMD-formatted markdown string. +// +// Numeric amounts are rounded to rounding decimal places (trailing zeros are +// removed). Pass a negative rounding value to use full float64 precision. +// +// The returned string contains a complete, parseable RecipeMD document that +// [Parser.Parse] can round-trip back to an equivalent [Recipe]. func (p *Parser) RenderMarkdown(r *Recipe, rounding int) string { funcs := renderFuncMap(rounding) funcs["topGroups"] = func(groups []IngredientGroup) []mdGroupCtx { From c78601925117f9a729d122779e1c67bcab36091d Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:36:47 -0400 Subject: [PATCH 23/29] fix: gofmt codebase --- parser.go | 12 ++++---- parser_test.go | 2 -- recipe.go | 76 ++++++++++++++++++++++++-------------------------- recipe_test.go | 16 +++++------ 4 files changed, 51 insertions(+), 55 deletions(-) diff --git a/parser.go b/parser.go index be8032b..47d2e4a 100644 --- a/parser.go +++ b/parser.go @@ -174,7 +174,7 @@ func (p *Parser) Parse(r io.Reader) (*Recipe, error) { } if em, ok := isOnlyEmphasis(para, bold); ok { if yieldsFound { - errs = errors.Join(errs,newParseError(source, nodeStartOffset(c), "yields already set")) + errs = errors.Join(errs, newParseError(source, nodeStartOffset(c), "yields already set")) c = c.NextSibling() continue } @@ -196,7 +196,7 @@ func (p *Parser) Parse(r io.Reader) (*Recipe, error) { continue } if tagsYieldsMode { - errs = errors.Join(errs,newParseError(source, nodeStartOffset(c), "unexpected content in tags/yields section")) + errs = errors.Join(errs, newParseError(source, nodeStartOffset(c), "unexpected content in tags/yields section")) c = c.NextSibling() continue } @@ -227,7 +227,7 @@ func (p *Parser) Parse(r io.Reader) (*Recipe, error) { // --- Ingredients --- for c != nil { if para, ok := c.(*ast.Paragraph); ok { - errs = errors.Join(errs,newParseError(source, nodeStartOffset(para), "paragraph not valid in ingredients section")) + errs = errors.Join(errs, newParseError(source, nodeStartOffset(para), "paragraph not valid in ingredients section")) c = c.NextSibling() continue } @@ -475,9 +475,9 @@ func parseIngredient(c ast.Node, source []byte, skipCheckbox bool) (Ingredient, // 8. Let i be an ingredient with amount a, name n, link l n = strings.TrimSpace(n) - if n == "" { - return Ingredient{}, fmt.Errorf("ingredient must have a name") - } + if n == "" { + return Ingredient{}, fmt.Errorf("ingredient must have a name") + } return Ingredient{Amount: a, Name: n, Link: l}, nil } diff --git a/parser_test.go b/parser_test.go index 86b1ad2..ef39063 100644 --- a/parser_test.go +++ b/parser_test.go @@ -697,7 +697,6 @@ func TestParse(t *testing.T) { }) } - func TestEncodeURLPath(t *testing.T) { t.Parallel() tests := []struct { @@ -795,4 +794,3 @@ func TestSkipLine(t *testing.T) { }) } } - diff --git a/recipe.go b/recipe.go index a0a33f1..1d58c1d 100644 --- a/recipe.go +++ b/recipe.go @@ -132,61 +132,61 @@ func (a Amount) FormatFactor(rounding int) string { // ScaleForYield returns an error when desiredYield has a unit that does not // match any yield in the recipe. func (r *Recipe) ScaleForYield(desiredYield Amount) error { - for _, y := range r.Yields { - if y.Unit == nil && desiredYield.Unit == nil { - r.Scale(desiredYield.Factor/y.Factor) - return nil - } - if y.Unit != nil && desiredYield.Unit != nil && *y.Unit == *desiredYield.Unit { - r.Scale(desiredYield.Factor/y.Factor) - return nil - } - } - - // fallback on scaling the whole recipe - if desiredYield.Unit == nil { - r.Scale(desiredYield.Factor) - return nil - } - - return errors.New("no matching yield unit found") + for _, y := range r.Yields { + if y.Unit == nil && desiredYield.Unit == nil { + r.Scale(desiredYield.Factor / y.Factor) + return nil + } + if y.Unit != nil && desiredYield.Unit != nil && *y.Unit == *desiredYield.Unit { + r.Scale(desiredYield.Factor / y.Factor) + return nil + } + } + + // fallback on scaling the whole recipe + if desiredYield.Unit == nil { + r.Scale(desiredYield.Factor) + return nil + } + + return errors.New("no matching yield unit found") } // Scale multiplies every ingredient amount and every yield in the recipe by // factor. The recipe title, description, tags, and instructions are unchanged. func (r *Recipe) Scale(factor float64) { - for i := range(r.Yields) { - r.Yields[i].Scale(factor) - } - for j := range(r.Ingredients) { - r.Ingredients[j].Scale(factor) - } - for k := range(r.IngredientGroups) { - r.IngredientGroups[k].Scale(factor) - } + for i := range r.Yields { + r.Yields[i].Scale(factor) + } + for j := range r.Ingredients { + r.Ingredients[j].Scale(factor) + } + for k := range r.IngredientGroups { + r.IngredientGroups[k].Scale(factor) + } } // Scale multiplies the amount's factor by factor. func (a *Amount) Scale(factor float64) { - a.Factor *= factor + a.Factor *= factor } // Scale scales the ingredient's amount by factor. It is a no-op when the // ingredient has no amount. func (i *Ingredient) Scale(factor float64) { - if i.Amount != nil { - i.Amount.Scale(factor) - } + if i.Amount != nil { + i.Amount.Scale(factor) + } } // Scale recursively scales all ingredients and nested sub-groups by factor. func (g *IngredientGroup) Scale(factor float64) { - for i := range(g.Ingredients) { - g.Ingredients[i].Scale(factor) - } - for j := range(g.IngredientGroups) { - g.IngredientGroups[j].Scale(factor) - } + for i := range g.Ingredients { + g.Ingredients[i].Scale(factor) + } + for j := range g.IngredientGroups { + g.IngredientGroups[j].Scale(factor) + } } // Serialize formats the amount as a human-readable string. @@ -237,5 +237,3 @@ func (g *IngredientGroup) LeafIngredients() []Ingredient { } return result } - - diff --git a/recipe_test.go b/recipe_test.go index 129caa6..5c966a3 100644 --- a/recipe_test.go +++ b/recipe_test.go @@ -90,8 +90,8 @@ func TestIngredient_Serialize(t *testing.T) { want string }{ {"name only", Ingredient{Name: "salt"}, "salt"}, - {"with amount", Ingredient{Name: "flour", Amount: &Amount{Factor:2, Unit: new("cups")}}, "2 cups flour"}, - {"amount no unit", Ingredient{Name: "eggs", Amount: &Amount{Factor:3, Unit: nil}}, "3 eggs"}, + {"with amount", Ingredient{Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cups")}}, "2 cups flour"}, + {"amount no unit", Ingredient{Name: "eggs", Amount: &Amount{Factor: 3, Unit: nil}}, "3 eggs"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -120,7 +120,7 @@ func TestIngredient_Scale(t *testing.T) { t.Parallel() t.Run("with amount", func(t *testing.T) { t.Parallel() - i := Ingredient{Name: "flour", Amount: &Amount{Factor:2, Unit: new("cups")}} + i := Ingredient{Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cups")}} i.Scale(0.5) if i.Amount.Factor != 1 { t.Errorf("Factor = %v, want 1", i.Amount.Factor) @@ -138,13 +138,13 @@ func TestIngredientGroup_Scale(t *testing.T) { g := IngredientGroup{ Title: "Sauce", Ingredients: []Ingredient{ - {Name: "tomato", Amount: &Amount{Factor:2, Unit: new("cups")}}, + {Name: "tomato", Amount: &Amount{Factor: 2, Unit: new("cups")}}, {Name: "basil"}, }, IngredientGroups: []IngredientGroup{ { Title: "Spices", - Ingredients: []Ingredient{{Name: "pepper", Amount: &Amount{Factor:1, Unit: new("tsp")}}}, + Ingredients: []Ingredient{{Name: "pepper", Amount: &Amount{Factor: 1, Unit: new("tsp")}}}, }, }, } @@ -164,12 +164,12 @@ func TestRecipe_Scale(t *testing.T) { {Factor: 4, Unit: new("servings")}, }, Ingredients: []Ingredient{ - {Name: "flour", Amount: &Amount{Factor:2, Unit: new("cups")}}, + {Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cups")}}, }, IngredientGroups: []IngredientGroup{ { Title: "Sauce", - Ingredients: []Ingredient{{Name: "tomato", Amount: &Amount{Factor:1, Unit: nil}}}, + Ingredients: []Ingredient{{Name: "tomato", Amount: &Amount{Factor: 1, Unit: nil}}}, }, }, } @@ -224,7 +224,7 @@ func TestRecipe_ScaleForYield(t *testing.T) { t.Parallel() r := &Recipe{ Yields: tt.yields, - Ingredients: []Ingredient{{Name: "x", Amount: &Amount{Factor:2, Unit: nil}}}, + Ingredients: []Ingredient{{Name: "x", Amount: &Amount{Factor: 2, Unit: nil}}}, } err := r.ScaleForYield(tt.desired) if tt.wantErr { From cdf9f879238359df5733e4b61e4b23d6feaf8ca4 Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:44:33 -0400 Subject: [PATCH 24/29] docs: add reflink limitation to HTML renderer --- render_html.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/render_html.go b/render_html.go index b5344f3..00c53dd 100644 --- a/render_html.go +++ b/render_html.go @@ -82,8 +82,14 @@ const htmlGroupsTmpl = `{{ range . -}} // starts at h2 for top-level groups and increments for each sub-level. // // WARNING: This method is a work-in-progress and not yet ready for production -// use. Known limitation: ingredient links reference raw .md file paths without -// any resolution or conversion to HTML-friendly URLs. +// use. Known limitations: +// - Ingredient links reference raw .md file paths without any resolution or +// conversion to HTML-friendly URLs. +// - CommonMark reference-style links (e.g. [text][ref] with a separate +// [ref]: url definition) only resolve correctly when the definition appears +// in the same section (Description or Instructions) as the usage. Definitions +// in one section are not visible when rendering the other, so cross-section +// reflinks are silently left unresolved. func (p *Parser) RenderHTML(r *Recipe, rounding int) string { funcs := htmlFuncMap(p, rounding) funcs["topGroups"] = func(groups []IngredientGroup) []htmlGroupCtx { From c38572607c434ad4ac3473d13d64e48db6a7ce15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 05:38:35 +0000 Subject: [PATCH 25/29] docs: write README as a valid RecipeMD recipe --- README.md | 324 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 323 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e57627d..752d470 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,325 @@ # recipemd-go -Go implementation of RecipeMD parser. +

+ Latest Release + GoDoc +

+ +A Go library for parsing, scaling, and rendering recipes in the [RecipeMD](https://recipemd.org) format. +This format builds on top of structured Markdown such that both humans and programs can digest it. + +*Go, parser, RecipeMD, Markdown* + +**1 Go module** + +--- + +- *1 pkg* `github.com/xcapaldi/recipemd-go` +- *1 pkg* `github.com/yuin/goldmark` Markdown parser + +## Examples + +- *1* `parse` — parse a recipe and output JSON +- *1* `scale` — scale a recipe by factor or target yield +- *1* `flatten` — inline all linked sub-recipe ingredients +- *1* `renderhtml` — render a recipe as an HTML `
` + +--- + +## Installation + +```bash +go get github.com/xcapaldi/recipemd-go +``` + +## What is RecipeMD? + +[RecipeMD](https://recipemd.org/specification.html) is a Markdown-based format for writing recipes. +A recipe file is plain Markdown with a defined structure: + +```markdown +# Carbonara + +A classic Roman pasta. + +*Italian, pasta* + +**2 servings** + +--- + +- *200 g* spaghetti +- *100 g* guanciale +- *2* eggs +- *50 g* Pecorino Romano + +## Sauce + +- *1 tbsp* black pepper, coarsely ground + +--- + +Boil pasta. Render guanciale. Whisk eggs with cheese and pepper. +Toss together off the heat. +``` + +The document has three sections divided by `---` thematic breaks: + +| Section | Content | +|---|---| +| Preamble | H1 title, optional description, optional *tags* (italic), optional **yields** (bold) | +| Ingredients | Unordered lists; H2+ headings introduce named ingredient groups | +| Instructions | Free-form Markdown text | + +Amounts are wrapped in emphasis: `*2 tbsp*`. Supported number formats: +integers (`3`), decimals (`1.5`), fractions (`1/2`), improper fractions +(`1 1/2`), and Unicode vulgar fractions (`½ ¼ ¾`). + +## Why RecipeMD? + +Shockingly there are multiple competing specifications in the world of plaintext cooking: + +- [Open Recipe Format](https://open-recipe-format.readthedocs.io/en/latest/) -- YAML +- [hrecipe](https://microformats.org/wiki/hrecipe) -- XML +- [schema.org Recipe](https://schema.org/Recipe) +- [Cooklang](https://cooklang.org) -- DSL + +Of the above, the only one that goes beyond a strictly formatted schema is Cooklang. +It is an extensive project with a CLI, web server and thoughtful features like inline ingredent declarations (these are extracted at render time). +Despite this, it lacks one fundamental advantage working in plain text -- it's not very readable in it's raw format. +This may seem like a minor inconvenience but it makes reading and writing recipes less accessible to humans and (increasingly important) AI agents. +The beauty of RecipeMD is that it's a ruleset on standard Markdown syntax with all of that format's inherent flexibility (images/tables/etc). +The nature of Cooklang's strict DSL enables some advanced features but I believe they can be acheived in RecipeMD through contextual analysis. + +As an example working in the raw formats, here is a moderately complex recipe in both: + +### RecipeMD + +```markdown +# Chicken Tikka Masala + +Tender charred chicken in a rich, spiced tomato-cream sauce. + +*Indian, chicken, curry* + +**4 servings** + +--- + +## Marinade + +- *700 g* boneless chicken thighs, cut into chunks +- *150 g* plain yogurt +- *2 tbsp* lemon juice +- *3 cloves* garlic, minced +- *1 tsp* fresh ginger, grated +- *1 tsp* garam masala +- *1 tsp* cumin +- *1/2 tsp* turmeric +- *1/2 tsp* cayenne pepper +- *1 tsp* salt + +## Sauce + +- *2 tbsp* ghee +- *1* large onion, finely diced +- *4 cloves* garlic, minced +- *1 tbsp* fresh ginger, grated +- *1 tbsp* garam masala +- *1 tsp* cumin +- *1 tsp* coriander +- *1/2 tsp* turmeric +- *1/2 tsp* cayenne pepper +- *400 g* canned crushed tomatoes +- *200 ml* heavy cream +- *1 tsp* salt + +## Serving + +- *300 g* basmati rice +- *1* handful fresh cilantro, roughly chopped + +--- + +Combine chicken with all marinade ingredients in a bowl. Cover and refrigerate for at least 1 hour, ideally overnight. + +Preheat a broiler or grill to high. Thread chicken onto skewers and cook for 10–12 minutes, turning once, until charred in spots and just cooked through. Set aside. + +Cook rice according to package directions. + +Heat ghee in a large saucepan over medium heat. Add onion and cook for 8–10 minutes until deep golden. Add garlic and ginger; cook 2 minutes until fragrant. + +Stir in garam masala, cumin, coriander, turmeric, and cayenne. Toast 1 minute. Add crushed tomatoes and simmer uncovered for 15 minutes, stirring occasionally, until sauce thickens. + +Pour in cream and stir to combine. Add the grilled chicken and simmer 5 minutes to meld flavors. Season with salt. + +Serve over rice, garnished with fresh cilantro. +``` + +### Cooklang + +``` +--- +title: Chicken Tikka Masala +description: Tender charred chicken in a rich, spiced tomato-cream sauce. +tags: Indian, chicken, curry +servings: 4 +--- + +== Marinate == + +Combine @chicken thighs{700%g}(boneless, cut into chunks) with @plain yogurt{150%g}, +@lemon juice{2%tbsp}, @garlic{3%cloves}(minced), @fresh ginger{1%tsp}(grated), +@garam masala{1%tsp}, @cumin{1%tsp}, @turmeric{1/2%tsp}, @cayenne pepper{1/2%tsp}, +and @salt{1%tsp} in a #large bowl{}. + +Cover and refrigerate for ~marinating{1%hour} (or overnight for best results). + +== Grill Chicken == + +Preheat a #grill or broiler to high. Thread chicken onto #skewers and cook for +~grilling{12%minutes}, turning once, until charred in spots and cooked through. Set aside. + +== Cook Rice == + +Cook @basmati rice{300%g} in a #saucepan{} according to package directions (~{18%minutes}). + +== Make Sauce == + +Heat @ghee{2%tbsp} in a #large saucepan{} over medium heat. +Add @onion{1%large}(finely diced) and cook for ~onion{10%minutes} until deep golden. + +Add @garlic{4%cloves}(minced) and @fresh ginger{1%tbsp}(grated); cook ~{2%minutes} until fragrant. + +Stir in @garam masala{1%tbsp}, @cumin{1%tsp}, @coriander{1%tsp}, @turmeric{1/2%tsp}, and @cayenne pepper{1/2%tsp}. +Toast ~{1%minute}, then add @crushed tomatoes{400%g} and simmer uncovered for ~{15%minutes}, +stirring occasionally, until thickened. + +Pour in @heavy cream{200%ml} and stir to combine. +Add grilled chicken and simmer ~finishing{5%minutes} to meld flavors. +Season with @salt{1%tsp}. + +== Serve == + +Plate over rice and garnish with @fresh cilantro{1%handful}(roughly chopped). +``` + +## Library Usage + +### Parsing + +`Parse` accepts any `io.Reader`: + +```go +import recipemd "github.com/xcapaldi/recipemd-go" + +p := recipemd.NewParser() + +f, _ := os.Open("carbonara.md") +defer f.Close() + +recipe, err := p.Parse(f) +if err != nil { + log.Fatal(err) +} + +fmt.Println(recipe.Title) // "Carbonara" +fmt.Println(recipe.Tags) // ["Italian", "pasta"] +fmt.Println(recipe.Yields) // [{Factor:2 Unit:"servings"}] +``` + +Parse collects all structural and value-level problems via `errors.Join`, +so a single call surfaces every issue at once. Individual errors carry line +and column information: + +```go +if parseError, ok := errors.AsType[*recipemd.ParseError](err); ok { + fmt.Printf("line %d, col %d: %s\n", parseError.Line, parseError.Column, parseError.Message) +} +``` + +#### Parser options + +While not part of the official spec, this implementation supports some parser options to make the format more ergonomic. +In particular `WithFrontmatter` will strip YAML/TOML frontmatter before parsing the remaining content as spec-complient RecipeMD. +This is particularly useful if you combine this format with another note management system (like [Denote](https://protesilaos.com/emacs/denote)) that relies on frontmatter. +In addition `WithGithubFormattedMarkdown` enables support for GFM features like tables, autolinks, task lists (in ingredients as well) and strikethrough. + +```go +parser := recipemd.NewParser( + recipemd.WithFrontmatter(), // strip YAML/TOML front matter + recipemd.WithGithubFormattedMarkdown(), // enable GFM (tables, task lists, …) +) +``` + +### Scaling + +```go +// Multiply all amounts by a factor. +recipe.Scale(2) + +// Scale to a specific yield. +desired, _ := recipemd.ParseAmountString("6 servings") +if err := recipe.ScaleForYield(desired); err != nil { + log.Fatal(err) +} +``` + +### Rendering + +```go +// RecipeMD Markdown (amounts rounded to 2 decimal places) +fmt.Print(p.RenderMarkdown(recipe, 2)) + +// Compact JSON +data, err := p.RenderJSON(recipe) + +// HTML
element (amounts rounded to 3 decimal places) (still WIP) +fmt.Println(p.RenderHTML(recipe, 3)) +``` + +## Examples + +The `examples/` directory contains small, self-contained programs that +demonstrate common use cases: + +| Example | Description | +|---|---| +| [`examples/parse`](examples/parse) | Parse a recipe and write compact JSON to stdout | +| [`examples/scale`](examples/scale) | Scale a recipe by factor or target yield, write RecipeMD to stdout | +| [`examples/flatten`](examples/flatten) | Inline all linked sub-recipes, write RecipeMD to stdout | +| [`examples/renderhtml`](examples/renderhtml) | Flatten and render as an HTML `
` | + +Run any example directly: + +```bash +go run ./examples/parse carbonara.md +go run ./examples/scale carbonara.md "4 servings" +go run ./examples/flatten main_dish.md +go run ./examples/renderhtml carbonara.md +``` + +## Running tests + +```bash +go test ./... +``` + +The suite includes the [RecipeMD canonical test suite](https://github.com/tstehr/RecipeMD/tree/master/test), +extensive golden tests and unit tests. + +## Performance + +This library is quite performant compared to the reference implementation but not due to any clever optimizations. +Go is simply much faster and this translates directly. +In practice, the difference should be almost unnoticeable for any level of personal use. +Nevertheless, here is a very crude benchmark parsing and scaling a short recipe: + +| Implementation | Command | Runs | Total | Avg/run | Speedup | +|----------------|---------|-----:|------:|--------:|--------:| +| Python recipemd 5.0.0 | `recipemd -y "10 ml" testdata/canonical/recipe.md` | 100 | 13,956 ms | 139.5 ms | 1x | +| Go (examples/scale) | `scale testdata/canonical/recipe.md "10 ml"` | 100 | 434 ms | 4.3 ms | **32x** | + +## License + +MIT — see [LICENSE](LICENSE). From d088df1c7a9e745310335fe0024d9ef8e4ad9826 Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:57:07 -0400 Subject: [PATCH 26/29] docs: fix badges in README --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 752d470..c21ca8c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ # recipemd-go -

- Latest Release - GoDoc -

+[![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. From 187b63760e62e156e92e491866e77c076a3e386b Mon Sep 17 00:00:00 2001 From: Xavier Capaldi <38892330+xcapaldi@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:51:47 -0400 Subject: [PATCH 27/29] docs: agent skill to work with recipemd format --- .agents/skills/recipemd-format/SKILL.md | 155 ++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 .agents/skills/recipemd-format/SKILL.md diff --git a/.agents/skills/recipemd-format/SKILL.md b/.agents/skills/recipemd-format/SKILL.md new file mode 100644 index 0000000..2e2a093 --- /dev/null +++ b/.agents/skills/recipemd-format/SKILL.md @@ -0,0 +1,155 @@ +--- +name: recipemd-format +description: Read, write, and edit recipes in RecipeMD format. Use when working with .md recipe files that follow the RecipeMD specification or when creating new recipes in structured Markdown. +license: MIT +--- + +# RecipeMD Format + +[RecipeMD](https://recipemd.org/specification.html) is a Markdown-based format for structured recipes. +A recipe is a valid CommonMark document with a defined three-section layout. + +## When to use + +- Editing or reviewing `.md` files that contain recipes +- Creating new recipes in plain Markdown +- Converting recipes from other formats to RecipeMD + +## Document structure + +Three sections separated by `---` (thematic breaks): + +``` +# Title <-- Preamble (required) +... +--- +- ingredients <-- Ingredients (required) +--- +instructions <-- Instructions (optional) +``` + +## Preamble + +The preamble is everything before the first `---`. It must start with an H1 title. + +| Element | Format | Required | +|---------|--------|----------| +| Title | `# Recipe Name` (H1 heading) | Yes | +| Description | Markdown paragraphs after the title | No | +| Tags | `*tag1, tag2, tag3*` (italic paragraph) | No | +| Yields | `**4 servings**` (bold paragraph) | No | + +Tags and yields each occupy their own paragraph. Multiple yields are comma-separated within one bold span: `**4 servings, 800 ml**`. + +## Ingredients + +Everything between the first and second `---`. Ingredients are unordered list items. + +### Basic ingredient + +```markdown +- ingredient name +``` + +### With amount + +Wrap the amount in emphasis (single `*`): + +```markdown +- *200 g* spaghetti +- *2* eggs +- *1 tbsp* olive oil +``` + +The amount is always the first element in the list item. The format is `*[number][unit]*` where unit is optional. + +### Ingredient groups + +Use H2 or deeper headings to create named groups: + +```markdown +## Marinade + +- *2 tbsp* soy sauce +- *1 tbsp* sesame oil + +## Stir Fry + +- *200 g* tofu +- *1* bell pepper, sliced +``` + +Groups can nest (H3 inside H2, etc). + +### Linked ingredients + +Link an ingredient name to another recipe file: + +```markdown +- *200 ml* [tomato sauce](./tomato-sauce.md) +``` + +## Instructions + +Everything after the second `---`. Free-form Markdown -- any valid CommonMark content (paragraphs, lists, images, tables, etc). + +The second `---` and instructions section are optional. A recipe with only a preamble and ingredients is valid. + +## Amount number formats + +Amounts support several number formats: + +| Format | Examples | +|--------|----------| +| Integer | `3`, `12` | +| Decimal | `1.5`, `0.75` | +| Decimal (comma) | `1,5` | +| Fraction | `1/2`, `3/4` | +| Mixed number | `1 1/2`, `2 3/4` | +| Vulgar fraction | `½`, `¼`, `¾`, `⅓`, `⅔` | + +## Complete example + +```markdown +# Carbonara + +A classic Roman pasta. + +*Italian, pasta* + +**2 servings** + +--- + +- *200 g* spaghetti +- *100 g* guanciale +- *2* eggs +- *50 g* Pecorino Romano + +## Sauce + +- *1 tbsp* black pepper, coarsely ground + +--- + +Boil pasta. Render guanciale. Whisk eggs with cheese and pepper. +Toss together off the heat. +``` + +## Common mistakes + +- Missing `---` between sections (preamble, ingredients, instructions must be separated by thematic breaks) +- Using `**bold**` for amounts instead of `*italic*` (amounts use single emphasis) +- Placing amounts outside emphasis markers: `200 g spaghetti` instead of `*200 g* spaghetti` +- Using ordered lists for ingredients (must be unordered `- `) +- Forgetting that tags use italic (`*...*`) and yields use bold (`**...**`) + +## Frontmatter (non-standard extension) + +Some tools support YAML (`---`) or TOML (`+++`) frontmatter before the recipe content. +This is not part of the official spec but my be useful when combining RecipeMD with note management systems. +Parsers that support this strip the frontmatter before applying the standard rules. + +## Reference + +Full specification: https://recipemd.org/specification.html From 2a1fe54c4f0c879b7bcfb32d85cbfc721fc8bc78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 05:52:12 +0000 Subject: [PATCH 28/29] Add InlineIngredients function for amount injection into instructions Adds InlineIngredients(r *Recipe, rounding int) *string which returns a copy of the instructions with each ingredient's amount injected before its name wherever it appears in the text. For example, "*1/2 tsp* cinnamon" in the ingredient list causes "add cinnamon and mix" to become "add 0.5 tsp cinnamon and mix". Matching is case-insensitive and respects word boundaries so partial words (e.g. "salt" inside "salted") are not affected. A single combined regex pass handles all ingredients at once, ensuring that a longer name like "brown sugar" is always matched before the shorter "sugar" and that the shorter name is not spuriously injected into already-replaced text. All ingredients nested inside IngredientGroups are also considered via Recipe.LeafIngredients(). https://claude.ai/code/session_012wP9EExNKgdFcpp9JJELGC --- inline_ingredients.go | 83 +++++++++++++++++ inline_ingredients_test.go | 184 +++++++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 inline_ingredients.go create mode 100644 inline_ingredients_test.go diff --git a/inline_ingredients.go b/inline_ingredients.go new file mode 100644 index 0000000..0c2b45b --- /dev/null +++ b/inline_ingredients.go @@ -0,0 +1,83 @@ +package recipemd + +import ( + "regexp" + "sort" + "strings" +) + +// InlineIngredients returns a copy of the recipe's Instructions with ingredient +// amounts injected before each matching ingredient name. +// +// For each ingredient that has an amount, every occurrence of the ingredient's +// name in the instructions (matched case-insensitively at word boundaries) is +// replaced with " ". For example, if an ingredient is +// "*1/2 tsp* cinnamon" and the instructions contain "add cinnamon and mix", +// the result is "add 1/2 tsp cinnamon and mix". The original casing of the +// matched word(s) in the instructions text is preserved. +// +// Multi-word ingredient names (e.g. "olive oil") are matched as complete +// phrases. When one ingredient name is a substring of another (e.g. "sugar" +// and "brown sugar"), the longer name is matched and replaced first so that +// the shorter name is not spuriously injected inside the already-replaced text. +// +// Amounts are formatted with the given rounding via [Amount.Serialize]. +// Ingredients without an amount are not injected. +// +// Returns nil when r.Instructions is nil. +func InlineIngredients(r *Recipe, rounding int) *string { + if r.Instructions == nil { + return nil + } + + type entry struct { + name string + amount string + } + + ingredients := r.LeafIngredients() + entries := make([]entry, 0, len(ingredients)) + for _, ing := range ingredients { + if ing.Amount == nil { + continue + } + entries = append(entries, entry{ + name: ing.Name, + amount: ing.Amount.Serialize(rounding), + }) + } + + if len(entries) == 0 { + s := *r.Instructions + return &s + } + + // Sort longest first so that "brown sugar" is listed before "sugar" in the + // alternation, ensuring the longer phrase wins when both could match. + sort.Slice(entries, func(i, j int) bool { + return len(entries[i].name) > len(entries[j].name) + }) + + // Build a single combined regex so all replacements happen in one pass. + // A single pass prevents a short ingredient (e.g. "sugar") from matching + // inside text that was already injected for a longer one ("brown sugar"). + alts := make([]string, len(entries)) + for i, e := range entries { + alts[i] = regexp.QuoteMeta(e.name) + } + pattern := `(?i)\b(?:` + strings.Join(alts, "|") + `)\b` + re := regexp.MustCompile(pattern) + + // Build a lookup map keyed by lowercased ingredient name. + amountFor := make(map[string]string, len(entries)) + for _, e := range entries { + amountFor[strings.ToLower(e.name)] = e.amount + } + + result := re.ReplaceAllStringFunc(*r.Instructions, func(match string) string { + amount := amountFor[strings.ToLower(match)] + return amount + " " + match + }) + + return &result +} diff --git a/inline_ingredients_test.go b/inline_ingredients_test.go new file mode 100644 index 0000000..7708fdd --- /dev/null +++ b/inline_ingredients_test.go @@ -0,0 +1,184 @@ +package recipemd + +import ( + "testing" +) + +func TestInlineIngredients(t *testing.T) { + t.Parallel() + + t.Run("nil instructions returns nil", func(t *testing.T) { + t.Parallel() + r := &Recipe{ + Instructions: nil, + Ingredients: []Ingredient{{Name: "salt", Amount: &Amount{Factor: 1}}}, + } + if got := InlineIngredients(r, 3); got != nil { + t.Errorf("expected nil, got %q", *got) + } + }) + + t.Run("ingredient without amount is not injected", func(t *testing.T) { + t.Parallel() + instructions := "add salt and stir" + r := &Recipe{ + Instructions: &instructions, + Ingredients: []Ingredient{{Name: "salt"}}, + IngredientGroups: []IngredientGroup{}, + } + got := InlineIngredients(r, 3) + if got == nil { + t.Fatal("expected non-nil result") + } + if *got != instructions { + t.Errorf("expected %q, got %q", instructions, *got) + } + }) + + t.Run("single ingredient injected", func(t *testing.T) { + t.Parallel() + instructions := "then add cinnamon and mix" + r := &Recipe{ + Instructions: &instructions, + Ingredients: []Ingredient{ + {Name: "cinnamon", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := InlineIngredients(r, 3) + if got == nil { + t.Fatal("expected non-nil result") + } + want := "then add 0.5 tsp cinnamon and mix" + if *got != want { + t.Errorf("expected %q, got %q", want, *got) + } + }) + + t.Run("case-insensitive match preserves original casing", func(t *testing.T) { + t.Parallel() + instructions := "Add Cinnamon to the bowl" + r := &Recipe{ + Instructions: &instructions, + Ingredients: []Ingredient{ + {Name: "cinnamon", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := InlineIngredients(r, 3) + if got == nil { + t.Fatal("expected non-nil result") + } + want := "Add 0.5 tsp Cinnamon to the bowl" + if *got != want { + t.Errorf("expected %q, got %q", want, *got) + } + }) + + t.Run("multi-word ingredient matched before partial", func(t *testing.T) { + t.Parallel() + instructions := "stir in brown sugar then add more sugar" + r := &Recipe{ + Instructions: &instructions, + Ingredients: []Ingredient{ + {Name: "brown sugar", Amount: &Amount{Factor: 1, Unit: new("cup")}}, + {Name: "sugar", Amount: &Amount{Factor: 2, Unit: new("tsp")}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := InlineIngredients(r, 3) + if got == nil { + t.Fatal("expected non-nil result") + } + want := "stir in 1 cup brown sugar then add more 2 tsp sugar" + if *got != want { + t.Errorf("expected %q, got %q", want, *got) + } + }) + + t.Run("multiple occurrences are all replaced", func(t *testing.T) { + t.Parallel() + instructions := "add flour, then more flour" + r := &Recipe{ + Instructions: &instructions, + Ingredients: []Ingredient{ + {Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cup")}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := InlineIngredients(r, 3) + if got == nil { + t.Fatal("expected non-nil result") + } + want := "add 2 cup flour, then more 2 cup flour" + if *got != want { + t.Errorf("expected %q, got %q", want, *got) + } + }) + + t.Run("ingredient in group is also considered", func(t *testing.T) { + t.Parallel() + instructions := "fold in vanilla" + r := &Recipe{ + Instructions: &instructions, + Ingredients: []Ingredient{}, + IngredientGroups: []IngredientGroup{ + { + Title: "Flavouring", + Ingredients: []Ingredient{ + {Name: "vanilla", Amount: &Amount{Factor: 1, Unit: new("tsp")}}, + }, + IngredientGroups: []IngredientGroup{}, + }, + }, + } + got := InlineIngredients(r, 3) + if got == nil { + t.Fatal("expected non-nil result") + } + want := "fold in 1 tsp vanilla" + if *got != want { + t.Errorf("expected %q, got %q", want, *got) + } + }) + + t.Run("word boundary prevents partial match", func(t *testing.T) { + t.Parallel() + instructions := "add salted butter" + r := &Recipe{ + Instructions: &instructions, + Ingredients: []Ingredient{ + {Name: "salt", Amount: &Amount{Factor: 1, Unit: new("tsp")}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := InlineIngredients(r, 3) + if got == nil { + t.Fatal("expected non-nil result") + } + // "salt" must NOT match inside "salted" + if *got != instructions { + t.Errorf("expected %q unchanged, got %q", instructions, *got) + } + }) + + t.Run("unitless amount injected", func(t *testing.T) { + t.Parallel() + instructions := "crack eggs into bowl" + r := &Recipe{ + Instructions: &instructions, + Ingredients: []Ingredient{ + {Name: "eggs", Amount: &Amount{Factor: 3}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := InlineIngredients(r, 3) + if got == nil { + t.Fatal("expected non-nil result") + } + want := "crack 3 eggs into bowl" + if *got != want { + t.Errorf("expected %q, got %q", want, *got) + } + }) +} From bb7b4dc9a6daa423b5a5aedc3fef780f6ca9504c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 04:02:46 +0000 Subject: [PATCH 29/29] Rework inline ingredients as a parser option with render-time injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the standalone InlineIngredients function in favour of a parser-level option: WithInlineIngredients(...InlineIngredientsOption). Injection now happens automatically in RenderMarkdown and RenderHTML. Sub-options: • WithInlineFormat(InlineIngredientsBefore|InlineIngredientsAfter) Controls whether the amount appears before the name ("3 eggs") or after it in parentheses ("eggs (3)"). Default is InlineIngredientsBefore. • WithInlineHTMLHover() For RenderHTML only: places the amount in the span's title attribute so it appears as a CSS/browser tooltip rather than inline text. Each matched name is wrapped in . • WithInlinePrepSeparators(seps ...string) Splits ingredient names on the first matching separator to extract a preparation note. "garlic, minced" (sep=",") → base="garlic", prep="minced". Paired closers are stripped automatically ("(" strips trailing ")"). Prep is included in the rendered amount: InlineIngredientsBefore → "3 cloves minced garlic" InlineIngredientsAfter → "garlic (3 cloves minced)" Hover → title="3 cloves minced" Pluralization (built-in, no external dependency): Matching is case-insensitive and covers both singular and plural forms so that ingredient "egg" also matches "eggs" in the instructions. wordForms() applies standard English suffix rules (y→ies, f→ves, ch/sh/x/z→es, +s) with an irregulars table for common food vocabulary (potato/potatoes, leaf/leaves, knife/knives, etc.) and invariant uncountable nouns (flour, garlic, rice, sugar, …). A single-pass combined regex ensures that longer names (e.g. "brown sugar") are always matched and replaced before shorter overlapping names ("sugar"), preventing double-injection into already-replaced text. HTML rendering post-processes the goldmark HTML output with a lightweight text-node splitter that leaves tag markup untouched, so no unsafe HTML is injected into the markdown source. https://claude.ai/code/session_012wP9EExNKgdFcpp9JJELGC --- inline_ingredients.go | 466 ++++++++++++++++++++++++++++---- inline_ingredients_test.go | 539 ++++++++++++++++++++++++++++--------- parser.go | 10 +- render_html.go | 29 +- render_markdown.go | 12 + 5 files changed, 871 insertions(+), 185 deletions(-) diff --git a/inline_ingredients.go b/inline_ingredients.go index 0c2b45b..aeddc5e 100644 --- a/inline_ingredients.go +++ b/inline_ingredients.go @@ -1,83 +1,451 @@ package recipemd import ( + "html" "regexp" "sort" "strings" + "unicode" ) -// InlineIngredients returns a copy of the recipe's Instructions with ingredient -// amounts injected before each matching ingredient name. -// -// For each ingredient that has an amount, every occurrence of the ingredient's -// name in the instructions (matched case-insensitively at word boundaries) is -// replaced with " ". For example, if an ingredient is -// "*1/2 tsp* cinnamon" and the instructions contain "add cinnamon and mix", -// the result is "add 1/2 tsp cinnamon and mix". The original casing of the -// matched word(s) in the instructions text is preserved. +// InlineIngredientsFormat controls where the injected amount appears relative +// to the ingredient name in the rendered output. +type InlineIngredientsFormat int + +const ( + // InlineIngredientsBefore places the formatted amount before the matched + // name: "add 3 eggs to the bowl". + InlineIngredientsBefore InlineIngredientsFormat = iota + + // InlineIngredientsAfter places the formatted amount in parentheses after + // the matched name: "add eggs (3) to the bowl". + InlineIngredientsAfter +) + +// InlineIngredientsOption is a functional option for configuring inline +// ingredient injection. Options are passed to [WithInlineIngredients]. +type InlineIngredientsOption func(*inlineIngredientsConfig) + +type inlineIngredientsConfig struct { + format InlineIngredientsFormat + htmlHover bool + prepSeparators []string +} + +// WithInlineFormat sets how the injected amount is positioned relative to the +// ingredient name. The default is [InlineIngredientsBefore]. +func WithInlineFormat(f InlineIngredientsFormat) InlineIngredientsOption { + return func(c *inlineIngredientsConfig) { c.format = f } +} + +// WithInlineHTMLHover renders the amount as a tooltip (HTML title attribute) +// rather than inline visible text when using [Parser.RenderHTML]. The +// ingredient name receives a "recipemd-inline-ingredient" span whose title +// attribute holds the amount (and prep note when applicable). The display +// format option is ignored when hover is enabled. +func WithInlineHTMLHover() InlineIngredientsOption { + return func(c *inlineIngredientsConfig) { c.htmlHover = true } +} + +// WithInlinePrepSeparators registers one or more separator strings that split +// an ingredient name into a base name and a preparation note. // -// Multi-word ingredient names (e.g. "olive oil") are matched as complete -// phrases. When one ingredient name is a substring of another (e.g. "sugar" -// and "brown sugar"), the longer name is matched and replaced first so that -// the shorter name is not spuriously injected inside the already-replaced text. +// For example, with separator "," the ingredient "3 cloves garlic, chopped" +// yields base="garlic" and prep="chopped". With separator "(" the ingredient +// "3 cloves garlic (chopped)" yields base="garlic" and prep="chopped" — the +// paired closing ")" is stripped automatically. // -// Amounts are formatted with the given rounding via [Amount.Serialize]. -// Ingredients without an amount are not injected. +// Only the first matching separator is applied. The base name is used for +// matching in the instructions; the prep note is included in the injected text. // -// Returns nil when r.Instructions is nil. -func InlineIngredients(r *Recipe, rounding int) *string { - if r.Instructions == nil { - return nil +// - [InlineIngredientsBefore]: "3 cloves chopped garlic" +// - [InlineIngredientsAfter]: "garlic (3 cloves chopped)" +// - Hover: title="3 cloves chopped", visible text="garlic" +func WithInlinePrepSeparators(seps ...string) InlineIngredientsOption { + return func(c *inlineIngredientsConfig) { + c.prepSeparators = append(c.prepSeparators, seps...) } +} - type entry struct { - name string - amount string +// WithInlineIngredients returns an [Option] that enables inline ingredient +// injection at render time. Every occurrence of an ingredient name in the +// instructions is annotated with its amount by [Parser.RenderMarkdown] and +// [Parser.RenderHTML]. +// +// Matching is case-insensitive and respects word boundaries so partial matches +// (e.g. "salt" inside "salted") are avoided. Both singular and plural forms +// of each ingredient name are matched (e.g. ingredient "egg" also matches +// "eggs" in the instructions). Multi-word names are matched as complete phrases +// and take priority over any shorter overlapping names. +// +// Sub-options control the display format, HTML hover behaviour, and optional +// preparation-note separators; see [WithInlineFormat], [WithInlineHTMLHover], +// and [WithInlinePrepSeparators]. +// +// p := recipemd.NewParser( +// recipemd.WithInlineIngredients( +// recipemd.WithInlineFormat(recipemd.InlineIngredientsBefore), +// recipemd.WithInlinePrepSeparators(",", "("), +// ), +// ) +func WithInlineIngredients(opts ...InlineIngredientsOption) Option { + return func(p *Parser) { + cfg := &inlineIngredientsConfig{} + for _, o := range opts { + o(cfg) + } + p.inlineIngredients = true + p.inlineIngredientsCfg = *cfg } +} +// --------------------------------------------------------------------------- +// Internal injector +// --------------------------------------------------------------------------- + +type inlineEntry struct { + amount string // formatted amount string, e.g. "3 cups" + prep string // preparation note stripped from name, e.g. "chopped" +} + +type inlineInjector struct { + cfg inlineIngredientsConfig + re *regexp.Regexp + lookup map[string]*inlineEntry // lowercase match key → entry +} + +// buildInjector constructs an inlineInjector for the given recipe. Returns nil +// when no ingredients with amounts are found. +func buildInjector(r *Recipe, cfg inlineIngredientsConfig, rounding int) *inlineInjector { ingredients := r.LeafIngredients() - entries := make([]entry, 0, len(ingredients)) + + lookup := make(map[string]*inlineEntry, len(ingredients)*2) + var alts []string + for _, ing := range ingredients { if ing.Amount == nil { continue } - entries = append(entries, entry{ - name: ing.Name, + + name := ing.Name + prep := "" + + // Split out preparation note using configured separators. + for _, sep := range cfg.prepSeparators { + if idx := strings.Index(name, sep); idx >= 0 { + raw := strings.TrimSpace(name[idx+len(sep):]) + // Strip the paired closing bracket when separator is an opener. + raw = stripClosingBracket(raw, sep) + prep = strings.TrimSpace(raw) + name = strings.TrimSpace(name[:idx]) + break + } + } + + entry := &inlineEntry{ amount: ing.Amount.Serialize(rounding), - }) + prep: prep, + } + + // Register all word forms (singular + plural) so that "egg" matches + // "eggs" in the instructions and vice-versa. + for _, form := range wordForms(name) { + if _, exists := lookup[form]; !exists { + lookup[form] = entry + alts = append(alts, regexp.QuoteMeta(form)) + } + } } - if len(entries) == 0 { - s := *r.Instructions - return &s + if len(alts) == 0 { + return nil } - // Sort longest first so that "brown sugar" is listed before "sugar" in the - // alternation, ensuring the longer phrase wins when both could match. - sort.Slice(entries, func(i, j int) bool { - return len(entries[i].name) > len(entries[j].name) - }) + // Sort longest alternatives first so that multi-word names (e.g. "brown + // sugar") take precedence over shorter sub-names (e.g. "sugar") within + // the combined alternation. + sort.Slice(alts, func(i, j int) bool { return len(alts[i]) > len(alts[j]) }) - // Build a single combined regex so all replacements happen in one pass. - // A single pass prevents a short ingredient (e.g. "sugar") from matching - // inside text that was already injected for a longer one ("brown sugar"). - alts := make([]string, len(entries)) - for i, e := range entries { - alts[i] = regexp.QuoteMeta(e.name) - } pattern := `(?i)\b(?:` + strings.Join(alts, "|") + `)\b` re := regexp.MustCompile(pattern) - // Build a lookup map keyed by lowercased ingredient name. - amountFor := make(map[string]string, len(entries)) - for _, e := range entries { - amountFor[strings.ToLower(e.name)] = e.amount + return &inlineInjector{cfg: cfg, re: re, lookup: lookup} +} + +// stripClosingBracket removes the paired closing bracket from s when sep is +// an opening bracket character, e.g. "(" → strip trailing ")". +func stripClosingBracket(s, sep string) string { + pairs := map[string]byte{"(": ')', "[": ']', "{": '}'} + if close, ok := pairs[sep]; ok { + s = strings.TrimRight(s, string(close)) } + return s +} + +// injectText applies inline injection to a plain-text (markdown) string. +// The result can be used directly in markdown output. +func (inj *inlineInjector) injectText(text string) string { + return inj.re.ReplaceAllStringFunc(text, func(match string) string { + e := inj.lookup[strings.ToLower(match)] + return inj.formatText(match, e) + }) +} - result := re.ReplaceAllStringFunc(*r.Instructions, func(match string) string { - amount := amountFor[strings.ToLower(match)] - return amount + " " + match +// injectHTML applies inline injection to an HTML string produced by goldmark, +// touching only text nodes (content between tags) and producing span elements. +func (inj *inlineInjector) injectHTML(htmlStr string) string { + return injectHTMLTextNodes(htmlStr, inj.re, func(match string) string { + e := inj.lookup[strings.ToLower(match)] + if inj.cfg.htmlHover { + return inj.formatHTMLHover(match, e) + } + return inj.formatHTMLSpan(match, e) }) +} + +// formatText builds the plain-text replacement for match. +func (inj *inlineInjector) formatText(match string, e *inlineEntry) string { + if e.prep != "" { + switch inj.cfg.format { + case InlineIngredientsBefore: + return e.amount + " " + e.prep + " " + match + case InlineIngredientsAfter: + return match + " (" + e.amount + " " + e.prep + ")" + } + } + switch inj.cfg.format { + case InlineIngredientsBefore: + return e.amount + " " + match + case InlineIngredientsAfter: + return match + " (" + e.amount + ")" + } + return match +} + +// formatHTMLSpan wraps the replacement in a recipemd-inline-ingredient span. +// match is raw text content from the HTML (already entity-encoded for simple +// ASCII names) and is reused verbatim to preserve entity encoding. +func (inj *inlineInjector) formatHTMLSpan(match string, e *inlineEntry) string { + var inner string + if e.prep != "" { + switch inj.cfg.format { + case InlineIngredientsBefore: + inner = html.EscapeString(e.amount+" "+e.prep) + " " + match + case InlineIngredientsAfter: + inner = match + " (" + html.EscapeString(e.amount+" "+e.prep) + ")" + } + } else { + switch inj.cfg.format { + case InlineIngredientsBefore: + inner = html.EscapeString(e.amount) + " " + match + case InlineIngredientsAfter: + inner = match + " (" + html.EscapeString(e.amount) + ")" + } + } + return `` + inner + `` +} + +// formatHTMLHover wraps match in a span whose title attribute holds the amount +// (and prep note). The visible text is the matched name only. +func (inj *inlineInjector) formatHTMLHover(match string, e *inlineEntry) string { + title := e.amount + if e.prep != "" { + title += " " + e.prep + } + return `` + match + `` +} + +// injectHTMLTextNodes runs replacer over text segments of an HTML string, +// leaving all tag markup untouched. It does not parse HTML fully; it splits +// on '<' and '>' which is sufficient for well-formed goldmark output. +func injectHTMLTextNodes(htmlStr string, re *regexp.Regexp, replacer func(string) string) string { + var b strings.Builder + b.Grow(len(htmlStr)) + for len(htmlStr) > 0 { + lt := strings.IndexByte(htmlStr, '<') + if lt < 0 { + b.WriteString(re.ReplaceAllStringFunc(htmlStr, replacer)) + break + } + if lt > 0 { + b.WriteString(re.ReplaceAllStringFunc(htmlStr[:lt], replacer)) + } + gt := strings.IndexByte(htmlStr[lt:], '>') + if gt < 0 { + b.WriteString(htmlStr[lt:]) + break + } + b.WriteString(htmlStr[lt : lt+gt+1]) + htmlStr = htmlStr[lt+gt+1:] + } + return b.String() +} + +// --------------------------------------------------------------------------- +// Pluralization helpers +// --------------------------------------------------------------------------- + +// wordForms returns the unique lowercase word forms (singular and plural) that +// should be matched for an ingredient name. For multi-word names each form is +// derived by transforming the last word only. +// +// The implementation covers standard English pluralization rules with a +// focused irregulars table for common food vocabulary, inspired by the +// go-pluralize package (github.com/gertd/go-pluralize). +func wordForms(word string) []string { + lower := strings.ToLower(word) + + // For multi-word names derive forms from the last word only. + parts := strings.Fields(lower) + if len(parts) > 1 { + prefix := strings.Join(parts[:len(parts)-1], " ") + " " + last := parts[len(parts)-1] + var forms []string + for _, f := range singleWordForms(last) { + forms = append(forms, prefix+f) + } + return dedup(forms) + } + + return singleWordForms(lower) +} + +func singleWordForms(lower string) []string { + sg := toSingular(lower) + pl := toPlural(lower) + if sg == pl { + return []string{sg} + } + return dedup([]string{sg, pl}) +} + +func dedup(ss []string) []string { + seen := make(map[string]bool, len(ss)) + out := ss[:0] + for _, s := range ss { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} + +// toPlural converts a lowercase singular word to its plural form. +func toPlural(word string) string { + if pl, ok := irregularsToPlural[word]; ok { + return pl + } + if _, ok := irregularsToSingular[word]; ok { + return word // already plural + } + switch { + case hasSuffix(word, "ies"): // already plural + return word + case hasSuffix(word, "y") && len(word) > 1 && isConsonant(rune(word[len(word)-2])): + return word[:len(word)-1] + "ies" + case hasSuffix(word, "fe"): + return word[:len(word)-2] + "ves" + case hasSuffix(word, "f") && !hasSuffix(word, "ff"): + return word[:len(word)-1] + "ves" + case hasSuffixAny(word, "ch", "sh", "x", "z", "s"): + return word + "es" + default: + return word + "s" + } +} + +// toSingular converts a lowercase plural word to its singular form. +func toSingular(word string) string { + if sg, ok := irregularsToSingular[word]; ok { + return sg + } + if _, ok := irregularsToPlural[word]; ok { + return word // already singular + } + switch { + case hasSuffix(word, "ies") && len(word) > 3: + return word[:len(word)-3] + "y" + case hasSuffix(word, "ves") && len(word) > 3: + // knives → knife, leaves → leaf: fall back to removing -ves and adding -fe/-f. + // Use the irregulars table for exact known cases; approximation otherwise. + return word[:len(word)-3] + "f" + case hasSuffixAny(word, "shes", "ches", "xes", "zes") && len(word) > 4: + return word[:len(word)-2] + case hasSuffix(word, "ses") && len(word) > 3: + return word[:len(word)-2] + case hasSuffix(word, "s") && !hasSuffix(word, "ss"): + return word[:len(word)-1] + default: + return word + } +} + +// irregularsToPlural maps singular → plural for words that don't follow the +// standard rules, with emphasis on food-relevant vocabulary. +var irregularsToPlural = map[string]string{ + // -o words that take -es + "potato": "potatoes", + "tomato": "tomatoes", + "hero": "heroes", + "mango": "mangoes", + // -f/-fe words + "leaf": "leaves", + "loaf": "loaves", + "half": "halves", + "shelf": "shelves", + "knife": "knives", + "wife": "wives", + "life": "lives", + "wolf": "wolves", + // Common English irregulars + "man": "men", + "woman": "women", + "child": "children", + "person": "people", + "tooth": "teeth", + "foot": "feet", + "goose": "geese", + "mouse": "mice", + // Invariant / uncountable culinary nouns + "flour": "flour", + "sugar": "sugar", + "salt": "salt", + "pepper": "pepper", + "rice": "rice", + "garlic": "garlic", + "ginger": "ginger", + "honey": "honey", + "oil": "oil", + "butter": "butter", + "milk": "milk", + "water": "water", + "vinegar": "vinegar", +} + +// irregularsToSingular is the reverse of irregularsToPlural. +var irregularsToSingular = func() map[string]string { + m := make(map[string]string, len(irregularsToPlural)) + for sg, pl := range irregularsToPlural { + if sg != pl { // skip invariants + m[pl] = sg + } + } + return m +}() + +func hasSuffix(s, suffix string) bool { return strings.HasSuffix(s, suffix) } + +func hasSuffixAny(s string, suffixes ...string) bool { + for _, suf := range suffixes { + if strings.HasSuffix(s, suf) { + return true + } + } + return false +} - return &result +func isConsonant(r rune) bool { + return !strings.ContainsRune("aeiou", unicode.ToLower(r)) } diff --git a/inline_ingredients_test.go b/inline_ingredients_test.go index 7708fdd..2bf1611 100644 --- a/inline_ingredients_test.go +++ b/inline_ingredients_test.go @@ -1,184 +1,465 @@ package recipemd import ( + "strings" "testing" ) -func TestInlineIngredients(t *testing.T) { +// --------------------------------------------------------------------------- +// wordForms (pluralization) +// --------------------------------------------------------------------------- + +func TestWordForms(t *testing.T) { t.Parallel() - t.Run("nil instructions returns nil", func(t *testing.T) { + cases := []struct { + word string + forms []string // expected lowercased forms + }{ + // Regular +s + {"apple", []string{"apple", "apples"}}, + {"egg", []string{"egg", "eggs"}}, + {"clove", []string{"clove", "cloves"}}, + // consonant+y → ies + {"berry", []string{"berry", "berries"}}, + {"cherry", []string{"cherry", "cherries"}}, + // -o irregulars + {"potato", []string{"potato", "potatoes"}}, + {"tomato", []string{"tomato", "tomatoes"}}, + // -f → ves + {"leaf", []string{"leaf", "leaves"}}, + {"loaf", []string{"loaf", "loaves"}}, + // -fe → ves + {"knife", []string{"knife", "knives"}}, + // -ch → es + {"peach", []string{"peach", "peaches"}}, + // Invariant uncountables + {"flour", []string{"flour"}}, + {"garlic", []string{"garlic"}}, + {"rice", []string{"rice"}}, + {"sugar", []string{"sugar"}}, + // Multi-word: only last word is inflected + {"bay leaf", []string{"bay leaf", "bay leaves"}}, + {"olive oil", []string{"olive oil"}}, // oil is invariant + } + + for _, tc := range cases { + tc := tc + t.Run(tc.word, func(t *testing.T) { + t.Parallel() + got := wordForms(tc.word) + if len(got) != len(tc.forms) { + t.Fatalf("wordForms(%q) = %v, want %v", tc.word, got, tc.forms) + } + for i, f := range tc.forms { + if got[i] != f { + t.Errorf("wordForms(%q)[%d] = %q, want %q", tc.word, i, got[i], f) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// WithInlineIngredients — parser option smoke test +// --------------------------------------------------------------------------- + +func TestWithInlineIngredients_DefaultNoop(t *testing.T) { + t.Parallel() + // Without the option, instructions are unchanged. + p := NewParser() + instructions := "add eggs and flour" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "egg", Amount: &Amount{Factor: 3}}}, + IngredientGroups: []IngredientGroup{}, + } + md := p.RenderMarkdown(r, 3) + // Plain parser must not inject amounts. + if strings.Contains(md, "3 eggs") || strings.Contains(md, "3 egg") { + t.Errorf("plain parser injected amount unexpectedly: %q", md) + } +} + +// --------------------------------------------------------------------------- +// RenderMarkdown — inline before/after +// --------------------------------------------------------------------------- + +func TestRenderMarkdown_InlineBefore(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsBefore), + )) + instructions := "crack eggs into the bowl then add flour" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{ + {Name: "egg", Amount: &Amount{Factor: 3}}, + {Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cups")}}, + }, + IngredientGroups: []IngredientGroup{}, + } + md := p.RenderMarkdown(r, 3) + if !strings.Contains(md, "3 eggs") { + t.Errorf("expected plural match injected as '3 eggs' in: %q", md) + } + if !strings.Contains(md, "2 cups flour") { + t.Errorf("expected '2 cups flour' in: %q", md) + } +} + +func TestRenderMarkdown_InlineAfter(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsAfter), + )) + instructions := "add eggs and stir" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{ + {Name: "egg", Amount: &Amount{Factor: 2}}, + }, + IngredientGroups: []IngredientGroup{}, + } + md := p.RenderMarkdown(r, 3) + if !strings.Contains(md, "eggs (2)") { + t.Errorf("expected 'eggs (2)' in: %q", md) + } +} + +// --------------------------------------------------------------------------- +// Pluralization matching +// --------------------------------------------------------------------------- + +func TestRenderMarkdown_Pluralization(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients()) + t.Run("singular ingredient matches plural in instructions", func(t *testing.T) { t.Parallel() + instructions := "fold in the berries" r := &Recipe{ - Instructions: nil, - Ingredients: []Ingredient{{Name: "salt", Amount: &Amount{Factor: 1}}}, + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "berry", Amount: &Amount{Factor: 200, Unit: new("g")}}}, + IngredientGroups: []IngredientGroup{}, } - if got := InlineIngredients(r, 3); got != nil { - t.Errorf("expected nil, got %q", *got) + md := p.RenderMarkdown(r, 3) + if !strings.Contains(md, "200 g berries") { + t.Errorf("expected plural match in: %q", md) } }) - t.Run("ingredient without amount is not injected", func(t *testing.T) { + t.Run("word boundary not partial", func(t *testing.T) { t.Parallel() - instructions := "add salt and stir" + instructions := "use salted butter" r := &Recipe{ + Title: "T", Instructions: &instructions, - Ingredients: []Ingredient{{Name: "salt"}}, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "salt", Amount: &Amount{Factor: 1, Unit: new("tsp")}}}, IngredientGroups: []IngredientGroup{}, } - got := InlineIngredients(r, 3) - if got == nil { - t.Fatal("expected non-nil result") - } - if *got != instructions { - t.Errorf("expected %q, got %q", instructions, *got) + md := p.RenderMarkdown(r, 3) + if strings.Contains(md, "1 tsp") { + t.Errorf("'salt' should not match inside 'salted': %q", md) } }) - t.Run("single ingredient injected", func(t *testing.T) { + t.Run("case-insensitive preserves original case", func(t *testing.T) { t.Parallel() - instructions := "then add cinnamon and mix" + instructions := "Add Eggs to the bowl" r := &Recipe{ - Instructions: &instructions, - Ingredients: []Ingredient{ - {Name: "cinnamon", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}}, - }, + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "egg", Amount: &Amount{Factor: 3}}}, IngredientGroups: []IngredientGroup{}, } - got := InlineIngredients(r, 3) - if got == nil { - t.Fatal("expected non-nil result") - } - want := "then add 0.5 tsp cinnamon and mix" - if *got != want { - t.Errorf("expected %q, got %q", want, *got) + md := p.RenderMarkdown(r, 3) + if !strings.Contains(md, "3 Eggs") { + t.Errorf("expected original casing preserved: %q", md) } }) +} + +// --------------------------------------------------------------------------- +// Preparation separators +// --------------------------------------------------------------------------- + +func TestRenderMarkdown_PrepSeparators(t *testing.T) { + t.Parallel() - t.Run("case-insensitive match preserves original casing", func(t *testing.T) { + t.Run("comma separator before format", func(t *testing.T) { t.Parallel() - instructions := "Add Cinnamon to the bowl" + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsBefore), + WithInlinePrepSeparators(","), + )) + instructions := "mince garlic and add to pan" r := &Recipe{ - Instructions: &instructions, - Ingredients: []Ingredient{ - {Name: "cinnamon", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}}, - }, + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "garlic, minced", Amount: &Amount{Factor: 3, Unit: new("cloves")}}}, IngredientGroups: []IngredientGroup{}, } - got := InlineIngredients(r, 3) - if got == nil { - t.Fatal("expected non-nil result") - } - want := "Add 0.5 tsp Cinnamon to the bowl" - if *got != want { - t.Errorf("expected %q, got %q", want, *got) + md := p.RenderMarkdown(r, 3) + // base="garlic", prep="minced" → "3 cloves minced garlic" + if !strings.Contains(md, "3 cloves minced garlic") { + t.Errorf("expected '3 cloves minced garlic' in: %q", md) } }) - t.Run("multi-word ingredient matched before partial", func(t *testing.T) { + t.Run("paren separator after format", func(t *testing.T) { t.Parallel() - instructions := "stir in brown sugar then add more sugar" + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsAfter), + WithInlinePrepSeparators("("), + )) + instructions := "slice garlic thin and cook" r := &Recipe{ - Instructions: &instructions, - Ingredients: []Ingredient{ - {Name: "brown sugar", Amount: &Amount{Factor: 1, Unit: new("cup")}}, - {Name: "sugar", Amount: &Amount{Factor: 2, Unit: new("tsp")}}, - }, + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "garlic (sliced)", Amount: &Amount{Factor: 4, Unit: new("cloves")}}}, IngredientGroups: []IngredientGroup{}, } - got := InlineIngredients(r, 3) - if got == nil { - t.Fatal("expected non-nil result") - } - want := "stir in 1 cup brown sugar then add more 2 tsp sugar" - if *got != want { - t.Errorf("expected %q, got %q", want, *got) + md := p.RenderMarkdown(r, 3) + // base="garlic", prep="sliced" → "garlic (4 cloves sliced)" + if !strings.Contains(md, "garlic (4 cloves sliced)") { + t.Errorf("expected 'garlic (4 cloves sliced)' in: %q", md) } }) - t.Run("multiple occurrences are all replaced", func(t *testing.T) { + t.Run("multi-word base with prep", func(t *testing.T) { t.Parallel() - instructions := "add flour, then more flour" + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsBefore), + WithInlinePrepSeparators(","), + )) + instructions := "add brown sugar and mix" r := &Recipe{ - Instructions: &instructions, - Ingredients: []Ingredient{ - {Name: "flour", Amount: &Amount{Factor: 2, Unit: new("cup")}}, - }, + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "brown sugar, packed", Amount: &Amount{Factor: 1, Unit: new("cup")}}}, IngredientGroups: []IngredientGroup{}, } - got := InlineIngredients(r, 3) - if got == nil { - t.Fatal("expected non-nil result") - } - want := "add 2 cup flour, then more 2 cup flour" - if *got != want { - t.Errorf("expected %q, got %q", want, *got) + md := p.RenderMarkdown(r, 3) + // base="brown sugar", prep="packed" → "1 cup packed brown sugar" + if !strings.Contains(md, "1 cup packed brown sugar") { + t.Errorf("expected '1 cup packed brown sugar' in: %q", md) } }) +} - t.Run("ingredient in group is also considered", func(t *testing.T) { - t.Parallel() - instructions := "fold in vanilla" - r := &Recipe{ - Instructions: &instructions, - Ingredients: []Ingredient{}, - IngredientGroups: []IngredientGroup{ - { - Title: "Flavouring", - Ingredients: []Ingredient{ - {Name: "vanilla", Amount: &Amount{Factor: 1, Unit: new("tsp")}}, - }, - IngredientGroups: []IngredientGroup{}, +// --------------------------------------------------------------------------- +// Multi-word priority +// --------------------------------------------------------------------------- + +func TestRenderMarkdown_MultiWordPriority(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsBefore), + )) + instructions := "mix brown sugar then add plain sugar" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{ + {Name: "brown sugar", Amount: &Amount{Factor: 1, Unit: new("cup")}}, + {Name: "sugar", Amount: &Amount{Factor: 2, Unit: new("tsp")}}, + }, + IngredientGroups: []IngredientGroup{}, + } + md := p.RenderMarkdown(r, 3) + if !strings.Contains(md, "1 cup brown sugar") { + t.Errorf("expected '1 cup brown sugar' in: %q", md) + } + if !strings.Contains(md, "2 tsp sugar") { + t.Errorf("expected '2 tsp sugar' in: %q", md) + } + // "sugar" inside "brown sugar" must not be double-injected. + if strings.Contains(md, "2 tsp brown sugar") || strings.Contains(md, "1 cup brown 2 tsp sugar") { + t.Errorf("double injection detected: %q", md) + } +} + +// --------------------------------------------------------------------------- +// Ingredient groups +// --------------------------------------------------------------------------- + +func TestRenderMarkdown_IngredientsInGroups(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients()) + instructions := "fold in vanilla and top with blueberries" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{}, + IngredientGroups: []IngredientGroup{ + { + Title: "Batter", + Ingredients: []Ingredient{ + {Name: "vanilla", Amount: &Amount{Factor: 1, Unit: new("tsp")}}, }, + IngredientGroups: []IngredientGroup{}, }, - } - got := InlineIngredients(r, 3) - if got == nil { - t.Fatal("expected non-nil result") - } - want := "fold in 1 tsp vanilla" - if *got != want { - t.Errorf("expected %q, got %q", want, *got) - } - }) - - t.Run("word boundary prevents partial match", func(t *testing.T) { - t.Parallel() - instructions := "add salted butter" - r := &Recipe{ - Instructions: &instructions, - Ingredients: []Ingredient{ - {Name: "salt", Amount: &Amount{Factor: 1, Unit: new("tsp")}}, + { + Title: "Topping", + Ingredients: []Ingredient{ + {Name: "blueberry", Amount: &Amount{Factor: 100, Unit: new("g")}}, + }, + IngredientGroups: []IngredientGroup{}, }, - IngredientGroups: []IngredientGroup{}, - } - got := InlineIngredients(r, 3) - if got == nil { - t.Fatal("expected non-nil result") - } - // "salt" must NOT match inside "salted" - if *got != instructions { - t.Errorf("expected %q unchanged, got %q", instructions, *got) - } - }) + }, + } + md := p.RenderMarkdown(r, 3) + if !strings.Contains(md, "1 tsp vanilla") { + t.Errorf("expected '1 tsp vanilla' in: %q", md) + } + if !strings.Contains(md, "100 g blueberries") { + t.Errorf("expected plural '100 g blueberries' in: %q", md) + } +} - t.Run("unitless amount injected", func(t *testing.T) { - t.Parallel() - instructions := "crack eggs into bowl" - r := &Recipe{ - Instructions: &instructions, - Ingredients: []Ingredient{ - {Name: "eggs", Amount: &Amount{Factor: 3}}, - }, - IngredientGroups: []IngredientGroup{}, - } - got := InlineIngredients(r, 3) - if got == nil { - t.Fatal("expected non-nil result") - } - want := "crack 3 eggs into bowl" - if *got != want { - t.Errorf("expected %q, got %q", want, *got) - } - }) +// --------------------------------------------------------------------------- +// RenderHTML — span wrapping and hover +// --------------------------------------------------------------------------- + +func TestRenderHTML_InlineSpan(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsBefore), + )) + instructions := "add eggs to the bowl" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{ + {Name: "egg", Amount: &Amount{Factor: 3}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTML(r, 3) + if !strings.Contains(got, `class="recipemd-inline-ingredient"`) { + t.Errorf("expected inline-ingredient span in: %q", got) + } + if !strings.Contains(got, "3 eggs") { + t.Errorf("expected '3 eggs' in HTML output: %q", got) + } +} + +func TestRenderHTML_InlineAfterSpan(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineFormat(InlineIngredientsAfter), + )) + instructions := "beat eggs until frothy" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{ + {Name: "egg", Amount: &Amount{Factor: 2}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTML(r, 3) + if !strings.Contains(got, "eggs (2)") { + t.Errorf("expected 'eggs (2)' in HTML output: %q", got) + } +} + +func TestRenderHTML_Hover(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineHTMLHover(), + )) + instructions := "season with pepper to taste" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{ + {Name: "pepper", Amount: &Amount{Factor: 0.5, Unit: new("tsp")}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTML(r, 3) + if !strings.Contains(got, `title="0.5 tsp"`) { + t.Errorf("expected title attribute in: %q", got) + } + if !strings.Contains(got, `>pepper<`) { + t.Errorf("expected ingredient name as visible text in: %q", got) + } +} + +func TestRenderHTML_HoverWithPrep(t *testing.T) { + t.Parallel() + p := NewParser(WithInlineIngredients( + WithInlineHTMLHover(), + WithInlinePrepSeparators(","), + )) + instructions := "sauté onion until translucent" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{ + {Name: "onion, diced", Amount: &Amount{Factor: 1}}, + }, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTML(r, 3) + // title should include amount + prep + if !strings.Contains(got, `title="1 diced"`) { + t.Errorf("expected title='1 diced' in: %q", got) + } + if !strings.Contains(got, `>onion<`) { + t.Errorf("expected 'onion' as visible text in: %q", got) + } +} + +func TestRenderHTML_NoInjectionWithoutOption(t *testing.T) { + t.Parallel() + p := NewParser() + instructions := "add eggs" + r := &Recipe{ + Title: "T", + Instructions: &instructions, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "egg", Amount: &Amount{Factor: 3}}}, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTML(r, 3) + if strings.Contains(got, `class="recipemd-inline-ingredient"`) { + t.Errorf("unexpected inline span without option: %q", got) + } } diff --git a/parser.go b/parser.go index 47d2e4a..e82486c 100644 --- a/parser.go +++ b/parser.go @@ -54,10 +54,12 @@ func WithGithubFormattedMarkdown() Option { type Parser struct { // Frontmatter reports whether the parser strips YAML/TOML front matter // before parsing. Set via [WithFrontmatter]. - Frontmatter bool - hasTaskList bool - goldmarkProcessor goldmark.Markdown - goldmarkExtensions []goldmark.Extender + Frontmatter bool + hasTaskList bool + goldmarkProcessor goldmark.Markdown + goldmarkExtensions []goldmark.Extender + inlineIngredients bool + inlineIngredientsCfg inlineIngredientsConfig } // NewParser creates a new Parser, applying any supplied options. diff --git a/render_html.go b/render_html.go index 00c53dd..75dc4cb 100644 --- a/render_html.go +++ b/render_html.go @@ -42,7 +42,7 @@ const htmlMainTmpl = `
{{- with deref .Instructions }}
-
{{ renderMD . }}
+
{{ renderInstructionsMD . }}
{{- end }}
` @@ -81,6 +81,12 @@ const htmlGroupsTmpl = `{{ range . -}} // Ingredient groups are rendered as nested
blocks; the heading level // starts at h2 for top-level groups and increments for each sub-level. // +// When the parser was configured with [WithInlineIngredients], ingredient +// amounts are injected into the instructions HTML. Each matched ingredient +// name is wrapped in a element. +// With [WithInlineHTMLHover] the amount is placed in the span's title +// attribute instead of the visible text. +// // WARNING: This method is a work-in-progress and not yet ready for production // use. Known limitations: // - Ingredient links reference raw .md file paths without any resolution or @@ -90,8 +96,16 @@ const htmlGroupsTmpl = `{{ range . -}} // in the same section (Description or Instructions) as the usage. Definitions // in one section are not visible when rendering the other, so cross-section // reflinks are silently left unresolved. +// - Ingredient names containing HTML-special characters (& < >) may not be +// matched in inline injection because goldmark entity-encodes them in the +// output. func (p *Parser) RenderHTML(r *Recipe, rounding int) string { - funcs := htmlFuncMap(p, rounding) + var inj *inlineInjector + if p.inlineIngredients { + inj = buildInjector(r, p.inlineIngredientsCfg, rounding) + } + + funcs := htmlFuncMap(p, rounding, inj) funcs["topGroups"] = func(groups []IngredientGroup) []htmlGroupCtx { out := make([]htmlGroupCtx, len(groups)) for i, g := range groups { @@ -109,7 +123,7 @@ func (p *Parser) RenderHTML(r *Recipe, rounding int) string { return buf.String() } -func htmlFuncMap(p *Parser, rounding int) template.FuncMap { +func htmlFuncMap(p *Parser, rounding int, inj *inlineInjector) template.FuncMap { return template.FuncMap{ "join": strings.Join, "deref": func(s *string) string { @@ -136,6 +150,15 @@ func htmlFuncMap(p *Parser, rounding int) template.FuncMap { _ = p.goldmarkProcessor.Convert([]byte(md), &buf) return template.HTML(buf.String()) }, + "renderInstructionsMD": func(md string) template.HTML { + var buf bytes.Buffer + _ = p.goldmarkProcessor.Convert([]byte(md), &buf) + htmlStr := buf.String() + if inj != nil { + htmlStr = inj.injectHTML(htmlStr) + } + return template.HTML(htmlStr) + }, "heading": func(level int, title string) template.HTML { escaped := template.HTMLEscapeString(title) return template.HTML(fmt.Sprintf(`%s`, level, escaped, level)) diff --git a/render_markdown.go b/render_markdown.go index 289a25b..ed0ac0c 100644 --- a/render_markdown.go +++ b/render_markdown.go @@ -53,7 +53,19 @@ const mdGroupsTmpl = `{{ range . }} // // The returned string contains a complete, parseable RecipeMD document that // [Parser.Parse] can round-trip back to an equivalent [Recipe]. +// +// When the parser was configured with [WithInlineIngredients], ingredient +// amounts are injected into the instructions text before rendering. func (p *Parser) RenderMarkdown(r *Recipe, rounding int) string { + if p.inlineIngredients && r.Instructions != nil { + if inj := buildInjector(r, p.inlineIngredientsCfg, rounding); inj != nil { + injected := inj.injectText(*r.Instructions) + r2 := *r + r2.Instructions = &injected + r = &r2 + } + } + funcs := renderFuncMap(rounding) funcs["topGroups"] = func(groups []IngredientGroup) []mdGroupCtx { out := make([]mdGroupCtx, len(groups))