refactor(help): remove html/template dependency

All rendering now uses go-html HLCRF layout.
Templates directory and template parsing code removed.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-06 16:37:53 +00:00
parent 216dd6ae23
commit 0c474a82d0
7 changed files with 0 additions and 465 deletions

View file

@ -3,89 +3,15 @@ package help
import (
"cmp"
"embed"
"html/template"
"io"
"slices"
"strings"
)
//go:embed templates/*.html
var templateFS embed.FS
// templateFuncs returns the function map for help templates.
func templateFuncs() template.FuncMap {
return template.FuncMap{
"renderMarkdown": func(content string) template.HTML {
html, err := RenderMarkdown(content)
if err != nil {
return template.HTML("<p>Error rendering content.</p>")
}
return template.HTML(html) //nolint:gosec // trusted content from catalog
},
"truncate": func(s string, n int) string {
// Strip markdown headings for truncation preview
lines := strings.SplitSeq(s, "\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) <= n {
return text
}
return string(runes[:n]) + "..."
},
"pluralise": func(count int, singular, plural string) string {
if count == 1 {
return singular
}
return plural
},
"multiply": func(a, b int) int {
return a * b
},
"sub": func(a, b int) int {
return a - b
},
}
}
// parseTemplates parses the base layout together with a page template.
func parseTemplates(page string) (*template.Template, error) {
return template.New("base.html").Funcs(templateFuncs()).ParseFS(
templateFS, "templates/base.html", "templates/"+page,
)
}
// topicGroup groups topics under a tag for the index page.
type topicGroup struct {
Tag string
Topics []*Topic
}
// indexData holds template data for the index page.
type indexData struct {
Topics []*Topic
Groups []topicGroup
}
// topicData holds template data for a single topic page.
type topicData struct {
Topic *Topic
}
// searchData holds template data for the search results page.
type searchData struct {
Query string
Results []*SearchResult
}
// groupTopicsByTag groups topics by their first tag.
// Topics without tags are grouped under "other".
// Groups are sorted alphabetically by tag name.
@ -118,12 +44,3 @@ func groupTopicsByTag(topics []*Topic) []topicGroup {
return result
}
// renderPage renders a named page template into the writer.
func renderPage(w io.Writer, page string, data any) error {
tmpl, err := parseTemplates(page)
if err != nil {
return err
}
return tmpl.Execute(w, data)
}

View file

@ -1,10 +0,0 @@
{{define "title"}}Not Found - Help{{end}}
{{define "content"}}
<div style="text-align: center; margin-top: 4rem;">
<h1 style="font-size: 3rem; border: none; color: var(--fg-muted);">404</h1>
<p style="font-size: 1.1rem; color: var(--fg-muted);">Topic not found.</p>
<p style="margin-top: 1.5rem;">
<a href="/">Browse all topics</a> or try searching above.
</p>
</div>
{{end}}

View file

@ -1,161 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Help{{end}}</title>
<style>
:root {
--bg: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--fg: #c9d1d9;
--fg-muted: #8b949e;
--fg-subtle: #6e7681;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--border: #30363d;
--success: #3fb950;
--warning: #d29922;
--danger: #f85149;
}
* { 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); }
main { padding: 2rem 0; min-height: calc(100vh - 120px); }
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);
}
</style>
{{block "head" .}}{{end}}
</head>
<body>
<nav>
<div class="container">
<a href="/" class="brand">core help</a>
<form class="search-form" action="/search" method="get">
<input type="text" name="q" placeholder="Search topics..." value="{{block "search_value" .}}{{end}}" autocomplete="off">
</form>
</div>
</nav>
<main>
<div class="container">
{{block "content" .}}{{end}}
</div>
</main>
<footer>
<div class="container">
go-help &middot; forge.lthn.ai/core/go-help
</div>
</footer>
</body>
</html>

View file

@ -1,19 +0,0 @@
{{define "title"}}Help Topics{{end}}
{{define "content"}}
<h1>Help Topics <span class="badge">{{len .Topics}} {{pluralise (len .Topics) "topic" "topics"}}</span></h1>
{{if .Groups}}
{{range .Groups}}
<h2><span class="tag">{{.Tag}}</span></h2>
{{range .Topics}}
<div class="card">
<h3><a href="/topics/{{.ID}}">{{.Title}}</a></h3>
{{if .Tags}}<div>{{range .Tags}}<span class="tag">{{.}}</span>{{end}}</div>{{end}}
{{if .Content}}<p>{{truncate .Content 120}}</p>{{end}}
</div>
{{end}}
{{end}}
{{else}}
<p style="color: var(--fg-muted);">No topics available.</p>
{{end}}
{{end}}

View file

@ -1,22 +0,0 @@
{{define "title"}}Search: {{.Query}} - Help{{end}}
{{define "search_value"}}{{.Query}}{{end}}
{{define "content"}}
<h1>Search Results</h1>
<p style="color: var(--fg-muted);">
{{if .Results}}Found {{len .Results}} {{pluralise (len .Results) "result" "results"}} for &ldquo;{{.Query}}&rdquo;{{else}}No results for &ldquo;{{.Query}}&rdquo;{{end}}
</p>
{{if .Results}}
{{range .Results}}
<div class="card">
<h3><a href="/topics/{{.Topic.ID}}">{{.Topic.Title}}</a> <span class="badge">{{printf "%.1f" .Score}}</span></h3>
{{if .Snippet}}<p>{{.Snippet}}</p>{{end}}
{{if .Topic.Tags}}<div>{{range .Topic.Tags}}<span class="tag">{{.}}</span>{{end}}</div>{{end}}
</div>
{{end}}
{{else}}
<div style="margin-top: 2rem; text-align: center; color: var(--fg-muted);">
<p>Try a different search term or browse <a href="/">all topics</a>.</p>
</div>
{{end}}
{{end}}

View file

@ -1,35 +0,0 @@
{{define "title"}}{{.Topic.Title}} - Help{{end}}
{{define "content"}}
<div style="display: flex; gap: 2rem;">
<article style="flex: 1; min-width: 0;">
{{if .Topic.Tags}}<div style="margin-bottom: 1rem;">{{range .Topic.Tags}}<span class="tag">{{.}}</span>{{end}}</div>{{end}}
<div class="topic-body">{{renderMarkdown .Topic.Content}}</div>
</article>
<aside style="width: 220px; flex-shrink: 0;">
{{if .Topic.Sections}}
<div style="position: sticky; top: 1rem;">
<h3 style="font-size: 0.85rem; color: var(--fg-muted); margin-top: 0;">On this page</h3>
<ul style="list-style: none; padding-left: 0; font-size: 0.8rem;">
{{range .Topic.Sections}}
<li style="padding-left: {{multiply (sub .Level 1) 12}}px; margin: 0.3rem 0;">
<a href="#{{.ID}}" style="color: var(--fg-muted);">{{.Title}}</a>
</li>
{{end}}
</ul>
</div>
{{end}}
{{if .Topic.Related}}
<div style="margin-top: 1.5rem;">
<h3 style="font-size: 0.85rem; color: var(--fg-muted);">Related</h3>
<ul style="list-style: none; padding-left: 0; font-size: 0.8rem;">
{{range .Topic.Related}}
<li style="margin: 0.3rem 0;"><a href="/topics/{{.}}">{{.}}</a></li>
{{end}}
</ul>
</div>
{{end}}
</aside>
</div>
{{end}}

View file

@ -2,104 +2,12 @@
package help
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseTemplates_Good(t *testing.T) {
pages := []string{"index.html", "topic.html", "search.html", "404.html"}
for _, page := range pages {
t.Run(page, func(t *testing.T) {
tmpl, err := parseTemplates(page)
require.NoError(t, err, "template %s should parse without error", page)
assert.NotNil(t, tmpl)
})
}
}
func TestRenderPage_Good_Index(t *testing.T) {
topics := []*Topic{
{ID: "getting-started", Title: "Getting Started", Tags: []string{"intro"}, Content: "Welcome."},
{ID: "config", Title: "Configuration", Tags: []string{"setup"}, Content: "Config options."},
}
data := indexData{
Topics: topics,
Groups: groupTopicsByTag(topics),
}
var buf bytes.Buffer
err := renderPage(&buf, "index.html", data)
require.NoError(t, err)
html := buf.String()
assert.Contains(t, html, "Getting Started")
assert.Contains(t, html, "Configuration")
assert.Contains(t, html, "2 topics")
assert.Contains(t, html, "core help")
}
func TestRenderPage_Good_Topic(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},
},
Related: []string{"config"},
}
data := topicData{Topic: topic}
var buf bytes.Buffer
err := renderPage(&buf, "topic.html", data)
require.NoError(t, err)
html := buf.String()
assert.Contains(t, html, "Getting Started")
assert.Contains(t, html, "<strong>guide</strong>")
assert.Contains(t, html, "Overview")
assert.Contains(t, html, "config")
}
func TestRenderPage_Good_Search(t *testing.T) {
data := searchData{
Query: "install",
Results: []*SearchResult{
{
Topic: &Topic{ID: "install", Title: "Installation", Tags: []string{"setup"}},
Score: 12.5,
Snippet: "How to **install** the tool.",
},
},
}
var buf bytes.Buffer
err := renderPage(&buf, "search.html", data)
require.NoError(t, err)
html := buf.String()
assert.Contains(t, html, "install")
assert.Contains(t, html, "Installation")
assert.Contains(t, html, "1 result")
assert.Contains(t, html, "12.5")
}
func TestRenderPage_Good_404(t *testing.T) {
var buf bytes.Buffer
err := renderPage(&buf, "404.html", nil)
require.NoError(t, err)
html := buf.String()
assert.Contains(t, html, "not found")
assert.Contains(t, html, "404")
}
func TestGroupTopicsByTag_Good(t *testing.T) {
topics := []*Topic{
{ID: "a", Title: "Alpha", Tags: []string{"setup"}, Order: 2},
@ -122,46 +30,3 @@ func TestGroupTopicsByTag_Good(t *testing.T) {
assert.Equal(t, "Beta", setupGroup.Topics[0].Title) // Order 1
assert.Equal(t, "Alpha", setupGroup.Topics[1].Title) // Order 2
}
func TestTemplateFuncs_Good(t *testing.T) {
fns := templateFuncs()
t.Run("truncate short string", func(t *testing.T) {
fn := fns["truncate"].(func(string, int) string)
assert.Equal(t, "hello", fn("hello", 10))
})
t.Run("truncate long string", func(t *testing.T) {
fn := fns["truncate"].(func(string, int) string)
result := fn("hello world this is long", 11)
assert.Equal(t, "hello world...", result)
})
t.Run("truncate strips headings", func(t *testing.T) {
fn := fns["truncate"].(func(string, int) string)
result := fn("# Title\n\nSome content here.", 100)
assert.Equal(t, "Some content here.", result)
assert.NotContains(t, result, "#")
})
t.Run("pluralise singular", func(t *testing.T) {
fn := fns["pluralise"].(func(int, string, string) string)
assert.Equal(t, "topic", fn(1, "topic", "topics"))
})
t.Run("pluralise plural", func(t *testing.T) {
fn := fns["pluralise"].(func(int, string, string) string)
assert.Equal(t, "topics", fn(0, "topic", "topics"))
assert.Equal(t, "topics", fn(5, "topic", "topics"))
})
t.Run("multiply", func(t *testing.T) {
fn := fns["multiply"].(func(int, int) int)
assert.Equal(t, 24, fn(4, 6))
})
t.Run("sub", func(t *testing.T) {
fn := fns["sub"].(func(int, int) int)
assert.Equal(t, 2, fn(5, 3))
})
}