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 }