nice clean recipes pear.dunkirk.sh
recipes
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use proper parser

+250 -87
+3
go.mod
··· 3 3 go 1.26.2 4 4 5 5 require ( 6 + github.com/aquilax/cooklang-go v0.2.0 6 7 github.com/go-chi/chi/v5 v5.2.5 7 8 github.com/mattn/go-sqlite3 v1.14.42 8 9 golang.org/x/net v0.53.0 9 10 ) 11 + 12 + require gopkg.in/yaml.v3 v3.0.1 // indirect
+6
go.sum
··· 1 + github.com/aquilax/cooklang-go v0.2.0 h1:oC0hGjqSkjPSNz0FGb9bwTvOYgX2euUxOQWDzzOyChQ= 2 + github.com/aquilax/cooklang-go v0.2.0/go.mod h1:w8UlyehrdabhHxM1Qg+c6U0Scthpy8OOQbCEmtNyvTY= 1 3 github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= 2 4 github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 3 5 github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= 4 6 github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 5 7 golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= 6 8 golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= 9 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+16 -3
internal/cooklang/export.go
··· 60 60 return sb.String() 61 61 } 62 62 63 - var timeRe = regexp.MustCompile(`(?i)\b(\d+)\s*(seconds?|minutes?|mins?|hours?|hrs?|h)\b`) 63 + var timeRangeExportRe = regexp.MustCompile(`(?i)\b(\d+-\d+)\s*(seconds?|minutes?|mins?|hours?|hrs?|h)\b`) 64 + var timeRe = regexp.MustCompile(`(?i)(^|[^0-9-])(\d+)\s*(seconds?|minutes?|mins?|hours?|hrs?|h)\b`) 64 65 65 66 func AnnotateStepForDisplay(text string, ingredients []models.Ingredient) string { 66 67 index := buildIngredientIndex(ingredients) ··· 117 118 annotated = annotated[:m.start] + m.repl + annotated[m.end:] 118 119 } 119 120 120 - annotated = timeRe.ReplaceAllStringFunc(annotated, func(matchStr string) string { 121 - parts := timeRe.FindStringSubmatch(matchStr) 121 + annotated = timeRangeExportRe.ReplaceAllStringFunc(annotated, func(matchStr string) string { 122 + parts := timeRangeExportRe.FindStringSubmatch(matchStr) 122 123 if len(parts) >= 3 { 123 124 qty := parts[1] 124 125 unit := parts[2] 125 126 unit = normalizeTimeUnit(unit) 126 127 return fmt.Sprintf("~{%s%%%s}", qty, unit) 128 + } 129 + return matchStr 130 + }) 131 + 132 + annotated = timeRe.ReplaceAllStringFunc(annotated, func(matchStr string) string { 133 + parts := timeRe.FindStringSubmatch(matchStr) 134 + if len(parts) >= 4 { 135 + leading := parts[1] 136 + qty := parts[2] 137 + unit := parts[3] 138 + unit = normalizeTimeUnit(unit) 139 + return leading + fmt.Sprintf("~{%s%%%s}", qty, unit) 127 140 } 128 141 return matchStr 129 142 })
+199 -83
internal/cooklang/parse.go
··· 1 1 package cooklang 2 2 3 3 import ( 4 + "fmt" 5 + "html/template" 4 6 "regexp" 7 + "sort" 5 8 "strconv" 6 9 "strings" 10 + 11 + cooklang "github.com/aquilax/cooklang-go" 7 12 ) 8 13 9 - type AnnotatedStep struct { 10 - Text string 11 - Items []AnnotatedItem 12 - } 14 + func ParseAndRender(text string) template.HTML { 15 + if strings.TrimSpace(text) == "" { 16 + return template.HTML("") 17 + } 13 18 14 - type AnnotatedItem struct { 15 - Type string // "ingredient", "timer", "cookware" 16 - Name string 17 - Quantity string 18 - Unit string 19 - } 19 + // Pre-process: replace timer range syntax ~{N-M%unit} with plain text "N-M unit" 20 + // since cooklang-go doesn't support range quantities for timers 21 + preprocessed := timerRangeSyntaxRe.ReplaceAllString(text, "$1 $2") 20 22 21 - var parseIngredientRe = regexp.MustCompile(`@([\w\s]+?)\{([^}%]*)(?:%([^}]*))?\}`) 22 - var parseTimerRe = regexp.MustCompile(`~([\w\s]*?)\{([^}%]*)(?:%([^}]*))?\}`) 23 + recipe, err := cooklang.ParseString(preprocessed) 24 + if err != nil || len(recipe.Steps) == 0 { 25 + return regexRender(text) 26 + } 23 27 24 - func ParseSteps(instructions []string) []AnnotatedStep { 25 - var steps []AnnotatedStep 26 - for _, text := range instructions { 27 - step := AnnotatedStep{Text: text} 28 - step.Items = parseAnnotations(text) 29 - steps = append(steps, step) 28 + var sb strings.Builder 29 + for i, step := range recipe.Steps { 30 + renderStep(step, &sb) 31 + if i < len(recipe.Steps)-1 { 32 + sb.WriteString("\n") 33 + } 30 34 } 31 - return steps 35 + return template.HTML(sb.String()) 32 36 } 33 37 34 - func parseAnnotations(text string) []AnnotatedItem { 35 - var items []AnnotatedItem 38 + var timerRangeSyntaxRe = regexp.MustCompile(`~\{(\d+-\d+)%(\w+)\}`) 36 39 37 - matches := parseIngredientRe.FindAllStringSubmatchIndex(text, -1) 38 - for _, m := range matches { 39 - name := text[m[2]:m[3]] 40 - qty := "" 41 - unit := "" 42 - if m[4] != -1 { 43 - qty = text[m[4]:m[5]] 40 + var cooklangIngredientRe = regexp.MustCompile(`@([\w\s/]+)\{[^}]*\}|@([\w/]+)`) 41 + var cooklangTimerRe = regexp.MustCompile(`~\{(\d+)%(\w+)\}`) 42 + 43 + func regexRender(text string) template.HTML { 44 + out := text 45 + 46 + // Replace @Name{qty%unit} or @Name{qty} or @Name{} with <span class="ing"> 47 + out = cooklangIngredientRe.ReplaceAllStringFunc(out, func(match string) string { 48 + parts := cooklangIngredientRe.FindStringSubmatch(match) 49 + name := parts[1] 50 + if name == "" { 51 + name = parts[2] 44 52 } 45 - if m[6] != -1 { 46 - unit = text[m[6]:m[7]] 53 + name = strings.TrimSpace(name) 54 + return fmt.Sprintf(`<span class="ing">%s</span>`, escHTML(name)) 55 + }) 56 + 57 + // Replace ~{qty%unit} with <span class="tmr"> 58 + out = cooklangTimerRe.ReplaceAllStringFunc(out, func(match string) string { 59 + parts := cooklangTimerRe.FindStringSubmatch(match) 60 + qty := parts[1] 61 + unit := parts[2] 62 + display := qty + " " + unit 63 + qtyInt, _ := strconv.Atoi(qty) 64 + secs := timerSeconds(float64(qtyInt), unit) 65 + return fmt.Sprintf(`<span class="tmr" data-seconds="%d">%s</span>`, secs, escHTML(display)) 66 + }) 67 + 68 + // Replace time ranges like "2-3 minutes" 69 + out = timeRangeRe.ReplaceAllStringFunc(out, func(match string) string { 70 + parts := timeRangeRe.FindStringSubmatch(match) 71 + if len(parts) >= 3 { 72 + qty := parts[1] 73 + unit := parts[2] 74 + display := qty + " " + unit 75 + secs := timeRangeSeconds(qty, unit) 76 + return fmt.Sprintf(`<span class="tmr" data-seconds="%d">%s</span>`, secs, escHTML(display)) 47 77 } 48 - items = append(items, AnnotatedItem{Type: "ingredient", Name: name, Quantity: qty, Unit: unit}) 78 + return match 79 + }) 80 + 81 + return template.HTML(out) 82 + } 83 + 84 + type htmlSpan struct { 85 + start int 86 + end int 87 + html string 88 + } 89 + 90 + func renderStep(step cooklang.Step, sb *strings.Builder) { 91 + dirs := step.Directions 92 + if dirs == "" { 93 + return 49 94 } 50 95 51 - matches = parseTimerRe.FindAllStringSubmatchIndex(text, -1) 52 - for _, m := range matches { 53 - name := "" 54 - if m[2] != -1 { 55 - name = text[m[2]:m[3]] 96 + var spans []htmlSpan 97 + 98 + for _, ing := range step.Ingredients { 99 + display := ing.Name 100 + if ing.Amount.QuantityRaw != "" { 101 + qty := ing.Amount.QuantityRaw 102 + if ing.Amount.Unit != "" { 103 + qty = qty + " " + ing.Amount.Unit 104 + } 105 + display = qty + " " + ing.Name 106 + } else if ing.Amount.IsNumeric && ing.Amount.Quantity > 0 && ing.Amount.Quantity != 1 { 107 + qty := strconv.FormatFloat(ing.Amount.Quantity, 'f', -1, 64) 108 + if ing.Amount.Unit != "" { 109 + qty = qty + " " + ing.Amount.Unit 110 + } 111 + display = qty + " " + ing.Name 56 112 } 57 - qty := "" 58 - unit := "" 59 - if m[4] != -1 { 60 - qty = text[m[4]:m[5]] 113 + idx := strings.Index(dirs, ing.Name) 114 + if idx >= 0 { 115 + spans = append(spans, htmlSpan{ 116 + start: idx, 117 + end: idx + len(ing.Name), 118 + html: fmt.Sprintf(`<span class="ing">%s</span>`, escHTML(display)), 119 + }) 61 120 } 62 - if m[6] != -1 { 63 - unit = text[m[6]:m[7]] 121 + } 122 + 123 + for _, tmr := range step.Timers { 124 + search := formatTimerSearch(tmr.Duration, tmr.Unit) 125 + display := search 126 + secs := timerSeconds(tmr.Duration, tmr.Unit) 127 + idx := strings.Index(dirs, search) 128 + if idx >= 0 { 129 + spans = append(spans, htmlSpan{ 130 + start: idx, 131 + end: idx + len(search), 132 + html: fmt.Sprintf(`<span class="tmr" data-seconds="%d">%s</span>`, secs, escHTML(display)), 133 + }) 64 134 } 65 - items = append(items, AnnotatedItem{Type: "timer", Name: name, Quantity: qty, Unit: unit}) 66 135 } 67 136 68 - return items 69 - } 137 + // Find time patterns not caught by cooklang syntax (e.g. "2-3 minutes", "4-5 seconds") 138 + for _, m := range timeRangeRe.FindAllStringSubmatchIndex(dirs, -1) { 139 + fullStart, fullEnd := m[0], m[1] 140 + qtyStart, qtyEnd := m[2], m[3] 141 + unitStart, unitEnd := m[4], m[5] 142 + qty := dirs[qtyStart:qtyEnd] 143 + unit := dirs[unitStart:unitEnd] 144 + display := qty + " " + unit 145 + secs := timeRangeSeconds(qty, unit) 146 + spans = append(spans, htmlSpan{ 147 + start: fullStart, 148 + end: fullEnd, 149 + html: fmt.Sprintf(`<span class="tmr" data-seconds="%d">%s</span>`, secs, escHTML(display)), 150 + }) 151 + } 152 + 153 + if len(spans) == 0 { 154 + sb.WriteString(escHTML(dirs)) 155 + return 156 + } 70 157 71 - func RenderStepHTML(text string) string { 72 - out := text 158 + sort.Slice(spans, func(i, j int) bool { 159 + return spans[i].start < spans[j].start 160 + }) 73 161 74 - out = parseIngredientRe.ReplaceAllStringFunc(out, func(match string) string { 75 - parts := parseIngredientRe.FindStringSubmatch(match) 76 - name := strings.TrimSpace(parts[1]) 77 - qty := parts[2] 78 - unit := "" 79 - if len(parts) >= 4 && parts[3] != "" { 80 - unit = parts[3] 162 + filtered := []htmlSpan{spans[0]} 163 + for _, s := range spans[1:] { 164 + last := &filtered[len(filtered)-1] 165 + if s.start >= last.end { 166 + filtered = append(filtered, s) 81 167 } 82 - display := name 83 - if qty != "" { 84 - display = qty 85 - if unit != "" { 86 - display = qty + " " + unit + " " + name 87 - } 168 + } 169 + 170 + pos := 0 171 + for _, s := range filtered { 172 + if s.start > pos { 173 + sb.WriteString(escHTML(dirs[pos:s.start])) 88 174 } 89 - return `<span class="ing">` + escHTML(display) + `</span>` 90 - }) 175 + sb.WriteString(s.html) 176 + pos = s.end 177 + } 178 + if pos < len(dirs) { 179 + sb.WriteString(escHTML(dirs[pos:])) 180 + } 181 + } 91 182 92 - out = parseTimerRe.ReplaceAllStringFunc(out, func(match string) string { 93 - parts := parseTimerRe.FindStringSubmatch(match) 94 - qty := parts[2] 95 - unit := "" 96 - if len(parts) >= 4 && parts[3] != "" { 97 - unit = parts[3] 98 - } 99 - display := qty 183 + func formatTimerSearch(duration float64, unit string) string { 184 + if duration == float64(int(duration)) { 185 + d := int(duration) 186 + s := strconv.Itoa(d) 100 187 if unit != "" { 101 - display = qty + " " + unit 188 + return s + " " + unit 102 189 } 103 - secs := timerToSeconds(qty, unit) 104 - return `<span class="tmr" data-seconds="` + strconv.Itoa(secs) + `">` + escHTML(display) + `</span>` 105 - }) 190 + return s 191 + } 192 + s := strconv.FormatFloat(duration, 'f', -1, 64) 193 + if unit != "" { 194 + return s + " " + unit 195 + } 196 + return s 197 + } 106 198 107 - return out 199 + func timerSeconds(duration float64, unit string) int { 200 + d := int(duration) 201 + switch strings.ToLower(unit) { 202 + case "second", "seconds", "sec", "secs": 203 + return d 204 + case "minute", "minutes", "min", "mins": 205 + return d * 60 206 + case "hour", "hours", "hr", "hrs", "h": 207 + return d * 3600 208 + default: 209 + if d == 0 { 210 + return 0 211 + } 212 + return d * 60 213 + } 108 214 } 109 215 110 216 func escHTML(s string) string { ··· 115 221 return s 116 222 } 117 223 118 - func timerToSeconds(qty, unit string) int { 119 - v, err := strconv.Atoi(qty) 224 + func RenderStepHTML(text string) string { 225 + return string(ParseAndRender(text)) 226 + } 227 + 228 + var timeRangeRe = regexp.MustCompile(`(?i)\b(\d+-\d+)\s+(seconds?|minutes?|mins?|hours?|hrs?|h)\b`) 229 + 230 + func timeRangeSeconds(qty, unit string) int { 231 + parts := strings.SplitN(qty, "-", 2) 232 + if len(parts) != 2 { 233 + return 0 234 + } 235 + hi, err := strconv.Atoi(parts[1]) 120 236 if err != nil { 121 237 return 0 122 238 } 123 239 switch strings.ToLower(unit) { 124 - case "second", "seconds": 125 - return v 240 + case "second", "seconds", "sec", "secs": 241 + return hi 126 242 case "minute", "minutes", "min", "mins": 127 - return v * 60 243 + return hi * 60 128 244 case "hour", "hours", "hr", "hrs", "h": 129 - return v * 3600 245 + return hi * 3600 130 246 default: 131 - return v * 60 247 + return hi * 60 132 248 } 133 - } 249 + }
+25
internal/extract/hrecipe/hrecipe.go
··· 27 27 } 28 28 if summary := findTextByClass(recipeNode, "p-summary"); summary != "" { 29 29 recipe.Description = summary 30 + } else if desc := findDescriptionFallback(recipeNode); desc != "" { 31 + recipe.Description = desc 30 32 } 31 33 if yield := findTextByClass(recipeNode, "p-yield"); yield != "" { 32 34 recipe.Yield = yield ··· 58 60 } 59 61 60 62 return recipe, true 63 + } 64 + 65 + func findDescriptionFallback(n *html.Node) string { 66 + var f func(*html.Node) string 67 + f = func(n *html.Node) string { 68 + if n.Type == html.ElementNode { 69 + if n.Data == "aside" || n.Data == "p" { 70 + if !hasClass(n, "p-ingredient") && !hasClass(n, "e-instructions") && !hasClass(n, "p-name") && !hasClass(n, "p-yield") && !hasClass(n, "dt-duration") { 71 + text := textContent(n) 72 + if len(text) > 20 { 73 + return text 74 + } 75 + } 76 + } 77 + } 78 + for c := n.FirstChild; c != nil; c = c.NextSibling { 79 + if result := f(c); result != "" { 80 + return result 81 + } 82 + } 83 + return "" 84 + } 85 + return f(n) 61 86 } 62 87 63 88 func findByClass(n *html.Node, class string) *html.Node {
+1 -1
main.go
··· 256 256 257 257 func renderStep(text string, ingredients []models.Ingredient) template.HTML { 258 258 annotated := cooklang.AnnotateStepForDisplay(text, ingredients) 259 - return template.HTML(cooklang.RenderStepHTML(annotated)) 259 + return cooklang.ParseAndRender(annotated) 260 260 } 261 261 262 262 func recipeToJSONLD(r *models.Recipe) map[string]interface{} {