go-help/generate.go
Snider 944cad006b feat(help): Phase 2 — HTTP server, rendering, static site generator, CLI ingestion
Add complete HTTP server and rendering layer for the help catalog:

- render.go: Markdown-to-HTML via goldmark (GFM, typographer, raw HTML)
- server.go: HTTP server with 6 routes (HTML index/topic/search + JSON API)
- templates.go: Embedded HTML templates with dark theme (bg #0d1117)
- templates/: base, index, topic, search, 404 page templates
- generate.go: Static site generator with client-side JS search
- ingest.go: CLI help text parser (Usage/Flags/Examples/Commands sections)

320 tests passing, 95.5% coverage, race-clean, vet-clean.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:50:10 +00:00

251 lines
7.8 KiB
Go

// SPDX-Licence-Identifier: EUPL-1.2
package help
import (
"encoding/json"
"os"
"path/filepath"
)
// searchIndexEntry represents a single topic in the client-side search index.
type searchIndexEntry struct {
ID string `json:"id"`
Title string `json:"title"`
Tags []string `json:"tags"`
Content string `json:"content"`
}
// Generate writes a complete static help site to outputDir.
// It creates:
// - index.html -- topic listing grouped by tags
// - topics/{id}.html -- one page per topic
// - search.html -- client-side search with inline JS
// - search-index.json -- JSON index for client-side search
// - 404.html -- not found page
//
// All CSS is inlined; no external stylesheets are needed.
func Generate(catalog *Catalog, outputDir string) error {
topics := catalog.List()
// Ensure output directories exist.
topicsDir := filepath.Join(outputDir, "topics")
if err := os.MkdirAll(topicsDir, 0o755); err != nil {
return err
}
// 1. index.html
if err := writeStaticPage(outputDir, "index.html", "index.html", indexData{
Topics: topics,
Groups: groupTopicsByTag(topics),
}); err != nil {
return err
}
// 2. topics/{id}.html -- one per topic
for _, t := range topics {
if err := writeStaticPage(topicsDir, t.ID+".html", "topic.html", topicData{Topic: t}); err != nil {
return err
}
}
// 3. search.html -- client-side search page
if err := writeSearchPage(outputDir); err != nil {
return err
}
// 4. search-index.json
if err := writeSearchIndex(outputDir, topics); err != nil {
return err
}
// 5. 404.html
if err := writeStaticPage(outputDir, "404.html", "404.html", nil); err != nil {
return err
}
return nil
}
// writeStaticPage renders a template page to a file.
func writeStaticPage(dir, filename, templatePage string, data any) error {
path := filepath.Join(dir, filename)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return renderPage(f, templatePage, data)
}
// writeSearchIndex writes the JSON search index for client-side search.
func writeSearchIndex(outputDir string, topics []*Topic) error {
entries := make([]searchIndexEntry, 0, len(topics))
for _, t := range topics {
// Truncate content for the index to keep file size reasonable.
content := t.Content
runes := []rune(content)
if len(runes) > 500 {
content = string(runes[:500])
}
entries = append(entries, searchIndexEntry{
ID: t.ID,
Title: t.Title,
Tags: t.Tags,
Content: content,
})
}
path := filepath.Join(outputDir, "search-index.json")
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
// writeSearchPage generates search.html with inline client-side JS search.
// The JS uses escapeHTML() on all data before DOM insertion to prevent XSS.
// Data comes from our own search-index.json, not external user input.
func writeSearchPage(outputDir string) error {
path := filepath.Join(outputDir, "search.html")
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
// Render via a search template with empty results + inject client-side JS.
data := searchData{Query: "", Results: nil}
if err := renderPage(f, "search.html", data); err != nil {
return err
}
// Append inline script for client-side search.
_, err = f.WriteString(clientSearchScript)
return err
}
// clientSearchScript is the inline JS for static-site client-side search.
// All values are escaped via escapeHTML() before DOM insertion.
// The search index is generated from our own catalog data, not user input.
const clientSearchScript = `
<script>
(function() {
let index = [];
fetch('search-index.json')
.then(r => r.json())
.then(data => { index = data; })
.catch(() => {});
const form = document.querySelector('.search-form');
const input = form ? form.querySelector('input[name="q"]') : null;
const main = document.querySelector('main');
const container = main ? main.querySelector('.container') : null;
if (form && input) {
form.addEventListener('submit', function(e) {
e.preventDefault();
doSearch(input.value.trim());
});
}
function escapeHTML(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function doSearch(query) {
if (!query || !container) return;
const lower = query.toLowerCase();
const words = lower.split(/\s+/);
const results = index
.map(function(entry) {
let score = 0;
const title = (entry.title || '').toLowerCase();
const content = (entry.content || '').toLowerCase();
const tags = (entry.tags || []).map(function(t) { return t.toLowerCase(); });
words.forEach(function(w) {
if (title.indexOf(w) !== -1) score += 10;
if (content.indexOf(w) !== -1) score += 1;
tags.forEach(function(tag) { if (tag.indexOf(w) !== -1) score += 3; });
});
return { entry: entry, score: score };
})
.filter(function(r) { return r.score > 0; })
.sort(function(a, b) { return b.score - a.score; });
// Build result using safe DOM methods
while (container.firstChild) container.removeChild(container.firstChild);
var h1 = document.createElement('h1');
h1.textContent = 'Search Results';
container.appendChild(h1);
var summary = document.createElement('p');
summary.style.color = 'var(--fg-muted)';
if (results.length > 0) {
summary.textContent = 'Found ' + results.length + ' result' + (results.length !== 1 ? 's' : '') + ' for \u201c' + query + '\u201d';
} else {
summary.textContent = 'No results for \u201c' + query + '\u201d';
}
container.appendChild(summary);
results.forEach(function(r) {
var e = r.entry;
var card = document.createElement('div');
card.className = 'card';
var heading = document.createElement('h3');
var link = document.createElement('a');
link.href = 'topics/' + encodeURIComponent(e.id) + '.html';
link.textContent = e.title;
heading.appendChild(link);
card.appendChild(heading);
if (e.content) {
var p = document.createElement('p');
var snippet = e.content.substring(0, 150);
p.textContent = snippet + (e.content.length > 150 ? '...' : '');
card.appendChild(p);
}
if (e.tags && e.tags.length) {
var tagDiv = document.createElement('div');
e.tags.forEach(function(t) {
var span = document.createElement('span');
span.className = 'tag';
span.textContent = t;
tagDiv.appendChild(span);
});
card.appendChild(tagDiv);
}
container.appendChild(card);
});
if (results.length === 0) {
var noResults = document.createElement('div');
noResults.style.cssText = 'margin-top:2rem;text-align:center;color:var(--fg-muted);';
var tip = document.createElement('p');
tip.textContent = 'Try a different search term or browse ';
var browseLink = document.createElement('a');
browseLink.href = 'index.html';
browseLink.textContent = 'all topics';
tip.appendChild(browseLink);
tip.appendChild(document.createTextNode('.'));
noResults.appendChild(tip);
container.appendChild(noResults);
}
}
})();
</script>
`