diff --git a/go.mod b/go.mod
index aacf4e8..eb6fb39 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module forge.lthn.ai/core/docs
-go 1.25.0
+go 1.26.0
require (
github.com/stretchr/testify v1.11.1
@@ -9,6 +9,10 @@ require (
)
require (
- github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
+ forge.lthn.ai/core/go-html v0.1.2 // indirect
+ forge.lthn.ai/core/go-i18n v0.1.0 // indirect
+ forge.lthn.ai/core/go-inference v0.1.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ golang.org/x/text v0.34.0 // indirect
)
diff --git a/go.sum b/go.sum
index f28c5f8..af591d9 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,23 @@
+forge.lthn.ai/core/go-html v0.1.2 h1:YnxnUHIYvAcl9BzDAzwfzQ5xIpkQcFakidv9iHvVzzc=
+forge.lthn.ai/core/go-html v0.1.2/go.mod h1:qQ01IFl64VuA40JTLZJHUk7cQ0gXktV/eSmRTb3EceA=
+forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI=
+forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs=
+forge.lthn.ai/core/go-inference v0.1.0 h1:pO7etYgqV8LMKFdpW8/2RWncuECZJCIcf8nnezeZ5R4=
+forge.lthn.ai/core/go-inference v0.1.0/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/pkg/help/layout.go b/pkg/help/layout.go
new file mode 100644
index 0000000..4be033f
--- /dev/null
+++ b/pkg/help/layout.go
@@ -0,0 +1,497 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+package help
+
+import (
+ "fmt"
+ "html"
+ "strings"
+
+ gohtml "forge.lthn.ai/core/go-html"
+)
+
+// pageCSS is the shared dark-theme stylesheet embedded in every page.
+const pageCSS = `
+:root {
+ --bg: #0d1117;
+ --bg-secondary: #161b22;
+ --bg-tertiary: #21262d;
+ --fg: #c9d1d9;
+ --fg-muted: #8b949e;
+ --fg-subtle: #6e7681;
+ --accent: #58a6ff;
+ --accent-hover: #79c0ff;
+ --border: #30363d;
+}
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
+ background: var(--bg);
+ color: var(--fg);
+ line-height: 1.6;
+ font-size: 14px;
+}
+a { color: var(--accent); text-decoration: none; }
+a:hover { color: var(--accent-hover); text-decoration: underline; }
+.container { max-width: 960px; margin: 0 auto; padding: 0 1.5rem; }
+nav {
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ padding: 0.75rem 0;
+}
+nav .container {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+nav .brand {
+ font-weight: bold;
+ font-size: 1rem;
+ color: var(--fg);
+ white-space: nowrap;
+}
+nav .brand:hover { color: var(--accent); text-decoration: none; }
+.search-form { display: flex; flex: 1; max-width: 400px; }
+.search-form input {
+ flex: 1;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--fg);
+ padding: 0.4rem 0.75rem;
+ font-family: inherit;
+ font-size: 0.85rem;
+}
+.search-form input::placeholder { color: var(--fg-subtle); }
+.search-form input:focus { outline: none; border-color: var(--accent); }
+.page-body { display: flex; gap: 2rem; padding: 2rem 0; min-height: calc(100vh - 120px); }
+.page-body > main { flex: 1; min-width: 0; }
+.page-body > aside { width: 220px; flex-shrink: 0; }
+.sidebar-nav { position: sticky; top: 1rem; }
+.sidebar-nav h3 { font-size: 0.85rem; color: var(--fg-muted); margin-top: 0; }
+.sidebar-nav ul { list-style: none; padding-left: 0; font-size: 0.8rem; }
+.sidebar-nav li { margin: 0.3rem 0; }
+.sidebar-nav a { color: var(--fg-muted); }
+.sidebar-nav a:hover { color: var(--accent); }
+.sidebar-group { margin-bottom: 1.25rem; }
+.sidebar-group-title { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-subtle); margin-bottom: 0.25rem; }
+footer {
+ border-top: 1px solid var(--border);
+ padding: 1rem 0;
+ text-align: center;
+ color: var(--fg-muted);
+ font-size: 0.8rem;
+}
+h1, h2, h3, h4, h5, h6 {
+ color: var(--fg);
+ margin: 1.5rem 0 0.75rem;
+ line-height: 1.3;
+}
+h1 { font-size: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
+h2 { font-size: 1.25rem; }
+h3 { font-size: 1.1rem; }
+p { margin: 0.5rem 0; }
+pre {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 1rem;
+ overflow-x: auto;
+ margin: 0.75rem 0;
+}
+code {
+ background: var(--bg-tertiary);
+ padding: 0.15rem 0.3rem;
+ border-radius: 3px;
+ font-size: 0.9em;
+}
+pre code { background: none; padding: 0; }
+table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 0.75rem 0;
+}
+th, td {
+ border: 1px solid var(--border);
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+}
+th { background: var(--bg-secondary); font-weight: 600; }
+ul, ol { padding-left: 1.5rem; margin: 0.5rem 0; }
+li { margin: 0.25rem 0; }
+.tag {
+ display: inline-block;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 0.1rem 0.5rem;
+ font-size: 0.75rem;
+ color: var(--accent);
+ margin: 0.15rem 0.15rem;
+}
+.card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 1rem 1.25rem;
+ margin: 0.75rem 0;
+ transition: border-color 0.15s;
+}
+.card:hover { border-color: var(--accent); }
+.card h3 { margin-top: 0; font-size: 1rem; }
+.card p { color: var(--fg-muted); font-size: 0.85rem; }
+.badge {
+ display: inline-block;
+ background: var(--bg-tertiary);
+ border-radius: 10px;
+ padding: 0.1rem 0.5rem;
+ font-size: 0.7rem;
+ color: var(--fg-muted);
+}
+.toc ul { list-style: none; padding-left: 0; font-size: 0.85rem; }
+.toc li { margin: 0.3rem 0; }
+.toc a { color: var(--fg-muted); }
+.toc a:hover { color: var(--accent); }
+.centre { text-align: center; }
+`
+
+// wrapPage wraps an HLCRF layout node in a complete HTML document.
+func wrapPage(title string, layout gohtml.Node) string {
+ head := gohtml.Raw(fmt.Sprintf(
+ `
`+
+ ``+
+ `%s`,
+ html.EscapeString(title), pageCSS,
+ ))
+ tail := gohtml.Raw(``)
+
+ doc := gohtml.El("div", head, layout, tail)
+ ctx := gohtml.NewContext()
+ // Render and strip the wrapper div
+ rendered := gohtml.Render(doc, ctx)
+ rendered = strings.TrimPrefix(rendered, "")
+ rendered = strings.TrimSuffix(rendered, "
")
+ return rendered
+}
+
+// headerNav returns the HLCRF Header node: nav bar with brand + search form.
+func headerNav(searchValue string) gohtml.Node {
+ escapedValue := html.EscapeString(searchValue)
+ return gohtml.Raw(fmt.Sprintf(
+ ``,
+ escapedValue,
+ ))
+}
+
+// footerContent returns the HLCRF Footer node: licence + source link.
+func footerContent() gohtml.Node {
+ return gohtml.Raw(
+ ``,
+ )
+}
+
+// sidebarTopicTree returns a sidebar node showing topics grouped by tag.
+func sidebarTopicTree(topics []*Topic) gohtml.Node {
+ if len(topics) == 0 {
+ return gohtml.Raw("")
+ }
+
+ groups := groupTopicsByTag(topics)
+ var b strings.Builder
+ b.WriteString(``)
+ return gohtml.Raw(b.String())
+}
+
+// topicTableOfContents returns a ToC node with section anchors.
+func topicTableOfContents(sections []Section) gohtml.Node {
+ if len(sections) == 0 {
+ return gohtml.Raw("")
+ }
+
+ var b strings.Builder
+ b.WriteString(`On this page
`)
+ for _, s := range sections {
+ indent := (s.Level - 1) * 12
+ b.WriteString(fmt.Sprintf(
+ `- %s
`,
+ indent,
+ html.EscapeString(s.ID),
+ html.EscapeString(s.Title),
+ ))
+ }
+ b.WriteString(`
`)
+ return gohtml.Raw(b.String())
+}
+
+// truncateContent returns a plain-text preview of markdown content.
+func truncateContent(content string, maxLen int) string {
+ lines := strings.Split(content, "\n")
+ var clean []string
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ clean = append(clean, trimmed)
+ }
+ text := strings.Join(clean, " ")
+ runes := []rune(text)
+ if len(runes) <= maxLen {
+ return text
+ }
+ return string(runes[:maxLen]) + "..."
+}
+
+// RenderIndexPage renders a full HTML page listing topics grouped by tag.
+func RenderIndexPage(topics []*Topic) string {
+ layout := gohtml.NewLayout("HCF")
+
+ // Header
+ layout.H(headerNav(""))
+
+ // Content
+ var content strings.Builder
+ content.WriteString(``)
+
+ count := len(topics)
+ noun := "topics"
+ if count == 1 {
+ noun = "topic"
+ }
+ content.WriteString(fmt.Sprintf(
+ `
Help Topics %d %s
`,
+ count, noun,
+ ))
+
+ if count > 0 {
+ groups := groupTopicsByTag(topics)
+ for _, g := range groups {
+ content.WriteString(fmt.Sprintf(
+ `
%s
`,
+ html.EscapeString(g.Tag),
+ ))
+ for _, t := range g.Topics {
+ content.WriteString(`
`)
+ content.WriteString(fmt.Sprintf(
+ `
`,
+ html.EscapeString(t.ID),
+ html.EscapeString(t.Title),
+ ))
+ if len(t.Tags) > 0 {
+ content.WriteString(`
`)
+ for _, tag := range t.Tags {
+ content.WriteString(fmt.Sprintf(
+ `%s`,
+ html.EscapeString(tag),
+ ))
+ }
+ content.WriteString(`
`)
+ }
+ if t.Content != "" {
+ content.WriteString(fmt.Sprintf(
+ `
%s
`,
+ html.EscapeString(truncateContent(t.Content, 120)),
+ ))
+ }
+ content.WriteString(`
`)
+ }
+ }
+ } else {
+ content.WriteString(`
No topics available.
`)
+ }
+
+ content.WriteString(`
`)
+ layout.C(gohtml.Raw(content.String()))
+
+ // Footer
+ layout.F(footerContent())
+
+ return wrapPage("Help Topics", layout)
+}
+
+// RenderTopicPage renders a full HTML page for a single topic with ToC and sidebar.
+func RenderTopicPage(topic *Topic, sidebar []*Topic) string {
+ hasSidebar := len(sidebar) > 0
+ variant := "HCF"
+ if hasSidebar {
+ variant = "HLCF"
+ }
+ layout := gohtml.NewLayout(variant)
+
+ // Header
+ layout.H(headerNav(""))
+
+ // Left sidebar (only when sidebar topics provided)
+ if hasSidebar {
+ layout.L(sidebarTopicTree(sidebar))
+ }
+
+ // Content
+ var content strings.Builder
+ content.WriteString(``)
+
+ // Tags
+ if len(topic.Tags) > 0 {
+ content.WriteString(`
`)
+ for _, tag := range topic.Tags {
+ content.WriteString(fmt.Sprintf(
+ `%s`,
+ html.EscapeString(tag),
+ ))
+ }
+ content.WriteString(`
`)
+ }
+
+ // Table of contents (inline, before content)
+ if len(topic.Sections) > 0 {
+ ctx := gohtml.NewContext()
+ content.WriteString(topicTableOfContents(topic.Sections).Render(ctx))
+ }
+
+ // Rendered markdown body
+ rendered, err := RenderMarkdown(topic.Content)
+ if err != nil {
+ rendered = "
Error rendering content.
"
+ }
+ content.WriteString(`
`)
+ content.WriteString(rendered)
+ content.WriteString(``)
+
+ // Related topics
+ if len(topic.Related) > 0 {
+ content.WriteString(`
`)
+ content.WriteString(`
Related
`)
+ content.WriteString(`
`)
+ for _, rel := range topic.Related {
+ content.WriteString(fmt.Sprintf(
+ `- %s
`,
+ html.EscapeString(rel),
+ html.EscapeString(rel),
+ ))
+ }
+ content.WriteString(`
`)
+ }
+
+ content.WriteString(`
`)
+ layout.C(gohtml.Raw(content.String()))
+
+ // Footer
+ layout.F(footerContent())
+
+ title := html.EscapeString(topic.Title) + " - Help"
+ return wrapPage(title, layout)
+}
+
+// RenderSearchPage renders a full HTML page showing search results.
+func RenderSearchPage(query string, results []*SearchResult) string {
+ layout := gohtml.NewLayout("HCF")
+
+ // Header (pre-fill search box with query)
+ layout.H(headerNav(query))
+
+ // Content
+ var content strings.Builder
+ content.WriteString(``)
+ content.WriteString(`
Search Results
`)
+
+ escapedQuery := html.EscapeString(query)
+ if len(results) > 0 {
+ noun := "results"
+ if len(results) == 1 {
+ noun = "result"
+ }
+ content.WriteString(fmt.Sprintf(
+ `
Found %d %s for “%s”
`,
+ len(results), noun, escapedQuery,
+ ))
+
+ for _, r := range results {
+ content.WriteString(`
`)
+ content.WriteString(fmt.Sprintf(
+ `
%s %.1f
`,
+ html.EscapeString(r.Topic.ID),
+ html.EscapeString(r.Topic.Title),
+ r.Score,
+ ))
+ if r.Snippet != "" {
+ content.WriteString(fmt.Sprintf(`
%s
`, r.Snippet))
+ }
+ if len(r.Topic.Tags) > 0 {
+ content.WriteString(`
`)
+ for _, tag := range r.Topic.Tags {
+ content.WriteString(fmt.Sprintf(
+ `%s`,
+ html.EscapeString(tag),
+ ))
+ }
+ content.WriteString(`
`)
+ }
+ content.WriteString(`
`)
+ }
+ } else {
+ content.WriteString(fmt.Sprintf(
+ `
No results for “%s”
`,
+ escapedQuery,
+ ))
+ content.WriteString(`
`)
+ content.WriteString(`
Try a different search term or browse all topics.
`)
+ content.WriteString(`
`)
+ }
+
+ content.WriteString(`
`)
+ layout.C(gohtml.Raw(content.String()))
+
+ // Footer
+ layout.F(footerContent())
+
+ return wrapPage("Search: "+html.EscapeString(query)+" - Help", layout)
+}
+
+// Render404Page renders a full HTML "Not Found" page.
+func Render404Page() string {
+ layout := gohtml.NewLayout("HCF")
+
+ // Header
+ layout.H(headerNav(""))
+
+ // Content
+ content := gohtml.Raw(
+ `` +
+ `
404
` +
+ `
Not Found
` +
+ `
` +
+ `Browse all topics or try searching above.` +
+ `
`,
+ )
+ layout.C(content)
+
+ // Footer
+ layout.F(footerContent())
+
+ return wrapPage("Not Found - Help", layout)
+}
diff --git a/pkg/help/layout_test.go b/pkg/help/layout_test.go
new file mode 100644
index 0000000..a26d08e
--- /dev/null
+++ b/pkg/help/layout_test.go
@@ -0,0 +1,176 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+package help
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRenderLayout_Good_IndexPage(t *testing.T) {
+ topics := []*Topic{
+ {ID: "getting-started", Title: "Getting Started", Tags: []string{"intro"}, Content: "Welcome to the guide."},
+ {ID: "config", Title: "Configuration", Tags: []string{"setup"}, Content: "Config options here."},
+ {ID: "advanced", Title: "Advanced Usage", Tags: []string{"intro"}, Content: "Power user tips."},
+ }
+
+ html := RenderIndexPage(topics)
+
+ // Must contain ARIA roles from HLCRF layout
+ assert.Contains(t, html, `role="banner"`, "header should have banner role")
+ assert.Contains(t, html, `role="main"`, "content should have main role")
+ assert.Contains(t, html, `role="contentinfo"`, "footer should have contentinfo role")
+
+ // Must contain topic titles
+ assert.Contains(t, html, "Getting Started")
+ assert.Contains(t, html, "Configuration")
+ assert.Contains(t, html, "Advanced Usage")
+
+ // Must contain brand
+ assert.Contains(t, html, "core.help")
+
+ // Must contain tag group headings
+ assert.Contains(t, html, "intro")
+ assert.Contains(t, html, "setup")
+}
+
+func TestRenderLayout_Good_TopicPage(t *testing.T) {
+ topic := &Topic{
+ ID: "getting-started",
+ Title: "Getting Started",
+ Content: "# Getting Started\n\nWelcome to the **guide**.\n",
+ Tags: []string{"intro"},
+ Sections: []Section{
+ {ID: "overview", Title: "Overview", Level: 2},
+ {ID: "installation", Title: "Installation", Level: 2},
+ {ID: "quick-start", Title: "Quick Start", Level: 3},
+ },
+ Related: []string{"config"},
+ }
+
+ sidebar := []*Topic{
+ {ID: "getting-started", Title: "Getting Started", Tags: []string{"intro"}},
+ {ID: "config", Title: "Configuration", Tags: []string{"setup"}},
+ }
+
+ html := RenderTopicPage(topic, sidebar)
+
+ // Must have table of contents with section anchors
+ assert.Contains(t, html, `href="#overview"`, "ToC should link to overview section")
+ assert.Contains(t, html, `href="#installation"`, "ToC should link to installation section")
+ assert.Contains(t, html, `href="#quick-start"`, "ToC should link to quick-start section")
+
+ // Must contain section titles in the ToC
+ assert.Contains(t, html, "Overview")
+ assert.Contains(t, html, "Installation")
+ assert.Contains(t, html, "Quick Start")
+
+ // Must contain rendered markdown content
+ assert.Contains(t, html, "guide")
+
+ // Must have sidebar with topic links
+ assert.Contains(t, html, "Configuration")
+
+ // Must have ARIA roles
+ assert.Contains(t, html, `role="banner"`)
+ assert.Contains(t, html, `role="main"`)
+ assert.Contains(t, html, `role="complementary"`)
+ assert.Contains(t, html, `role="contentinfo"`)
+}
+
+func TestRenderLayout_Good_TopicPage_NoSidebar(t *testing.T) {
+ topic := &Topic{
+ ID: "solo",
+ Title: "Solo Topic",
+ Content: "Just content, no sidebar.",
+ }
+
+ html := RenderTopicPage(topic, nil)
+
+ // Should still render content
+ assert.Contains(t, html, "Solo Topic")
+ assert.Contains(t, html, `role="main"`)
+
+ // Without sidebar topics, left aside should not appear
+ assert.NotContains(t, html, `role="complementary"`)
+}
+
+func TestRenderLayout_Good_SearchPage(t *testing.T) {
+ results := []*SearchResult{
+ {
+ Topic: &Topic{ID: "install", Title: "Installation Guide", Tags: []string{"setup"}},
+ Score: 12.5,
+ Snippet: "How to **install** the tool.",
+ },
+ {
+ Topic: &Topic{ID: "config", Title: "Configuration", Tags: []string{"setup"}},
+ Score: 8.0,
+ Snippet: "Set up your **config** file.",
+ },
+ }
+
+ html := RenderSearchPage("install", results)
+
+ // Must show the query
+ assert.Contains(t, html, "install")
+
+ // Must show result titles
+ assert.Contains(t, html, "Installation Guide")
+ assert.Contains(t, html, "Configuration")
+
+ // Must have ARIA roles
+ assert.Contains(t, html, `role="banner"`)
+ assert.Contains(t, html, `role="main"`)
+}
+
+func TestRenderLayout_Good_SearchPage_NoResults(t *testing.T) {
+ html := RenderSearchPage("nonexistent", nil)
+
+ assert.Contains(t, html, "nonexistent")
+ assert.Contains(t, html, "No results")
+}
+
+func TestRenderLayout_Good_HasDoctype(t *testing.T) {
+ tests := []struct {
+ name string
+ html string
+ }{
+ {"index", RenderIndexPage(nil)},
+ {"topic", RenderTopicPage(&Topic{ID: "t", Title: "T"}, nil)},
+ {"search", RenderSearchPage("q", nil)},
+ {"404", Render404Page()},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.True(t, strings.HasPrefix(tt.html, ""),
+ "page should start with , got: %s", tt.html[:min(50, len(tt.html))])
+ })
+ }
+}
+
+func TestRenderLayout_Good_404Page(t *testing.T) {
+ html := Render404Page()
+
+ assert.Contains(t, html, "Not Found")
+ assert.Contains(t, html, "404")
+ assert.Contains(t, html, `role="banner"`)
+ assert.Contains(t, html, `role="main"`)
+ assert.Contains(t, html, `role="contentinfo"`)
+}
+
+func TestRenderLayout_Good_EscapesHTML(t *testing.T) {
+ topic := &Topic{
+ ID: "xss",
+ Title: ``,
+ Content: "Safe content.",
+ }
+
+ html := RenderIndexPage([]*Topic{topic})
+
+ // Title must be escaped
+ assert.NotContains(t, html, `