WIP Phase 2: content loader, stage contract, search index, templ components

This commit is contained in:
2026-10-26 20:01:38 +11:00
parent b267e55f8c
commit ea2c722816
13 changed files with 2368 additions and 0 deletions

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792773339,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

27
flake.nix Normal file
View File

@@ -0,0 +1,27 @@
{
description = "Ai Resume — build environment (Go + templ + SQLite). Node, Vite and bundlers are deliberately absent.";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in {
devShells = forAll (pkgs: {
default = pkgs.mkShell {
packages = [
pkgs.go
pkgs.templ
pkgs.sqlite # the sqlite3 CLI, for inspecting the FTS5 index
pkgs.gnumake
pkgs.curl
];
shellHook = ''
echo "ai-resume dev shell — go $(go version | cut -d' ' -f3), templ $(templ version)"
echo "targets: make generate build test dev"
'';
};
});
};
}

25
go.mod Normal file
View File

@@ -0,0 +1,25 @@
module gitea.lab.audasmedia.com.au/sam/ai-resume
go 1.25.0
require (
github.com/a-h/templ v0.3.1020
github.com/alecthomas/chroma/v2 v2.27.0
github.com/yuin/goldmark v1.8.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.41.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.34.5 // indirect
)

46
go.sum Normal file
View File

@@ -0,0 +1,46 @@
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=

456
internal/content/content.go Normal file
View File

@@ -0,0 +1,456 @@
// Package content loads the Markdown source of truth in content/ into documents
// that both renderers (Mode A static pages, Mode B corpus) read.
//
// The schema is frozen; see docs/content-schema.md. This package does not
// re-implement the validator (tools/validate_content.py is the gate). It does
// enforce the invariants the renderer needs, and it fails loudly rather than
// rendering a broken page.
package content
import (
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
"gopkg.in/yaml.v3"
)
// Confidence values. These are displayed on the page and are never promoted.
const (
ConfidenceDelivered = "delivered"
ConfidenceRunning = "running"
ConfidenceInProgress = "in-progress"
ConfidenceAbandoned = "abandoned"
ConfidenceHistoric = "historic"
)
// Area is one of the six top-level navigation areas. Structure lives in code
// (it comes from reference/content-map.md, not from the content tree); the
// documents that fill it live in content/.
type Area struct {
Key string
Title string
Role string
Blurb string
Gutter string // two-letter mark used by the nav and the stage panel
}
// Areas is the fixed navigation order from reference/content-map.md.
var Areas = []Area{
{Key: "ai", Title: "AI & Agentic Systems", Role: "AI / LLM Engineer", Gutter: "AI",
Blurb: "Harnesses, subagents and skills, memory, model routing and cost, retrieval, voice, guardrails, agent observability."},
{Key: "platform", Title: "Platform & Infrastructure", Role: "Platform / DevOps Engineer", Gutter: "PL",
Blurb: "Declarative hosts, packaging, a fifty-container Docker estate, networking, storage and restore-tested backups, observability."},
{Key: "edge", Title: "Edge & Devices", Role: "Edge / IoT Engineer", Gutter: "ED",
Blurb: "Twenty-node fleet on constrained devices, custom radio, solar power, deep sleep, sensors and actuation — and the failures."},
{Key: "products", Title: "Products", Role: "", Gutter: "PR",
Blurb: "Three active businesses, built and run rather than advised on."},
{Key: "history", Title: "History", Role: "", Gutter: "HI",
Blurb: "2000–2014: agencies, clients, engagements, and the code that survives from them."},
{Key: "about", Title: "About", Role: "", Gutter: "AB",
Blurb: "The narrative, the non-IT work that came first, education, and how to get in touch."},
}
// Positioning is the site's single line of introduction.
const (
Positioning = "Twenty-five years shipping software. Now building agent systems, the platforms under them, and the hardware at the edge."
SupportLine = "…and it all runs unattended."
Location = "Melbourne"
)
// AreaByKey returns the area with the given key.
func AreaByKey(key string) (Area, bool) {
for _, a := range Areas {
if a.Key == key {
return a, true
}
}
return Area{}, false
}
// Metric is one approved chip on a page, e.g. {label: "edge nodes", value: 20}.
type Metric struct {
Label string `yaml:"label"`
Value any `yaml:"value"`
}
// String renders the chip value with its label: "20 edge nodes".
func (m Metric) String() string {
return fmt.Sprintf("%v %s", m.Value, m.Label)
}
// Artefact is a drill-down link. Every URL must resolve to a file in this
// repository; nothing external is allowed.
type Artefact struct {
Type string `yaml:"type"` // repo | image | video | doc
Label string `yaml:"label"`
URL string `yaml:"url"`
}
// Document is one Markdown file plus its front-matter.
type Document struct {
ID string `yaml:"id"`
Area string `yaml:"area"`
Level int `yaml:"level"`
Title string `yaml:"title"`
Claim string `yaml:"claim"`
Confidence string `yaml:"confidence"`
Period string `yaml:"period"`
Tech []string `yaml:"tech"`
Metrics []Metric `yaml:"metrics"`
Artefacts []Artefact `yaml:"artefacts"`
Public bool `yaml:"public"`
LastVerified string `yaml:"last_verified"`
Tags []string `yaml:"tags"`
// Derived by Load.
Path string `yaml:"-"` // path relative to the content root, e.g. edge/edge-fleet.md
URL string `yaml:"-"` // canonical page URL, e.g. /edge/edge-fleet/
HTML string `yaml:"-"` // rendered body
Plain string `yaml:"-"` // body reduced to text, for the search index
bodyRaw string // Markdown body, rendered by the markdown renderer
// Backlinks are the documents that reference this one as an artefact.
Backlinks []*Document `yaml:"-"`
}
// ResolvedArtefact is an Artefact with its target resolved.
type ResolvedArtefact struct {
Artefact
Target *Document // non-nil when the artefact is an L3/L2 document
File string // absolute path on disk
Rel string // path relative to the content root, e.g. assets/x.svg
Href string // site URL: a page URL for documents, /assets/... for files
Kind string // Fragment kind: text | code | diagram | image | video | links
}
// Set is every loaded document, indexed.
type Set struct {
Root string
Docs []*Document
byID map[string]*Document
byRel map[string]*Document
}
// ByID returns the document with the given id.
func (s *Set) ByID(id string) (*Document, bool) {
d, ok := s.byID[id]
return d, ok
}
// ByRel returns the document at the given content-relative path.
func (s *Set) ByRel(rel string) (*Document, bool) {
d, ok := s.byRel[rel]
return d, ok
}
// InArea returns the public documents for an area, ordered L1, L2, L3.
func (s *Set) InArea(key string) []*Document {
var out []*Document
for _, d := range s.Docs {
if d.Area == key {
out = append(out, d)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Level != out[j].Level {
return out[i].Level < out[j].Level
}
return out[i].Title < out[j].Title
})
return out
}
// AreaLanding returns the L1 document for an area.
func (s *Set) AreaLanding(key string) (*Document, bool) {
for _, d := range s.InArea(key) {
if d.Level == 1 {
return d, true
}
}
return nil, false
}
// Load reads every Markdown file under root. Documents with public: false are
// loaded (so links to them still resolve) but are not published.
func Load(root string) (*Set, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("content root %q: %w", root, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("content root %q is not a directory", root)
}
s := &Set{Root: root, byID: map[string]*Document{}, byRel: map[string]*Document{}}
err = filepath.WalkDir(root, func(p string, e os.DirEntry, err error) error {
if err != nil {
return err
}
name := e.Name()
if e.IsDir() {
if p != root && (strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(name, ".md") || strings.HasPrefix(name, ".") {
return nil
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
raw, rerr := os.ReadFile(p)
if rerr != nil {
return rerr
}
doc, derr := parse(rel, p, raw)
if derr != nil {
return derr
}
if prev, dup := s.byID[doc.ID]; dup {
return fmt.Errorf("%s: duplicate id %q (already used by %s)", rel, doc.ID, prev.Path)
}
doc.URL = PageURL(doc)
s.Docs = append(s.Docs, doc)
s.byID[doc.ID] = doc
s.byRel[rel] = doc
return nil
})
if err != nil {
return nil, err
}
if len(s.Docs) == 0 {
return nil, fmt.Errorf("content root %q contains no documents", root)
}
sort.SliceStable(s.Docs, func(i, j int) bool {
if s.Docs[i].Area != s.Docs[j].Area {
return areaOrder(s.Docs[i].Area) < areaOrder(s.Docs[j].Area)
}
if s.Docs[i].Level != s.Docs[j].Level {
return s.Docs[i].Level < s.Docs[j].Level
}
return s.Docs[i].ID < s.Docs[j].ID
})
for _, d := range s.Docs {
for _, a := range d.Artefacts {
ra, rerr := s.Resolve(d, a)
if rerr != nil {
return nil, rerr
}
if ra.Target != nil && ra.Target.ID != d.ID {
ra.Target.Backlinks = append(ra.Target.Backlinks, d)
}
}
}
return s, nil
}
func areaOrder(key string) int {
for i, a := range Areas {
if a.Key == key {
return i
}
}
return len(Areas)
}
func parse(rel, abs string, raw []byte) (*Document, error) {
fm, body, err := splitFrontMatter(string(raw))
if err != nil {
return nil, fmt.Errorf("%s: %w", rel, err)
}
var d Document
dec := yaml.NewDecoder(strings.NewReader(fm))
dec.KnownFields(true) // unknown front-matter fields are a schema violation
if err := dec.Decode(&d); err != nil {
return nil, fmt.Errorf("%s: front-matter: %w", rel, err)
}
d.Path = rel
d.bodyRaw = body
if err := d.check(rel); err != nil {
return nil, err
}
return &d, nil
}
// BodyMarkdown returns the raw Markdown body (everything after the front-matter).
func (d *Document) BodyMarkdown() string { return d.bodyRaw }
func (d *Document) check(rel string) error {
switch {
case d.ID == "":
return fmt.Errorf("%s: id is required", rel)
case d.Title == "":
return fmt.Errorf("%s: title is required", rel)
case d.Claim == "":
return fmt.Errorf("%s: claim is required", rel)
}
if _, ok := AreaByKey(d.Area); !ok {
return fmt.Errorf("%s: unknown area %q", rel, d.Area)
}
if d.Level < 1 || d.Level > 3 {
return fmt.Errorf("%s: level must be 1, 2 or 3, got %d", rel, d.Level)
}
switch d.Confidence {
case ConfidenceDelivered, ConfidenceRunning, ConfidenceInProgress, ConfidenceAbandoned, ConfidenceHistoric:
default:
return fmt.Errorf("%s: unknown confidence %q", rel, d.Confidence)
}
if d.LastVerified == "" {
return fmt.Errorf("%s: last_verified is required", rel)
}
stem := strings.TrimSuffix(path.Base(rel), ".md")
if stem != d.ID {
return fmt.Errorf("%s: id %q must equal the file name %q", rel, d.ID, stem)
}
for _, a := range d.Artefacts {
if a.URL == "" || a.Label == "" {
return fmt.Errorf("%s: artefact %q needs both label and url", rel, a.Label)
}
if strings.Contains(a.URL, "://") {
return fmt.Errorf("%s: artefact %q uses an external URL; create an in-repo stub instead", rel, a.URL)
}
}
return nil
}
func splitFrontMatter(src string) (fm, body string, err error) {
src = strings.ReplaceAll(src, "\r\n", "\n")
if !strings.HasPrefix(src, "---\n") {
return "", "", fmt.Errorf("missing front-matter block on line 1")
}
rest := src[len("---\n"):]
for _, delim := range []string{"\n---\n", "\n...\n"} {
if i := strings.Index(rest, delim); i >= 0 {
return rest[:i], rest[i+len(delim):], nil
}
}
return "", "", fmt.Errorf("unterminated front-matter block")
}
// PageURL is the canonical URL for a document. Depth maps onto the URL path so
// the drill-down is legible: /edge/ · /edge/edge-fleet/ · /edge/a/edge-zigbee/.
func PageURL(d *Document) string {
switch d.Level {
case 1:
return "/" + d.Area + "/"
case 2:
return "/" + d.Area + "/" + d.ID + "/"
default:
return "/" + d.Area + "/a/" + d.ID + "/"
}
}
// Resolve maps an artefact reference onto a file, a site URL and a stage
// fragment kind. Paths may be repository-relative ("content/edge/l3/x.md") or
// document-relative ("./l3/x.md", "l3/x.md").
func (s *Set) Resolve(from *Document, a Artefact) (ResolvedArtefact, error) {
rel := a.URL
var target string
switch {
case strings.HasPrefix(rel, "content/"):
target = strings.TrimPrefix(rel, "content/")
case strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, "../"):
return ResolvedArtefact{}, fmt.Errorf("%s: artefact %q must not be root-absolute or escape the repo", from.Path, rel)
default:
target = path.Join(path.Dir(from.Path), rel)
}
target = path.Clean(target)
out := ResolvedArtefact{Artefact: a, Rel: target}
abs := filepath.Join(s.Root, filepath.FromSlash(target))
st, err := os.Stat(abs)
if err != nil {
return out, fmt.Errorf("%s: artefact %q does not resolve to a file (%s)", from.Path, a.URL, target)
}
if st.IsDir() {
return out, fmt.Errorf("%s: artefact %q resolves to a directory", from.Path, a.URL)
}
out.File = abs
if strings.HasSuffix(target, ".md") {
doc, ok := s.byRel[target]
if !ok {
return out, fmt.Errorf("%s: artefact %q is not a loaded document", from.Path, a.URL)
}
out.Target = doc
out.Href = doc.URL
out.Kind = stage.KindText
return out, nil
}
out.Href = "/assets/" + strings.TrimPrefix(target, "assets/")
out.Kind = KindForFile(target)
return out, nil
}
var codeExtensions = map[string]string{
".go": "go", ".js": "javascript", ".mjs": "javascript", ".ts": "typescript",
".nix": "nix", ".php": "php", ".as": "actionscript", ".hpp": "cpp", ".h": "cpp",
".c": "c", ".cpp": "cpp", ".py": "python", ".sh": "bash", ".yaml": "yaml",
".yml": "yaml", ".json": "json", ".css": "css", ".sql": "sql", ".templ": "templ",
}
// KindForFile maps a file extension onto a stage fragment kind.
func KindForFile(name string) string {
ext := strings.ToLower(path.Ext(name))
if lang, ok := codeExtensions[ext]; ok && lang != "" {
return stage.KindCode
}
switch ext {
case ".svg":
return stage.KindDiagram
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif":
return stage.KindImage
case ".mp4", ".webm", ".mov":
return stage.KindVideo
case ".md":
return stage.KindText
}
return stage.KindLinks
}
// LanguageFor returns the chroma language for a file, if it is code.
func LanguageFor(name string) string {
return codeExtensions[strings.ToLower(path.Ext(name))]
}
// Truncate shortens s to at most n runes, on a word boundary where possible.
func Truncate(s string, n int) string {
s = strings.TrimSpace(s)
if len([]rune(s)) <= n {
return s
}
r := []rune(s)[:n]
if i := strings.LastIndex(string(r), " "); i > n/2 {
return strings.TrimSpace(string(r)[:i]) + "…"
}
return strings.TrimSpace(string(r)) + "…"
}
// MetricValue renders a metric value for display.
func MetricValue(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case int:
return strconv.Itoa(t)
case bool:
return strconv.FormatBool(t)
default:
return fmt.Sprint(t)
}
}

View File

@@ -0,0 +1,180 @@
package content
import (
"bytes"
"fmt"
stdhtml "html"
"io"
"path"
"regexp"
"strings"
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
"github.com/yuin/goldmark/text"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
)
// RendererStyle is the chroma style. Dark by default, matching the rest of
// Sam's surfaces.
const RendererStyle = "github-dark"
// Renderer turns Markdown into server-side HTML. Nothing here runs in a
// browser: goldmark for structure and tables, chroma for code, at build time.
type Renderer struct {
md goldmark.Markdown
set *Set
}
// NewRenderer builds a renderer bound to a document set (needed to rewrite
// relative .md links into site URLs).
func NewRenderer(s *Set) *Renderer {
r := &Renderer{set: s}
r.md = goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Footnote,
extension.Typographer,
highlighting.NewHighlighting(
highlighting.WithStyle(RendererStyle),
highlighting.WithFormatOptions(chromahtml.WithClasses(true)),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithASTTransformers(util.Prioritized(r, 100)),
),
goldmark.WithRendererOptions(html.WithUnsafe()), // trusted, in-repo Markdown
)
return r
}
// Transform rewrites document-relative links into site URLs so the
// pre-rendered pages work as static files. The current document is carried in
// the parser context by Render and Highlight-free callers.
func (r *Renderer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
current, ok := pc.Get(docKey).(*Document)
if !ok || current == nil {
return
}
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Link:
node.Destination = r.rewrite(current, string(node.Destination), false)
case *ast.Image:
node.Destination = r.rewrite(current, string(node.Destination), true)
}
return ast.WalkContinue, nil
})
}
func (r *Renderer) rewrite(from *Document, dest string, image bool) []byte {
if dest == "" || strings.Contains(dest, "://") || strings.HasPrefix(dest, "/") ||
strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "mailto:") {
return []byte(dest)
}
target := dest
suffix := ""
if i := strings.IndexAny(target, "#?"); i >= 0 {
suffix = target[i:]
target = target[:i]
}
rel := target
if strings.HasPrefix(rel, "content/") {
rel = strings.TrimPrefix(rel, "content/")
} else {
rel = path.Join(path.Dir(from.Path), rel)
}
rel = path.Clean(rel)
if d, ok := r.set.byRel[rel]; ok {
return []byte(d.URL + suffix)
}
if image || KindForFile(rel) != stage.KindLinks {
return []byte(AssetHref(rel) + suffix)
}
return []byte(dest)
}
// docKey carries the document being rendered through the parser context so
// the link rewriter knows what "relative" means.
var docKey = parser.NewContextKey()
// Render fills HTML and Plain on every document in the set.
func (r *Renderer) Render() error {
for _, d := range r.set.Docs {
var buf bytes.Buffer
ctx := parser.NewContext()
ctx.Set(docKey, d)
if err := r.md.Convert([]byte(d.BodyMarkdown()), &buf, parser.WithContext(ctx)); err != nil {
return fmt.Errorf("%s: render Markdown: %w", d.Path, err)
}
d.HTML = buf.String()
d.Plain = PlainText(d.HTML)
}
return nil
}
// Highlight renders a standalone code file with chroma. Used by L3 code
// artefacts and by the make-target that keeps the code exhibits in sync.
func (r *Renderer) Highlight(w io.Writer, lang string, src []byte) error {
lexer := lexers.Get(lang)
if lexer == nil {
lexer = lexers.Analyse(string(src))
}
if lexer == nil {
_, err := fmt.Fprintf(w, "<pre class=\"chroma\"><code>%s</code></pre>", stdhtml.EscapeString(string(src)))
return err
}
lexer = chroma.Coalesce(lexer)
it, err := lexer.Tokenise(nil, string(src))
if err != nil {
return err
}
style := styles.Get(RendererStyle)
if style == nil {
return fmt.Errorf("unknown chroma style %q", RendererStyle)
}
formatter := chromahtml.New(chromahtml.WithClasses(true), chromahtml.WithLineNumbers(false))
return formatter.Format(w, style, it)
}
// ChromaCSS returns the stylesheet for the highlighted classes, so the built
// site makes no colour decisions inline.
func (r *Renderer) ChromaCSS() (string, error) {
style := styles.Get(RendererStyle)
if style == nil {
return "", fmt.Errorf("unknown chroma style %q", RendererStyle)
}
var buf bytes.Buffer
if err := chromahtml.New(chromahtml.WithClasses(true)).WriteCSS(&buf, style); err != nil {
return "", err
}
return buf.String(), nil
}
// AssetHref maps a content-relative asset path onto its published URL.
func AssetHref(rel string) string {
return "/assets/" + strings.TrimPrefix(path.Clean(rel), "assets/")
}
var tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
// PlainText reduces rendered HTML to indexable text.
func PlainText(h string) string {
h = tagRe.ReplaceAllString(h, " ")
h = stdhtml.UnescapeString(h)
return strings.Join(strings.Fields(h), " ")
}

187
internal/search/search.go Normal file
View File

@@ -0,0 +1,187 @@
// Package search builds the SQLite FTS5 index over the static pages.
//
// The index is written by the build step and opened read-only by the server, so
// a search never touches the content tree and works with every AI service
// stopped. The same index is the keyword half of Phase 4's hybrid retrieval:
// its schema is documented in docs/phase-2-notes.md and must not change shape
// without both phases agreeing.
package search
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
_ "modernc.org/sqlite"
)
// Doc is one indexable page.
type Doc struct {
ID string
URL string
Title string
Area string
Level int
Confidence string
Body string
}
// Result is one hit.
type Result struct {
URL string `json:"url"`
Title string `json:"title"`
Area string `json:"area"`
Level int `json:"level"`
Confidence string `json:"confidence"`
Snippet string `json:"snippet"`
Score float64 `json:"score"`
}
// Schema is the FTS5 table. Columns marked UNINDEXED are still selectable.
const schema = `
CREATE VIRTUAL TABLE pages USING fts5(
id UNINDEXED,
url UNINDEXED,
title,
area UNINDEXED,
level UNINDEXED,
confidence UNINDEXED,
body,
tokenize = 'porter unicode61 remove_diacritics 2'
);`
// Build writes a fresh index at path. Any existing file is replaced, so the
// index always describes exactly one build.
func Build(path string, docs []Doc) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
return fmt.Errorf("open index %s: %w", path, err)
}
defer db.Close()
db.SetMaxOpenConns(1)
if _, err := db.Exec(`PRAGMA journal_mode = OFF; PRAGMA synchronous = OFF;`); err != nil {
return err
}
if _, err := db.Exec(schema); err != nil {
return fmt.Errorf("create fts5 table: %w", err)
}
tx, err := db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare(`INSERT INTO pages (id, url, title, area, level, confidence, body) VALUES (?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, d := range docs {
if _, err := stmt.Exec(d.ID, d.URL, d.Title, d.Area, d.Level, d.Confidence, d.Body); err != nil {
return fmt.Errorf("index %s: %w", d.ID, err)
}
}
if err := tx.Commit(); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO pages (pages) VALUES ('optimize')`); err != nil {
return err
}
return nil
}
// Index is a read-only handle on a built index.
type Index struct {
db *sql.DB
path string
}
// Open opens a built index read-only.
func Open(path string) (*Index, error) {
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("search index %s: %w", path, err)
}
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return &Index{db: db, path: path}, nil
}
// Close releases the handle.
func (i *Index) Close() error { return i.db.Close() }
// Count returns the number of indexed pages.
func (i *Index) Count() (int, error) {
var n int
err := i.db.QueryRow(`SELECT count(*) FROM pages`).Scan(&n)
return n, err
}
// Query runs a keyword search. Every term is matched as a prefix so "zigbee"
// finds "Zigbee" and "routing" finds "routers".
func (i *Index) Query(q string, limit int) ([]Result, error) {
match := MatchExpression(q)
if match == "" {
return nil, nil
}
if limit <= 0 || limit > 50 {
limit = 20
}
rows, err := i.db.Query(`
SELECT url, title, area, CAST(level AS INTEGER), confidence,
snippet(pages, 6, '<mark>', '</mark>', '…', 18),
bm25(pages)
FROM pages
WHERE pages MATCH ?
ORDER BY bm25(pages)
LIMIT ?`, match, limit)
if err != nil {
return nil, fmt.Errorf("search %q: %w", q, err)
}
defer rows.Close()
var out []Result
for rows.Next() {
var r Result
if err := rows.Scan(&r.URL, &r.Title, &r.Area, &r.Level, &r.Confidence, &r.Snippet, &r.Score); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// MatchExpression turns free text into a safe FTS5 query: bare words only, each
// as a prefix, all required. FTS5 operators in the input are never honoured, so
// a visitor cannot shape the query beyond words.
func MatchExpression(q string) string {
terms := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool {
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-')
})
if len(terms) == 0 {
return ""
}
out := make([]string, 0, len(terms))
for _, t := range terms {
t = strings.Trim(t, "-")
if t == "" {
continue
}
out = append(out, `"`+t+`"*`)
}
return strings.Join(out, " AND ")
}

303
internal/stage/stage.go Normal file
View File

@@ -0,0 +1,303 @@
// Package stage defines the presentation panel as a content sink rather than a
// page renderer.
//
// The contract (docs/phase-2-static-site.md §2.4):
//
// Stage ← driver(route) // Mode A: HTMX fragment swap, pre-rendered files
// Stage ← driver(SSE) // Mode B: streamed fragments (Phase 5)
//
// Layout code renders a Binding. It never asks which driver produced it, so
// re-pointing the panel at a stream is a one-line change at the call site in
// Mode B's page and no change at all to Layout or Stage.
package stage
import (
"context"
"encoding/json"
"fmt"
"sort"
"github.com/a-h/templ"
)
// Fragment kinds the panel can display. These are the whole vocabulary: adding
// one is a deliberate change to §2.4.
const (
KindText = "text"
KindCode = "code"
KindImage = "image"
KindVideo = "video"
KindDiagram = "diagram"
KindLinks = "links"
)
// PanelID is the element both drivers swap into. The templates render this id
// and the bindings target it, so there is one name for it in the codebase.
const PanelID = "stage-content"
// PanelTarget is the CSS selector for PanelID.
func PanelTarget() string { return "#" + PanelID }
// Mode is how a panel receives content.
type Mode string
const (
// ModeRoute pulls one pre-rendered fragment per interaction. Mode A.
ModeRoute Mode = "route"
// ModeStream receives fragments pushed over SSE. Mode B.
ModeStream Mode = "stream"
)
// Code is a highlighted code exhibit.
type Code struct {
Language string
Label string
HTML templ.Component // chroma-highlighted, or a <pre> fallback
}
// Link is one row of a link-list fragment.
type Link struct {
Label string
Href string
Note string
}
// Fragment is one thing the panel can show. The same struct is rendered to a
// static file for the route driver and pushed over SSE for the stream driver.
type Fragment struct {
ID string
Kind string
Title string
Subtitle string
Body templ.Component // pre-rendered HTML for text fragments
Code *Code
Src string // image, video or diagram source
Alt string
Links []Link
DocURL string
DocTitle string
Confidence string
}
// Binding is everything the Stage component needs to know about its driver.
// Route mode contributes nothing but a marker; stream mode contributes the SSE
// wiring. Both are rendered by the same Stage template.
type Binding struct {
Mode Mode
Endpoint string // stream: the SSE endpoint
Event string // stream: the event name to swap on
Target string // CSS selector the driver swaps into
Swap string // htmx swap style
Label string // human description of the driver, shown in the empty state
Hint string // one line telling the reader what to do
}
// Attributes returns the htmx/SSE attributes for the panel. Only the stream
// driver needs any: in route mode the trigger elements carry hx-get and
// hx-target, because a fragment is chosen by clicking evidence.
func (b Binding) Attributes() templ.Attributes {
attrs := templ.Attributes{"data-stage-driver": string(b.Mode)}
switch b.Mode {
case ModeStream:
attrs["hx-ext"] = "sse"
attrs["sse-connect"] = b.Endpoint
attrs["sse-swap"] = b.Event
attrs["hx-swap"] = b.Swap
attrs["aria-live"] = "polite"
}
return attrs
}
// TargetSelector is the panel a link or a stream should swap into.
func (b Binding) TargetSelector() string {
if b.Target == "" {
return PanelTarget()
}
return b.Target
}
// Driver is the whole contract between the panel and whatever feeds it.
type Driver interface {
Mode() Mode
// Bind returns the binding to render on the Stage panel.
Bind() Binding
}
// RouteDriver feeds the panel from pre-rendered fragment files. Mode A.
type RouteDriver struct {
// BaseURL is where fragment files are served from, e.g. "/stage".
BaseURL string
Label string
Hint string
}
// Mode is part of the Driver contract.
func (RouteDriver) Mode() Mode { return ModeRoute }
// Bind is part of the Driver contract.
func (d RouteDriver) Bind() Binding {
return Binding{
Mode: ModeRoute,
Target: PanelTarget(),
Swap: "innerHTML",
Label: defaultString(d.Label, "Evidence panel · driven by route"),
Hint: defaultString(d.Hint, "Choose a claim from the evidence table; the artefact appears here."),
}
}
// FragmentURL is the pre-rendered URL for a fragment id.
func (d RouteDriver) FragmentURL(id string) string {
base := d.BaseURL
if base == "" {
base = "/stage"
}
return base + "/" + id
}
// SSEDriver feeds the panel from an event stream. Same panel, same fragments,
// different transport. Used by the Phase 5 console; demonstrated in Phase 2 by
// a stream of the pre-built fragments so the plumbing is provable without AI.
type SSEDriver struct {
Endpoint string
Event string
Label string
Hint string
}
// Mode is part of the Driver contract.
func (SSEDriver) Mode() Mode { return ModeStream }
// Bind is part of the Driver contract.
func (d SSEDriver) Bind() Binding {
event := d.Event
if event == "" {
event = "stage"
}
return Binding{
Mode: ModeStream,
Endpoint: d.Endpoint,
Event: event,
Target: PanelTarget(),
Swap: "innerHTML",
Label: defaultString(d.Label, "Evidence panel · driven by stream"),
Hint: defaultString(d.Hint, "Frames arrive as they are produced and replace the panel each time."),
}
}
func defaultString(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
// Source produces the fragments a stream driver pushes. Phase 2 provides
// ManifestSource; Phase 5 provides the retrieval-driven source. Neither
// touches the panel.
type Source interface {
// Stream sends fragments for keys, in order, until done or ctx is done.
// Returning an error ends the stream; the panel keeps the last fragment.
Stream(ctx context.Context, keys []string, send func(Fragment) error) error
}
// Entry is one pre-rendered fragment, as recorded at build time.
type Entry struct {
ID string `json:"id"`
DocID string `json:"doc"`
Title string `json:"title"`
Kind string `json:"kind"`
// File is relative to the published root, e.g. "stage/edge-fleet/1.html".
File string `json:"file"`
}
// Manifest is the build-time index of fragments. The server uses it to stream
// fragments that were rendered once, and the route driver serves the same
// files directly.
type Manifest struct {
Entries []Entry `json:"entries"`
}
// ByID looks an entry up.
func (m *Manifest) ByID(id string) (Entry, bool) {
for _, e := range m.Entries {
if e.ID == id {
return e, true
}
}
return Entry{}, false
}
// ForDoc returns the entries belonging to a document, oldest first.
func (m *Manifest) ForDoc(docID string) []Entry {
var out []Entry
for _, e := range m.Entries {
if e.DocID == docID {
out = append(out, e)
}
}
return out
}
// Marshal encodes the manifest for the build to write.
func (m *Manifest) Marshal() ([]byte, error) { return json.MarshalIndent(m, "", " ") }
// LoadManifest parses a manifest written by the build.
func LoadManifest(b []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("stage manifest: %w", err)
}
return &m, nil
}
// ManifestSource streams fragments that were pre-rendered at build time. It
// exists to prove the stream path end to end without a model in the loop; the
// Phase 5 source implements the same interface with retrieval behind it.
type ManifestSource struct {
manifest *Manifest
// HTML returns the pre-rendered markup for an entry.
HTML func(Entry) (string, error)
// Frame is an optional hook called as each fragment is sent, for pacing.
Frame func(index int)
}
// NewManifestSource wires a manifest and a reader together.
func NewManifestSource(m *Manifest, read func(Entry) (string, error), frame func(int)) *ManifestSource {
return &ManifestSource{manifest: m, HTML: read, Frame: frame}
}
// Stream is part of the Source contract.
func (s *ManifestSource) Stream(ctx context.Context, keys []string, send func(Fragment) error) error {
entries := make([]Entry, 0, len(keys))
if len(keys) == 0 {
entries = append(entries, s.manifest.Entries...)
}
for _, k := range keys {
if e, ok := s.manifest.ByID(k); ok {
entries = append(entries, e)
continue
}
// A document id expands to that document's fragments.
entries = append(entries, s.manifest.ForDoc(k)...)
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].ID < entries[j].ID })
for i, e := range entries {
if err := ctx.Err(); err != nil {
return err
}
html, err := s.HTML(e)
if err != nil {
return err
}
if s.Frame != nil {
s.Frame(i)
}
frag := Fragment{ID: e.ID, Kind: e.Kind, Title: e.Title, Body: templ.Raw(html)}
if err := send(frag); err != nil {
return err
}
}
return nil
}

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

290
internal/web/static/sse.js Normal file
View File

@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()

View File

@@ -0,0 +1,345 @@
package templates
import (
"strconv"
"github.com/a-h/templ"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
)
// Layout is the whole document. It renders the stage binding it is handed and
// never inspects which driver produced it.
templ Layout(v LayoutView) {
<!DOCTYPE html>
<html lang="en-AU">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ v.Title }</title>
<meta name="description" content={ v.Description }/>
<meta name="color-scheme" content="dark"/>
<link rel="stylesheet" href="/static/site.css"/>
<link rel="stylesheet" href="/static/chroma.css"/>
<script src="/static/htmx.min.js" defer></script>
if v.Stage.Mode == stage.ModeStream {
<script src="/static/sse.js" defer></script>
}
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
@Nav(v.Nav, v.Area)
<div class="shell">
<main id="main" class="main">
@Breadcrumbs(v.Crumbs)
{ children... }
</main>
@Stage(StageView{Binding: v.Stage})
</div>
@Footer(v)
</body>
</html>
}
// Nav is the six-area navigation: a sidebar on desktop, a <details> disclosure
// below 900 px so it still works with JavaScript disabled.
templ Nav(items []NavItem, current string) {
<header class="topbar">
<a class="brand" href="/">
<span class="brand-mark">SR</span>
<span class="brand-text">
Sam Rolfe
<span class="brand-sub">AI · Platform · Edge</span>
</span>
</a>
@SearchForm("", "nav-search")
<details class="nav-disclosure">
<summary class="nav-toggle" aria-label="Sections">Sections</summary>
<nav class="nav" aria-label="Sections">
@navLinks(items, current)
@evidenceLink(current)
</nav>
</details>
<nav class="nav nav-rail" aria-label="Sections">
@navLinks(items, current)
@evidenceLink(current)
</nav>
</header>
}
templ navLinks(items []NavItem, current string) {
for _, it := range items {
<a href={ templ.SafeURL(it.Href) } class={ navClass(it) } aria-current={ currentAttr(it) }>
<span class="nav-gutter" aria-hidden="true">{ it.Gutter }</span>
<span class="nav-title">{ it.Title }</span>
</a>
}
}
templ evidenceLink(current string) {
<a href="/evidence/" class={ navClassFor(current == "evidence") }>
<span class="nav-gutter" aria-hidden="true">EV</span>
<span class="nav-title">Evidence index</span>
</a>
}
// Breadcrumbs shows where the reader is, and is a real link trail.
templ Breadcrumbs(crumbs []Crumb) {
if len(crumbs) > 0 {
<nav class="crumbs" aria-label="Breadcrumb">
<ol>
for i, c := range crumbs {
<li>
if i == len(crumbs)-1 {
<span aria-current="page">{ c.Label }</span>
} else {
<a href={ templ.SafeURL(c.Href) }>{ c.Label }</a>
<span class="crumb-sep" aria-hidden="true">/</span>
}
</li>
}
</ol>
</nav>
}
}
// SearchForm is a GET form: it works with JavaScript disabled and is upgraded by
// HTMX when scripts are available.
templ SearchForm(q string, class string) {
<form class={ "search", class } action="/search" method="get" role="search"
hx-get="/search" hx-target="#search-results" hx-swap="innerHTML" hx-push-url="true">
<label class="visually-hidden" for={ "q-" + class }>Search the site</label>
<input id={ "q-" + class } type="search" name="q" value={ q } placeholder="Search — try “zigbee”, “signage”, “routing”" autocomplete="off"/>
<button type="submit">Search</button>
</form>
}
// Stage is the presentation panel. Everything about how it is fed comes from
// the binding: route mode swaps in pre-rendered fragments, stream mode listens
// on an event stream. The markup is identical either way.
templ Stage(v StageView) {
<aside id="stage" class="stage" aria-label="Presentation panel">
<header class="stage-head">
<h2 class="stage-title">Presentation panel</h2>
<p class="stage-driver" data-driver={ string(v.Binding.Mode) }>{ driverLabel(v.Binding.Mode) }</p>
</header>
<div id={ stage.PanelID } class="stage-body" { v.Binding.Attributes()... }>
if v.Fragment != nil {
@FragmentView(*v.Fragment)
} else {
<div class="stage-empty">
<p class="stage-empty-title">{ v.Binding.Label }</p>
<p class="stage-hint">{ v.Binding.Hint }</p>
</div>
}
</div>
<footer class="stage-foot">
<a href="/evidence/">Every artefact</a>
<a href="/stream/">Streaming panel</a>
</footer>
</aside>
}
// FragmentView renders one stage fragment. This is the only place that knows
// what a fragment kind looks like, and it is shared by the route driver (at
// build time) and the stream driver (at request time).
templ FragmentView(f stage.Fragment) {
<article class={ "frag frag-" + f.Kind }>
<header class="frag-head">
<p class="frag-kind">{ f.Kind }</p>
<h3 class="frag-title">{ f.Title }</h3>
if f.Subtitle != "" {
<p class="frag-sub">{ f.Subtitle }</p>
}
</header>
switch f.Kind {
case stage.KindText:
<div class="frag-text">{ f.Body }</div>
case stage.KindCode:
if f.Code != nil {
<figure class="code">
<figcaption>{ f.Code.Label }<span class="code-lang">{ f.Code.Language }</span></figcaption>
@f.Code.HTML
</figure>
}
case stage.KindDiagram, stage.KindImage:
<figure class="media">
<img src={ templ.SafeURL(f.Src) } alt={ f.Alt } loading="lazy"/>
if f.Subtitle != "" {
<figcaption>{ f.Subtitle }</figcaption>
}
</figure>
case stage.KindVideo:
<figure class="media">
<video controls preload="metadata" src={ templ.SafeURL(f.Src) }></video>
if f.Subtitle != "" {
<figcaption>{ f.Subtitle }</figcaption>
}
</figure>
case stage.KindLinks:
if len(f.Links) > 0 {
<ul class="frag-links">
for _, l := range f.Links {
<li>
<a href={ templ.SafeURL(l.Href) }>{ l.Label }</a>
if l.Note != "" {
<span class="frag-note">{ l.Note }</span>
}
</li>
}
</ul>
}
}
<footer class="frag-foot">
if f.Confidence != "" {
<span class={ ConfidenceClass(f.Confidence) }>{ f.Confidence }</span>
}
if f.DocURL != "" {
<a class="frag-doc" href={ templ.SafeURL(f.DocURL) }>{ f.DocTitle }</a>
}
</footer>
</article>
}
// EvidenceTable lists artefacts. Rows with HXGet load the artefact into the
// stage panel; every row is also a plain link, so this works with JavaScript
// disabled.
templ EvidenceTable(rows []EvidenceRow) {
<table class="evidence">
<thead>
<tr>
<th scope="col">Evidence</th>
<th scope="col">Kind</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
for _, r := range rows {
<tr>
<td>
if r.HXGet != "" {
<a href={ templ.SafeURL(r.Href) } hx-get={ r.HXGet } hx-target={ stage.PanelTarget() } hx-swap="innerHTML" hx-push-url="false">{ r.Label }</a>
} else {
<a href={ templ.SafeURL(r.Href) }>{ r.Label }</a>
}
</td>
<td><span class="kind">{ r.Kind }</span></td>
<td>
if r.Confidence != "" {
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
}
</td>
</tr>
}
</tbody>
</table>
}
// PillarCard is one landing-page pillar: the claim, the numbers, and the way in.
templ PillarCard(c PillarCardView) {
<article class="pillar">
<p class="pillar-kicker">
<span class="pillar-gutter" aria-hidden="true">{ c.Area.Gutter }</span>
if c.Area.Role != "" {
<span>{ c.Area.Role }</span>
}
</p>
<h2 class="pillar-title">
<a href={ templ.SafeURL(c.Href) }>{ c.Title }</a>
</h2>
<p class="pillar-claim">{ c.Claim }</p>
if len(c.Metrics) > 0 {
<ul class="metrics">
for _, m := range c.Metrics {
<li class="chip">
<strong>{ m.Value }</strong>
<span>{ m.Label }</span>
</li>
}
</ul>
}
if len(c.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range c.Tech {
<li>{ t }</li>
}
</ul>
}
<p class="pillar-foot">
<a class="pillar-go" href={ templ.SafeURL(c.Href) }>
if c.Level == 1 {
<span>Start here</span>
} else {
<span>Read the capability page</span>
}
</a>
<span class="pillar-count">{ strconv.Itoa(c.Pages) } pages</span>
<span class={ ConfidenceClass(c.Confidence) }>{ c.Confidence }</span>
</p>
</article>
}
// Timeline renders the history area as a timeline.
templ Timeline(items []TimelineItem) {
<ol class="timeline">
for _, it := range items {
<li class="timeline-item">
<p class="timeline-period">{ it.Period }</p>
<div class="timeline-body">
<h3><a href={ templ.SafeURL(it.Href) }>{ it.Title }</a></h3>
<p>{ it.Claim }</p>
<span class={ ConfidenceClass(it.Confidence) }>{ it.Confidence }</span>
</div>
</li>
}
</ol>
}
// CodeBlock renders an already-highlighted code exhibit.
templ CodeBlock(c stage.Code) {
<figure class="code">
<figcaption>{ c.Label }<span class="code-lang">{ c.Language }</span></figcaption>
{ c.HTML }
</figure>
}
// Footer closes the page with the honesty notes a recruiter needs.
templ Footer(v LayoutView) {
<footer class="footer">
<p class="footer-line">Twenty-five years shipping software · Melbourne · remote · hybrid · office · travel</p>
<p class="footer-note">
Status labels are literal: <span class="conf conf-running">running</span> and
<span class="conf conf-delivered">delivered</span> are claims; <span class="conf conf-in-progress">in-progress</span> and
<span class="conf conf-abandoned">abandoned</span> are stated as such.
</p>
<p class="footer-links">
<a href="/evidence/">Evidence index</a>
<a href="/search">Search</a>
<a href="/stream/">Streaming panel</a>
<span class="footer-copy">© { strconv.Itoa(v.Year) } Sam Rolfe</span>
</p>
</footer>
}
func navClass(it NavItem) string { return navClassFor(it.Current) }
func navClassFor(current bool) string {
if current {
return "nav-link nav-current"
}
return "nav-link"
}
func currentAttr(it NavItem) string {
if it.Current {
return "page"
}
return ""
}
func driverLabel(m stage.Mode) string {
if m == stage.ModeStream {
return "stream driver · SSE"
}
return "route driver · pre-rendered"
}

View File

@@ -0,0 +1,258 @@
package templates
import (
"strconv"
"github.com/a-h/templ"
)
// HomePage is the landing page: the 90-second read.
templ HomePage(v HomeView) {
@Layout(v.Layout) {
<section class="hero">
<p class="hero-kicker">Melbourne · twenty-five years · AI · platform · edge</p>
<h1 class="hero-title">{ v.Positioning }</h1>
<p class="hero-support">{ v.Support }</p>
<p class="hero-facts">
<span>{ strconv.Itoa(v.Areas) } areas</span>
<span>{ strconv.Itoa(v.Pages) } pages</span>
<span>{ strconv.Itoa(v.Artifacts) } linked artefacts</span>
<span>no page needs a model to render</span>
</p>
@SearchForm("", "hero-search")
</section>
<section class="pillars" aria-label="Six areas">
for _, c := range v.Cards {
@PillarCard(c)
}
</section>
<section class="honesty" aria-labelledby="how-to-read">
<h2 id="how-to-read">How to read this site</h2>
<ul>
<li><strong>Level 1</strong> is one screen per area: the claim, the numbers, the way in.</li>
<li><strong>Level 2</strong> is what is actually run, with an evidence table and the trade-offs.</li>
<li><strong>Level 3</strong> is the artefact: code, diagram, screenshot, log.</li>
<li>Every claim carries a <strong>status</strong>, and finished, running, in-progress and abandoned are all shown as they are.</li>
<li>Every artefact is at most three clicks from here, and the <a href="/evidence/">evidence index</a> lists all of them on one page.</li>
</ul>
</section>
}
}
// DocPage renders an L1, L2 or L3 document.
templ DocPage(v DocView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">{ v.AreaTitle } · { v.LevelLabel }</p>
<h1 class="doc-title">{ v.Doc.Title }</h1>
<p class="doc-claim">{ v.Doc.Claim }</p>
<div class="doc-chips">
<span class={ ConfidenceClass(v.Doc.Confidence) }>{ v.Doc.Confidence }</span>
if v.Doc.Period != "" {
<span class="chip chip-plain">{ v.Doc.Period }</span>
}
for _, m := range v.Metrics {
<li class="chip">
<strong>{ m.Value }</strong>
<span>{ m.Label }</span>
</li>
}
</div>
if len(v.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range v.Tech {
<li>{ t }</li>
}
</ul>
}
</header>
<div class="prose">
@v.Body
</div>
if len(v.Evidence) > 0 {
<section class="doc-evidence" aria-labelledby="evidence-heading">
<h2 id="evidence-heading">Evidence for this page</h2>
<p class="section-note">Select a row to load the artefact into the presentation panel. Each row is also a link, so this works without JavaScript.</p>
@EvidenceTable(evidenceRows(v.Evidence))
</section>
}
if len(v.Timeline) > 0 {
<section class="doc-timeline" aria-labelledby="timeline-heading">
<h2 id="timeline-heading">In time order</h2>
@Timeline(v.Timeline)
</section>
}
if len(v.SubPages) > 0 {
<section class="doc-sub" aria-labelledby="sub-heading">
<h2 id="sub-heading">{ subHeading(v.Doc.Level) }</h2>
<ul class="drill-list">
for _, s := range v.SubPages {
<li>
<a href={ templ.SafeURL(s.Href) }>{ s.Label }</a>
<span class="drill-kind">{ s.Kind }</span>
</li>
}
</ul>
</section>
}
if len(v.Backlinks) > 0 {
<section class="doc-back" aria-labelledby="back-heading">
<h2 id="back-heading">Referenced from</h2>
<ul class="drill-list">
for _, b := range v.Backlinks {
<li><a href={ templ.SafeURL(b.Href) }>{ b.Label }</a></li>
}
</ul>
</section>
}
<footer class="doc-foot">
<p>Status <span class={ ConfidenceClass(v.Doc.Confidence) }>{ v.Doc.Confidence }</span> — { ConfidenceNote(v.Doc.Confidence) }</p>
<p>Last verified { v.Doc.LastVerified } · id <code>{ v.Doc.ID }</code> · { v.Doc.Path }</p>
</footer>
</article>
}
}
// EvidencePage is one page listing every artefact, grouped by area. It is the
// shortest path from the landing page to any proof.
templ EvidencePage(v EvidenceView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">Index</p>
<h1>Evidence index</h1>
<p class="doc-claim">Every artefact this site claims, on one page. { strconv.Itoa(v.Total) } links, each one a file in the repository.</p>
</header>
for _, g := range v.Groups {
<section class="evidence-group">
<h2><a href={ templ.SafeURL(g.Href) }>{ g.AreaTitle }</a></h2>
@EvidenceTable(g.Rows)
</section>
}
</article>
}
}
// SearchPage is the keyword search. The results block is swapped by HTMX on the
// same page; with JavaScript off the form simply reloads the page.
templ SearchPage(v SearchView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">Keyword search</p>
<h1>Search { strconv.Itoa(v.Indexed) } pages</h1>
<p class="doc-claim">Full-text search over page titles and body text. Served from a local index — no model, no network call.</p>
</header>
@SearchForm(v.Query, "page-search")
@SearchResults(v)
</article>
}
}
// SearchResults is swapped into #search-results by HTMX.
templ SearchResults(v SearchView) {
<div id="search-results" aria-live="polite">
if v.Query == "" {
<p class="section-note">Try “zigbee”, “signage”, “routing”, “backups” or “guardrails”.</p>
} else if v.Failed {
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
} else if len(v.Results) == 0 {
<p class="search-empty">No page matches <q>{ v.Query }</q>. The <a href="/evidence/">evidence index</a> lists everything by area.</p>
} else {
<p class="search-count">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
<ol class="results">
for _, r := range v.Results {
<li class="result">
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
<p class="result-meta">
<span class="kind">{ r.Area }</span>
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
</p>
<p class="result-snippet">{ SafeSnippet(r.Snippet) }</p>
</li>
}
</ol>
}
</div>
}
// StreamPage proves the panel can be re-pointed at a stream. The layout and the
// panel markup are the ones every other page uses; only the binding differs.
templ StreamPage(v StreamView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">Design note · not a product feature</p>
<h1>The panel takes content from a driver</h1>
<p class="doc-claim">This page renders the same layout and the same panel as every other page. Its panel is bound to an event stream instead of pre-rendered files, and no layout code changed.</p>
</header>
<div class="prose">
<p>The presentation panel is a content sink. In Mode A it takes pre-rendered fragments over
HTMX: you click a row in an evidence table and one file is swapped in. On this page the same
panel is bound to <code>/api/stage/stream</code> and receives { strconv.Itoa(v.Frames) } fragments
over server-sent events, one after another.</p>
<p>Fragments are pushed, not requested. Nothing about the panel — its markup, its fragment
view, its empty state — knows the difference. That is the point: the Phase 5 console
re-points this panel at retrieval output and the layout does not change.</p>
<p class="section-note">This demonstration streams the artefacts already built for the site, with
no model in the loop. It exists to prove the transport, not to pretend an AI is answering.</p>
</div>
if len(v.Fragment) > 0 {
<section class="doc-sub">
<h2>Fragments in this stream</h2>
<ul class="drill-list">
for _, f := range v.Fragment {
<li>
<a href={ templ.SafeURL(f.Href) } hx-get={ f.HXGet } hx-target="#stage-content" hx-swap="innerHTML">{ f.Label }</a>
<span class="drill-kind">{ f.Kind }</span>
</li>
}
</ul>
</section>
}
</article>
}
}
// NotFoundPage is the 404.
templ NotFoundPage(v NotFoundView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">404</p>
<h1>That page is not here</h1>
<p class="doc-claim">The link may be from an older build. The six areas and the evidence index are the reliable ways in.</p>
</header>
<ul class="drill-list">
<li><a href="/">Landing page</a></li>
<li><a href="/evidence/">Evidence index</a></li>
<li><a href="/search">Search</a></li>
</ul>
</article>
}
}
func evidenceRows(links []LinkView) []EvidenceRow {
rows := make([]EvidenceRow, 0, len(links))
for _, l := range links {
rows = append(rows, EvidenceRow{Label: l.Label, Href: l.Href, HXGet: l.HXGet, Kind: l.Kind, Confidence: l.Confidence})
}
return rows
}
func subHeading(level int) string {
if level == 1 {
return "Drill-downs in this area"
}
return "Artefacts behind this page"
}

View File

@@ -0,0 +1,223 @@
// Package templates holds every templ component and the view models they read.
// Layout never branches on the stage driver: it renders the binding it is given.
package templates
import (
stdhtml "html"
"strings"
"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"
)
// NavItem is one left-nav entry.
type NavItem struct {
Title string
Href string
Gutter string
Current bool
}
// Crumb is one breadcrumb.
type Crumb struct {
Label string
Href string
}
// LayoutView is everything the chrome needs.
type LayoutView struct {
Title string
Description string
Area string
Nav []NavItem
Crumbs []Crumb
Stage stage.Binding
SearchQuery string
Year int
SkipAI bool // true on Mode A pages, which never need a model
}
// StageView wraps the binding so the Stage component has one argument.
type StageView struct {
Binding stage.Binding
// Fragment, when set, is rendered into the panel at build time.
Fragment *stage.Fragment
}
// MetricView is one approved chip.
type MetricView struct {
Label string
Value string
}
// LinkView is a drill-down link. HXGet makes it a stage trigger; Href keeps it
// a working link when JavaScript is off.
type LinkView struct {
Label string
Href string
HXGet string
Kind string
Note string
Confidence string
}
// EvidenceRow is one row of an EvidenceTable.
type EvidenceRow struct {
Label string
Href string
HXGet string
Kind string
Confidence string
}
// EvidenceGroup groups rows by area on the evidence index.
type EvidenceGroup struct {
AreaTitle string
Href string
Rows []EvidenceRow
}
// TimelineItem is one row of a timeline.
type TimelineItem struct {
Period string
Title string
Href string
Confidence string
Claim string
}
// PillarCardView is one of the six landing cards.
type PillarCardView struct {
Area content.Area
Title string
Claim string
Href string
Metrics []MetricView
Tech []string
Confidence string
Level int
Pages int
}
// Layout of each page kind.
type (
// HomeView is the landing page.
HomeView struct {
Layout LayoutView
Positioning string
Support string
Location string
Cards []PillarCardView
Artifacts int
Pages int
Areas int
}
// DocView is an L1, L2 or L3 content page.
DocView struct {
Layout LayoutView
Doc *content.Document
AreaTitle string
Role string
Body templ.Component
Metrics []MetricView
Tech []string
Evidence []LinkView
Backlinks []LinkView
SubPages []LinkView
Timeline []TimelineItem
LevelLabel string
}
// EvidenceView is the index of every artefact.
EvidenceView struct {
Layout LayoutView
Groups []EvidenceGroup
Total int
}
// SearchView is the keyword search page.
SearchView struct {
Layout LayoutView
Query string
Results []search.Result
Total int
Indexed int
Failed bool
}
// StreamView is the SSE-driven stage demonstration page.
StreamView struct {
Layout LayoutView
Frames int
Fragment []LinkView
}
// NotFoundView is the 404 page.
NotFoundView struct {
Layout LayoutView
}
)
// MarkdownBody wraps pre-rendered Markdown HTML.
func MarkdownBody(html string) templ.Component { return templ.Raw(html) }
// SafeSnippet escapes a search snippet but keeps the <mark> tags the index
// inserted. Content is in-repo and trusted; escaping is defence in depth.
func SafeSnippet(s string) templ.Component {
s = stdhtml.EscapeString(s)
s = strings.ReplaceAll(s, "&lt;mark&gt;", "<mark>")
s = strings.ReplaceAll(s, "&lt;/mark&gt;", "</mark>")
return templ.Raw(s)
}
// ConfidenceClass maps a confidence value onto a CSS class. The value itself is
// always displayed — never softened.
func ConfidenceClass(c string) string {
switch c {
case content.ConfidenceDelivered:
return "conf conf-delivered"
case content.ConfidenceRunning:
return "conf conf-running"
case content.ConfidenceInProgress:
return "conf conf-in-progress"
case content.ConfidenceAbandoned:
return "conf conf-abandoned"
case content.ConfidenceHistoric:
return "conf conf-historic"
}
return "conf"
}
// ConfidenceNote is the sentence shown under a status badge.
func ConfidenceNote(c string) string {
switch c {
case content.ConfidenceDelivered:
return "Delivered."
case content.ConfidenceRunning:
return "Running today."
case content.ConfidenceInProgress:
return "In progress — not finished."
case content.ConfidenceAbandoned:
return "Abandoned. Kept in the record deliberately."
case content.ConfidenceHistoric:
return "Historic — worked on, not maintained today."
}
return ""
}
// LevelLabel names a depth level for the page kicker.
func LevelLabel(level int) string {
switch level {
case 1:
return "Level 1 · capability in one screen"
case 2:
return "Level 2 · what is actually run"
case 3:
return "Level 3 · artefact"
}
return ""
}