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 "fmt"
5 "net/http"
6 "net/http/httptest"
7 "strings"
8 "testing"
9
10 "golang.org/x/net/html"
11)
12
13// The three shapes instructions actually arrive in, plus an @graph wrapper,
14// since a fixed lookup misses every site that uses one.
15const recipePage = `<html><head>
16<script type="application/ld+json">
17{"@context":"https://schema.org","@graph":[
18 {"@type":"WebPage","name":"not the recipe"},
19 {"@type":["Recipe","Thing"],
20 "name":"Breakfast Burritos",
21 "recipeYield":["8","8 burritos"],
22 "prepTime":"PT20M","cookTime":"PT1H15M","totalTime":"PT1H35M",
23 "recipeIngredient":["8 large eggs","1/2 cup shredded cheddar","4 burrito-size flour tortillas","2 tbsp butter"],
24 "recipeInstructions":[
25 {"@type":"HowToSection","itemListElement":[
26 {"@type":"HowToStep","text":"Beat the eggs with a pinch of salt."},
27 {"@type":"HowToStep","text":"Melt the butter and scramble over low heat."}]},
28 {"@type":"HowToStep","text":"Warm each tortilla until pliable, fill, and roll."}]}]}
29</script></head><body><p>words words words</p></body></html>`
30
31func TestRecipeFromJSONLD(t *testing.T) {
32 doc, err := html.Parse(strings.NewReader(recipePage))
33 if err != nil {
34 t.Fatal(err)
35 }
36 r, ok := recipeFromJSONLD(doc)
37 if !ok {
38 t.Fatal("no recipe found in a page that has one")
39 }
40 if r.Name != "Breakfast Burritos" {
41 t.Errorf("name = %q", r.Name)
42 }
43 if r.Yield != "8" {
44 t.Errorf("yield = %q", r.Yield)
45 }
46 if r.TotalTime != "1h 35m" {
47 t.Errorf("total time = %q, want the ISO duration in words", r.TotalTime)
48 }
49 if r.CookTime != "1h 15m" {
50 t.Errorf("cook time = %q", r.CookTime)
51 }
52 if r.PrepTime != "20 minutes" {
53 t.Errorf("prep time = %q", r.PrepTime)
54 }
55 if len(r.Ingredients) != 4 || !strings.HasPrefix(r.Ingredients[0], "8 large eggs") {
56 t.Errorf("ingredients = %v", r.Ingredients)
57 }
58 // The point of the whole file: the quantity survives.
59 md := r.Markdown()
60 for _, want := range []string{"8 large eggs", "1/2 cup shredded cheddar", "Makes: 8", "1. Beat the eggs"} {
61 if !strings.Contains(md, want) {
62 t.Errorf("markdown is missing %q:\n%s", want, md)
63 }
64 }
65 // A section wrapping steps must flatten rather than disappear.
66 if len(r.Steps) != 3 {
67 t.Errorf("want 3 steps flattened out of the section, got %d: %v", len(r.Steps), r.Steps)
68 }
69}
70
71func TestRecipeIgnoresNonRecipe(t *testing.T) {
72 doc, _ := html.Parse(strings.NewReader(
73 `<html><head><script type="application/ld+json">{"@type":"Article","name":"x"}</script></head></html>`))
74 if _, ok := recipeFromJSONLD(doc); ok {
75 t.Error("claimed a recipe on an article page")
76 }
77}
78
79// TestFetchKeepsRecipe goes through the whole of Fetch rather than calling the
80// parser directly, because the first version of this worked in isolation and
81// did nothing in production: stripAndPick removes script tags in place, so
82// reading the structured data after it ran found an empty document.
83func TestFetchKeepsRecipe(t *testing.T) {
84 page := `<html><head>
85<script type="application/ld+json">
86{"@type":"Recipe","name":"Breakfast Burritos","recipeYield":"8",
87 "recipeIngredient":["8 large eggs","1/2 cup shredded cheddar","4 flour tortillas"],
88 "recipeInstructions":[{"@type":"HowToStep","text":"Beat the eggs."},
89 {"@type":"HowToStep","text":"Scramble and roll."}]}
90</script></head><body><article>` +
91 strings.Repeat("Some prose about burritos that mentions eggs and cheese but no amounts. ", 12) +
92 `</article></body></html>`
93
94 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95 w.Header().Set("Content-Type", "text/html; charset=utf-8")
96 fmt.Fprint(w, page)
97 }))
98 defer srv.Close()
99
100 p, err := Fetch(srv.Client(), srv.URL)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if !hasStructuredRecipe(p) {
105 t.Fatalf("the recipe block is not at the top of the markdown:\n%.300s", p.Markdown)
106 }
107 for _, want := range []string{"8 large eggs", "1/2 cup shredded cheddar", "Makes: 8"} {
108 if !strings.Contains(p.Markdown, want) {
109 t.Errorf("markdown lost %q", want)
110 }
111 }
112}