orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 stdhtml "html"
7 "strings"
8
9 "golang.org/x/net/html"
10 "golang.org/x/net/html/atom"
11)
12
13// A recipe page is the one case where the useful content is published as data
14// and then thrown away by reading the prose.
15//
16// Asking a model to pull ingredients out of an article gives a list with no
17// quantities, because the quantities live in a table the markdown conversion
18// flattens and the surrounding prose says "add the eggs" rather than "add
19// eight eggs". Nearly every recipe site publishes a schema.org Recipe in a
20// script tag with `recipeIngredient` as exact strings and `recipeInstructions`
21// in order, so the numbers are right there.
22//
23// The block this renders is prepended to the page markdown, which puts it in
24// the first passage and makes it the piece most likely to be selected.
25
26type recipeData struct {
27 Name string
28 Yield string
29 PrepTime string
30 CookTime string
31 TotalTime string
32 Ingredients []string
33 Steps []string
34}
35
36func (r recipeData) usable() bool {
37 return len(r.Ingredients) >= 3 && len(r.Steps) >= 2
38}
39
40// Markdown renders the block that goes at the top of the page.
41func (r recipeData) Markdown() string {
42 var b strings.Builder
43 b.WriteString(recipeHeading + "\n")
44 if r.Name != "" {
45 fmt.Fprintf(&b, "%s\n\n", r.Name)
46 }
47 for _, f := range []struct{ label, v string }{
48 {"Makes", r.Yield}, {"Prep time", r.PrepTime},
49 {"Cook time", r.CookTime}, {"Total time", r.TotalTime},
50 } {
51 if f.v != "" {
52 fmt.Fprintf(&b, "%s: %s\n", f.label, f.v)
53 }
54 }
55 b.WriteString("\n### Ingredients\n\n")
56 for _, i := range r.Ingredients {
57 fmt.Fprintf(&b, "- %s\n", i)
58 }
59 b.WriteString("\n### Method\n\n")
60 for i, s := range r.Steps {
61 fmt.Fprintf(&b, "%d. %s\n", i+1, s)
62 }
63 return b.String()
64}
65
66// recipeFromJSONLD finds a schema.org Recipe anywhere in the page's structured
67// data. The type can be a string or a list, and the object is often buried in
68// an @graph, so this walks rather than looking in a fixed place.
69func recipeFromJSONLD(root *html.Node) (recipeData, bool) {
70 var out recipeData
71 found := false
72
73 var walk func(*html.Node)
74 walk = func(n *html.Node) {
75 if found {
76 return
77 }
78 if n.Type == html.ElementNode && n.DataAtom == atom.Script {
79 for _, a := range n.Attr {
80 if a.Key == "type" && strings.Contains(a.Val, "ld+json") && n.FirstChild != nil {
81 var v any
82 if json.Unmarshal([]byte(n.FirstChild.Data), &v) == nil {
83 if r, ok := digRecipe(v); ok {
84 out, found = r, true
85 return
86 }
87 }
88 }
89 }
90 }
91 for c := n.FirstChild; c != nil; c = c.NextSibling {
92 walk(c)
93 }
94 }
95 walk(root)
96 return out, found && out.usable()
97}
98
99func digRecipe(v any) (recipeData, bool) {
100 switch x := v.(type) {
101 case map[string]any:
102 if isType(x["@type"], "Recipe") {
103 return buildRecipe(x), true
104 }
105 for _, sub := range x {
106 if r, ok := digRecipe(sub); ok {
107 return r, true
108 }
109 }
110 case []any:
111 for _, sub := range x {
112 if r, ok := digRecipe(sub); ok {
113 return r, true
114 }
115 }
116 }
117 return recipeData{}, false
118}
119
120func isType(v any, want string) bool {
121 switch t := v.(type) {
122 case string:
123 return strings.EqualFold(t, want)
124 case []any:
125 for _, s := range t {
126 if str, ok := s.(string); ok && strings.EqualFold(str, want) {
127 return true
128 }
129 }
130 }
131 return false
132}
133
134func buildRecipe(m map[string]any) recipeData {
135 r := recipeData{
136 Name: str(m["name"]),
137 Yield: firstString(m["recipeYield"]),
138 PrepTime: humanDuration(str(m["prepTime"])),
139 CookTime: humanDuration(str(m["cookTime"])),
140 TotalTime: humanDuration(str(m["totalTime"])),
141 }
142 for _, i := range asList(m["recipeIngredient"]) {
143 if s := strings.TrimSpace(str(i)); s != "" {
144 r.Ingredients = append(r.Ingredients, s)
145 }
146 }
147 r.Steps = flattenSteps(m["recipeInstructions"])
148 return r
149}
150
151// flattenSteps handles the three shapes instructions arrive in: a list of
152// strings, a list of HowToStep objects, and a list of HowToSection objects each
153// holding its own list.
154func flattenSteps(v any) []string {
155 var out []string
156 switch x := v.(type) {
157 case string:
158 for _, line := range strings.Split(x, "\n") {
159 if s := strings.TrimSpace(line); s != "" {
160 out = append(out, s)
161 }
162 }
163 case []any:
164 for _, item := range x {
165 out = append(out, flattenSteps(item)...)
166 }
167 case map[string]any:
168 if sub, ok := x["itemListElement"]; ok {
169 return flattenSteps(sub)
170 }
171 if s := strings.TrimSpace(str(x["text"])); s != "" {
172 out = append(out, s)
173 } else if s := strings.TrimSpace(str(x["name"])); s != "" {
174 out = append(out, s)
175 }
176 }
177 return out
178}
179
180func str(v any) string {
181 s, _ := v.(string)
182 // These arrive HTML escaped, so "1 & 1/2 pounds bacon" reaches the
183 // answer verbatim unless it is unescaped here.
184 return strings.TrimSpace(stdhtml.UnescapeString(s))
185}
186
187func firstString(v any) string {
188 switch x := v.(type) {
189 case string:
190 return x
191 case float64:
192 return fmt.Sprintf("%g", x)
193 case []any:
194 for _, s := range x {
195 if got := firstString(s); got != "" {
196 return got
197 }
198 }
199 }
200 return ""
201}
202
203func asList(v any) []any {
204 switch x := v.(type) {
205 case []any:
206 return x
207 case string:
208 return []any{x}
209 }
210 return nil
211}
212
213// humanDuration turns ISO 8601 PT30M into words, since nobody reads PT1H15M.
214func humanDuration(iso string) string {
215 if !strings.HasPrefix(iso, "PT") {
216 return ""
217 }
218 rest := iso[2:]
219 var hours, mins string
220 if i := strings.Index(rest, "H"); i >= 0 {
221 hours, rest = rest[:i], rest[i+1:]
222 }
223 if i := strings.Index(rest, "M"); i >= 0 {
224 mins = rest[:i]
225 }
226 switch {
227 case hours != "" && mins != "" && mins != "0":
228 return hours + "h " + mins + "m"
229 case hours != "":
230 return hours + "h"
231 case mins != "":
232 return mins + " minutes"
233 }
234 return ""
235}
236
237// recipeHeading is what Markdown writes first, and checking for it is how a
238// cached page is recognised as carrying a real recipe without re-parsing it.
239const recipeHeading = "## Recipe\n"
240
241func hasStructuredRecipe(p *Page) bool {
242 return p != nil && strings.HasPrefix(p.Markdown, recipeHeading)
243}