Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions recipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
6 changes: 5 additions & 1 deletion render_html.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
184 changes: 184 additions & 0 deletions tags.go
Original file line number Diff line number Diff line change
@@ -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 <span class="recipemd-temperature"> 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(
`<span class="recipemd-temperature" data-unit="%s" data-value="%s">%s</span>`,
tempUnitCode(fromUnit),
formatTagValue(value, rounding),
match,
)
})
})
}

// annotateTimes wraps detected time patterns in HTML text nodes with
// <span class="recipemd-time"> 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(
`<span class="recipemd-time" data-unit="%s" data-value="%s">%s</span>`,
norm,
strconv.FormatFloat(value, 'f', -1, 64),
match,
)
})
})
}
Loading