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 "net/http"
6 "strings"
7)
8
9// matches is a substring scan over title, description and tags. Bodies are not
10// searched, since a match nothing highlights looks like a wrong result.
11func matches(post *Post, needle string) bool {
12 if strings.Contains(strings.ToLower(post.Title), needle) ||
13 strings.Contains(strings.ToLower(post.Description), needle) {
14 return true
15 }
16 for _, tag := range post.Tags {
17 if strings.Contains(strings.ToLower(tag), needle) {
18 return true
19 }
20 }
21 return false
22}
23
24func search(posts []*Post, query string, limit int) []*Post {
25 needle := strings.ToLower(strings.TrimSpace(query))
26 if needle == "" {
27 return nil
28 }
29 var out []*Post
30 for _, post := range posts {
31 if matches(post, needle) {
32 out = append(out, post)
33 if limit > 0 && len(out) == limit {
34 break
35 }
36 }
37 }
38 return out
39}
40
41func (s *site) search(w http.ResponseWriter, r *http.Request) {
42 query := r.URL.Query().Get("q")
43 published, _, _ := s.lib.Published()
44
45 data := s.page(r, "Search",
46 "Search posts on webdev, coding, security, and sysadmin.")
47 data.ShowSocial = true
48 data.Heading = "Search"
49 data.Query = query
50 data.Breadcrumbs = []Crumb{{Title: "Home", URL: "/"}, {Title: "Search"}}
51
52 if strings.TrimSpace(query) == "" {
53 // An empty query offers somewhere to go rather than no results.
54 data.RandomPosts = pickRandom(published, 6)
55 } else {
56 data.Posts = search(published, query, 0)
57 data.NoResults = len(data.Posts) == 0
58 }
59
60 s.renderer.Render(w, http.StatusOK, "search.html", data)
61}
62
63// searchLive backs the type-ahead in search.js, which renders a short dropdown.
64func (s *site) searchLive(w http.ResponseWriter, r *http.Request) {
65 published, _, _ := s.lib.Published()
66
67 type result struct {
68 Title string `json:"title"`
69 Description string `json:"description"`
70 URL string `json:"url"`
71 }
72
73 // An empty array, never null: search.js reads data.length.
74 out := []result{}
75 for _, post := range search(published, r.URL.Query().Get("q"), 5) {
76 out = append(out, result{post.Title, post.Description, post.URL()})
77 }
78
79 w.Header().Set("Content-Type", "application/json")
80 w.Header().Set("Cache-Control", "no-store")
81
82 // search.js builds rows from text nodes, so the default escaping would only
83 // corrupt titles.
84 encoder := json.NewEncoder(w)
85 encoder.SetEscapeHTML(false)
86 _ = encoder.Encode(out)
87}