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
181 lines
5.3 KiB
Go
181 lines
5.3 KiB
Go
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"
|
|
"github.com/yuin/goldmark"
|
|
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
|
"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/text"
|
|
"github.com/yuin/goldmark/util"
|
|
|
|
"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), " ")
|
|
}
|