From 1ccaacc30a6055e0b30963afc4f9e6d4c50ebc9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 21:38:21 +0000 Subject: [PATCH 1/3] feat: detect and annotate temperatures and times in HTML output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add temperature and time detection during HTML rendering. Detected values are wrapped in tags with recipemd-temperature and recipemd-time CSS classes plus data attributes (data-unit, data-value). New RenderHTMLWithOptions method accepts HTMLOptions to control behavior. ConvertTemperature option converts detected temperatures between Celsius and Fahrenheit, displaying the converted value while preserving the original in data-original-unit and data-original-value attributes. Supported temperature patterns: °C, °F, celsius, fahrenheit Supported time patterns: minutes, min, hours, hr, hrs, seconds, secs https://claude.ai/code/session_016zD2491XMNatvyYLR2zghb --- render_html.go | 21 ++- tags.go | 180 ++++++++++++++++++++++++ tags_test.go | 366 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 tags.go create mode 100644 tags_test.go diff --git a/render_html.go b/render_html.go index 00c53dd..fd83bf8 100644 --- a/render_html.go +++ b/render_html.go @@ -91,6 +91,21 @@ const htmlGroupsTmpl = `{{ range . -}} // 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 { + return p.RenderHTMLWithOptions(r, rounding, HTMLOptions{}) +} + +// RenderHTMLWithOptions renders r as an HTML
element with +// configurable behavior. +// +// Temperatures and times detected in the output text are wrapped in +// tags with CSS classes "recipemd-temperature" and "recipemd-time" +// respectively, along with data attributes for the parsed value and unit. +// +// When [HTMLOptions.ConvertTemperature] is set, all detected temperatures +// are converted to the target unit. The converted value is displayed while +// the original value and unit are preserved in data-original-value and +// data-original-unit attributes. +func (p *Parser) RenderHTMLWithOptions(r *Recipe, rounding int, opts HTMLOptions) string { funcs := htmlFuncMap(p, rounding) funcs["topGroups"] = func(groups []IngredientGroup) []htmlGroupCtx { out := make([]htmlGroupCtx, len(groups)) @@ -106,7 +121,11 @@ func (p *Parser) RenderHTML(r *Recipe, rounding int) string { var buf bytes.Buffer _ = tmpl.Execute(&buf, r) - return buf.String() + + html := buf.String() + html = annotateTemperatures(html, opts.ConvertTemperature, rounding) + html = annotateTimes(html) + return html } func htmlFuncMap(p *Parser, rounding int) template.FuncMap { diff --git a/tags.go b/tags.go new file mode 100644 index 0000000..f75fa18 --- /dev/null +++ b/tags.go @@ -0,0 +1,180 @@ +package recipemd + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" +) + +// TemperatureUnit represents a temperature measurement system. +type TemperatureUnit int + +const ( + // Celsius represents the Celsius temperature scale. + Celsius TemperatureUnit = iota + // Fahrenheit represents the Fahrenheit temperature scale. + Fahrenheit +) + +// HTMLOptions controls optional behavior for [Parser.RenderHTMLWithOptions]. +type HTMLOptions struct { + // ConvertTemperature, when non-nil, converts all detected temperatures + // to the specified unit system. The rendered output shows the converted + // value and unit, with the original preserved in data attributes. + ConvertTemperature *TemperatureUnit +} + +// temperatureRe matches patterns like "180°C", "350 °F", "200 celsius", "375 fahrenheit". +// It requires either the ° symbol or the full word to avoid false positives on bare C/F. +var temperatureRe = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*(°\s*[CcFf]|[Cc]elsius|[Ff]ahrenheit)`) + +// timeRe matches patterns like "30 minutes", "2 hours", "1 hr", "45min", "10 secs". +var timeRe = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(minutes?|mins?|hours?|hrs?|seconds?|secs?)\b`) + +func parseTemperatureUnit(unitStr string) TemperatureUnit { + u := strings.ToLower(strings.ReplaceAll(unitStr, " ", "")) + if strings.Contains(u, "f") { + return Fahrenheit + } + return Celsius +} + +func tempUnitCode(u TemperatureUnit) string { + if u == Fahrenheit { + return "F" + } + return "C" +} + +func tempSymbol(u TemperatureUnit) string { + if u == Fahrenheit { + return "°F" + } + return "°C" +} + +func convertTemp(value float64, from, to TemperatureUnit) float64 { + if from == to { + return value + } + if from == Celsius { + return value*9.0/5.0 + 32 + } + return (value - 32) * 5.0 / 9.0 +} + +func normalizeTimeUnit(unit string) string { + u := strings.ToLower(unit) + switch { + case strings.HasPrefix(u, "h"): + return "h" + case strings.HasPrefix(u, "min"): + return "min" + case strings.HasPrefix(u, "sec"): + return "s" + default: + return u + } +} + +// replaceInTextNodes applies a replacement function to text content in an HTML +// string, leaving anything inside < > tags untouched. +func replaceInTextNodes(html string, replacer func(string) string) string { + var b strings.Builder + b.Grow(len(html)) + i := 0 + for i < len(html) { + if html[i] == '<' { + j := strings.IndexByte(html[i:], '>') + if j < 0 { + b.WriteString(html[i:]) + break + } + b.WriteString(html[i : i+j+1]) + i += j + 1 + } else { + j := strings.IndexByte(html[i:], '<') + if j < 0 { + b.WriteString(replacer(html[i:])) + break + } + b.WriteString(replacer(html[i : i+j])) + i += j + } + } + return b.String() +} + +func formatTagValue(v float64, rounding int) string { + if rounding < 0 { + return strconv.FormatFloat(v, 'f', -1, 64) + } + p := math.Pow(10, float64(rounding)) + rounded := math.Round(v*p) / p + s := strconv.FormatFloat(rounded, 'f', rounding, 64) + if rounding > 0 { + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + } + return s +} + +func annotateTemperatures(html string, convert *TemperatureUnit, rounding int) string { + return replaceInTextNodes(html, func(text string) string { + return temperatureRe.ReplaceAllStringFunc(text, func(match string) string { + groups := temperatureRe.FindStringSubmatch(match) + if groups == nil { + return match + } + value, err := strconv.ParseFloat(groups[1], 64) + if err != nil { + return match + } + fromUnit := parseTemperatureUnit(groups[2]) + + if convert != nil && *convert != fromUnit { + toUnit := *convert + converted := convertTemp(value, fromUnit, toUnit) + cStr := formatTagValue(converted, rounding) + vStr := formatTagValue(value, rounding) + return fmt.Sprintf( + `%s%s`, + tempUnitCode(toUnit), cStr, + tempUnitCode(fromUnit), vStr, + cStr, tempSymbol(toUnit), + ) + } + + return fmt.Sprintf( + `%s`, + tempUnitCode(fromUnit), + formatTagValue(value, rounding), + match, + ) + }) + }) +} + +func annotateTimes(html string) string { + return replaceInTextNodes(html, func(text string) string { + return timeRe.ReplaceAllStringFunc(text, func(match string) string { + groups := timeRe.FindStringSubmatch(match) + if groups == nil { + return match + } + value, err := strconv.ParseFloat(groups[1], 64) + if err != nil { + return match + } + norm := normalizeTimeUnit(groups[2]) + return fmt.Sprintf( + `%s`, + norm, + strconv.FormatFloat(value, 'f', -1, 64), + match, + ) + }) + }) +} diff --git a/tags_test.go b/tags_test.go new file mode 100644 index 0000000..d96ad49 --- /dev/null +++ b/tags_test.go @@ -0,0 +1,366 @@ +package recipemd + +import ( + "strings" + "testing" +) + +func TestAnnotateTemperatures(t *testing.T) { + t.Parallel() + + t.Run("degree celsius", func(t *testing.T) { + t.Parallel() + got := annotateTemperatures("Bake at 180°C for a bit.", nil, 2) + if !strings.Contains(got, `class="recipemd-temperature"`) { + t.Fatalf("missing temperature class in: %s", got) + } + if !strings.Contains(got, `data-unit="C"`) { + t.Errorf("missing data-unit in: %s", got) + } + if !strings.Contains(got, `data-value="180"`) { + t.Errorf("missing data-value in: %s", got) + } + if !strings.Contains(got, `>180°C`) { + t.Errorf("original text not preserved in: %s", got) + } + }) + + t.Run("degree fahrenheit", func(t *testing.T) { + t.Parallel() + got := annotateTemperatures("Bake at 350°F.", nil, 2) + if !strings.Contains(got, `data-unit="F"`) { + t.Errorf("missing data-unit F in: %s", got) + } + if !strings.Contains(got, `data-value="350"`) { + t.Errorf("missing data-value in: %s", got) + } + }) + + t.Run("space before degree symbol", func(t *testing.T) { + t.Parallel() + got := annotateTemperatures("Set oven to 200 °C.", nil, 2) + if !strings.Contains(got, `data-unit="C"`) { + t.Errorf("missing data-unit in: %s", got) + } + if !strings.Contains(got, `data-value="200"`) { + t.Errorf("missing data-value in: %s", got) + } + }) + + t.Run("word celsius", func(t *testing.T) { + t.Parallel() + got := annotateTemperatures("Heat to 100 celsius.", nil, 2) + if !strings.Contains(got, `data-unit="C"`) { + t.Errorf("missing data-unit in: %s", got) + } + if !strings.Contains(got, `>100 celsius`) { + t.Errorf("text not preserved in: %s", got) + } + }) + + t.Run("word Fahrenheit", func(t *testing.T) { + t.Parallel() + got := annotateTemperatures("Heat to 212 Fahrenheit.", nil, 2) + if !strings.Contains(got, `data-unit="F"`) { + t.Errorf("missing data-unit in: %s", got) + } + }) + + t.Run("decimal temperature", func(t *testing.T) { + t.Parallel() + got := annotateTemperatures("Cook at 162.5°C.", nil, 2) + if !strings.Contains(got, `data-value="162.5"`) { + t.Errorf("missing decimal data-value in: %s", got) + } + }) + + t.Run("convert C to F", func(t *testing.T) { + t.Parallel() + f := Fahrenheit + got := annotateTemperatures("Bake at 100°C.", &f, 2) + if !strings.Contains(got, `data-unit="F"`) { + t.Errorf("should be converted to F in: %s", got) + } + if !strings.Contains(got, `data-value="212"`) { + t.Errorf("100C should convert to 212F in: %s", got) + } + if !strings.Contains(got, `data-original-unit="C"`) { + t.Errorf("missing original unit in: %s", got) + } + if !strings.Contains(got, `data-original-value="100"`) { + t.Errorf("missing original value in: %s", got) + } + if !strings.Contains(got, `>212°F`) { + t.Errorf("displayed text should show converted value in: %s", got) + } + }) + + t.Run("convert F to C", func(t *testing.T) { + t.Parallel() + c := Celsius + got := annotateTemperatures("Bake at 212°F.", &c, 2) + if !strings.Contains(got, `data-unit="C"`) { + t.Errorf("should be converted to C in: %s", got) + } + if !strings.Contains(got, `data-value="100"`) { + t.Errorf("212F should convert to 100C in: %s", got) + } + if !strings.Contains(got, `>100°C`) { + t.Errorf("displayed text should show converted value in: %s", got) + } + }) + + t.Run("no conversion when same unit", func(t *testing.T) { + t.Parallel() + c := Celsius + got := annotateTemperatures("Heat to 180°C.", &c, 2) + if strings.Contains(got, `data-original`) { + t.Errorf("should not have original attrs when unit matches in: %s", got) + } + if !strings.Contains(got, `>180°C`) { + t.Errorf("text should be preserved as-is in: %s", got) + } + }) + + t.Run("inside HTML preserves tags", func(t *testing.T) { + t.Parallel() + input := `

Bake at 180°C

` + got := annotateTemperatures(input, nil, 2) + if !strings.Contains(got, "

") { + t.Errorf("HTML tags damaged in: %s", got) + } + if !strings.Contains(got, `class="recipemd-temperature"`) { + t.Errorf("temperature not annotated in: %s", got) + } + }) + + t.Run("does not modify HTML attributes", func(t *testing.T) { + t.Parallel() + input := `

some text
` + got := annotateTemperatures(input, nil, 2) + if !strings.Contains(got, `data-temp="180°C"`) { + t.Errorf("HTML attribute modified in: %s", got) + } + }) + + t.Run("multiple temperatures", func(t *testing.T) { + t.Parallel() + got := annotateTemperatures("First 180°C then 200°F.", nil, 2) + if strings.Count(got, `class="recipemd-temperature"`) != 2 { + t.Errorf("expected 2 temperature annotations in: %s", got) + } + }) +} + +func TestAnnotateTimes(t *testing.T) { + t.Parallel() + + t.Run("minutes", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Cook for 30 minutes.") + if !strings.Contains(got, `class="recipemd-time"`) { + t.Fatalf("missing time class in: %s", got) + } + if !strings.Contains(got, `data-unit="min"`) { + t.Errorf("missing data-unit in: %s", got) + } + if !strings.Contains(got, `data-value="30"`) { + t.Errorf("missing data-value in: %s", got) + } + if !strings.Contains(got, `>30 minutes`) { + t.Errorf("text not preserved in: %s", got) + } + }) + + t.Run("min abbreviation", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Wait 5 min.") + if !strings.Contains(got, `data-unit="min"`) { + t.Errorf("missing data-unit in: %s", got) + } + if !strings.Contains(got, `data-value="5"`) { + t.Errorf("missing data-value in: %s", got) + } + }) + + t.Run("mins abbreviation", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Wait 15 mins.") + if !strings.Contains(got, `data-unit="min"`) { + t.Errorf("missing data-unit in: %s", got) + } + }) + + t.Run("hours", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Rest for 2 hours.") + if !strings.Contains(got, `data-unit="h"`) { + t.Errorf("missing data-unit in: %s", got) + } + if !strings.Contains(got, `data-value="2"`) { + t.Errorf("missing data-value in: %s", got) + } + }) + + t.Run("hr abbreviation", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Wait 1 hr.") + if !strings.Contains(got, `data-unit="h"`) { + t.Errorf("missing data-unit in: %s", got) + } + }) + + t.Run("hrs abbreviation", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Marinate 4 hrs.") + if !strings.Contains(got, `data-unit="h"`) { + t.Errorf("missing data-unit in: %s", got) + } + }) + + t.Run("seconds", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Microwave 90 seconds.") + if !strings.Contains(got, `data-unit="s"`) { + t.Errorf("missing data-unit in: %s", got) + } + if !strings.Contains(got, `data-value="90"`) { + t.Errorf("missing data-value in: %s", got) + } + }) + + t.Run("sec abbreviation", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Pulse for 10 secs.") + if !strings.Contains(got, `data-unit="s"`) { + t.Errorf("missing data-unit in: %s", got) + } + }) + + t.Run("no space before unit", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Bake 45min.") + if !strings.Contains(got, `data-unit="min"`) { + t.Errorf("missing data-unit in: %s", got) + } + if !strings.Contains(got, `data-value="45"`) { + t.Errorf("missing data-value in: %s", got) + } + }) + + t.Run("decimal time", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Cook for 1.5 hours.") + if !strings.Contains(got, `data-value="1.5"`) { + t.Errorf("missing decimal data-value in: %s", got) + } + }) + + t.Run("inside HTML preserves tags", func(t *testing.T) { + t.Parallel() + got := annotateTimes(`

Cook for 30 minutes.

`) + if !strings.Contains(got, "

") { + t.Errorf("HTML tags damaged in: %s", got) + } + if !strings.Contains(got, `class="recipemd-time"`) { + t.Errorf("time not annotated in: %s", got) + } + }) + + t.Run("multiple times", func(t *testing.T) { + t.Parallel() + got := annotateTimes("Boil 10 minutes, rest 2 hours.") + if strings.Count(got, `class="recipemd-time"`) != 2 { + t.Errorf("expected 2 time annotations in: %s", got) + } + }) +} + +func TestRenderHTMLWithOptions(t *testing.T) { + t.Parallel() + p := NewParser() + + t.Run("annotates temperatures in instructions", func(t *testing.T) { + t.Parallel() + instructions := "Bake at 180°C for 30 minutes." + r := &Recipe{ + Title: "Cake", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + got := p.RenderHTMLWithOptions(r, 2, HTMLOptions{}) + if !strings.Contains(got, `class="recipemd-temperature"`) { + t.Errorf("temperature not annotated in: %s", got) + } + if !strings.Contains(got, `class="recipemd-time"`) { + t.Errorf("time not annotated in: %s", got) + } + }) + + t.Run("annotates temperatures in description", func(t *testing.T) { + t.Parallel() + desc := "A bread baked at 220°C." + r := &Recipe{ + Title: "Bread", + Description: &desc, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTMLWithOptions(r, 2, HTMLOptions{}) + if !strings.Contains(got, `class="recipemd-temperature"`) { + t.Errorf("temperature not annotated in description: %s", got) + } + }) + + t.Run("converts celsius to fahrenheit", func(t *testing.T) { + t.Parallel() + instructions := "Bake at 200°C." + r := &Recipe{ + Title: "Pie", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "apples"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + f := Fahrenheit + got := p.RenderHTMLWithOptions(r, 0, HTMLOptions{ConvertTemperature: &f}) + if !strings.Contains(got, `data-unit="F"`) { + t.Errorf("should convert to F in: %s", got) + } + if !strings.Contains(got, `data-value="392"`) { + t.Errorf("200C = 392F, got: %s", got) + } + if !strings.Contains(got, `data-original-unit="C"`) { + t.Errorf("missing original unit in: %s", got) + } + if !strings.Contains(got, `data-original-value="200"`) { + t.Errorf("missing original value in: %s", got) + } + }) + + t.Run("backward compatible with RenderHTML", func(t *testing.T) { + t.Parallel() + instructions := "Bake at 180°C for 30 minutes." + r := &Recipe{ + Title: "Test", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + got := p.RenderHTML(r, 2) + if !strings.Contains(got, `class="recipemd-temperature"`) { + t.Errorf("RenderHTML should also annotate temperatures: %s", got) + } + if !strings.Contains(got, `class="recipemd-time"`) { + t.Errorf("RenderHTML should also annotate times: %s", got) + } + }) +} From be4826381430c2749f6db1d18b4d277a06adc80e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 21:48:30 +0000 Subject: [PATCH 2/3] refactor: make temperature conversion available across all render formats Rename HTMLOptions to RenderOptions and apply temperature conversion to all renderers. For Markdown and JSON, detected temperatures in Description and Instructions are converted in-place without HTML markup. For HTML, span wrapping and data attributes are preserved as before. Add RenderMarkdownWithOptions and RenderJSONWithOptions methods. Add convertTemperaturesInText for plain-text replacement and recipeWithConvertedTemperatures to clone a Recipe with converted fields without mutating the original. https://claude.ai/code/session_016zD2491XMNatvyYLR2zghb --- render_html.go | 6 +- render_json.go | 13 ++++ render_markdown.go | 14 ++++ tags.go | 52 ++++++++++++- tags_test.go | 179 ++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 254 insertions(+), 10 deletions(-) diff --git a/render_html.go b/render_html.go index fd83bf8..ae46932 100644 --- a/render_html.go +++ b/render_html.go @@ -91,7 +91,7 @@ const htmlGroupsTmpl = `{{ range . -}} // 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 { - return p.RenderHTMLWithOptions(r, rounding, HTMLOptions{}) + return p.RenderHTMLWithOptions(r, rounding, RenderOptions{}) } // RenderHTMLWithOptions renders r as an HTML

element with @@ -101,11 +101,11 @@ func (p *Parser) RenderHTML(r *Recipe, rounding int) string { // tags with CSS classes "recipemd-temperature" and "recipemd-time" // respectively, along with data attributes for the parsed value and unit. // -// When [HTMLOptions.ConvertTemperature] is set, all detected temperatures +// When [RenderOptions.ConvertTemperature] is set, all detected temperatures // are converted to the target unit. The converted value is displayed while // the original value and unit are preserved in data-original-value and // data-original-unit attributes. -func (p *Parser) RenderHTMLWithOptions(r *Recipe, rounding int, opts HTMLOptions) string { +func (p *Parser) RenderHTMLWithOptions(r *Recipe, rounding int, opts RenderOptions) string { funcs := htmlFuncMap(p, rounding) funcs["topGroups"] = func(groups []IngredientGroup) []htmlGroupCtx { out := make([]htmlGroupCtx, len(groups)) diff --git a/render_json.go b/render_json.go index ec8b1a6..69d5f8f 100644 --- a/render_json.go +++ b/render_json.go @@ -8,5 +8,18 @@ import "encoding/json" // preserve human-readable precision. All other fields use their standard JSON // representations. func (p *Parser) RenderJSON(r *Recipe) ([]byte, error) { + return p.RenderJSONWithOptions(r, RenderOptions{}) +} + +// RenderJSONWithOptions serialises r as compact JSON with configurable behavior. +// +// When [RenderOptions.ConvertTemperature] is set, all detected temperatures in +// the Description and Instructions fields are converted to the target unit +// before serialisation. A rounding of 2 decimal places is used for converted +// temperature values. +func (p *Parser) RenderJSONWithOptions(r *Recipe, opts RenderOptions) ([]byte, error) { + if opts.ConvertTemperature != nil { + r = recipeWithConvertedTemperatures(r, *opts.ConvertTemperature, 2) + } return json.Marshal(r) } diff --git a/render_markdown.go b/render_markdown.go index 289a25b..a9b13bc 100644 --- a/render_markdown.go +++ b/render_markdown.go @@ -54,6 +54,20 @@ const mdGroupsTmpl = `{{ range . }} // 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 { + return p.RenderMarkdownWithOptions(r, rounding, RenderOptions{}) +} + +// RenderMarkdownWithOptions renders r as a RecipeMD-formatted markdown string +// with configurable behavior. +// +// When [RenderOptions.ConvertTemperature] is set, all detected temperatures in +// the Description and Instructions fields are converted to the target unit +// before rendering. The converted value and unit symbol replace the original +// text in the output. +func (p *Parser) RenderMarkdownWithOptions(r *Recipe, rounding int, opts RenderOptions) string { + if opts.ConvertTemperature != nil { + r = recipeWithConvertedTemperatures(r, *opts.ConvertTemperature, rounding) + } funcs := renderFuncMap(rounding) funcs["topGroups"] = func(groups []IngredientGroup) []mdGroupCtx { out := make([]mdGroupCtx, len(groups)) diff --git a/tags.go b/tags.go index f75fa18..52ddcbc 100644 --- a/tags.go +++ b/tags.go @@ -18,11 +18,17 @@ const ( Fahrenheit ) -// HTMLOptions controls optional behavior for [Parser.RenderHTMLWithOptions]. -type HTMLOptions struct { +// RenderOptions controls optional behavior for rendering methods. +// +// All rendering methods that accept RenderOptions apply temperature conversion +// to the free-text fields (Description, Instructions) of the recipe. The HTML +// renderer additionally wraps detected temperatures and times in tags +// with data attributes. +type RenderOptions struct { // ConvertTemperature, when non-nil, converts all detected temperatures - // to the specified unit system. The rendered output shows the converted - // value and unit, with the original preserved in data attributes. + // to the specified unit system. For HTML output the original value is + // preserved in data attributes; for Markdown and JSON the text is + // rewritten in place. ConvertTemperature *TemperatureUnit } @@ -157,6 +163,44 @@ func annotateTemperatures(html string, convert *TemperatureUnit, rounding int) s }) } +// convertTemperaturesInText replaces temperature patterns in plain text with +// the converted value and unit symbol. Unlike annotateTemperatures it does not +// add any HTML markup, making it suitable for Markdown and JSON output. +func convertTemperaturesInText(text string, to TemperatureUnit, rounding int) string { + return temperatureRe.ReplaceAllStringFunc(text, func(match string) string { + groups := temperatureRe.FindStringSubmatch(match) + if groups == nil { + return match + } + value, err := strconv.ParseFloat(groups[1], 64) + if err != nil { + return match + } + fromUnit := parseTemperatureUnit(groups[2]) + if fromUnit == to { + return match + } + converted := convertTemp(value, fromUnit, to) + return formatTagValue(converted, rounding) + tempSymbol(to) + }) +} + +// recipeWithConvertedTemperatures returns a shallow copy of r with temperature +// patterns in Description and Instructions converted to the target unit. The +// original Recipe is not modified. +func recipeWithConvertedTemperatures(r *Recipe, to TemperatureUnit, rounding int) *Recipe { + clone := *r + if clone.Description != nil { + d := convertTemperaturesInText(*clone.Description, to, rounding) + clone.Description = &d + } + if clone.Instructions != nil { + i := convertTemperaturesInText(*clone.Instructions, to, rounding) + clone.Instructions = &i + } + return &clone +} + func annotateTimes(html string) string { return replaceInTextNodes(html, func(text string) string { return timeRe.ReplaceAllStringFunc(text, func(match string) string { diff --git a/tags_test.go b/tags_test.go index d96ad49..3db5432 100644 --- a/tags_test.go +++ b/tags_test.go @@ -291,7 +291,7 @@ func TestRenderHTMLWithOptions(t *testing.T) { IngredientGroups: []IngredientGroup{}, Instructions: &instructions, } - got := p.RenderHTMLWithOptions(r, 2, HTMLOptions{}) + got := p.RenderHTMLWithOptions(r, 2, RenderOptions{}) if !strings.Contains(got, `class="recipemd-temperature"`) { t.Errorf("temperature not annotated in: %s", got) } @@ -311,7 +311,7 @@ func TestRenderHTMLWithOptions(t *testing.T) { Ingredients: []Ingredient{{Name: "flour"}}, IngredientGroups: []IngredientGroup{}, } - got := p.RenderHTMLWithOptions(r, 2, HTMLOptions{}) + got := p.RenderHTMLWithOptions(r, 2, RenderOptions{}) if !strings.Contains(got, `class="recipemd-temperature"`) { t.Errorf("temperature not annotated in description: %s", got) } @@ -329,7 +329,7 @@ func TestRenderHTMLWithOptions(t *testing.T) { Instructions: &instructions, } f := Fahrenheit - got := p.RenderHTMLWithOptions(r, 0, HTMLOptions{ConvertTemperature: &f}) + got := p.RenderHTMLWithOptions(r, 0, RenderOptions{ConvertTemperature: &f}) if !strings.Contains(got, `data-unit="F"`) { t.Errorf("should convert to F in: %s", got) } @@ -364,3 +364,176 @@ func TestRenderHTMLWithOptions(t *testing.T) { } }) } + +func TestConvertTemperaturesInText(t *testing.T) { + t.Parallel() + + t.Run("C to F", func(t *testing.T) { + t.Parallel() + got := convertTemperaturesInText("Bake at 100°C.", Fahrenheit, 2) + if got != "Bake at 212°F." { + t.Errorf("got %q, want %q", got, "Bake at 212°F.") + } + }) + + t.Run("F to C", func(t *testing.T) { + t.Parallel() + got := convertTemperaturesInText("Bake at 212°F.", Celsius, 2) + if got != "Bake at 100°C." { + t.Errorf("got %q, want %q", got, "Bake at 100°C.") + } + }) + + t.Run("same unit unchanged", func(t *testing.T) { + t.Parallel() + got := convertTemperaturesInText("Bake at 180°C.", Celsius, 2) + if got != "Bake at 180°C." { + t.Errorf("got %q, want %q", got, "Bake at 180°C.") + } + }) + + t.Run("word fahrenheit to celsius", func(t *testing.T) { + t.Parallel() + got := convertTemperaturesInText("Heat to 392 Fahrenheit.", Celsius, 2) + if got != "Heat to 200°C." { + t.Errorf("got %q, want %q", got, "Heat to 200°C.") + } + }) + + t.Run("multiple temperatures", func(t *testing.T) { + t.Parallel() + got := convertTemperaturesInText("First 100°C then 212°F.", Celsius, 2) + if got != "First 100°C then 100°C." { + t.Errorf("got %q, want %q", got, "First 100°C then 100°C.") + } + }) +} + +func TestRenderMarkdownWithOptions(t *testing.T) { + t.Parallel() + p := NewParser() + + t.Run("converts temperatures in instructions", func(t *testing.T) { + t.Parallel() + instructions := "Bake at 100°C for 30 minutes." + r := &Recipe{ + Title: "Cake", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + f := Fahrenheit + got := p.RenderMarkdownWithOptions(r, 2, RenderOptions{ConvertTemperature: &f}) + if !strings.Contains(got, "212°F") { + t.Errorf("temperature not converted in instructions: %s", got) + } + if strings.Contains(got, "100°C") { + t.Errorf("original temperature should be replaced: %s", got) + } + }) + + t.Run("converts temperatures in description", func(t *testing.T) { + t.Parallel() + desc := "A bread baked at 200°C." + r := &Recipe{ + Title: "Bread", + Description: &desc, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + } + f := Fahrenheit + got := p.RenderMarkdownWithOptions(r, 0, RenderOptions{ConvertTemperature: &f}) + if !strings.Contains(got, "392°F") { + t.Errorf("temperature not converted in description: %s", got) + } + }) + + t.Run("no conversion without option", func(t *testing.T) { + t.Parallel() + instructions := "Bake at 180°C." + r := &Recipe{ + Title: "Test", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + got := p.RenderMarkdownWithOptions(r, 2, RenderOptions{}) + if !strings.Contains(got, "180°C") { + t.Errorf("temperature should be unchanged: %s", got) + } + }) + + t.Run("does not mutate original recipe", func(t *testing.T) { + t.Parallel() + instructions := "Bake at 100°C." + r := &Recipe{ + Title: "Test", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + f := Fahrenheit + p.RenderMarkdownWithOptions(r, 2, RenderOptions{ConvertTemperature: &f}) + if *r.Instructions != "Bake at 100°C." { + t.Errorf("original recipe was mutated: %s", *r.Instructions) + } + }) +} + +func TestRenderJSONWithOptions(t *testing.T) { + t.Parallel() + p := NewParser() + + t.Run("converts temperatures", func(t *testing.T) { + t.Parallel() + instructions := "Bake at 100°C." + r := &Recipe{ + Title: "Test", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + f := Fahrenheit + got, err := p.RenderJSONWithOptions(r, RenderOptions{ConvertTemperature: &f}) + if err != nil { + t.Fatal(err) + } + s := string(got) + if !strings.Contains(s, "212°F") { + t.Errorf("temperature not converted in JSON: %s", s) + } + if strings.Contains(s, "100°C") { + t.Errorf("original temperature should be replaced in JSON: %s", s) + } + }) + + t.Run("no conversion without option", func(t *testing.T) { + t.Parallel() + instructions := "Bake at 180°C." + r := &Recipe{ + Title: "Test", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + got, err := p.RenderJSONWithOptions(r, RenderOptions{}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "180°C") { + t.Errorf("temperature should be unchanged: %s", string(got)) + } + }) +} From 940a37f052a34c56efbc23b5be282d3d07627484 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 01:16:39 +0000 Subject: [PATCH 3/3] refactor: move temperature conversion to Recipe.ConvertTemperatures mutation Replace RenderOptions/WithOptions pattern with a Recipe.ConvertTemperatures mutation method, consistent with the existing Scale/ScaleForYield design. Callers convert before rendering rather than passing options through each renderer. - Add Recipe.ConvertTemperatures(to, rounding) that rewrites Description and Instructions in place - Remove RenderOptions, RenderHTMLWithOptions, RenderMarkdownWithOptions, RenderJSONWithOptions - Simplify annotateTemperatures to annotation-only (no conversion logic) - Renderers return to their original signatures https://claude.ai/code/session_016zD2491XMNatvyYLR2zghb --- recipe.go | 21 +++ render_html.go | 17 +-- render_json.go | 13 -- render_markdown.go | 14 -- tags.go | 94 ++++---------- tags_test.go | 310 ++++++++++++++++----------------------------- 6 files changed, 158 insertions(+), 311 deletions(-) diff --git a/recipe.go b/recipe.go index 1d58c1d..1525e17 100644 --- a/recipe.go +++ b/recipe.go @@ -237,3 +237,24 @@ func (g *IngredientGroup) LeafIngredients() []Ingredient { } return result } + +// ConvertTemperatures rewrites temperature patterns found in the recipe's +// Description and Instructions fields to the target unit system. +// +// Detected patterns include "180°C", "350 °F", "200 celsius", and +// "375 fahrenheit". Temperatures already in the target unit are left +// unchanged. The numeric value is converted and rounded to rounding +// decimal places (trailing zeros removed). +// +// This is a mutation method like [Recipe.Scale]; it modifies the receiver +// in place. Call it before rendering to get converted output in any format. +func (r *Recipe) ConvertTemperatures(to TemperatureUnit, rounding int) { + if r.Description != nil { + d := convertTemperaturesInText(*r.Description, to, rounding) + r.Description = &d + } + if r.Instructions != nil { + i := convertTemperaturesInText(*r.Instructions, to, rounding) + r.Instructions = &i + } +} diff --git a/render_html.go b/render_html.go index ae46932..98bd8c6 100644 --- a/render_html.go +++ b/render_html.go @@ -91,21 +91,6 @@ const htmlGroupsTmpl = `{{ range . -}} // 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 { - return p.RenderHTMLWithOptions(r, rounding, RenderOptions{}) -} - -// RenderHTMLWithOptions renders r as an HTML
element with -// configurable behavior. -// -// Temperatures and times detected in the output text are wrapped in -// tags with CSS classes "recipemd-temperature" and "recipemd-time" -// respectively, along with data attributes for the parsed value and unit. -// -// When [RenderOptions.ConvertTemperature] is set, all detected temperatures -// are converted to the target unit. The converted value is displayed while -// the original value and unit are preserved in data-original-value and -// data-original-unit attributes. -func (p *Parser) RenderHTMLWithOptions(r *Recipe, rounding int, opts RenderOptions) string { funcs := htmlFuncMap(p, rounding) funcs["topGroups"] = func(groups []IngredientGroup) []htmlGroupCtx { out := make([]htmlGroupCtx, len(groups)) @@ -123,7 +108,7 @@ func (p *Parser) RenderHTMLWithOptions(r *Recipe, rounding int, opts RenderOptio _ = tmpl.Execute(&buf, r) html := buf.String() - html = annotateTemperatures(html, opts.ConvertTemperature, rounding) + html = annotateTemperatures(html, rounding) html = annotateTimes(html) return html } diff --git a/render_json.go b/render_json.go index 69d5f8f..ec8b1a6 100644 --- a/render_json.go +++ b/render_json.go @@ -8,18 +8,5 @@ import "encoding/json" // preserve human-readable precision. All other fields use their standard JSON // representations. func (p *Parser) RenderJSON(r *Recipe) ([]byte, error) { - return p.RenderJSONWithOptions(r, RenderOptions{}) -} - -// RenderJSONWithOptions serialises r as compact JSON with configurable behavior. -// -// When [RenderOptions.ConvertTemperature] is set, all detected temperatures in -// the Description and Instructions fields are converted to the target unit -// before serialisation. A rounding of 2 decimal places is used for converted -// temperature values. -func (p *Parser) RenderJSONWithOptions(r *Recipe, opts RenderOptions) ([]byte, error) { - if opts.ConvertTemperature != nil { - r = recipeWithConvertedTemperatures(r, *opts.ConvertTemperature, 2) - } return json.Marshal(r) } diff --git a/render_markdown.go b/render_markdown.go index a9b13bc..289a25b 100644 --- a/render_markdown.go +++ b/render_markdown.go @@ -54,20 +54,6 @@ const mdGroupsTmpl = `{{ range . }} // 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 { - return p.RenderMarkdownWithOptions(r, rounding, RenderOptions{}) -} - -// RenderMarkdownWithOptions renders r as a RecipeMD-formatted markdown string -// with configurable behavior. -// -// When [RenderOptions.ConvertTemperature] is set, all detected temperatures in -// the Description and Instructions fields are converted to the target unit -// before rendering. The converted value and unit symbol replace the original -// text in the output. -func (p *Parser) RenderMarkdownWithOptions(r *Recipe, rounding int, opts RenderOptions) string { - if opts.ConvertTemperature != nil { - r = recipeWithConvertedTemperatures(r, *opts.ConvertTemperature, rounding) - } funcs := renderFuncMap(rounding) funcs["topGroups"] = func(groups []IngredientGroup) []mdGroupCtx { out := make([]mdGroupCtx, len(groups)) diff --git a/tags.go b/tags.go index 52ddcbc..5ac6677 100644 --- a/tags.go +++ b/tags.go @@ -18,20 +18,6 @@ const ( Fahrenheit ) -// RenderOptions controls optional behavior for rendering methods. -// -// All rendering methods that accept RenderOptions apply temperature conversion -// to the free-text fields (Description, Instructions) of the recipe. The HTML -// renderer additionally wraps detected temperatures and times in tags -// with data attributes. -type RenderOptions struct { - // ConvertTemperature, when non-nil, converts all detected temperatures - // to the specified unit system. For HTML output the original value is - // preserved in data attributes; for Markdown and JSON the text is - // rewritten in place. - ConvertTemperature *TemperatureUnit -} - // temperatureRe matches patterns like "180°C", "350 °F", "200 celsius", "375 fahrenheit". // It requires either the ° symbol or the full word to avoid false positives on bare C/F. var temperatureRe = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*(°\s*[CcFf]|[Cc]elsius|[Ff]ahrenheit)`) @@ -127,45 +113,9 @@ func formatTagValue(v float64, rounding int) string { return s } -func annotateTemperatures(html string, convert *TemperatureUnit, rounding int) string { - return replaceInTextNodes(html, func(text string) string { - return temperatureRe.ReplaceAllStringFunc(text, func(match string) string { - groups := temperatureRe.FindStringSubmatch(match) - if groups == nil { - return match - } - value, err := strconv.ParseFloat(groups[1], 64) - if err != nil { - return match - } - fromUnit := parseTemperatureUnit(groups[2]) - - if convert != nil && *convert != fromUnit { - toUnit := *convert - converted := convertTemp(value, fromUnit, toUnit) - cStr := formatTagValue(converted, rounding) - vStr := formatTagValue(value, rounding) - return fmt.Sprintf( - `%s%s`, - tempUnitCode(toUnit), cStr, - tempUnitCode(fromUnit), vStr, - cStr, tempSymbol(toUnit), - ) - } - - return fmt.Sprintf( - `%s`, - tempUnitCode(fromUnit), - formatTagValue(value, rounding), - match, - ) - }) - }) -} - -// convertTemperaturesInText replaces temperature patterns in plain text with -// the converted value and unit symbol. Unlike annotateTemperatures it does not -// add any HTML markup, making it suitable for Markdown and JSON output. +// convertTemperaturesInText replaces temperature patterns in plain text, +// converting values from one unit system to another. Temperatures already in +// the target unit are left unchanged. func convertTemperaturesInText(text string, to TemperatureUnit, rounding int) string { return temperatureRe.ReplaceAllStringFunc(text, func(match string) string { groups := temperatureRe.FindStringSubmatch(match) @@ -185,22 +135,32 @@ func convertTemperaturesInText(text string, to TemperatureUnit, rounding int) st }) } -// recipeWithConvertedTemperatures returns a shallow copy of r with temperature -// patterns in Description and Instructions converted to the target unit. The -// original Recipe is not modified. -func recipeWithConvertedTemperatures(r *Recipe, to TemperatureUnit, rounding int) *Recipe { - clone := *r - if clone.Description != nil { - d := convertTemperaturesInText(*clone.Description, to, rounding) - clone.Description = &d - } - if clone.Instructions != nil { - i := convertTemperaturesInText(*clone.Instructions, to, rounding) - clone.Instructions = &i - } - return &clone +// annotateTemperatures wraps detected temperature patterns in HTML text nodes +// with tags and data attributes. +func annotateTemperatures(html string, rounding int) string { + return replaceInTextNodes(html, func(text string) string { + return temperatureRe.ReplaceAllStringFunc(text, func(match string) string { + groups := temperatureRe.FindStringSubmatch(match) + if groups == nil { + return match + } + value, err := strconv.ParseFloat(groups[1], 64) + if err != nil { + return match + } + fromUnit := parseTemperatureUnit(groups[2]) + return fmt.Sprintf( + `%s`, + tempUnitCode(fromUnit), + formatTagValue(value, rounding), + match, + ) + }) + }) } +// annotateTimes wraps detected time patterns in HTML text nodes with +// tags and data attributes. func annotateTimes(html string) string { return replaceInTextNodes(html, func(text string) string { return timeRe.ReplaceAllStringFunc(text, func(match string) string { diff --git a/tags_test.go b/tags_test.go index 3db5432..9b0ab6d 100644 --- a/tags_test.go +++ b/tags_test.go @@ -1,6 +1,7 @@ package recipemd import ( + "encoding/json" "strings" "testing" ) @@ -10,7 +11,7 @@ func TestAnnotateTemperatures(t *testing.T) { t.Run("degree celsius", func(t *testing.T) { t.Parallel() - got := annotateTemperatures("Bake at 180°C for a bit.", nil, 2) + got := annotateTemperatures("Bake at 180°C for a bit.", 2) if !strings.Contains(got, `class="recipemd-temperature"`) { t.Fatalf("missing temperature class in: %s", got) } @@ -27,7 +28,7 @@ func TestAnnotateTemperatures(t *testing.T) { t.Run("degree fahrenheit", func(t *testing.T) { t.Parallel() - got := annotateTemperatures("Bake at 350°F.", nil, 2) + got := annotateTemperatures("Bake at 350°F.", 2) if !strings.Contains(got, `data-unit="F"`) { t.Errorf("missing data-unit F in: %s", got) } @@ -38,7 +39,7 @@ func TestAnnotateTemperatures(t *testing.T) { t.Run("space before degree symbol", func(t *testing.T) { t.Parallel() - got := annotateTemperatures("Set oven to 200 °C.", nil, 2) + got := annotateTemperatures("Set oven to 200 °C.", 2) if !strings.Contains(got, `data-unit="C"`) { t.Errorf("missing data-unit in: %s", got) } @@ -49,7 +50,7 @@ func TestAnnotateTemperatures(t *testing.T) { t.Run("word celsius", func(t *testing.T) { t.Parallel() - got := annotateTemperatures("Heat to 100 celsius.", nil, 2) + got := annotateTemperatures("Heat to 100 celsius.", 2) if !strings.Contains(got, `data-unit="C"`) { t.Errorf("missing data-unit in: %s", got) } @@ -60,7 +61,7 @@ func TestAnnotateTemperatures(t *testing.T) { t.Run("word Fahrenheit", func(t *testing.T) { t.Parallel() - got := annotateTemperatures("Heat to 212 Fahrenheit.", nil, 2) + got := annotateTemperatures("Heat to 212 Fahrenheit.", 2) if !strings.Contains(got, `data-unit="F"`) { t.Errorf("missing data-unit in: %s", got) } @@ -68,64 +69,16 @@ func TestAnnotateTemperatures(t *testing.T) { t.Run("decimal temperature", func(t *testing.T) { t.Parallel() - got := annotateTemperatures("Cook at 162.5°C.", nil, 2) + got := annotateTemperatures("Cook at 162.5°C.", 2) if !strings.Contains(got, `data-value="162.5"`) { t.Errorf("missing decimal data-value in: %s", got) } }) - t.Run("convert C to F", func(t *testing.T) { - t.Parallel() - f := Fahrenheit - got := annotateTemperatures("Bake at 100°C.", &f, 2) - if !strings.Contains(got, `data-unit="F"`) { - t.Errorf("should be converted to F in: %s", got) - } - if !strings.Contains(got, `data-value="212"`) { - t.Errorf("100C should convert to 212F in: %s", got) - } - if !strings.Contains(got, `data-original-unit="C"`) { - t.Errorf("missing original unit in: %s", got) - } - if !strings.Contains(got, `data-original-value="100"`) { - t.Errorf("missing original value in: %s", got) - } - if !strings.Contains(got, `>212°F`) { - t.Errorf("displayed text should show converted value in: %s", got) - } - }) - - t.Run("convert F to C", func(t *testing.T) { - t.Parallel() - c := Celsius - got := annotateTemperatures("Bake at 212°F.", &c, 2) - if !strings.Contains(got, `data-unit="C"`) { - t.Errorf("should be converted to C in: %s", got) - } - if !strings.Contains(got, `data-value="100"`) { - t.Errorf("212F should convert to 100C in: %s", got) - } - if !strings.Contains(got, `>100°C`) { - t.Errorf("displayed text should show converted value in: %s", got) - } - }) - - t.Run("no conversion when same unit", func(t *testing.T) { - t.Parallel() - c := Celsius - got := annotateTemperatures("Heat to 180°C.", &c, 2) - if strings.Contains(got, `data-original`) { - t.Errorf("should not have original attrs when unit matches in: %s", got) - } - if !strings.Contains(got, `>180°C`) { - t.Errorf("text should be preserved as-is in: %s", got) - } - }) - t.Run("inside HTML preserves tags", func(t *testing.T) { t.Parallel() input := `

Bake at 180°C

` - got := annotateTemperatures(input, nil, 2) + got := annotateTemperatures(input, 2) if !strings.Contains(got, "

") { t.Errorf("HTML tags damaged in: %s", got) } @@ -137,7 +90,7 @@ func TestAnnotateTemperatures(t *testing.T) { t.Run("does not modify HTML attributes", func(t *testing.T) { t.Parallel() input := `

some text
` - got := annotateTemperatures(input, nil, 2) + got := annotateTemperatures(input, 2) if !strings.Contains(got, `data-temp="180°C"`) { t.Errorf("HTML attribute modified in: %s", got) } @@ -145,7 +98,7 @@ func TestAnnotateTemperatures(t *testing.T) { t.Run("multiple temperatures", func(t *testing.T) { t.Parallel() - got := annotateTemperatures("First 180°C then 200°F.", nil, 2) + got := annotateTemperatures("First 180°C then 200°F.", 2) if strings.Count(got, `class="recipemd-temperature"`) != 2 { t.Errorf("expected 2 temperature annotations in: %s", got) } @@ -178,9 +131,6 @@ func TestAnnotateTimes(t *testing.T) { if !strings.Contains(got, `data-unit="min"`) { t.Errorf("missing data-unit in: %s", got) } - if !strings.Contains(got, `data-value="5"`) { - t.Errorf("missing data-value in: %s", got) - } }) t.Run("mins abbreviation", func(t *testing.T) { @@ -276,95 +226,6 @@ func TestAnnotateTimes(t *testing.T) { }) } -func TestRenderHTMLWithOptions(t *testing.T) { - t.Parallel() - p := NewParser() - - t.Run("annotates temperatures in instructions", func(t *testing.T) { - t.Parallel() - instructions := "Bake at 180°C for 30 minutes." - r := &Recipe{ - Title: "Cake", - Yields: []Amount{}, - Tags: []string{}, - Ingredients: []Ingredient{{Name: "flour"}}, - IngredientGroups: []IngredientGroup{}, - Instructions: &instructions, - } - got := p.RenderHTMLWithOptions(r, 2, RenderOptions{}) - if !strings.Contains(got, `class="recipemd-temperature"`) { - t.Errorf("temperature not annotated in: %s", got) - } - if !strings.Contains(got, `class="recipemd-time"`) { - t.Errorf("time not annotated in: %s", got) - } - }) - - t.Run("annotates temperatures in description", func(t *testing.T) { - t.Parallel() - desc := "A bread baked at 220°C." - r := &Recipe{ - Title: "Bread", - Description: &desc, - Yields: []Amount{}, - Tags: []string{}, - Ingredients: []Ingredient{{Name: "flour"}}, - IngredientGroups: []IngredientGroup{}, - } - got := p.RenderHTMLWithOptions(r, 2, RenderOptions{}) - if !strings.Contains(got, `class="recipemd-temperature"`) { - t.Errorf("temperature not annotated in description: %s", got) - } - }) - - t.Run("converts celsius to fahrenheit", func(t *testing.T) { - t.Parallel() - instructions := "Bake at 200°C." - r := &Recipe{ - Title: "Pie", - Yields: []Amount{}, - Tags: []string{}, - Ingredients: []Ingredient{{Name: "apples"}}, - IngredientGroups: []IngredientGroup{}, - Instructions: &instructions, - } - f := Fahrenheit - got := p.RenderHTMLWithOptions(r, 0, RenderOptions{ConvertTemperature: &f}) - if !strings.Contains(got, `data-unit="F"`) { - t.Errorf("should convert to F in: %s", got) - } - if !strings.Contains(got, `data-value="392"`) { - t.Errorf("200C = 392F, got: %s", got) - } - if !strings.Contains(got, `data-original-unit="C"`) { - t.Errorf("missing original unit in: %s", got) - } - if !strings.Contains(got, `data-original-value="200"`) { - t.Errorf("missing original value in: %s", got) - } - }) - - t.Run("backward compatible with RenderHTML", func(t *testing.T) { - t.Parallel() - instructions := "Bake at 180°C for 30 minutes." - r := &Recipe{ - Title: "Test", - Yields: []Amount{}, - Tags: []string{}, - Ingredients: []Ingredient{{Name: "flour"}}, - IngredientGroups: []IngredientGroup{}, - Instructions: &instructions, - } - got := p.RenderHTML(r, 2) - if !strings.Contains(got, `class="recipemd-temperature"`) { - t.Errorf("RenderHTML should also annotate temperatures: %s", got) - } - if !strings.Contains(got, `class="recipemd-time"`) { - t.Errorf("RenderHTML should also annotate times: %s", got) - } - }) -} - func TestConvertTemperaturesInText(t *testing.T) { t.Parallel() @@ -409,50 +270,44 @@ func TestConvertTemperaturesInText(t *testing.T) { }) } -func TestRenderMarkdownWithOptions(t *testing.T) { +func TestRecipeConvertTemperatures(t *testing.T) { t.Parallel() - p := NewParser() - t.Run("converts temperatures in instructions", func(t *testing.T) { + t.Run("converts description and instructions", func(t *testing.T) { t.Parallel() - instructions := "Bake at 100°C for 30 minutes." + desc := "Preheat oven to 200°C." + instructions := "Bake at 200°C for 30 minutes." r := &Recipe{ Title: "Cake", + Description: &desc, Yields: []Amount{}, Tags: []string{}, Ingredients: []Ingredient{{Name: "flour"}}, IngredientGroups: []IngredientGroup{}, Instructions: &instructions, } - f := Fahrenheit - got := p.RenderMarkdownWithOptions(r, 2, RenderOptions{ConvertTemperature: &f}) - if !strings.Contains(got, "212°F") { - t.Errorf("temperature not converted in instructions: %s", got) + r.ConvertTemperatures(Fahrenheit, 0) + if !strings.Contains(*r.Description, "392°F") { + t.Errorf("description not converted: %s", *r.Description) } - if strings.Contains(got, "100°C") { - t.Errorf("original temperature should be replaced: %s", got) + if !strings.Contains(*r.Instructions, "392°F") { + t.Errorf("instructions not converted: %s", *r.Instructions) } }) - t.Run("converts temperatures in description", func(t *testing.T) { + t.Run("nil fields are safe", func(t *testing.T) { t.Parallel() - desc := "A bread baked at 200°C." r := &Recipe{ - Title: "Bread", - Description: &desc, + Title: "Simple", Yields: []Amount{}, Tags: []string{}, - Ingredients: []Ingredient{{Name: "flour"}}, + Ingredients: []Ingredient{{Name: "salt"}}, IngredientGroups: []IngredientGroup{}, } - f := Fahrenheit - got := p.RenderMarkdownWithOptions(r, 0, RenderOptions{ConvertTemperature: &f}) - if !strings.Contains(got, "392°F") { - t.Errorf("temperature not converted in description: %s", got) - } + r.ConvertTemperatures(Fahrenheit, 2) // should not panic }) - t.Run("no conversion without option", func(t *testing.T) { + t.Run("same unit is no-op", func(t *testing.T) { t.Parallel() instructions := "Bake at 180°C." r := &Recipe{ @@ -463,37 +318,37 @@ func TestRenderMarkdownWithOptions(t *testing.T) { IngredientGroups: []IngredientGroup{}, Instructions: &instructions, } - got := p.RenderMarkdownWithOptions(r, 2, RenderOptions{}) - if !strings.Contains(got, "180°C") { - t.Errorf("temperature should be unchanged: %s", got) + r.ConvertTemperatures(Celsius, 2) + if *r.Instructions != "Bake at 180°C." { + t.Errorf("same-unit conversion should be no-op: %s", *r.Instructions) } }) - t.Run("does not mutate original recipe", func(t *testing.T) { + t.Run("works with markdown rendering", func(t *testing.T) { t.Parallel() - instructions := "Bake at 100°C." + p := NewParser() + instructions := "Bake at 100°C for 30 minutes." r := &Recipe{ - Title: "Test", + Title: "Cake", Yields: []Amount{}, Tags: []string{}, Ingredients: []Ingredient{{Name: "flour"}}, IngredientGroups: []IngredientGroup{}, Instructions: &instructions, } - f := Fahrenheit - p.RenderMarkdownWithOptions(r, 2, RenderOptions{ConvertTemperature: &f}) - if *r.Instructions != "Bake at 100°C." { - t.Errorf("original recipe was mutated: %s", *r.Instructions) + r.ConvertTemperatures(Fahrenheit, 2) + got := p.RenderMarkdown(r, 2) + if !strings.Contains(got, "212°F") { + t.Errorf("converted temperature not in markdown output: %s", got) + } + if strings.Contains(got, "100°C") { + t.Errorf("original temperature should be gone: %s", got) } }) -} -func TestRenderJSONWithOptions(t *testing.T) { - t.Parallel() - p := NewParser() - - t.Run("converts temperatures", func(t *testing.T) { + t.Run("works with JSON rendering", func(t *testing.T) { t.Parallel() + p := NewParser() instructions := "Bake at 100°C." r := &Recipe{ Title: "Test", @@ -503,37 +358,90 @@ func TestRenderJSONWithOptions(t *testing.T) { IngredientGroups: []IngredientGroup{}, Instructions: &instructions, } - f := Fahrenheit - got, err := p.RenderJSONWithOptions(r, RenderOptions{ConvertTemperature: &f}) + r.ConvertTemperatures(Fahrenheit, 2) + got, err := p.RenderJSON(r) if err != nil { t.Fatal(err) } - s := string(got) - if !strings.Contains(s, "212°F") { - t.Errorf("temperature not converted in JSON: %s", s) + var parsed map[string]any + if err := json.Unmarshal(got, &parsed); err != nil { + t.Fatal(err) + } + instr, ok := parsed["instructions"].(string) + if !ok { + t.Fatal("instructions missing from JSON") } - if strings.Contains(s, "100°C") { - t.Errorf("original temperature should be replaced in JSON: %s", s) + if !strings.Contains(instr, "212°F") { + t.Errorf("converted temperature not in JSON: %s", instr) } }) - t.Run("no conversion without option", func(t *testing.T) { + t.Run("works with HTML rendering", func(t *testing.T) { t.Parallel() - instructions := "Bake at 180°C." + p := NewParser() + instructions := "Bake at 100°C for 30 minutes." r := &Recipe{ - Title: "Test", + Title: "Cake", Yields: []Amount{}, Tags: []string{}, Ingredients: []Ingredient{{Name: "flour"}}, IngredientGroups: []IngredientGroup{}, Instructions: &instructions, } - got, err := p.RenderJSONWithOptions(r, RenderOptions{}) - if err != nil { - t.Fatal(err) + r.ConvertTemperatures(Fahrenheit, 0) + got := p.RenderHTML(r, 0) + // Should show converted value in a temperature span + if !strings.Contains(got, `class="recipemd-temperature"`) { + t.Errorf("temperature not annotated in HTML: %s", got) } - if !strings.Contains(string(got), "180°C") { - t.Errorf("temperature should be unchanged: %s", string(got)) + if !strings.Contains(got, `data-unit="F"`) { + t.Errorf("should show F after conversion: %s", got) + } + // Time annotation should still work + if !strings.Contains(got, `class="recipemd-time"`) { + t.Errorf("time not annotated in HTML: %s", got) + } + }) +} + +func TestRenderHTMLAnnotation(t *testing.T) { + t.Parallel() + p := NewParser() + + t.Run("annotates temperatures in instructions", func(t *testing.T) { + t.Parallel() + instructions := "Bake at 180°C for 30 minutes." + r := &Recipe{ + Title: "Cake", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + got := p.RenderHTML(r, 2) + if !strings.Contains(got, `class="recipemd-temperature"`) { + t.Errorf("temperature not annotated in: %s", got) + } + if !strings.Contains(got, `class="recipemd-time"`) { + t.Errorf("time not annotated in: %s", got) + } + }) + + t.Run("annotates temperatures in description", func(t *testing.T) { + t.Parallel() + desc := "A bread baked at 220°C." + r := &Recipe{ + Title: "Bread", + Description: &desc, + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + } + got := p.RenderHTML(r, 2) + if !strings.Contains(got, `class="recipemd-temperature"`) { + t.Errorf("temperature not annotated in description: %s", got) } }) }