Files
LokiStack/backend/services/docsite.go
T
opsx 48e68250bb
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
feat(docs): 用户手册接入界面截图
- 6 张核心页面截图(仪表盘/向导/日志查询/日志分析/告警/设置)存 docs/assets/screenshots,
  随发布包分发;产品介绍页新增「界面预览」两列网格,各功能页顶部内嵌对应截图
- goldmark 开启 WithUnsafe 以渲染网格 HTML(文档自维护,无注入面);截图经 DOM 校验全部加载成功
2026-10-29 16:36:21 +08:00

210 lines
5.2 KiB
Go

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"
htmlRenderer "github.com/yuin/goldmark/renderer/html"
)
// 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()), goldmark.WithRendererOptions(htmlRenderer.WithUnsafe()))
docsTOCPattern = regexp.MustCompile(`<h([23]) id="([^"]+)"[^>]*>([^<]+)</h[23]>`)
)
// 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, "<h1"); i >= 0 {
if j := strings.Index(html[i:], "</h1>"); j >= 0 {
html = html[:i] + html[i+j+len("</h1>"):]
}
}
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
}