diff --git a/docs/plans/2026-03-06-docs-help-engine-plan.md b/docs/plans/2026-03-06-docs-help-engine-plan.md new file mode 100644 index 0000000..8c6c408 --- /dev/null +++ b/docs/plans/2026-03-06-docs-help-engine-plan.md @@ -0,0 +1,1112 @@ +# core/docs Help Engine — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Merge go-help into core/docs as `pkg/help`, replace Hugo with native static generator using go-html HLCRF layout, deploy to core.help via CLI. + +**Architecture:** core/docs becomes a Go module (`forge.lthn.ai/core/docs`) with the help engine at `pkg/help/`. go-html provides the HLCRF layout (header/sidebar/content/footer). go-devops `core docs sync` gains a `gohelp` target that outputs to the static generator instead of Hugo. + +**Tech Stack:** Go 1.26, go-html (HLCRF), goldmark (Markdown), yaml.v3 (frontmatter), testify (tests) + +--- + +### Task 1: Initialise core/docs as a Go module + +The docs repo is currently content-only (no go.mod). We need to make it a Go module. + +**Files:** +- Create: `/Users/snider/Code/core/docs/go.mod` +- Create: `/Users/snider/Code/core/docs/pkg/help/.gitkeep` + +**Step 1: Create go.mod** + +```bash +cd /Users/snider/Code/core/docs +go mod init forge.lthn.ai/core/docs +``` + +**Step 2: Create pkg/help directory** + +```bash +mkdir -p /Users/snider/Code/core/docs/pkg/help +``` + +**Step 3: Add to go.work** + +Edit `~/Code/go.work` — add `./core/docs` to the use block. + +**Step 4: Verify module resolves** + +```bash +cd /Users/snider/Code +go work sync +``` + +Expected: no errors + +**Step 5: Commit** + +```bash +cd /Users/snider/Code/core/docs +git add go.mod pkg/ +git commit -m "feat: initialise Go module for help engine + +Co-Authored-By: Virgil " +``` + +--- + +### Task 2: Copy go-help source into pkg/help + +Move all `.go` files and `templates/` from go-help into `pkg/help/`. Change the package name from `help` to `help` (stays the same — `package help`). + +**Files:** +- Copy from: `/Users/snider/Code/core/go-help/*.go` → `/Users/snider/Code/core/docs/pkg/help/` +- Copy from: `/Users/snider/Code/core/go-help/templates/` → `/Users/snider/Code/core/docs/pkg/help/templates/` + +**Step 1: Copy all source files** + +```bash +cp /Users/snider/Code/core/go-help/*.go /Users/snider/Code/core/docs/pkg/help/ +cp -r /Users/snider/Code/core/go-help/templates /Users/snider/Code/core/docs/pkg/help/templates +``` + +**Step 2: Add dependencies to go.mod** + +```bash +cd /Users/snider/Code/core/docs +go get github.com/yuin/goldmark +go get gopkg.in/yaml.v3 +go get github.com/stretchr/testify +``` + +**Step 3: Verify build** + +```bash +cd /Users/snider/Code/core/docs +go build ./pkg/help/... +``` + +Expected: builds cleanly + +**Step 4: Verify tests pass** + +```bash +cd /Users/snider/Code/core/docs +go test ./pkg/help/... -v +``` + +Expected: all existing go-help tests pass (~18 tests) + +**Step 5: Commit** + +```bash +cd /Users/snider/Code/core/docs +git add pkg/help/ go.mod go.sum +git commit -m "feat: import go-help library as pkg/help + +All source, tests, and templates copied from forge.lthn.ai/core/go-help. +94% test coverage preserved. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 3: Add go-html dependency and create layout.go + +Replace the `html/template` rendering with go-html HLCRF compositor. The layout produces the same dark-theme page structure but with semantic HTML. + +**Files:** +- Create: `/Users/snider/Code/core/docs/pkg/help/layout.go` +- Test: `/Users/snider/Code/core/docs/pkg/help/layout_test.go` + +**Step 1: Write the failing test** + +Create `layout_test.go`: + +```go +package help + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRenderLayout_Good_IndexPage(t *testing.T) { + topics := []*Topic{ + {ID: "getting-started", Title: "Getting Started", Tags: []string{"guide"}}, + {ID: "config", Title: "Configuration", Tags: []string{"guide"}}, + } + result := RenderIndexPage(topics) + + assert.Contains(t, result, `role="banner"`) + assert.Contains(t, result, `role="complementary"`) + assert.Contains(t, result, `role="main"`) + assert.Contains(t, result, `role="contentinfo"`) + assert.Contains(t, result, "core.help") + assert.Contains(t, result, "Getting Started") + assert.Contains(t, result, "Configuration") +} + +func TestRenderLayout_Good_TopicPage(t *testing.T) { + topic := &Topic{ + ID: "rate-limiting", + Title: "Rate Limiting", + Content: "## Overview\n\nRate limiting controls...\n\n## Configuration\n\nSet the limit...", + Sections: []Section{ + {ID: "overview", Title: "Overview", Level: 2}, + {ID: "configuration", Title: "Configuration", Level: 2}, + }, + Tags: []string{"api"}, + } + sidebar := []*Topic{ + {ID: "rate-limiting", Title: "Rate Limiting", Tags: []string{"api"}}, + {ID: "auth", Title: "Authentication", Tags: []string{"api"}}, + } + result := RenderTopicPage(topic, sidebar) + + assert.Contains(t, result, `role="banner"`) + assert.Contains(t, result, `role="main"`) + assert.Contains(t, result, "Rate Limiting") + // Section anchors in ToC + assert.Contains(t, result, `href="#overview"`) + assert.Contains(t, result, `href="#configuration"`) +} + +func TestRenderLayout_Good_SearchPage(t *testing.T) { + results := []*SearchResult{ + {Topic: &Topic{ID: "rate-limiting", Title: "Rate Limiting"}, Score: 10.0, Snippet: "Rate limiting controls..."}, + } + result := RenderSearchPage("rate limit", results) + + assert.Contains(t, result, `role="main"`) + assert.Contains(t, result, "rate limit") + assert.Contains(t, result, "Rate Limiting") +} + +func TestRenderLayout_Good_HasDoctype(t *testing.T) { + topics := []*Topic{{ID: "test", Title: "Test"}} + result := RenderIndexPage(topics) + + assert.True(t, strings.HasPrefix(result, "")) +} + +func TestRenderLayout_Good_404Page(t *testing.T) { + result := Render404Page() + + assert.Contains(t, result, `role="main"`) + assert.Contains(t, result, "Not Found") +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd /Users/snider/Code/core/docs +go test ./pkg/help/ -run TestRenderLayout -v +``` + +Expected: FAIL — `RenderIndexPage` undefined + +**Step 3: Add go-html dependency** + +```bash +cd /Users/snider/Code/core/docs +go get forge.lthn.ai/core/go-html +``` + +**Step 4: Implement layout.go** + +Create `layout.go` — uses go-html HLCRF to build full page wrappers: + +```go +// SPDX-Licence-Identifier: EUPL-1.2 +package help + +import ( + "fmt" + "strings" + + html "forge.lthn.ai/core/go-html" +) + +// pageCSS is the inline dark-theme stylesheet for all pages. +const pageCSS = ` +*{margin:0;padding:0;box-sizing:border-box} +:root{--bg:#0d1117;--bg-card:#161b22;--fg:#c9d1d9;--fg-muted:#8b949e;--accent:#58a6ff;--border:#30363d;--radius:6px} +body{background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;line-height:1.6} +a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline} +header[role=banner]{background:var(--bg-card);border-bottom:1px solid var(--border);padding:0.75rem 1.5rem;display:flex;align-items:center;justify-content:space-between} +header .brand{font-weight:600;font-size:1.1rem;color:var(--fg)} +header .search-form{display:flex;gap:0.5rem} +header input[type=search]{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:0.4rem 0.75rem;width:220px} +aside[role=complementary]{width:250px;min-width:200px;padding:1rem;border-right:1px solid var(--border);overflow-y:auto;background:var(--bg-card)} +aside .nav-group{margin-bottom:1rem} +aside .nav-group-title{font-size:0.75rem;text-transform:uppercase;color:var(--fg-muted);margin-bottom:0.25rem;font-weight:600} +aside .nav-link{display:block;padding:0.2rem 0.5rem;border-radius:var(--radius);font-size:0.9rem;color:var(--fg)} +aside .nav-link:hover{background:var(--border);text-decoration:none} +main[role=main]{flex:1;padding:2rem;max-width:900px;overflow-y:auto} +main h1,main h2,main h3,main h4,main h5,main h6{margin:1.5rem 0 0.75rem;color:var(--fg)} +main h1{font-size:2rem;border-bottom:1px solid var(--border);padding-bottom:0.3rem} +main h2{font-size:1.5rem} +main code{background:var(--bg-card);padding:0.2em 0.4em;border-radius:3px;font-size:0.85em} +main pre{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;overflow-x:auto;margin:1rem 0} +main pre code{background:none;padding:0} +main table{border-collapse:collapse;width:100%;margin:1rem 0} +main th,main td{border:1px solid var(--border);padding:0.5rem 0.75rem;text-align:left} +main th{background:var(--bg-card)} +.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;margin:0.75rem 0} +.card h3{margin:0 0 0.5rem} +.tag{display:inline-block;background:var(--border);color:var(--fg-muted);padding:0.1rem 0.5rem;border-radius:3px;font-size:0.8rem;margin-right:0.25rem} +.toc{margin:1rem 0;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius)} +.toc-title{font-weight:600;margin-bottom:0.5rem;font-size:0.9rem} +.toc a{display:block;padding:0.15rem 0;font-size:0.85rem} +.wrapper{display:flex;min-height:100vh;flex-direction:column} +.body-wrap{display:flex;flex:1} +footer[role=contentinfo]{background:var(--bg-card);border-top:1px solid var(--border);padding:0.75rem 1.5rem;text-align:center;color:var(--fg-muted);font-size:0.85rem} +@media(max-width:768px){aside[role=complementary]{display:none}.body-wrap{flex-direction:column}} +` + +// wrapPage wraps HLCRF body content in a full HTML document. +func wrapPage(title string, body string) string { + var b strings.Builder + b.WriteString("\n\n\n") + b.WriteString("\n") + b.WriteString("\n") + b.WriteString("") + b.WriteString(escapeHTMLText(title)) + b.WriteString(" — core.help\n") + b.WriteString("\n") + b.WriteString("\n\n
\n") + b.WriteString(body) + b.WriteString("\n
\n\n") + return b.String() +} + +// escapeHTMLText escapes text for safe HTML insertion. +func escapeHTMLText(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// renderHeader builds the banner header with branding and search. +func renderHeader() html.Node { + return html.El("div", + html.Attr(html.El("span", html.Raw("core.help")), "class", "brand"), + html.Raw(`
`), + ) +} + +// renderSidebar builds the left aside with topic groups. +func renderSidebar(topics []*Topic) html.Node { + groups := groupTopicsByTag(topics) + var children []html.Node + for _, g := range groups { + var links []html.Node + links = append(links, html.Attr(html.El("div", html.Raw(escapeHTMLText(g.Tag))), "class", "nav-group-title")) + for _, t := range g.Topics { + link := html.Attr( + html.El("a", html.Raw(escapeHTMLText(t.Title))), + "href", "/topics/"+t.ID, + ) + link = html.Attr(link, "class", "nav-link") + links = append(links, link) + } + group := html.Attr(html.El("div", links...), "class", "nav-group") + children = append(children, group) + } + return html.El("nav", children...) +} + +// renderFooter builds the footer content. +func renderFooter() html.Node { + return html.Raw("EUPL-1.2 — Source") +} + +// buildPage constructs a full HLCRF page. +func buildPage(title string, sidebar []*Topic, content html.Node) string { + layout := html.NewLayout("HLCF") + layout.H(renderHeader()) + if len(sidebar) > 0 { + layout = html.NewLayout("HLCRF") + layout.H(renderHeader()) + layout.L(renderSidebar(sidebar)) + } + layout.C(content) + layout.F(renderFooter()) + + ctx := html.NewContext() + body := layout.Render(ctx) + + // Wrap the aside+main in body-wrap div + // The HLCRF renders H, then L+C+R, then F + // We need the body-wrap around L+C for flex layout + // Since go-html renders sequentially, we post-process + body = strings.Replace(body, `