nice clean recipes pear.dunkirk.sh
recipes
1
fork

Configure Feed

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

feat: add wp recipes

+603 -24
+33 -8
internal/cooklang/export.go
··· 50 50 } 51 51 52 52 if len(unmatched) > 0 { 53 - sb.WriteString("\n\n== Ingredients ==\n") 54 - for _, ing := range unmatched { 55 - ref := ingredientCookRef(ing) 56 - sb.WriteString(fmt.Sprintf("- %s\n", ref)) 53 + groups := groupIngredients(unmatched) 54 + for _, g := range groups { 55 + sb.WriteString("\n\n== ") 56 + if g.Name != "" { 57 + sb.WriteString(g.Name + " ") 58 + } 59 + sb.WriteString("Ingredients ==\n") 60 + for _, ing := range g.Items { 61 + ref := ingredientCookRef(ing) 62 + sb.WriteString(fmt.Sprintf("- %s\n", ref)) 63 + } 57 64 } 58 65 } 59 66 60 67 return sb.String() 68 + } 69 + 70 + type ingredientGroup struct { 71 + Name string 72 + Items []models.Ingredient 73 + } 74 + 75 + func groupIngredients(ings []models.Ingredient) []ingredientGroup { 76 + var groups []ingredientGroup 77 + var current *ingredientGroup 78 + for _, ing := range ings { 79 + if current == nil || ing.Group != current.Name { 80 + groups = append(groups, ingredientGroup{Name: ing.Group}) 81 + current = &groups[len(groups)-1] 82 + } 83 + current.Items = append(current.Items, ing) 84 + } 85 + return groups 61 86 } 62 87 63 88 var timeRangeExportRe = regexp.MustCompile(`(?i)\b(\d+-\d+)\s*(seconds?|minutes?|mins?|hours?|hrs?|h)\b`) ··· 250 275 return index 251 276 } 252 277 253 - var ingredientPrefixRe = regexp.MustCompile(`^(?i)(\d+\s*\S*\s+)?(?:of\s+)?(.+)$`) 278 + var ingredientPrefixRe = regexp.MustCompile(`^(?i)(\d+\s*\d*/?\d*\s+)?(?:of\s+)?(.+)$`) 254 279 255 280 func extractIngredientName(raw string) string { 256 281 raw = strings.TrimSpace(raw) 257 - parts := ingredientPrefixRe.FindStringSubmatch(raw) 258 - if len(parts) >= 3 && parts[2] != "" { 259 - name := parts[2] 282 + m := ingredientPrefixRe.FindStringSubmatch(raw) 283 + if len(m) >= 3 && m[2] != "" { 284 + name := m[2] 260 285 name = strings.TrimPrefix(name, "of ") 261 286 name = strings.TrimSpace(name) 262 287 name = strings.TrimSuffix(name, ",")
+152
internal/cooklang/parse.go
··· 11 11 cooklang "github.com/aquilax/cooklang-go" 12 12 ) 13 13 14 + func Highlight(raw string) template.HTML { 15 + if strings.TrimSpace(raw) == "" { 16 + return template.HTML("") 17 + } 18 + 19 + var buf strings.Builder 20 + lines := strings.Split(raw, "\n") 21 + for i, line := range lines { 22 + buf.WriteString(highlightLine(line)) 23 + if i < len(lines)-1 { 24 + buf.WriteByte('\n') 25 + } 26 + } 27 + return template.HTML(buf.String()) 28 + } 29 + 30 + var metaDelimRe = regexp.MustCompile(`^---\s*$`) 31 + var metaKVRe = regexp.MustCompile(`^(\w+):\s*(.*)$`) 32 + var sectionRe = regexp.MustCompile(`^==\s+.+\s+==\s*$`) 33 + 34 + func highlightLine(line string) string { 35 + if metaDelimRe.MatchString(line) { 36 + return `<span class="ck-delim">---</span>` 37 + } 38 + if m := metaKVRe.FindStringSubmatch(line); m != nil { 39 + if m[2] != "" { 40 + return `<span class="ck-key">` + escHTML(m[1]) + `:</span> <span class="ck-val">` + escHTML(m[2]) + `</span>` 41 + } 42 + return `<span class="ck-key">` + escHTML(m[1]) + `:</span>` 43 + } 44 + if sectionRe.MatchString(line) { 45 + return `<span class="ck-section">` + escHTML(line) + `</span>` 46 + } 47 + return highlightCookSyntax(line) 48 + } 49 + 50 + func highlightCookSyntax(line string) string { 51 + preprocessed := timerRangeSyntaxRe.ReplaceAllString(line, "$1 $2") 52 + recipe, err := cooklang.ParseString(preprocessed) 53 + if err != nil || len(recipe.Steps) == 0 { 54 + return regexHighlight(line) 55 + } 56 + 57 + type span struct { 58 + start int 59 + end int 60 + html string 61 + } 62 + 63 + var spans []span 64 + for _, step := range recipe.Steps { 65 + dirs := step.Directions 66 + for _, ing := range step.Ingredients { 67 + idx := strings.Index(dirs, ing.Name) 68 + if idx >= 0 { 69 + name := escHTML(ing.Name) 70 + if ing.Amount.QuantityRaw != "" { 71 + qty := escHTML(ing.Amount.QuantityRaw) 72 + if ing.Amount.Unit != "" { 73 + unit := escHTML(ing.Amount.Unit) 74 + spans = append(spans, span{idx, idx + len(ing.Name), 75 + fmt.Sprintf(`<span class="ck-ing">@%s{<span class="ck-qty">%s</span>%%<span class="ck-unit">%s</span>}</span>`, name, qty, unit)}) 76 + } else { 77 + spans = append(spans, span{idx, idx + len(ing.Name), 78 + fmt.Sprintf(`<span class="ck-ing">@%s{<span class="ck-qty">%s</span>}</span>`, name, qty)}) 79 + } 80 + } else { 81 + spans = append(spans, span{idx, idx + len(ing.Name), 82 + fmt.Sprintf(`<span class="ck-ing">@%s</span>`, name)}) 83 + } 84 + } 85 + } 86 + for _, tmr := range step.Timers { 87 + search := formatTimerSearch(tmr.Duration, tmr.Unit) 88 + idx := strings.Index(dirs, search) 89 + if idx >= 0 { 90 + qty := escHTML(strconv.FormatFloat(tmr.Duration, 'f', -1, 64)) 91 + unit := escHTML(tmr.Unit) 92 + if unit != "" { 93 + spans = append(spans, span{idx, idx + len(search), 94 + fmt.Sprintf(`<span class="ck-tmr">~{<span class="ck-qty">%s</span>%%<span class="ck-unit">%s</span>}</span>`, qty, unit)}) 95 + } else { 96 + spans = append(spans, span{idx, idx + len(search), 97 + fmt.Sprintf(`<span class="ck-tmr">~{<span class="ck-qty">%s</span>}</span>`, qty)}) 98 + } 99 + } 100 + } 101 + } 102 + 103 + if len(spans) == 0 { 104 + return escHTML(line) 105 + } 106 + 107 + sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) 108 + filtered := []span{spans[0]} 109 + for _, s := range spans[1:] { 110 + last := &filtered[len(filtered)-1] 111 + if s.start >= last.end { 112 + filtered = append(filtered, s) 113 + } 114 + } 115 + 116 + dirs := recipe.Steps[0].Directions 117 + var buf strings.Builder 118 + pos := 0 119 + for _, s := range filtered { 120 + if s.start > pos { 121 + buf.WriteString(escHTML(dirs[pos:s.start])) 122 + } 123 + buf.WriteString(s.html) 124 + pos = s.end 125 + } 126 + if pos < len(dirs) { 127 + buf.WriteString(escHTML(dirs[pos:])) 128 + } 129 + return buf.String() 130 + } 131 + 132 + var hlIngredientRe = regexp.MustCompile(`@([\w\s/]+)\{([^}%]*)(?:%([^}]*))?\}|@([\w/]+)`) 133 + var hlTimerRe = regexp.MustCompile(`~\{([^%]*)(?:%([^}]*))?\}`) 134 + 135 + func regexHighlight(line string) string { 136 + out := escHTML(line) 137 + out = hlIngredientRe.ReplaceAllStringFunc(out, func(match string) string { 138 + parts := hlIngredientRe.FindStringSubmatch(match) 139 + name := parts[1] 140 + if name == "" { 141 + name = parts[4] 142 + } 143 + name = strings.TrimSpace(name) 144 + qty := parts[2] 145 + unit := parts[3] 146 + if qty != "" && unit != "" { 147 + return fmt.Sprintf(`<span class="ck-ing">@%s{<span class="ck-qty">%s</span>%%<span class="ck-unit">%s</span>}</span>`, escHTML(name), escHTML(qty), escHTML(unit)) 148 + } 149 + if qty != "" { 150 + return fmt.Sprintf(`<span class="ck-ing">@%s{<span class="ck-qty">%s</span>}</span>`, escHTML(name), escHTML(qty)) 151 + } 152 + return fmt.Sprintf(`<span class="ck-ing">@%s</span>`, escHTML(name)) 153 + }) 154 + out = hlTimerRe.ReplaceAllStringFunc(out, func(match string) string { 155 + parts := hlTimerRe.FindStringSubmatch(match) 156 + qty := parts[1] 157 + unit := parts[2] 158 + if unit != "" { 159 + return fmt.Sprintf(`<span class="ck-tmr">~{<span class="ck-qty">%s</span>%%<span class="ck-unit">%s</span>}</span>`, escHTML(qty), escHTML(unit)) 160 + } 161 + return fmt.Sprintf(`<span class="ck-tmr">~{<span class="ck-qty">%s</span>}</span>`, escHTML(qty)) 162 + }) 163 + return out 164 + } 165 + 14 166 func ParseAndRender(text string) template.HTML { 15 167 if strings.TrimSpace(text) == "" { 16 168 return template.HTML("")
+2 -1
internal/extract/hrecipe/hrecipe.go
··· 3 3 import ( 4 4 "strings" 5 5 6 + "tangled.org/dunkirk.sh/pare/internal/extract/schema" 6 7 "tangled.org/dunkirk.sh/pare/internal/models" 7 8 8 9 "golang.org/x/net/html" ··· 42 43 43 44 ingredients := findAllTextByClass(recipeNode, "p-ingredient") 44 45 for _, ing := range ingredients { 45 - recipe.Ingredients = append(recipe.Ingredients, models.Ingredient{RawText: ing}) 46 + recipe.Ingredients = append(recipe.Ingredients, schema.ParseIngredient(ing)) 46 47 } 47 48 48 49 instructions := findInnerHTMLByClass(recipeNode, "e-instructions")
+7
internal/extract/pipeline.go
··· 12 12 13 13 "tangled.org/dunkirk.sh/pare/internal/extract/hrecipe" 14 14 "tangled.org/dunkirk.sh/pare/internal/extract/schema" 15 + "tangled.org/dunkirk.sh/pare/internal/extract/wprm" 15 16 "tangled.org/dunkirk.sh/pare/internal/models" 16 17 ) 17 18 ··· 55 56 } else { 56 57 return &Result{Error: fmt.Errorf("fetching page: %w", err)} 57 58 } 59 + } 60 + 61 + if recipe, ok := wprm.Extract(body); ok { 62 + recipe.SourceURL = targetURL 63 + recipe.SourceDomain = domainOf(targetURL) 64 + return &Result{Recipe: recipe} 58 65 } 59 66 60 67 if recipe, ok := schema.Extract(body); ok {
+20 -4
internal/extract/schema/jsonld.go
··· 355 355 return ingredients 356 356 } 357 357 358 - var ingredientRe = regexp.MustCompile(`^(\d+\s*\d*/\d*|\d+\.?\d*)\s+(cups?|tablespoons?|teaspoons?|tbsp|tsp|c|oz|lbs?|pounds?|grams?|g|kg|ml|liters?|l|pinch|dash|cloves?|slices?|pieces?|heads?|sprigs?|bunches?|cans?|bottles?|packages?|sticks?|quarts?|pints?|gallons?)\s+(.+)$`) 358 + var unitList = `cups?|tablespoons?|teaspoons?|tbsp|tsp|c|oz|lbs?|pounds?|grams?|g|kg|ml|liters?|l|pinch(?:es)?|dash(?:es)?|cloves?|slices?|pieces?|heads?|sprigs?|bunch(?:es)?|cans?|bottles?|packages?|sticks?|quarts?|pints?|gallons?` 359 359 360 - var ingredientFracRe = regexp.MustCompile(`^(\d+\s+\d/\d+)\s+(cups?|tablespoons?|teaspoons?|tbsp|tsp|c|oz|lbs?|pounds?|grams?|g|kg|ml|liters?|l|pinch|dash|cloves?|slices?|pieces?|heads?|sprigs?|bunches?|cans?|bottles?|packages?|sticks?|quarts?|pints?|gallons?)\s+(.+)$`) 360 + var numPat = `(\d+\s+\d/\d+|\d+/\d+|\d+\.?\d*)` 361 + 362 + var ingredientRangeRe = regexp.MustCompile(`^(\d+[/\d]*\s*[-–]\s*\d+[/\.\d]*)\s+(` + unitList + `)\s+(.+)$`) 363 + var ingredientFracRe = regexp.MustCompile(`^` + numPat + `\s+(` + unitList + `)\s+(.+)$`) 364 + var ingredientFracAdjUnitRe = regexp.MustCompile(`^` + numPat + `\s+((?:\w+\s+){1,2})(` + unitList + `)\s+(.+)$`) 365 + var ingredientNoUnitRe = regexp.MustCompile(`^` + numPat + `\s+(.+)$`) 366 + 367 + func ParseIngredient(text string) models.Ingredient { 368 + return parseIngredient(text) 369 + } 361 370 362 371 func parseIngredient(text string) models.Ingredient { 363 372 text = strings.TrimSpace(text) 364 373 365 - if m := ingredientFracRe.FindStringSubmatch(text); len(m) == 4 { 374 + if m := ingredientRangeRe.FindStringSubmatch(text); len(m) == 4 { 366 375 return models.Ingredient{RawText: text, Quantity: m[1], Unit: m[2], Name: m[3]} 367 376 } 368 - if m := ingredientRe.FindStringSubmatch(text); len(m) == 4 { 377 + if m := ingredientFracAdjUnitRe.FindStringSubmatch(text); len(m) == 5 { 378 + name := strings.TrimSpace(m[2]) + " " + m[4] 379 + return models.Ingredient{RawText: text, Quantity: m[1], Unit: m[3], Name: name} 380 + } 381 + if m := ingredientFracRe.FindStringSubmatch(text); len(m) == 4 { 369 382 return models.Ingredient{RawText: text, Quantity: m[1], Unit: m[2], Name: m[3]} 383 + } 384 + if m := ingredientNoUnitRe.FindStringSubmatch(text); len(m) == 3 { 385 + return models.Ingredient{RawText: text, Quantity: m[1], Name: m[2]} 370 386 } 371 387 return models.Ingredient{RawText: text} 372 388 }
+180
internal/extract/wprm/wprm.go
··· 1 + package wprm 2 + 3 + import ( 4 + "encoding/json" 5 + "regexp" 6 + "strings" 7 + 8 + "tangled.org/dunkirk.sh/pare/internal/models" 9 + 10 + "golang.org/x/net/html" 11 + ) 12 + 13 + func Extract(body string) (*models.Recipe, bool) { 14 + doc, err := html.Parse(strings.NewReader(body)) 15 + if err != nil { 16 + return nil, false 17 + } 18 + 19 + data := findWPRMData(doc) 20 + if data == nil { 21 + return nil, false 22 + } 23 + 24 + for _, recipe := range data { 25 + if r := parseWPRMRecipe(recipe); r != nil { 26 + return r, true 27 + } 28 + } 29 + return nil, false 30 + } 31 + 32 + var wprmRe = regexp.MustCompile(`window\.wprm_recipes\s*=\s*(\{.+?\})\s*;`) 33 + 34 + func findWPRMData(n *html.Node) map[string]json.RawMessage { 35 + var f func(*html.Node) map[string]json.RawMessage 36 + f = func(n *html.Node) map[string]json.RawMessage { 37 + if n.Type == html.ElementNode && n.Data == "script" { 38 + text := collectText(n) 39 + if m := wprmRe.FindStringSubmatch(text); len(m) == 2 { 40 + var data map[string]json.RawMessage 41 + if json.Unmarshal([]byte(m[1]), &data) == nil { 42 + return data 43 + } 44 + } 45 + } 46 + for c := n.FirstChild; c != nil; c = c.NextSibling { 47 + if result := f(c); result != nil { 48 + return result 49 + } 50 + } 51 + return nil 52 + } 53 + return f(n) 54 + } 55 + 56 + func collectText(n *html.Node) string { 57 + if n.Type == html.TextNode { 58 + return n.Data 59 + } 60 + var sb strings.Builder 61 + for c := n.FirstChild; c != nil; c = c.NextSibling { 62 + sb.WriteString(collectText(c)) 63 + } 64 + return sb.String() 65 + } 66 + 67 + func parseWPRMRecipe(raw json.RawMessage) *models.Recipe { 68 + var r struct { 69 + Name string `json:"name"` 70 + Description string `json:"summary"` 71 + ImageURL string `json:"image_url"` 72 + OriginalServings string `json:"originalServings"` 73 + PrepTime string `json:"prep_time"` 74 + CookTime string `json:"cook_time"` 75 + TotalTime string `json:"total_time"` 76 + Ingredients []struct { 77 + Amount string `json:"amount"` 78 + Unit string `json:"unit"` 79 + Name string `json:"name"` 80 + Notes string `json:"notes"` 81 + UID int `json:"uid"` 82 + } `json:"ingredients"` 83 + Instructions []struct { 84 + Text string `json:"text"` 85 + } `json:"instructions"` 86 + IngredientGroups []struct { 87 + Name string `json:"name"` 88 + Ingredients []int `json:"ingredients"` 89 + } `json:"ingredient_groups"` 90 + } 91 + 92 + if err := json.Unmarshal(raw, &r); err != nil { 93 + return nil 94 + } 95 + if r.Name == "" { 96 + return nil 97 + } 98 + 99 + recipe := &models.Recipe{ 100 + Name: r.Name, 101 + Description: r.Description, 102 + ImageURL: r.ImageURL, 103 + Yield: r.OriginalServings, 104 + ExtractionMethod: "wprm", 105 + } 106 + 107 + if r.PrepTime != "" { 108 + recipe.PrepTime = "PT" + r.PrepTime 109 + } 110 + if r.CookTime != "" { 111 + recipe.CookTime = "PT" + r.CookTime 112 + } 113 + if r.TotalTime != "" { 114 + recipe.TotalTime = "PT" + r.TotalTime 115 + } 116 + 117 + uidToIng := make(map[int]models.Ingredient) 118 + for _, ing := range r.Ingredients { 119 + uidToIng[ing.UID] = buildWPRMIngredient(ing, "") 120 + } 121 + 122 + if len(r.IngredientGroups) > 0 { 123 + assigned := make(map[int]bool) 124 + for _, group := range r.IngredientGroups { 125 + for _, uid := range group.Ingredients { 126 + if ing, ok := uidToIng[uid]; ok { 127 + ing.Group = group.Name 128 + recipe.Ingredients = append(recipe.Ingredients, ing) 129 + assigned[uid] = true 130 + } 131 + } 132 + } 133 + for _, ing := range r.Ingredients { 134 + if !assigned[ing.UID] { 135 + recipe.Ingredients = append(recipe.Ingredients, uidToIng[ing.UID]) 136 + } 137 + } 138 + } else { 139 + for _, ing := range r.Ingredients { 140 + recipe.Ingredients = append(recipe.Ingredients, uidToIng[ing.UID]) 141 + } 142 + } 143 + 144 + for _, instr := range r.Instructions { 145 + if instr.Text != "" { 146 + recipe.Instructions = append(recipe.Instructions, models.Instruction{Text: instr.Text}) 147 + } 148 + } 149 + 150 + return recipe 151 + } 152 + 153 + func buildWPRMIngredient(ing struct { 154 + Amount string `json:"amount"` 155 + Unit string `json:"unit"` 156 + Name string `json:"name"` 157 + Notes string `json:"notes"` 158 + UID int `json:"uid"` 159 + }, group string) models.Ingredient { 160 + var rawParts []string 161 + if ing.Amount != "" { 162 + rawParts = append(rawParts, ing.Amount) 163 + } 164 + if ing.Unit != "" { 165 + rawParts = append(rawParts, ing.Unit) 166 + } 167 + rawParts = append(rawParts, ing.Name) 168 + if ing.Notes != "" { 169 + rawParts = append(rawParts, ing.Notes) 170 + } 171 + rawText := strings.Join(rawParts, " ") 172 + 173 + return models.Ingredient{ 174 + RawText: rawText, 175 + Quantity: ing.Amount, 176 + Unit: ing.Unit, 177 + Name: ing.Name, 178 + Group: group, 179 + } 180 + }
+1
internal/models/recipe.go
··· 23 23 Quantity string 24 24 Unit string 25 25 Name string 26 + Group string 26 27 } 27 28 28 29 type Instruction struct {
+69 -1
main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "encoding/json" 4 5 "flag" 5 6 "fmt" 6 7 "html/template" ··· 52 53 "isoToSeconds": isoToSeconds, 53 54 "cleanSource": cleanSource, 54 55 "renderStep": renderStep, 56 + "cookHighlight": cookHighlight, 57 + "groupIngredients": groupIngredients, 58 + "json": func(v string) string { b, _ := json.Marshal(v); return string(b) }, 55 59 "trimProto": func(s string) string { return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") }, 56 60 }).ParseFS(ui.Templates, "templates/*.html") 57 61 if err != nil { ··· 79 83 } 80 84 81 85 r.Get("/", srv.handleIndex) 86 + r.Get("/cook", srv.handleCookView) 82 87 r.Get("/export.cook", srv.handleCookExport) 83 88 r.Get("/recipe", srv.handleRecipeQuery) 84 89 r.Get("/status", srv.handleStatus) ··· 279 284 } 280 285 281 286 func (s *Server) renderRecipe(w http.ResponseWriter, recipe *models.Recipe, targetURL string) { 287 + filename := strings.ReplaceAll(recipe.Name, " ", "-") + ".cook" 282 288 data := map[string]interface{}{ 283 289 "Recipe": recipe, 284 290 "TargetURL": targetURL, 291 + "Filename": filename, 285 292 "GitHash": s.gitHash, 286 293 "BaseURL": s.baseURL, 287 294 } ··· 289 296 s.templates.ExecuteTemplate(w, "recipe_page", data) 290 297 } 291 298 299 + func (s *Server) handleCookView(w http.ResponseWriter, r *http.Request) { 300 + targetURL := r.URL.Query().Get("url") 301 + if targetURL == "" { 302 + http.Error(w, "missing url parameter", http.StatusBadRequest) 303 + return 304 + } 305 + if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { 306 + targetURL = "https://" + targetURL 307 + } 308 + 309 + recipe, err := s.cache.Get(targetURL) 310 + if err != nil { 311 + log.Printf("cache read error: %v", err) 312 + } 313 + if recipe == nil { 314 + result := s.pipeline.Extract(targetURL) 315 + if result.Error != nil { 316 + s.renderError(w, result.Error.Error(), targetURL) 317 + return 318 + } 319 + recipe = result.Recipe 320 + } 321 + 322 + cook := cooklang.Export(recipe) 323 + filename := strings.ReplaceAll(recipe.Name, " ", "-") + ".cook" 324 + 325 + data := map[string]interface{}{ 326 + "Recipe": recipe, 327 + "TargetURL": targetURL, 328 + "CookFile": cook, 329 + "Filename": filename, 330 + "GitHash": s.gitHash, 331 + "BaseURL": s.baseURL, 332 + } 333 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 334 + s.templates.ExecuteTemplate(w, "cook_page", data) 335 + } 336 + 292 337 func (s *Server) handleCookExport(w http.ResponseWriter, r *http.Request) { 293 338 targetURL := r.URL.Query().Get("url") 294 339 if targetURL == "" { ··· 313 358 } 314 359 315 360 cook := cooklang.Export(recipe) 316 - filename := url.PathEscape(recipe.Name) + ".cook" 361 + filename := strings.ReplaceAll(recipe.Name, " ", "-") + ".cook" 317 362 318 363 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 319 364 w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) ··· 416 461 func renderStep(text string, ingredients []models.Ingredient) template.HTML { 417 462 annotated := cooklang.AnnotateStepForDisplay(text, ingredients) 418 463 return cooklang.ParseAndRender(annotated) 464 + } 465 + 466 + func groupIngredients(ings []models.Ingredient) []ingredientGroup { 467 + var groups []ingredientGroup 468 + var current *ingredientGroup 469 + for i, ing := range ings { 470 + if current == nil || ing.Group != current.Name { 471 + groups = append(groups, ingredientGroup{Name: ing.Group, StartIdx: i}) 472 + current = &groups[len(groups)-1] 473 + } 474 + current.Items = append(current.Items, ing) 475 + } 476 + return groups 477 + } 478 + 479 + type ingredientGroup struct { 480 + Name string 481 + Items []models.Ingredient 482 + StartIdx int 483 + } 484 + 485 + func cookHighlight(raw string) template.HTML { 486 + return cooklang.Highlight(raw) 419 487 } 420 488 421 489 func recipeToJSONLD(r *models.Recipe) map[string]interface{} {
+74 -1
ui/static/style.css
··· 198 198 flex-shrink:0; 199 199 } 200 200 .ingredient-list li label{cursor:pointer;flex:1} 201 + .ingredient-group{ 202 + font-size:0.8rem; 203 + text-transform:uppercase; 204 + letter-spacing:0.06em; 205 + color:var(--text-muted); 206 + margin:1rem 0 0.25rem; 207 + font-family:'Poppins',system-ui,sans-serif; 208 + font-weight:600; 209 + } 201 210 202 211 .instruction-list{counter-reset:step;list-style:none} 203 212 .instruction-list li{ ··· 282 291 .tmr{font-weight:500;cursor:pointer;transition:color 0.2s;white-space:nowrap} 283 292 .tmr:hover{color:var(--accent)} 284 293 .tmr::before{content:"⏲";font-size:0.8em;margin-right:0.4em} 294 + 295 + .cook-link{ 296 + font-family:'SF Mono','Fira Code','Cascadia Code',monospace; 297 + font-size:0.7rem; 298 + color:var(--text-muted); 299 + background:var(--check-bg); 300 + padding:0.15rem 0.4rem; 301 + border-radius:4px; 302 + text-decoration:none; 303 + vertical-align:middle; 304 + margin-left:0.4rem; 305 + transition:all 0.15s; 306 + } 307 + .cook-link:hover{color:var(--accent);background:var(--border);text-decoration:none} 285 308 286 309 .actions{ 287 310 display:flex; ··· 410 433 .search-form form{flex-direction:column} 411 434 .actions{flex-direction:column} 412 435 .actions a,.actions button{width:100%;justify-content:center} 413 - } 436 + } 437 + 438 + .cook-header{margin-bottom:1rem} 439 + .cook-back{color:var(--text-muted);font-size:0.85rem;font-family:'Poppins',system-ui,sans-serif} 440 + .cook-back:hover{color:var(--accent)} 441 + .cook-header h2{font-family:'Poppins',system-ui,sans-serif;font-size:0.95rem;font-weight:500;color:var(--text-muted);margin-top:0.25rem} 442 + .cook-filename{color:var(--text-muted);text-decoration:none;font-weight:500;display:inline-flex;align-items:center;gap:0.3rem} 443 + .cook-filename:hover{color:var(--accent);text-decoration:underline} 444 + .cook-filename svg{flex-shrink:0} 445 + .cook-copy-btn{ 446 + position:absolute; 447 + top:0.5rem; 448 + right:0.5rem; 449 + padding:0.4rem; 450 + border:1px solid #3c3c3c; 451 + border-radius:6px; 452 + background:#2d2d2d; 453 + color:#999; 454 + cursor:pointer; 455 + transition:all 0.15s; 456 + opacity:0; 457 + } 458 + .cook-viewer:hover .cook-copy-btn{opacity:1} 459 + .cook-copy-btn:hover{border-color:var(--accent);color:var(--accent)} 460 + .cook-copy-done{border-color:#22c55e!important;color:#22c55e!important;opacity:1!important} 461 + 462 + .cook-viewer{ 463 + position:relative; 464 + border:1px solid var(--border); 465 + border-radius:var(--radius); 466 + background:#1e1e1e; 467 + overflow-x:auto; 468 + } 469 + .cook-code{ 470 + padding:1.25rem; 471 + margin:0; 472 + font-family:'SF Mono','Fira Code','Cascadia Code',monospace; 473 + font-size:0.85rem; 474 + line-height:1.65; 475 + color:#d4d4d4; 476 + white-space:pre; 477 + overflow-x:auto; 478 + } 479 + .cook-code .ck-delim{color:#6a9955;font-weight:600} 480 + .cook-code .ck-key{color:#9cdcfe} 481 + .cook-code .ck-val{color:#ce9178} 482 + .cook-code .ck-section{color:#569cd6;font-weight:600} 483 + .cook-code .ck-ing{color:#4ec9b0} 484 + .cook-code .ck-tmr{color:#dcdcaa} 485 + .cook-code .ck-qty{color:#b5cea8} 486 + .cook-code .ck-unit{color:#ce9178}
+52
ui/templates/cook.html
··· 1 + {{define "cook_page"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width,initial-scale=1"> 7 + <title>{{.Recipe.Name}} — cook file</title> 8 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 9 + <link rel="stylesheet" href="/static/style.css"> 10 + </head> 11 + <body> 12 + <nav> 13 + <a href="/" class="wordmark">pare</a> 14 + </nav> 15 + <div class="page"> 16 + <div class="cook-header"> 17 + <a href="/{{trimProto .TargetURL}}" class="cook-back">&larr; Back to recipe</a> 18 + <h2><a href="/export.cook?url={{.TargetURL | urlquery}}" download="{{.Filename}}" class="cook-filename"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> {{.Filename}}</a></h2> 19 + </div> 20 + 21 + <div class="cook-viewer"> 22 + <button class="cook-copy-btn" id="copy-btn" onclick="copyCook()" title="Copy to clipboard"> 23 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> 24 + </button> 25 + <pre class="cook-code">{{cookHighlight .CookFile}}</pre> 26 + </div> 27 + 28 + <div class="actions"> 29 + <a class="btn-primary no-ext" href="/export.cook?url={{.TargetURL | urlquery}}" download="{{.Filename}}">Download .cook</a> 30 + </div> 31 + </div> 32 + <footer> 33 + <span>made by <a href="https://dunkirk.sh" target="_blank" rel="noopener">Kieran Klukas</a></span> 34 + <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" target="_blank" rel="noopener" class="commit-link">{{.GitHash}}</a> 35 + </footer> 36 + <script> 37 + function copyCook() { 38 + const text = {{.CookFile | json}}; 39 + navigator.clipboard.writeText(text).then(() => { 40 + const btn = document.getElementById('copy-btn'); 41 + btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 42 + btn.classList.add('cook-copy-done'); 43 + setTimeout(() => { 44 + btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; 45 + btn.classList.remove('cook-copy-done'); 46 + }, 1500); 47 + }); 48 + } 49 + </script> 50 + </body> 51 + </html> 52 + {{end}}
+13 -9
ui/templates/recipe.html
··· 35 35 {{if .Recipe.PrepTime}}<span>⏱ {{fmtDuration .Recipe.PrepTime}} prep</span>{{end}} 36 36 {{if .Recipe.CookTime}}<span>⏱ {{fmtDuration .Recipe.CookTime}} cook</span>{{end}} 37 37 {{if .Recipe.Yield}}<span>Serves {{.Recipe.Yield}}</span>{{end}} 38 + <a href="/export.cook?url={{.TargetURL | urlquery}}" class="cook-link" download>.cook</a> 38 39 </div> 39 40 {{if .Recipe.Description}}<p class="description">{{.Recipe.Description}}</p>{{end}} 40 41 </div> ··· 42 43 {{if .Recipe.Ingredients}} 43 44 <div class="section"> 44 45 <h3>Ingredients</h3> 46 + {{range $g := groupIngredients $.Recipe.Ingredients}} 47 + {{if $g.Name}}<h4 class="ingredient-group">{{$g.Name}}</h4>{{end}} 45 48 <ul class="ingredient-list"> 46 - {{range $i, $ing := .Recipe.Ingredients}} 47 - <li id="ing-{{$i}}" onclick="toggleCheck(this)"> 48 - <input type="checkbox" id="cb-{{$i}}"> 49 - <label for="cb-{{$i}}">{{$ing.RawText}}</label> 49 + {{range $j, $ing := $g.Items}} 50 + <li onclick="toggleCheck(this)"> 51 + <input type="checkbox"> 52 + <label>{{$ing.RawText}}</label> 50 53 </li> 51 54 {{end}} 52 55 </ul> 56 + {{end}} 53 57 </div> 54 58 {{end}} 55 59 ··· 75 79 {{end}} 76 80 77 81 <div class="actions"> 78 - <a class="btn-primary no-ext" href="/export.cook?url={{.TargetURL | urlquery}}">Download .cook</a> 79 82 </div> 80 83 81 84 <script> ··· 89 92 function saveChecks() { 90 93 const state = {}; 91 94 document.querySelectorAll('.ingredient-list li').forEach(li => { 92 - state[li.id] = li.classList.contains('checked'); 95 + const label = li.querySelector('label').textContent; 96 + if (li.classList.contains('checked')) state[label] = true; 93 97 }); 94 98 localStorage.setItem('pare:' + location.pathname.slice(1), JSON.stringify({t: Date.now(), s: state})); 95 99 } ··· 102 106 localStorage.removeItem('pare:' + location.pathname.slice(1)); 103 107 return; 104 108 } 105 - Object.entries(raw.s).forEach(([id, checked]) => { 106 - const li = document.getElementById(id); 107 - if (li && checked) { 109 + document.querySelectorAll('.ingredient-list li').forEach(li => { 110 + const label = li.querySelector('label').textContent; 111 + if (raw.s[label]) { 108 112 li.classList.add('checked'); 109 113 li.querySelector('input[type="checkbox"]').checked = true; 110 114 }