From 6b0443c6f734f6f57ba178fc8d8548bbb25becda Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 6 Mar 2026 16:30:16 +0000 Subject: [PATCH] feat: import go-help library as pkg/help All source, tests, and templates from forge.lthn.ai/core/go-help. 94% test coverage preserved. All tests pass. Co-Authored-By: Virgil --- go.mod | 11 + go.sum | 12 + pkg/help/catalog.go | 100 +++ pkg/help/catalog_test.go | 212 ++++++ pkg/help/generate.go | 251 +++++++ pkg/help/generate_test.go | 195 ++++++ pkg/help/ingest.go | 243 +++++++ pkg/help/ingest_test.go | 277 ++++++++ pkg/help/parser.go | 186 +++++ pkg/help/parser_test.go | 696 +++++++++++++++++++ pkg/help/render.go | 36 + pkg/help/render_test.go | 132 ++++ pkg/help/search.go | 562 +++++++++++++++ pkg/help/search_bench_test.go | 176 +++++ pkg/help/search_test.go | 1169 ++++++++++++++++++++++++++++++++ pkg/help/server.go | 162 +++++ pkg/help/server_test.go | 231 +++++++ pkg/help/stemmer.go | 93 +++ pkg/help/stemmer_test.go | 222 ++++++ pkg/help/templates.go | 129 ++++ pkg/help/templates/404.html | 10 + pkg/help/templates/base.html | 161 +++++ pkg/help/templates/index.html | 19 + pkg/help/templates/search.html | 22 + pkg/help/templates/topic.html | 35 + pkg/help/templates_test.go | 167 +++++ pkg/help/topic.go | 31 + 27 files changed, 5540 insertions(+) create mode 100644 go.sum create mode 100644 pkg/help/catalog.go create mode 100644 pkg/help/catalog_test.go create mode 100644 pkg/help/generate.go create mode 100644 pkg/help/generate_test.go create mode 100644 pkg/help/ingest.go create mode 100644 pkg/help/ingest_test.go create mode 100644 pkg/help/parser.go create mode 100644 pkg/help/parser_test.go create mode 100644 pkg/help/render.go create mode 100644 pkg/help/render_test.go create mode 100644 pkg/help/search.go create mode 100644 pkg/help/search_bench_test.go create mode 100644 pkg/help/search_test.go create mode 100644 pkg/help/server.go create mode 100644 pkg/help/server_test.go create mode 100644 pkg/help/stemmer.go create mode 100644 pkg/help/stemmer_test.go create mode 100644 pkg/help/templates.go create mode 100644 pkg/help/templates/404.html create mode 100644 pkg/help/templates/base.html create mode 100644 pkg/help/templates/index.html create mode 100644 pkg/help/templates/search.html create mode 100644 pkg/help/templates/topic.html create mode 100644 pkg/help/templates_test.go create mode 100644 pkg/help/topic.go diff --git a/go.mod b/go.mod index 914eb45..aacf4e8 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module forge.lthn.ai/core/docs go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + github.com/yuin/goldmark v1.7.16 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f28c5f8 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/help/catalog.go b/pkg/help/catalog.go new file mode 100644 index 0000000..c7d4d17 --- /dev/null +++ b/pkg/help/catalog.go @@ -0,0 +1,100 @@ +package help + +import ( + "fmt" + "iter" + "maps" + "slices" +) + +// Catalog manages help topics. +type Catalog struct { + topics map[string]*Topic + index *searchIndex +} + +// DefaultCatalog returns a catalog with built-in topics. +func DefaultCatalog() *Catalog { + c := &Catalog{ + topics: make(map[string]*Topic), + index: newSearchIndex(), + } + + // Add default topics + c.Add(&Topic{ + ID: "getting-started", + Title: "Getting Started", + Content: `# Getting Started + +Welcome to Core! This CLI tool helps you manage development workflows. + +## Common Commands + +- core dev: Development workflows +- core setup: Setup repository +- core doctor: Check environment health +- core test: Run tests + +## Next Steps + +Run 'core help ' to learn more about a specific topic. +`, + }) + c.Add(&Topic{ + ID: "config", + Title: "Configuration", + Content: `# Configuration + +Core is configured via environment variables and config files. + +## Environment Variables + +- CORE_DEBUG: Enable debug logging +- GITHUB_TOKEN: GitHub API token + +## Config Files + +Config is stored in ~/.core/config.yaml +`, + }) + return c +} + +// Add adds a topic to the catalog. +func (c *Catalog) Add(t *Topic) { + c.topics[t.ID] = t + c.index.Add(t) +} + +// List returns all topics. +func (c *Catalog) List() []*Topic { + var list []*Topic + for _, t := range c.topics { + list = append(list, t) + } + return list +} + +// All returns an iterator for all topics. +func (c *Catalog) All() iter.Seq[*Topic] { + return maps.Values(c.topics) +} + +// Search searches for topics. +func (c *Catalog) Search(query string) []*SearchResult { + return c.index.Search(query) +} + +// SearchResults returns an iterator for search results. +func (c *Catalog) SearchResults(query string) iter.Seq[*SearchResult] { + return slices.Values(c.Search(query)) +} + +// Get returns a topic by ID. +func (c *Catalog) Get(id string) (*Topic, error) { + t, ok := c.topics[id] + if !ok { + return nil, fmt.Errorf("topic not found: %s", id) + } + return t, nil +} diff --git a/pkg/help/catalog_test.go b/pkg/help/catalog_test.go new file mode 100644 index 0000000..bd669cc --- /dev/null +++ b/pkg/help/catalog_test.go @@ -0,0 +1,212 @@ +package help + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultCatalog_Good(t *testing.T) { + c := DefaultCatalog() + + require.NotNil(t, c) + require.NotNil(t, c.topics) + require.NotNil(t, c.index) + + t.Run("contains built-in topics", func(t *testing.T) { + topics := c.List() + assert.GreaterOrEqual(t, len(topics), 2, "should have at least 2 default topics") + }) + + t.Run("getting-started topic exists", func(t *testing.T) { + topic, err := c.Get("getting-started") + require.NoError(t, err) + assert.Equal(t, "Getting Started", topic.Title) + assert.Contains(t, topic.Content, "Common Commands") + }) + + t.Run("config topic exists", func(t *testing.T) { + topic, err := c.Get("config") + require.NoError(t, err) + assert.Equal(t, "Configuration", topic.Title) + assert.Contains(t, topic.Content, "Environment Variables") + }) +} + +func TestCatalog_Add_Good(t *testing.T) { + c := &Catalog{ + topics: make(map[string]*Topic), + index: newSearchIndex(), + } + + topic := &Topic{ + ID: "test-topic", + Title: "Test Topic", + Content: "This is a test topic for unit testing.", + Tags: []string{"test", "unit"}, + } + + c.Add(topic) + + t.Run("topic is retrievable after add", func(t *testing.T) { + got, err := c.Get("test-topic") + require.NoError(t, err) + assert.Equal(t, topic, got) + }) + + t.Run("topic is searchable after add", func(t *testing.T) { + results := c.Search("test") + assert.NotEmpty(t, results) + }) + + t.Run("overwrite existing topic", func(t *testing.T) { + replacement := &Topic{ + ID: "test-topic", + Title: "Replaced Topic", + Content: "Replacement content.", + } + c.Add(replacement) + + got, err := c.Get("test-topic") + require.NoError(t, err) + assert.Equal(t, "Replaced Topic", got.Title) + }) +} + +func TestCatalog_List_Good(t *testing.T) { + c := &Catalog{ + topics: make(map[string]*Topic), + index: newSearchIndex(), + } + + t.Run("empty catalog returns empty list", func(t *testing.T) { + list := c.List() + assert.Empty(t, list) + }) + + t.Run("returns all added topics", func(t *testing.T) { + c.Add(&Topic{ID: "alpha", Title: "Alpha"}) + c.Add(&Topic{ID: "beta", Title: "Beta"}) + c.Add(&Topic{ID: "gamma", Title: "Gamma"}) + + list := c.List() + assert.Len(t, list, 3) + + // Collect IDs (order is not guaranteed from map) + ids := make(map[string]bool) + for _, t := range list { + ids[t.ID] = true + } + assert.True(t, ids["alpha"]) + assert.True(t, ids["beta"]) + assert.True(t, ids["gamma"]) + }) +} + +func TestCatalog_Search_Good(t *testing.T) { + c := DefaultCatalog() + + t.Run("finds default topics", func(t *testing.T) { + results := c.Search("configuration") + assert.NotEmpty(t, results) + }) + + t.Run("empty query returns nil", func(t *testing.T) { + results := c.Search("") + assert.Nil(t, results) + }) + + t.Run("no match returns empty", func(t *testing.T) { + results := c.Search("zzzyyyxxx") + assert.Empty(t, results) + }) +} + +func TestCatalog_Get_Good(t *testing.T) { + c := &Catalog{ + topics: make(map[string]*Topic), + index: newSearchIndex(), + } + + c.Add(&Topic{ID: "exists", Title: "Existing Topic"}) + + t.Run("existing topic", func(t *testing.T) { + topic, err := c.Get("exists") + require.NoError(t, err) + assert.Equal(t, "Existing Topic", topic.Title) + }) + + t.Run("missing topic returns error", func(t *testing.T) { + topic, err := c.Get("does-not-exist") + assert.Nil(t, topic) + assert.Error(t, err) + assert.Contains(t, err.Error(), "topic not found") + assert.Contains(t, err.Error(), "does-not-exist") + }) +} + +func TestCatalog_Search_Good_ScoreTiebreaking(t *testing.T) { + // Tests the alphabetical tie-breaking in search result sorting (search.go:165). + c := &Catalog{ + topics: make(map[string]*Topic), + index: newSearchIndex(), + } + + // Add topics with identical content so they receive the same score. + c.Add(&Topic{ + ID: "zebra-topic", + Title: "Zebra", + Content: "Unique keyword zephyr.", + }) + c.Add(&Topic{ + ID: "alpha-topic", + Title: "Alpha", + Content: "Unique keyword zephyr.", + }) + + results := c.Search("zephyr") + require.Len(t, results, 2) + + // With equal scores, results should be sorted alphabetically by title. + assert.Equal(t, "Alpha", results[0].Topic.Title) + assert.Equal(t, "Zebra", results[1].Topic.Title) + assert.Equal(t, results[0].Score, results[1].Score, + "scores should be equal for tie-breaking to apply") +} + +func BenchmarkSearch(b *testing.B) { + // Build a catalog with 100+ topics for benchmarking. + c := &Catalog{ + topics: make(map[string]*Topic), + index: newSearchIndex(), + } + + for i := range 150 { + c.Add(&Topic{ + ID: fmt.Sprintf("topic-%d", i), + Title: fmt.Sprintf("Topic Number %d About Various Subjects", i), + Content: fmt.Sprintf("This is the content of topic %d. It covers installation, configuration, deployment, and testing of the system.", i), + Tags: []string{"generated", fmt.Sprintf("tag%d", i%10)}, + Sections: []Section{ + { + ID: fmt.Sprintf("section-%d-a", i), + Title: "Overview", + Content: "An overview of the topic and its purpose.", + }, + { + ID: fmt.Sprintf("section-%d-b", i), + Title: "Details", + Content: "Detailed information about the topic including examples and usage.", + }, + }, + }) + } + + b.ResetTimer() + b.ReportAllocs() + for range b.N { + c.Search("installation configuration") + } +} diff --git a/pkg/help/generate.go b/pkg/help/generate.go new file mode 100644 index 0000000..6f0fa6c --- /dev/null +++ b/pkg/help/generate.go @@ -0,0 +1,251 @@ +// 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 = ` + +` diff --git a/pkg/help/generate_test.go b/pkg/help/generate_test.go new file mode 100644 index 0000000..50033bc --- /dev/null +++ b/pkg/help/generate_test.go @@ -0,0 +1,195 @@ +// SPDX-Licence-Identifier: EUPL-1.2 +package help + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testCatalog builds a small catalog for generator tests. +func testCatalog() *Catalog { + c := &Catalog{ + topics: make(map[string]*Topic), + index: newSearchIndex(), + } + c.Add(&Topic{ + ID: "getting-started", + Title: "Getting Started", + Content: "# Getting Started\n\nWelcome to the **guide**.\n", + Tags: []string{"intro"}, + Sections: []Section{ + {ID: "getting-started", Title: "Getting Started", Level: 1}, + }, + }) + c.Add(&Topic{ + ID: "config", + Title: "Configuration", + Content: "# Configuration\n\nSet up your environment.\n", + Tags: []string{"setup"}, + Related: []string{"getting-started"}, + }) + return c +} + +func TestGenerate_Good_FileStructure(t *testing.T) { + dir := t.TempDir() + catalog := testCatalog() + + err := Generate(catalog, dir) + require.NoError(t, err) + + // Verify expected file structure + expectedFiles := []string{ + "index.html", + "search.html", + "search-index.json", + "404.html", + "topics/getting-started.html", + "topics/config.html", + } + + for _, f := range expectedFiles { + path := filepath.Join(dir, f) + _, err := os.Stat(path) + assert.NoError(t, err, "expected file %s to exist", f) + } +} + +func TestGenerate_Good_IndexContainsTopics(t *testing.T) { + dir := t.TempDir() + catalog := testCatalog() + + err := Generate(catalog, dir) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(dir, "index.html")) + require.NoError(t, err) + + html := string(content) + assert.Contains(t, html, "Getting Started") + assert.Contains(t, html, "Configuration") +} + +func TestGenerate_Good_TopicContainsRenderedMarkdown(t *testing.T) { + dir := t.TempDir() + catalog := testCatalog() + + err := Generate(catalog, dir) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(dir, "topics", "getting-started.html")) + require.NoError(t, err) + + html := string(content) + assert.Contains(t, html, "Getting Started") + assert.Contains(t, html, "guide") +} + +func TestGenerate_Good_SearchIndexJSON(t *testing.T) { + dir := t.TempDir() + catalog := testCatalog() + + err := Generate(catalog, dir) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(dir, "search-index.json")) + require.NoError(t, err) + + var entries []searchIndexEntry + require.NoError(t, json.Unmarshal(content, &entries)) + assert.Len(t, entries, 2, "search index should contain all topics") + + // Verify fields are populated + ids := make(map[string]bool) + for _, e := range entries { + ids[e.ID] = true + assert.NotEmpty(t, e.Title) + assert.NotEmpty(t, e.Content) + } + assert.True(t, ids["getting-started"]) + assert.True(t, ids["config"]) +} + +func TestGenerate_Good_404Exists(t *testing.T) { + dir := t.TempDir() + catalog := testCatalog() + + err := Generate(catalog, dir) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(dir, "404.html")) + require.NoError(t, err) + + html := string(content) + assert.Contains(t, html, "404") + assert.Contains(t, html, "not found") +} + +func TestGenerate_Good_EmptyDir(t *testing.T) { + dir := t.TempDir() + catalog := testCatalog() + + // Should succeed in an empty directory + err := Generate(catalog, dir) + assert.NoError(t, err) +} + +func TestGenerate_Good_OverwriteExisting(t *testing.T) { + dir := t.TempDir() + catalog := testCatalog() + + // Generate once + err := Generate(catalog, dir) + require.NoError(t, err) + + // Generate again -- should overwrite without error + err = Generate(catalog, dir) + assert.NoError(t, err) + + // Verify files still exist and are valid + content, err := os.ReadFile(filepath.Join(dir, "index.html")) + require.NoError(t, err) + assert.Contains(t, string(content), "Getting Started") +} + +func TestGenerate_Good_SearchPageHasScript(t *testing.T) { + dir := t.TempDir() + catalog := testCatalog() + + err := Generate(catalog, dir) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(dir, "search.html")) + require.NoError(t, err) + + html := string(content) + assert.Contains(t, html, "