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:
parent
216dd6ae23
commit
0c474a82d0
7 changed files with 0 additions and 465 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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 · forge.lthn.ai/core/go-help
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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 “{{.Query}}”{{else}}No results for “{{.Query}}”{{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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue