Phase 2: Mode A static site — build, serve, search, stage panel
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
This commit is contained in:
318
internal/site/build.go
Normal file
318
internal/site/build.go
Normal file
@@ -0,0 +1,318 @@
|
||||
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
|
||||
}
|
||||
200
internal/site/fragments.go
Normal file
200
internal/site/fragments.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
||||
)
|
||||
|
||||
// buildFragments renders every stage fragment once, in manifest order:
|
||||
//
|
||||
// 1. every document's own fragment, L1 first in area order
|
||||
// 2. every artefact fragment, in the same document order
|
||||
//
|
||||
// The order matters: the first len(content.Areas) entries are the six area
|
||||
// landing fragments, which is the default stream, and the server reads that
|
||||
// order out of the manifest without loading the content tree. The panel is a
|
||||
// content sink, so this is the only place that decides what a fragment is.
|
||||
// Neither driver renders anything itself.
|
||||
func (s *Site) buildFragments() error {
|
||||
order := make([]*content.Document, 0, len(s.Content.Docs))
|
||||
for _, a := range content.Areas {
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
if d.Level == 1 {
|
||||
order = append(order, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
rest := make([]*content.Document, 0, len(s.Content.Docs))
|
||||
for _, a := range content.Areas {
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
if d.Level != 1 {
|
||||
rest = append(rest, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
order = append(order, rest...)
|
||||
|
||||
for _, d := range order {
|
||||
if err := s.addFragment(s.docFragment(d)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, d := range order {
|
||||
for i, a := range d.Artefacts {
|
||||
f, err := s.artefactFragment(d, a, stage.ArtefactFragmentID(d.ID, i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.addFragment(f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Site) addFragment(f *Fragment) error {
|
||||
if _, dup := s.fragByID[f.ID]; dup {
|
||||
return fmt.Errorf("stage fragment id %q is not unique", f.ID)
|
||||
}
|
||||
html, err := renderFragment(f.component())
|
||||
if err != nil {
|
||||
return fmt.Errorf("render stage fragment %s: %w", f.ID, err)
|
||||
}
|
||||
f.HTML = html
|
||||
s.Fragments = append(s.Fragments, f)
|
||||
s.fragByID[f.ID] = f
|
||||
s.FragmentsByDoc[f.DocID] = append(s.FragmentsByDoc[f.DocID], f.ID)
|
||||
s.Manifest.Entries = append(s.Manifest.Entries, stage.Entry{
|
||||
ID: f.ID,
|
||||
DocID: f.DocID,
|
||||
Title: f.Title,
|
||||
Kind: f.Kind,
|
||||
File: f.File,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// fragment builds the stage value this fragment renders from.
|
||||
func (f *Fragment) component() stage.Fragment {
|
||||
out := stage.Fragment{
|
||||
ID: f.ID,
|
||||
Kind: f.Kind,
|
||||
Title: f.Title,
|
||||
}
|
||||
out.Subtitle = f.subtitle
|
||||
out.Body = f.body
|
||||
out.Code = f.code
|
||||
out.Src = f.src
|
||||
out.Alt = f.alt
|
||||
out.Links = f.links
|
||||
out.Confidence = f.confidence
|
||||
out.DocURL = f.docURL
|
||||
out.DocTitle = f.docTitle
|
||||
return out
|
||||
}
|
||||
|
||||
func renderFragment(f stage.Fragment) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := templates.FragmentView(f).Render(context.Background(), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// docFragment is the document's own body, shown in the panel when a reader
|
||||
// asks for the page itself rather than one of its artefacts.
|
||||
func (s *Site) docFragment(d *content.Document) *Fragment {
|
||||
area, _ := content.AreaByKey(d.Area)
|
||||
return &Fragment{
|
||||
ID: stage.DocFragmentID(d.ID),
|
||||
DocID: d.ID,
|
||||
Kind: stage.KindText,
|
||||
Title: d.Title,
|
||||
subtitle: area.Title + " · " + templates.LevelLabel(d.Level),
|
||||
body: templ.Raw(d.HTML),
|
||||
confidence: d.Confidence,
|
||||
docURL: d.URL,
|
||||
docTitle: "Read the page",
|
||||
File: "stage/" + stage.DocFragmentID(d.ID) + ".html",
|
||||
}
|
||||
}
|
||||
|
||||
// artefactFragment renders one artefact into the kind the panel understands.
|
||||
func (s *Site) artefactFragment(d *content.Document, a content.Artefact, id string) (*Fragment, error) {
|
||||
ra, err := s.Content.Resolve(d, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := &Fragment{
|
||||
ID: id,
|
||||
DocID: d.ID,
|
||||
Kind: ra.Kind,
|
||||
Title: a.Label,
|
||||
ArtefactURL: a.URL,
|
||||
confidence: d.Confidence,
|
||||
File: "stage/" + id + ".html",
|
||||
}
|
||||
if ra.Target != nil {
|
||||
f.subtitle = ra.Target.Title
|
||||
f.body = templ.Raw(ra.Target.HTML)
|
||||
f.confidence = ra.Target.Confidence
|
||||
f.docURL = ra.Target.URL
|
||||
f.docTitle = "Open " + ra.Target.Title
|
||||
return f, nil
|
||||
}
|
||||
f.subtitle = ra.Rel
|
||||
switch ra.Kind {
|
||||
case stage.KindCode:
|
||||
src, err := os.ReadFile(ra.File)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: read code artefact: %w", d.Path, err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
lang := content.LanguageFor(ra.File)
|
||||
if err := s.Renderer.Highlight(&buf, lang, src); err != nil {
|
||||
return nil, fmt.Errorf("%s: highlight artefact: %w", d.Path, err)
|
||||
}
|
||||
f.code = &stage.Code{Language: lang, Label: path.Base(ra.Rel), HTML: templ.Raw(buf.String())}
|
||||
case stage.KindImage, stage.KindDiagram, stage.KindVideo:
|
||||
f.src = ra.Href
|
||||
f.alt = a.Label
|
||||
default:
|
||||
f.links = []stage.Link{{Label: a.Label, Href: ra.Href, Note: ra.Rel}}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// fragForEntry looks up a build-time fragment by manifest entry.
|
||||
func (s *Site) fragForEntry(e stage.Entry) (*Fragment, bool) {
|
||||
f, ok := s.fragByID[e.ID]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// FragmentHTML returns the pre-rendered markup for a fragment id. Both drivers
|
||||
// serve these bytes: the route driver as a file, the stream driver as an event.
|
||||
func (s *Site) FragmentHTML(id string) (string, bool) {
|
||||
f, ok := s.fragByID[id]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return f.HTML, true
|
||||
}
|
||||
|
||||
// FragmentsByID is a copy of the fragment index, for tests and the manifest.
|
||||
func (s *Site) FragmentsByID() map[string]*Fragment {
|
||||
out := make(map[string]*Fragment, len(s.fragByID))
|
||||
for k, v := range s.fragByID {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
509
internal/site/site.go
Normal file
509
internal/site/site.go
Normal file
@@ -0,0 +1,509 @@
|
||||
// Package site assembles the Mode A static site out of content/ and knows how
|
||||
// to write it to disk.
|
||||
//
|
||||
// One assembler, two callers: cmd/build writes the whole site to a directory,
|
||||
// and cmd/server uses the same view models for the two things that cannot be
|
||||
// pre-rendered — a keyword search with a query in it, and the streamed stage
|
||||
// demonstration. Everything else in the running server is a file on disk, so a
|
||||
// page is never rendered per request and no page needs an AI service.
|
||||
package site
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"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/stage"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
||||
)
|
||||
|
||||
// Config is where the site is read from and written to.
|
||||
type Config struct {
|
||||
// Root is the repository root. Static assets are read from it.
|
||||
Root string
|
||||
// ContentDir is the Markdown source of truth. Defaults to Root/content.
|
||||
ContentDir string
|
||||
// OutDir is where Build writes the pre-rendered site. Defaults to Root/dist.
|
||||
OutDir string
|
||||
}
|
||||
|
||||
func (c *Config) defaults() {
|
||||
if c.Root == "" {
|
||||
c.Root = "."
|
||||
}
|
||||
if c.ContentDir == "" {
|
||||
c.ContentDir = filepath.Join(c.Root, "content")
|
||||
}
|
||||
if c.OutDir == "" {
|
||||
c.OutDir = filepath.Join(c.Root, "dist")
|
||||
}
|
||||
}
|
||||
|
||||
// Shell is the chrome every page shares. It needs no content tree, so the
|
||||
// running server can render a search page with only dist/ on disk.
|
||||
type Shell struct {
|
||||
// Year is stamped in the footer.
|
||||
Year int
|
||||
}
|
||||
|
||||
// NewShell returns the chrome for the current year.
|
||||
func NewShell() Shell { return Shell{Year: year()} }
|
||||
|
||||
// Nav builds the six-area navigation, marking area as current.
|
||||
func (Shell) Nav(area string) []templates.NavItem {
|
||||
items := make([]templates.NavItem, 0, len(content.Areas))
|
||||
for _, a := range content.Areas {
|
||||
items = append(items, templates.NavItem{
|
||||
Title: a.Title,
|
||||
Href: "/" + a.Key + "/",
|
||||
Gutter: a.Gutter,
|
||||
Current: a.Key == area,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// RouteBinding is the Mode A driver: one pre-rendered fragment per click.
|
||||
func (Shell) RouteBinding() stage.Binding {
|
||||
return stage.RouteDriver{BaseURL: "/stage"}.Bind()
|
||||
}
|
||||
|
||||
// StreamBinding is the Mode B driver: fragments pushed over SSE.
|
||||
func (Shell) StreamBinding(endpoint string) stage.Binding {
|
||||
return stage.SSEDriver{Endpoint: endpoint}.Bind()
|
||||
}
|
||||
|
||||
// LayoutOpts are the per-page chrome values.
|
||||
type LayoutOpts struct {
|
||||
Title string
|
||||
Description string
|
||||
Area string
|
||||
Crumbs []templates.Crumb
|
||||
Binding stage.Binding
|
||||
}
|
||||
|
||||
// Layout builds the chrome for one page. It never looks at the stage driver
|
||||
// beyond handing the binding to the template.
|
||||
func (sh Shell) Layout(o LayoutOpts) templates.LayoutView {
|
||||
return templates.LayoutView{
|
||||
Title: o.Title,
|
||||
Description: o.Description,
|
||||
Area: o.Area,
|
||||
Nav: sh.Nav(o.Area),
|
||||
Crumbs: o.Crumbs,
|
||||
Stage: o.Binding,
|
||||
Year: sh.Year,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchView builds the search page. Search is the one Mode A page that reads a
|
||||
// query, so the server renders it; with JavaScript off the form posts and the
|
||||
// same page comes back with results.
|
||||
func (sh Shell) SearchView(indexed int, q string, results []search.Result, failed bool) templates.SearchView {
|
||||
return templates.SearchView{
|
||||
Layout: sh.Layout(LayoutOpts{
|
||||
Title: "Search — Sam Rolfe",
|
||||
Description: "Keyword search across every page of this site. No model, no network call.",
|
||||
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Search"}},
|
||||
Binding: sh.RouteBinding(),
|
||||
}),
|
||||
Query: q,
|
||||
Results: results,
|
||||
Indexed: indexed,
|
||||
Failed: failed,
|
||||
}
|
||||
}
|
||||
|
||||
// NotFoundPage is the 404, pre-rendered at build time and also served live.
|
||||
func (sh Shell) NotFoundPage() templates.NotFoundView {
|
||||
return templates.NotFoundView{
|
||||
Layout: sh.Layout(LayoutOpts{
|
||||
Title: "Page not found — Sam Rolfe",
|
||||
Description: "That page is not here. The six areas and the evidence index are the reliable ways in.",
|
||||
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Not found"}},
|
||||
Binding: sh.RouteBinding(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Site is the content tree plus everything derived from it.
|
||||
type Site struct {
|
||||
Shell
|
||||
Config Config
|
||||
Content *content.Set
|
||||
Renderer *content.Renderer
|
||||
|
||||
// Fragments are the pre-rendered stage fragments, in manifest order. The
|
||||
// first len(content.Areas) are the six area landing pages, which is the
|
||||
// default stream.
|
||||
Fragments []*Fragment
|
||||
// Manifest indexes the same fragments the way the server sees them.
|
||||
Manifest *stage.Manifest
|
||||
// FragmentsByDoc lists fragment ids per document, in reading order.
|
||||
FragmentsByDoc map[string][]string
|
||||
|
||||
fragByID map[string]*Fragment
|
||||
}
|
||||
|
||||
// New loads content/ and renders every page and stage fragment in memory. It
|
||||
// touches no network and starts no service.
|
||||
func New(cfg Config) (*Site, error) {
|
||||
cfg.defaults()
|
||||
set, err := content.Load(cfg.ContentDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := content.NewRenderer(set)
|
||||
if err := r.Render(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Site{
|
||||
Shell: NewShell(),
|
||||
Config: cfg,
|
||||
Content: set,
|
||||
Renderer: r,
|
||||
Manifest: &stage.Manifest{},
|
||||
FragmentsByDoc: map[string][]string{},
|
||||
fragByID: map[string]*Fragment{},
|
||||
}
|
||||
if err := s.buildFragments(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// PublicDocs returns the published documents: public: true, in navigation order.
|
||||
func (s *Site) PublicDocs() []*content.Document {
|
||||
out := make([]*content.Document, 0, len(s.Content.Docs))
|
||||
for _, d := range s.Content.Docs {
|
||||
if d.Public {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PublicInArea returns the published documents of one area, L1 first.
|
||||
func (s *Site) PublicInArea(area string) []*content.Document {
|
||||
var out []*content.Document
|
||||
for _, d := range s.PublicDocs() {
|
||||
if d.Area == area {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pageCount counts published pages in an area.
|
||||
func (s *Site) pageCount(area string) int { return len(s.PublicInArea(area)) }
|
||||
|
||||
// artefactCount counts every resolved artefact on the site.
|
||||
func (s *Site) artefactCount() int {
|
||||
n := 0
|
||||
for _, d := range s.PublicDocs() {
|
||||
n += len(d.Artefacts)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Home is the landing page view: the ninety-second read.
|
||||
func (s *Site) Home() templates.HomeView {
|
||||
cardArea := func(a content.Area) templates.PillarCardView {
|
||||
card := templates.PillarCardView{
|
||||
Area: a,
|
||||
Title: a.Title,
|
||||
Claim: a.Blurb,
|
||||
Href: "/" + a.Key + "/",
|
||||
Level: 1,
|
||||
Pages: s.pageCount(a.Key),
|
||||
Metrics: []templates.MetricView{},
|
||||
Tech: []string{},
|
||||
}
|
||||
if d, ok := s.Content.AreaLanding(a.Key); ok {
|
||||
card.Claim = d.Claim
|
||||
card.Confidence = d.Confidence
|
||||
card.Title = d.Title
|
||||
for _, m := range d.Metrics {
|
||||
card.Metrics = append(card.Metrics, templates.MetricView{Label: m.Label, Value: content.MetricValue(m.Value)})
|
||||
}
|
||||
card.Tech = d.Tech
|
||||
}
|
||||
return card
|
||||
}
|
||||
cards := make([]templates.PillarCardView, 0, len(content.Areas))
|
||||
for _, a := range content.Areas {
|
||||
cards = append(cards, cardArea(a))
|
||||
}
|
||||
return templates.HomeView{
|
||||
Layout: s.Layout(LayoutOpts{
|
||||
Title: "Sam Rolfe — AI systems, the platforms under them, the hardware at the edge",
|
||||
Description: content.Positioning + " " + content.SupportLine,
|
||||
Crumbs: []templates.Crumb{{Label: "Home"}},
|
||||
Binding: s.RouteBinding(),
|
||||
}),
|
||||
Positioning: content.Positioning,
|
||||
Support: content.SupportLine,
|
||||
Location: content.Location,
|
||||
Cards: cards,
|
||||
Artifacts: s.artefactCount(),
|
||||
Pages: len(s.PublicDocs()),
|
||||
Areas: len(content.Areas),
|
||||
}
|
||||
}
|
||||
|
||||
// Doc builds the view for one document at any depth.
|
||||
func (s *Site) Doc(d *content.Document) templates.DocView {
|
||||
area, _ := content.AreaByKey(d.Area)
|
||||
levelLabel := templates.LevelLabel(d.Level)
|
||||
|
||||
view := templates.DocView{
|
||||
Layout: s.Layout(LayoutOpts{
|
||||
Title: d.Title + " — Sam Rolfe",
|
||||
Description: content.Truncate(d.Claim, 160),
|
||||
Area: d.Area,
|
||||
Crumbs: s.crumbs(d),
|
||||
Binding: s.RouteBinding(),
|
||||
}),
|
||||
Doc: d,
|
||||
AreaTitle: area.Title,
|
||||
Role: area.Role,
|
||||
Body: templates.MarkdownBody(d.HTML),
|
||||
Metrics: make([]templates.MetricView, 0, len(d.Metrics)),
|
||||
Tech: d.Tech,
|
||||
Evidence: s.evidence(d),
|
||||
Backlinks: s.backlinks(d),
|
||||
SubPages: s.subPages(d),
|
||||
LevelLabel: levelLabel,
|
||||
}
|
||||
for _, m := range d.Metrics {
|
||||
view.Metrics = append(view.Metrics, templates.MetricView{Label: m.Label, Value: content.MetricValue(m.Value)})
|
||||
}
|
||||
if d.Area == "history" && d.Level <= 2 {
|
||||
view.Timeline = s.timeline(d.Area)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (s *Site) crumbs(d *content.Document) []templates.Crumb {
|
||||
area, _ := content.AreaByKey(d.Area)
|
||||
out := []templates.Crumb{
|
||||
{Label: "Home", Href: "/"},
|
||||
{Label: area.Title, Href: "/" + d.Area + "/"},
|
||||
}
|
||||
if d.Level > 1 {
|
||||
out = append(out, templates.Crumb{Label: d.Title, Href: d.URL})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// evidence turns each artefact into a row that loads the artefact into the
|
||||
// stage panel and is also a working link.
|
||||
func (s *Site) evidence(d *content.Document) []templates.LinkView {
|
||||
out := make([]templates.LinkView, 0, len(d.Artefacts))
|
||||
ids := s.FragmentsByDoc[d.ID]
|
||||
for _, a := range d.Artefacts {
|
||||
ra, err := s.Content.Resolve(d, a)
|
||||
if err != nil {
|
||||
// Load already rejected dangling artefacts; keep going rather than
|
||||
// render a broken link.
|
||||
continue
|
||||
}
|
||||
conf := d.Confidence
|
||||
if ra.Target != nil {
|
||||
conf = ra.Target.Confidence
|
||||
}
|
||||
link := templates.LinkView{
|
||||
Label: a.Label,
|
||||
Href: ra.Href,
|
||||
Kind: ra.Kind,
|
||||
Confidence: conf,
|
||||
}
|
||||
for _, id := range ids {
|
||||
if f, ok := s.fragByID[id]; ok && f.ArtefactURL == a.URL {
|
||||
link.HXGet = s.RouteFragmentURL(id)
|
||||
}
|
||||
}
|
||||
out = append(out, link)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Site) backlinks(d *content.Document) []templates.LinkView {
|
||||
out := make([]templates.LinkView, 0, len(d.Backlinks))
|
||||
for _, b := range d.Backlinks {
|
||||
out = append(out, templates.LinkView{Label: b.Title, Href: b.URL, Kind: templates.LevelLabel(b.Level)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// subPages is the drill-down list under a page. L1 lists the capability pages
|
||||
// in its area; every other level lists artefacts in the area that this page
|
||||
// does not already cite, so no page can become unreachable.
|
||||
func (s *Site) subPages(d *content.Document) []templates.LinkView {
|
||||
cited := map[string]bool{}
|
||||
for _, a := range d.Artefacts {
|
||||
if ra, err := s.Content.Resolve(d, a); err == nil && ra.Target != nil {
|
||||
cited[ra.Target.ID] = true
|
||||
}
|
||||
}
|
||||
var out []templates.LinkView
|
||||
for _, other := range s.PublicInArea(d.Area) {
|
||||
if other.ID == d.ID || cited[other.ID] {
|
||||
continue
|
||||
}
|
||||
if d.Level == 1 && other.Level != 2 {
|
||||
continue
|
||||
}
|
||||
if d.Level > 1 && other.Level < 3 {
|
||||
continue
|
||||
}
|
||||
out = append(out, templates.LinkView{
|
||||
Label: other.Title,
|
||||
Href: other.URL,
|
||||
Kind: other.Confidence,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// timeline orders an area's deeper pages by period, newest first.
|
||||
func (s *Site) timeline(area string) []templates.TimelineItem {
|
||||
var items []templates.TimelineItem
|
||||
for _, d := range s.PublicInArea(area) {
|
||||
if d.Level < 2 {
|
||||
continue
|
||||
}
|
||||
items = append(items, templates.TimelineItem{
|
||||
Period: d.Period,
|
||||
Title: d.Title,
|
||||
Href: d.URL,
|
||||
Confidence: d.Confidence,
|
||||
Claim: d.Claim,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// Evidence is the one-page index of every artefact on the site.
|
||||
func (s *Site) Evidence() templates.EvidenceView {
|
||||
var groups []templates.EvidenceGroup
|
||||
total := 0
|
||||
for _, a := range content.Areas {
|
||||
group := templates.EvidenceGroup{AreaTitle: a.Title, Href: "/" + a.Key + "/"}
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
for _, l := range s.evidence(d) {
|
||||
group.Rows = append(group.Rows, templates.EvidenceRow{
|
||||
Label: d.Title + " · " + l.Label,
|
||||
Href: l.Href,
|
||||
HXGet: l.HXGet,
|
||||
Kind: l.Kind,
|
||||
Confidence: l.Confidence,
|
||||
})
|
||||
total++
|
||||
}
|
||||
}
|
||||
if len(group.Rows) > 0 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
return templates.EvidenceView{
|
||||
Layout: s.Layout(LayoutOpts{
|
||||
Title: "Evidence index — Sam Rolfe",
|
||||
Description: "Every artefact this site claims, on one page.",
|
||||
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Evidence"}},
|
||||
Binding: s.RouteBinding(),
|
||||
}),
|
||||
Groups: groups,
|
||||
Total: total,
|
||||
}
|
||||
}
|
||||
|
||||
// StreamKeys is the fragment sequence the streaming panel demonstrates: the six
|
||||
// area landing fragments, in navigation order. No model is involved.
|
||||
func (s *Site) StreamKeys() []string {
|
||||
n := len(content.Areas)
|
||||
if n > len(s.Fragments) {
|
||||
n = len(s.Fragments)
|
||||
}
|
||||
keys := make([]string, 0, n)
|
||||
for _, f := range s.Fragments[:n] {
|
||||
keys = append(keys, f.ID)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Stream is the page that re-points the panel at an event stream. The layout is
|
||||
// the same as every other page; only the binding differs.
|
||||
func (s *Site) Stream(endpoint string) templates.StreamView {
|
||||
keys := s.StreamKeys()
|
||||
links := make([]templates.LinkView, 0, len(keys))
|
||||
for _, id := range keys {
|
||||
if f, ok := s.fragByID[id]; ok {
|
||||
links = append(links, templates.LinkView{
|
||||
Label: f.Title,
|
||||
Href: s.DocURL(f.DocID),
|
||||
HXGet: s.RouteFragmentURL(id),
|
||||
Kind: f.Kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
return templates.StreamView{
|
||||
Layout: s.Layout(LayoutOpts{
|
||||
Title: "Streaming panel — Sam Rolfe",
|
||||
Description: "The presentation panel bound to an event stream instead of pre-rendered files.",
|
||||
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Streaming panel"}},
|
||||
Binding: s.StreamBinding(endpoint),
|
||||
}),
|
||||
Frames: len(links),
|
||||
Fragment: links,
|
||||
}
|
||||
}
|
||||
|
||||
// DocURL is the published URL for a document id.
|
||||
func (s *Site) DocURL(id string) string {
|
||||
if d, ok := s.Content.ByID(id); ok {
|
||||
return d.URL
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// RouteFragmentURL is where the route driver's fragment file is served from.
|
||||
func (s *Site) RouteFragmentURL(id string) string {
|
||||
return stage.RouteDriver{BaseURL: "/stage"}.FragmentURL(id)
|
||||
}
|
||||
|
||||
// Fragment is one pre-rendered stage fragment.
|
||||
type Fragment struct {
|
||||
ID string
|
||||
DocID string
|
||||
Kind string
|
||||
Title string
|
||||
// ArtefactURL is the artefact this fragment was built from, if any. It is
|
||||
// how an evidence row finds the fragment that belongs to it.
|
||||
ArtefactURL string
|
||||
// File is relative to the published root, e.g. stage/doc-edge-overview.html.
|
||||
File string
|
||||
// HTML is the rendered fragment markup, written to File at build time.
|
||||
HTML string
|
||||
|
||||
subtitle string
|
||||
body templ.Component
|
||||
code *stage.Code
|
||||
src string
|
||||
alt string
|
||||
links []stage.Link
|
||||
confidence string
|
||||
docURL string
|
||||
docTitle string
|
||||
}
|
||||
|
||||
// Fragment returns a pre-rendered fragment by id.
|
||||
func (s *Site) Fragment(id string) (*Fragment, bool) {
|
||||
f, ok := s.fragByID[id]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
func year() int { return time.Now().Year() }
|
||||
174
internal/site/site_test.go
Normal file
174
internal/site/site_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
||||
)
|
||||
|
||||
// newSite loads the repository's own content. If this fails, every other test
|
||||
// here would be testing nothing.
|
||||
func newSite(t *testing.T) *Site {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := New(Config{Root: root, OutDir: filepath.Join(t.TempDir(), "dist")})
|
||||
if err != nil {
|
||||
t.Fatalf("load site: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestSixAreasHaveLandingPages is the DoD line "L1 (6) and L2 (6) pages
|
||||
// complete": every area has a published level 1 page and at least one level 2
|
||||
// page, and every area's L1 fragment is the default stream.
|
||||
func TestSixAreasHaveLandingPages(t *testing.T) {
|
||||
s := newSite(t)
|
||||
if len(content.Areas) != 6 {
|
||||
t.Fatalf("%d areas, want 6", len(content.Areas))
|
||||
}
|
||||
if got := len(s.StreamKeys()); got != 6 {
|
||||
t.Fatalf("stream keys = %d, want the six area landing fragments", got)
|
||||
}
|
||||
for i, a := range content.Areas {
|
||||
l1, ok := s.Content.AreaLanding(a.Key)
|
||||
if !ok {
|
||||
t.Errorf("%s: no level 1 page", a.Key)
|
||||
continue
|
||||
}
|
||||
if !l1.Public {
|
||||
t.Errorf("%s: level 1 page %s is not public", a.Key, l1.ID)
|
||||
}
|
||||
if f, ok := s.Fragment(s.StreamKeys()[i]); !ok {
|
||||
t.Errorf("%s: stream key %s is not a fragment", a.Key, s.StreamKeys()[i])
|
||||
} else if f.DocID != l1.ID {
|
||||
t.Errorf("%s: stream fragment %s belongs to %s, want %s", a.Key, f.ID, f.DocID, l1.ID)
|
||||
}
|
||||
levels := map[int]int{}
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
levels[d.Level]++
|
||||
}
|
||||
if levels[2] == 0 {
|
||||
t.Errorf("%s: no level 2 pages", a.Key)
|
||||
}
|
||||
if levels[3] == 0 {
|
||||
t.Errorf("%s: no level 3 pages", a.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryArtefactIsReachableWithinThreeClicks is acceptance test C4 against
|
||||
// the view models: landing → area → artefact page is three clicks, and every
|
||||
// artefact has a working link.
|
||||
func TestEveryArtefactIsReachableWithinThreeClicks(t *testing.T) {
|
||||
s := newSite(t)
|
||||
for _, area := range content.Areas {
|
||||
l1, ok := s.Content.AreaLanding(area.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
linkedFromL1 := map[string]bool{}
|
||||
for _, l := range s.Doc(l1).SubPages {
|
||||
linkedFromL1[l.Href] = true
|
||||
}
|
||||
for _, l := range s.Doc(l1).Evidence {
|
||||
linkedFromL1[l.Href] = true
|
||||
}
|
||||
|
||||
for _, d := range s.PublicInArea(area.Key) {
|
||||
if d.Level == 1 {
|
||||
continue
|
||||
}
|
||||
if d.Level == 2 {
|
||||
if !linkedFromL1[d.URL] {
|
||||
t.Errorf("%s/level 2 page %s is not linked from the area landing page", area.Key, d.URL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Level 3: linked from an L2 page's drill-down or evidence table.
|
||||
reachable := false
|
||||
for _, l2 := range s.PublicInArea(area.Key) {
|
||||
if l2.Level != 2 {
|
||||
continue
|
||||
}
|
||||
view := s.Doc(l2)
|
||||
for _, l := range view.SubPages {
|
||||
if l.Href == d.URL {
|
||||
reachable = true
|
||||
}
|
||||
}
|
||||
for _, l := range view.Evidence {
|
||||
if l.Href == d.URL {
|
||||
reachable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reachable {
|
||||
t.Errorf("%s/level 3 page %s is not linked from any level 2 page", area.Key, d.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryArtefactHasAStageFragment keeps the panel honest: if an evidence row
|
||||
// offers hx-get, the fragment it names must exist.
|
||||
func TestEveryArtefactHasAStageFragment(t *testing.T) {
|
||||
s := newSite(t)
|
||||
for _, d := range s.PublicDocs() {
|
||||
for _, l := range s.evidence(d) {
|
||||
if l.HXGet == "" {
|
||||
t.Errorf("%s: evidence %q has no stage fragment", d.ID, l.Label)
|
||||
continue
|
||||
}
|
||||
id := strings.TrimPrefix(l.HXGet, "/stage/")
|
||||
if _, ok := s.Fragment(id); !ok {
|
||||
t.Errorf("%s: evidence %q points at missing fragment %q", d.ID, l.Label, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildWritesEveryPublishedPage runs the real build once and checks the
|
||||
// files the server depends on.
|
||||
func TestBuildWritesEveryPublishedPage(t *testing.T) {
|
||||
s := newSite(t)
|
||||
if err := s.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
for _, url := range s.PublishedPages() {
|
||||
file := s.filePathForURL(url)
|
||||
if !strings.HasPrefix(file, s.Config.OutDir) {
|
||||
t.Errorf("%s: output escapes the build directory", url)
|
||||
}
|
||||
if _, err := os.ReadFile(file); err != nil {
|
||||
t.Errorf("%s: %v", url, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{IndexFile, ManifestFile, BuildInfoFile, "static/site.css", "static/htmx.min.js", "static/chroma.css"} {
|
||||
if _, err := os.ReadFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(name))); err != nil {
|
||||
t.Errorf("build output %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
info, err := LoadBuildInfo(s.Config.OutDir)
|
||||
if err != nil {
|
||||
t.Fatalf("build info: %v", err)
|
||||
}
|
||||
if info.Pages == 0 || info.Fragments == 0 || info.Indexed == 0 {
|
||||
t.Errorf("build info is empty: %+v", info)
|
||||
}
|
||||
// The manifest order is the server's default stream: six document
|
||||
// fragments first, and they are the six area landings.
|
||||
if len(s.Manifest.Entries) < 6 {
|
||||
t.Fatalf("manifest has %d entries", len(s.Manifest.Entries))
|
||||
}
|
||||
for _, e := range s.Manifest.Entries[:6] {
|
||||
if !strings.HasPrefix(e.ID, "doc-") {
|
||||
t.Errorf("manifest entry %s is not a document fragment; the default stream must be the six areas", e.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user