repos
/ orchard main

orchard

mirror

Every 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

5.9 KB · 244 lines · Go Raw History
  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}