` | Rendered Markdown with section anchors (id attrs on headings) | -| F | `
` | Licence, version, links | - -Semantic HTML with ARIA roles. Deterministic `data-block` IDs for CSS targeting. Dark theme CSS stays inline (no external stylesheets). - -## Fragment Linking - -go-help's `GenerateID()` produces URL-safe slugs from headings: -- `"Rate Limiting"` -> `rate-limiting` -- `"API / Rate Limits"` -> `api-rate-limits` - -Every heading rendered with an `id` attribute. Works identically in-app and on the public site. - -The JSON API (`/api/topics/{id}`) returns sections with IDs, so a client can request a subsection programmatically. - -## Search - -No changes to the search engine: -- Inverted index with stemming and fuzzy matching (Levenshtein, max distance 2) -- Phrase search via quoted strings -- Relevance scoring: title 10x, phrase 8x, section 5x, tag 3x -- Static site: `search-index.json` with client-side JS -- In-app server: server-side `catalog.Search()` - -## Repo Lifecycle - -| Repo | Action | -|------|--------| -| go-help | Archive on forge after merge | -| docs-site | Archive on forge | -| docs | Becomes Go module, gains `pkg/help/` + `go.mod` | - -## Not In Scope - -- i18n for docs content (English only; go-i18n is a transitive dep of go-html, not used for content) -- Authentication (core.help is public, in-app is local) -- Wiki sync (separate concern: kb.yaml / OpenBrain) -- Database (catalog is in-memory) - -## Testing - -- All existing go-help tests (~94% coverage) move to `pkg/help/` -- New tests for go-html HLCRF layout rendering (output structure, section anchor IDs) -- Integration test: parse `docs/` folder -> generate static site -> verify file structure + search index diff --git a/docs/plans/2026-03-06-docs-help-engine-plan.md b/docs/plans/2026-03-06-docs-help-engine-plan.md deleted file mode 100644 index 8c6c408..0000000 --- a/docs/plans/2026-03-06-docs-help-engine-plan.md +++ /dev/null @@ -1,1112 +0,0 @@ -# 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, `