// 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) }