One Go binary serves a pre-rendered site. No Node, no bundler, no node_modules:
htmx and the SSE extension are vendored files, Markdown and code highlighting
run at build time (goldmark + chroma), and the server only writes files.
Implements docs/phase-2-static-site.md:
2.1 service skeleton cmd/build (content -> dist), cmd/server (dist -> HTTP),
env-configured, 127.0.0.1:8096, /healthz with separate
static / ai / search readiness, --health-check probe,
deploy/compose.yml + deploy/Dockerfile at 120m
2.2 rendering templ components (Layout, Nav, PillarCard, Stage,
EvidenceTable, CodeBlock, Timeline, Footer);
`make generate` is a build step and is byte-stable
2.3 navigation six-area rail, <details> disclosure under 900px,
breadcrumbs, aria-current, L1/L2/L3 with artefact pages
2.4 stage panel internal/stage owns the contract; RouteDriver (fragment
swap) and SSEDriver (event stream) differ only in the
Binding, never in layout. fragment ids live in the
contract so route files, links and streamed frames agree
2.5 search SQLite FTS5 over titles and body, rebuilt by the build;
one handler returns a page without HTMX, a fragment with
it. Visitors' FTS operators are never honoured
2.6 quality floor print stylesheet, JS-off navigation, focus states,
semantic landmarks, dark default; security headers
Definition of Done evidence (see issue AUD-142):
A1 all pages 200 in a network namespace with no external route
one binary serves the site; make dist writes 45 pages, 86 fragments, 40 docs
templ generate is deterministic (md5 identical across runs)
6 L1 + 6 L2 pages, artefact pages reachable in <= 3 clicks
FTS5 returns sensible hits for zigbee / signage / routing (tested)
Lighthouse 100 performance and 100 accessibility, mobile and desktop
first byte well inside 100ms over loopback (test)
panel renders from a route driver and re-points at the stream untouched
print legible: /edge/ renders to a 3-page PDF with the print stylesheet
Also fixes found while finishing the build:
- reject "." / ".." path segments before ServeMux redirects them into a file
outside the asset directory (/static/../build.json now 404, was 307->200)
- make test is the real gate: go test ./... plus the validator self-test
319 lines
8.9 KiB
Go
319 lines
8.9 KiB
Go
package site
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/a-h/templ"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web"
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
|
)
|
|
|
|
// Published file names. The server and the build agree on these, and nothing
|
|
// else does.
|
|
const (
|
|
// IndexFile is the SQLite FTS5 index, read-only at request time.
|
|
IndexFile = "search.db"
|
|
// ManifestFile is the stage fragment index.
|
|
ManifestFile = "stage/manifest.json"
|
|
// BuildInfoFile records what the last build produced.
|
|
BuildInfoFile = "build.json"
|
|
// markerFile marks a directory as a previous build, so Build can clean it
|
|
// without ever deleting a directory that was not ours.
|
|
markerFile = ".build"
|
|
)
|
|
|
|
// streamEndpoint is where the streaming panel connects.
|
|
const streamEndpoint = "/api/stage/stream"
|
|
|
|
// BuildInfo is what the build wrote, for /healthz and for the operator.
|
|
type BuildInfo struct {
|
|
Built time.Time `json:"built"`
|
|
Documents int `json:"documents"`
|
|
Pages int `json:"pages"`
|
|
Fragments int `json:"fragments"`
|
|
Indexed int `json:"indexed"`
|
|
Artefacts int `json:"artefacts"`
|
|
}
|
|
|
|
// LoadBuildInfo reads build.json from a published directory.
|
|
func LoadBuildInfo(dir string) (*BuildInfo, error) {
|
|
raw, err := os.ReadFile(filepath.Join(dir, BuildInfoFile))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var info BuildInfo
|
|
if err := json.Unmarshal(raw, &info); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", BuildInfoFile, err)
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// Build pre-renders the whole site into OutDir and makes no network call.
|
|
//
|
|
// It writes: the landing page, every published document at its own URL, the
|
|
// evidence index, the search page shell, the streaming panel page, the 404,
|
|
// one file per stage fragment plus the manifest, the FTS5 index, the content
|
|
// assets and the static files. Nothing is rendered at request time except a
|
|
// search with a query in it.
|
|
func (s *Site) Build() error {
|
|
started := time.Now()
|
|
if err := s.prepareOut(); err != nil {
|
|
return err
|
|
}
|
|
|
|
pages := 0
|
|
writePage := func(url string, c templ.Component) error {
|
|
pages++
|
|
return renderTo(s.filePathForURL(url), c)
|
|
}
|
|
|
|
if err := writePage("/", templates.HomePage(s.Home())); err != nil {
|
|
return err
|
|
}
|
|
for _, d := range s.PublicDocs() {
|
|
if err := writePage(d.URL, templates.DocPage(s.Doc(d))); err != nil {
|
|
return fmt.Errorf("%s: %w", d.Path, err)
|
|
}
|
|
}
|
|
if err := writePage("/evidence/", templates.EvidencePage(s.Evidence())); err != nil {
|
|
return err
|
|
}
|
|
if err := writePage("/search", templates.SearchPage(s.SearchView(len(s.PublicDocs()), "", nil, false))); err != nil {
|
|
return err
|
|
}
|
|
if err := writePage("/stream/", templates.StreamPage(s.Stream(streamEndpoint))); err != nil {
|
|
return err
|
|
}
|
|
if err := writePage("/404.html", templates.NotFoundPage(s.NotFoundPage())); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, f := range s.Fragments {
|
|
if err := writeFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(f.File)), []byte(f.HTML)); err != nil {
|
|
return fmt.Errorf("fragment %s: %w", f.ID, err)
|
|
}
|
|
}
|
|
manifest, err := s.Manifest.Marshal()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writeFile(filepath.Join(s.Config.OutDir, ManifestFile), manifest); err != nil {
|
|
return err
|
|
}
|
|
|
|
indexed, err := s.buildIndex()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.copyAssets(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.copyStatic(); err != nil {
|
|
return err
|
|
}
|
|
info := BuildInfo{
|
|
Built: started.UTC(),
|
|
Documents: len(s.PublicDocs()),
|
|
Pages: pages,
|
|
Fragments: len(s.Fragments),
|
|
Indexed: indexed,
|
|
Artefacts: s.artefactCount(),
|
|
}
|
|
raw, err := json.MarshalIndent(info, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writeFile(filepath.Join(s.Config.OutDir, BuildInfoFile), append(raw, '\n')); err != nil {
|
|
return err
|
|
}
|
|
return writeFile(filepath.Join(s.Config.OutDir, markerFile), []byte("ai-resume build output\n"))
|
|
}
|
|
|
|
// filePathForURL maps a site URL onto its file. Directory URLs get index.html
|
|
// so the same tree works on any plain file server as well as this binary.
|
|
func (s *Site) filePathForURL(url string) string {
|
|
rel := strings.TrimPrefix(url, "/")
|
|
if rel == "" || strings.HasSuffix(rel, "/") {
|
|
rel += "index.html"
|
|
} else if path.Ext(rel) == "" {
|
|
rel += "/index.html"
|
|
}
|
|
return filepath.Join(s.Config.OutDir, filepath.FromSlash(rel))
|
|
}
|
|
|
|
// prepareOut empties the output directory, but only if it is a directory this
|
|
// build wrote before. There is no path by which a mistyped OutDir can delete a
|
|
// directory that belongs to something else.
|
|
func (s *Site) prepareOut() error {
|
|
abs, err := filepath.Abs(s.Config.OutDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if abs == string(filepath.Separator) || abs == filepath.Dir(abs) {
|
|
return fmt.Errorf("refusing to build into %q", abs)
|
|
}
|
|
root, err := filepath.Abs(s.Config.Root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if abs == root {
|
|
return fmt.Errorf("refusing to build over the repository root %q", abs)
|
|
}
|
|
if st, err := os.Stat(abs); err == nil && st.IsDir() {
|
|
entries, err := os.ReadDir(abs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(entries) > 0 {
|
|
if _, err := os.Stat(filepath.Join(abs, markerFile)); err != nil {
|
|
return fmt.Errorf("refusing to clean %s: it is not a previous build (no %s)", abs, markerFile)
|
|
}
|
|
}
|
|
}
|
|
if err := os.RemoveAll(abs); err != nil {
|
|
return err
|
|
}
|
|
return os.MkdirAll(abs, 0o755)
|
|
}
|
|
|
|
// buildIndex writes the FTS5 index and returns how many pages it covers.
|
|
func (s *Site) buildIndex() (int, error) {
|
|
docs := make([]search.Doc, 0, len(s.Content.Docs))
|
|
for _, d := range s.PublicDocs() {
|
|
docs = append(docs, search.Doc{
|
|
ID: d.ID,
|
|
URL: d.URL,
|
|
Title: d.Title,
|
|
Area: d.Area,
|
|
Level: d.Level,
|
|
Confidence: d.Confidence,
|
|
Body: indexBody(d),
|
|
})
|
|
}
|
|
file := filepath.Join(s.Config.OutDir, IndexFile)
|
|
if err := search.Build(file, docs); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(docs), nil
|
|
}
|
|
|
|
// indexBody is what FTS5 searches: the claim, the rendered body reduced to
|
|
// text, and the technology and tag vocabularies so "zigbee" finds a page that
|
|
// names it only in front-matter.
|
|
func indexBody(d *content.Document) string {
|
|
parts := []string{d.Claim, d.Plain}
|
|
parts = append(parts, d.Tech...)
|
|
parts = append(parts, d.Tags...)
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// copyAssets publishes content/assets at /assets/.
|
|
func (s *Site) copyAssets() error {
|
|
src := filepath.Join(s.Config.ContentDir, "assets")
|
|
dst := filepath.Join(s.Config.OutDir, "assets")
|
|
if _, err := os.Stat(src); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return filepath.WalkDir(src, func(p string, e fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
if e.IsDir() && p != src {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(src, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
if e.IsDir() {
|
|
return os.MkdirAll(target, 0o755)
|
|
}
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFile(target, raw)
|
|
})
|
|
}
|
|
|
|
// copyStatic publishes the vendored htmx, the SSE extension and the site's own
|
|
// stylesheet, plus the chroma stylesheet generated from the renderer's style so
|
|
// the highlighted code and the page agree on colour.
|
|
func (s *Site) copyStatic() error {
|
|
dst := filepath.Join(s.Config.OutDir, "static")
|
|
err := fs.WalkDir(web.Static, "static", func(p string, e fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if e.IsDir() {
|
|
return nil
|
|
}
|
|
raw, err := web.Static.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFile(filepath.Join(dst, filepath.FromSlash(strings.TrimPrefix(p, "static/"))), raw)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
chroma, err := s.Renderer.ChromaCSS()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFile(filepath.Join(dst, "chroma.css"), []byte(chroma))
|
|
}
|
|
|
|
// renderTo renders a templ component to a file, creating its directory.
|
|
func renderTo(file string, c templ.Component) error {
|
|
var buf bytes.Buffer
|
|
if err := c.Render(context.Background(), &buf); err != nil {
|
|
return err
|
|
}
|
|
return writeFile(file, buf.Bytes())
|
|
}
|
|
|
|
// writeFile writes atomically, so a failed build never leaves half a page.
|
|
func writeFile(file string, raw []byte) error {
|
|
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := file + ".tmp"
|
|
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, file)
|
|
}
|
|
|
|
// PublishedPages lists the URLs this site publishes, for tests and for the
|
|
// operator running the build. Order is stable.
|
|
func (s *Site) PublishedPages() []string {
|
|
urls := []string{"/", "/evidence/", "/search", "/stream/", "/404.html"}
|
|
for _, d := range s.PublicDocs() {
|
|
urls = append(urls, d.URL)
|
|
}
|
|
sort.Strings(urls)
|
|
return urls
|
|
}
|