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( + `
` + + `EUPL-1.2 · forge.lthn.ai/core/docs` + + `
`, + ) +} + +// 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

`) + 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( + `

%s

`, + 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(`
`) + } + + 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, `