package services // 用户手册文档站:docs/user-guide/*.md 按 manifest.json 分组, // 服务端 goldmark 渲染 + 三栏布局(侧栏/内容/本页目录)+ 顶栏搜索。 // 布局与交互参考 opsxapi/opsxshell 文档站,换 LokiStack 品牌绿。 import ( "encoding/json" "fmt" "html/template" "os" "path/filepath" "regexp" "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" ) // DocsManifest docs/user-guide/manifest.json type DocsManifest struct { ProductVersion string `json:"productVersion"` Groups []struct { Name string `json:"name"` Pages []struct { File string `json:"file"` Slug string `json:"slug"` Title string `json:"title"` Updated string `json:"updated"` } `json:"pages"` } `json:"groups"` } // DocsPage 渲染一页所需的数据 type DocsPage struct { Title string Group string Updated string Slug string Content template.HTML TOC []DocsTOCItem Prev *DocsNav Next *DocsNav } type DocsTOCItem struct { Level int ID string Text string } type DocsNav struct { Slug string Title string } var ( docsManifestCache *DocsManifest docsBasePath string docsMD = goldmark.New(goldmark.WithExtensions(extension.Table), goldmark.WithParserOptions(parser.WithAutoHeadingID())) docsTOCPattern = regexp.MustCompile(`]*>([^<]+)`) ) // DocsBaseDir 定位 docs 根(含 user-guide/manifest.json);找不到返回空 func DocsBaseDir() string { if docsBasePath != "" { return docsBasePath } for _, p := range []string{"docs", "../docs", "../../docs"} { abs, err := filepath.Abs(p) if err != nil { continue } if _, err := os.Stat(filepath.Join(abs, "user-guide", "manifest.json")); err == nil { docsBasePath = abs return abs } } return "" } // DocsLoadManifest 解析(并缓存)manifest func DocsLoadManifest() (*DocsManifest, error) { if docsManifestCache != nil { return docsManifestCache, nil } base := DocsBaseDir() if base == "" { return nil, fmt.Errorf("docs 目录不存在") } data, err := os.ReadFile(filepath.Join(base, "user-guide", "manifest.json")) if err != nil { return nil, err } var m DocsManifest if err := json.Unmarshal(data, &m); err != nil { return nil, err } docsManifestCache = &m return docsManifestCache, nil } // DocsFlat 顺序展开全部页面(slug 定位与上下页) func DocsFlat(m *DocsManifest) []struct { Group string File string Slug string Title string Updated string } { var out []struct { Group string File string Slug string Title string Updated string } for _, g := range m.Groups { for _, p := range g.Pages { out = append(out, struct { Group string File string Slug string Title string Updated string }{g.Name, p.File, p.Slug, p.Title, p.Updated}) } } return out } // DocsRenderPage 渲染指定 slug;含 TOC 与上一页/下一页 func DocsRenderPage(slug string) (*DocsPage, *DocsManifest, error) { m, err := DocsLoadManifest() if err != nil { return nil, nil, err } flat := DocsFlat(m) idx := -1 for i, p := range flat { if p.Slug == slug { idx = i break } } if idx < 0 { return nil, nil, fmt.Errorf("页面不存在") } cur := flat[idx] data, err := os.ReadFile(filepath.Join(DocsBaseDir(), "user-guide", cur.File)) if err != nil { return nil, nil, err } var sb strings.Builder if err := docsMD.Convert(data, &sb); err != nil { return nil, nil, err } html := sb.String() // 页面头已渲染标题,剥掉正文首个 H1 避免重复 if i := strings.Index(html, "= 0 { if j := strings.Index(html[i:], ""); j >= 0 { html = html[:i] + html[i+j+len(""):] } } page := &DocsPage{Title: cur.Title, Group: cur.Group, Updated: cur.Updated, Slug: cur.Slug, Content: template.HTML(html)} for _, m2 := range docsTOCPattern.FindAllStringSubmatch(html, -1) { page.TOC = append(page.TOC, DocsTOCItem{Level: int(m2[1][0] - '0'), ID: m2[2], Text: m2[3]}) } if idx > 0 { page.Prev = &DocsNav{Slug: flat[idx-1].Slug, Title: flat[idx-1].Title} } if idx < len(flat)-1 { page.Next = &DocsNav{Slug: flat[idx+1].Slug, Title: flat[idx+1].Title} } return page, m, nil } // DocsSearchData 顶栏搜索索引(标题 + 纯文本摘要) func DocsSearchData() ([]map[string]string, error) { m, err := DocsLoadManifest() if err != nil { return nil, err } var out []map[string]string for _, p := range DocsFlat(m) { data, err := os.ReadFile(filepath.Join(DocsBaseDir(), "user-guide", p.File)) if err != nil { continue } text := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(string(data), " ") text = strings.Join(strings.Fields(text), " ") if len(text) > 4000 { text = text[:4000] } out = append(out, map[string]string{"slug": p.Slug, "title": p.Title, "group": p.Group, "text": text}) } return out, nil } // DocsFirstSlug 首页重定向目标 func DocsFirstSlug() string { m, err := DocsLoadManifest() if err != nil || len(m.Groups) == 0 || len(m.Groups[0].Pages) == 0 { return "readme" } return m.Groups[0].Pages[0].Slug }