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

2.3 KB · 88 lines · Go Raw History
 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}