326 lines
9.2 KiB
Go
326 lines
9.2 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
|
|
}
|
|
// The platform guides are configuration and public copy at the same time.
|
|
// Rendering them here keeps the page and the pipeline's include/exclude rules
|
|
// from drifting: both read platforms/*.yml.
|
|
if _, err := RenderPlatforms(s.Config.Root, s.Config.OutDir); err != nil {
|
|
return fmt.Errorf("platform guides: %w", err)
|
|
}
|
|
pages++
|
|
|
|
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
|
|
}
|