// 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 = ` `