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 00c53dd..98bd8c6 100644 --- a/render_html.go +++ b/render_html.go @@ -106,7 +106,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, 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..5ac6677 --- /dev/null +++ b/tags.go @@ -0,0 +1,184 @@ +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 +) + +// 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 +} + +// 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) + 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) + }) +} + +// 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 { + 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..9b0ab6d --- /dev/null +++ b/tags_test.go @@ -0,0 +1,447 @@ +package recipemd + +import ( + "encoding/json" + "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.", 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.", 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.", 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.", 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.", 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.", 2) + if !strings.Contains(got, `data-value="162.5"`) { + t.Errorf("missing decimal data-value in: %s", got) + } + }) + + t.Run("inside HTML preserves tags", func(t *testing.T) { + t.Parallel() + input := `
Bake at 180°C
` + got := annotateTemperatures(input, 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 := `
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 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 TestRecipeConvertTemperatures(t *testing.T) { + t.Parallel() + + t.Run("converts description and instructions", func(t *testing.T) { + t.Parallel() + 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, + } + r.ConvertTemperatures(Fahrenheit, 0) + if !strings.Contains(*r.Description, "392°F") { + t.Errorf("description not converted: %s", *r.Description) + } + if !strings.Contains(*r.Instructions, "392°F") { + t.Errorf("instructions not converted: %s", *r.Instructions) + } + }) + + t.Run("nil fields are safe", func(t *testing.T) { + t.Parallel() + r := &Recipe{ + Title: "Simple", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "salt"}}, + IngredientGroups: []IngredientGroup{}, + } + r.ConvertTemperatures(Fahrenheit, 2) // should not panic + }) + + t.Run("same unit is no-op", 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, + } + 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("works with markdown rendering", func(t *testing.T) { + t.Parallel() + p := NewParser() + instructions := "Bake at 100°C for 30 minutes." + r := &Recipe{ + Title: "Cake", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &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) + } + }) + + t.Run("works with JSON rendering", func(t *testing.T) { + t.Parallel() + p := NewParser() + instructions := "Bake at 100°C." + r := &Recipe{ + Title: "Test", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + r.ConvertTemperatures(Fahrenheit, 2) + 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.Fatal(err) + } + instr, ok := parsed["instructions"].(string) + if !ok { + t.Fatal("instructions missing from JSON") + } + if !strings.Contains(instr, "212°F") { + t.Errorf("converted temperature not in JSON: %s", instr) + } + }) + + t.Run("works with HTML rendering", func(t *testing.T) { + t.Parallel() + p := NewParser() + instructions := "Bake at 100°C for 30 minutes." + r := &Recipe{ + Title: "Cake", + Yields: []Amount{}, + Tags: []string{}, + Ingredients: []Ingredient{{Name: "flour"}}, + IngredientGroups: []IngredientGroup{}, + Instructions: &instructions, + } + 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(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) + } + }) +}