// Package docs 在 /docs 路径提供产品用户文档(Markdown 嵌入二进制,SSR 渲染)。 package docs import ( "embed" "encoding/json" "fmt" "net/http" "path" "regexp" "strings" "github.com/gin-gonic/gin" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" gmhtml "github.com/yuin/goldmark/renderer/html" ) //go:embed content assets template.html var files embed.FS type PageDef struct { File string `json:"file"` Slug string `json:"slug"` Title string `json:"title"` Updated string `json:"updated,omitempty"` } type GroupDef struct { Name string `json:"name"` Pages []PageDef `json:"pages"` } type Manifest struct { ProductVersion string `json:"productVersion"` Groups []GroupDef `json:"groups"` } // TocItem 本页目录项 type TocItem struct { ID string `json:"id"` Level int `json:"level"` Text string `json:"text"` } type SearchEntry struct { Slug string `json:"slug"` Title string `json:"title"` Group string `json:"group"` Text string `json:"text"` } type NavLink struct { Slug string Title string } type Page struct { Slug string Title string Group string Updated string HTML string TOC []TocItem Prev *NavLink Next *NavLink } // Handler 文档服务 type Handler struct { manifest Manifest pages map[string]*Page order []string searchData []byte tpl string } func New() *Handler { h := &Handler{pages: map[string]*Page{}} raw, err := files.ReadFile("content/manifest.json") if err != nil { panic("docs: manifest.json 缺失: " + err.Error()) } if err := json.Unmarshal(raw, &h.manifest); err != nil { panic("docs: manifest.json 解析失败: " + err.Error()) } tplRaw, err := files.ReadFile("template.html") if err != nil { panic("docs: template.html 缺失: " + err.Error()) } h.tpl = string(tplRaw) md := goldmark.New( goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList), goldmark.WithRendererOptions(gmhtml.WithHardWraps()), ) search := []SearchEntry{} var prev *NavLink for _, group := range h.manifest.Groups { for _, def := range group.Pages { src, err := files.ReadFile("content/" + def.File) if err != nil { panic("docs: 缺少内容文件 " + def.File) } var buf strings.Builder if err := md.Convert(src, &buf); err != nil { panic("docs: 渲染失败 " + def.File + ": " + err.Error()) } body, toc := injectHeadingIDs(buf.String()) page := &Page{ Slug: def.Slug, Title: def.Title, Group: group.Name, Updated: def.Updated, HTML: body, TOC: toc, Prev: prev, } h.pages[def.Slug] = page h.order = append(h.order, def.Slug) prev = &NavLink{Slug: def.Slug, Title: def.Title} search = append(search, SearchEntry{ Slug: def.Slug, Title: def.Title, Group: group.Name, Text: plainText(string(src)), }) } } // 下一页链接 for i, slug := range h.order { if i+1 < len(h.order) { next := h.pages[h.order[i+1]] h.pages[slug].Next = &NavLink{Slug: next.Slug, Title: next.Title} } } searchJSON, _ := json.Marshal(search) h.searchData = searchJSON return h } // Register 注册路由(单一 catch-all,避免静态/参数段冲突) func (h *Handler) Register(r *gin.Engine) { r.GET("/docs", func(c *gin.Context) { c.Redirect(http.StatusFound, "/docs/"+h.order[0]) }) r.GET("/docs/*path", h.dispatch) } func (h *Handler) dispatch(c *gin.Context) { p := strings.Trim(c.Param("path"), "/") switch { case p == "": c.Redirect(http.StatusFound, "/docs/"+h.order[0]) case p == "search-data.json": h.SearchData(c) case strings.HasPrefix(p, "assets/"): c.Params = []gin.Param{{Key: "file", Value: "/" + strings.TrimPrefix(p, "assets")}} h.Asset(c) default: // 取第一段作为 slug(忽略子路径) slug := p if i := strings.IndexByte(p, '/'); i >= 0 { slug = p[:i] } c.Params = []gin.Param{{Key: "slug", Value: slug}} h.Page(c) } } // Page 渲染文档页 func (h *Handler) Page(c *gin.Context) { slug := c.Param("slug") page, ok := h.pages[slug] if !ok { c.Status(http.StatusNotFound) _, _ = c.Writer.WriteString(notFoundHTML(h.order[0])) //nolint:errcheck return } c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Cache-Control", "no-cache") _, _ = c.Writer.WriteString(renderPage(h, page)) } // SearchData 搜索索引 func (h *Handler) SearchData(c *gin.Context) { c.Data(http.StatusOK, "application/json; charset=utf-8", h.searchData) } // Asset 静态资源 func (h *Handler) Asset(c *gin.Context) { name := path.Clean(c.Param("file")) if strings.Contains(name, "..") { c.Status(http.StatusBadRequest) return } data, err := files.ReadFile("assets" + name) if err != nil { c.Status(http.StatusNotFound) return } switch { case strings.HasSuffix(name, ".css"): c.Header("Content-Type", "text/css; charset=utf-8") case strings.HasSuffix(name, ".js"): c.Header("Content-Type", "application/javascript; charset=utf-8") case strings.HasSuffix(name, ".svg"): c.Header("Content-Type", "image/svg+xml") default: c.Header("Content-Type", "application/octet-stream") } c.Header("Cache-Control", "public, max-age=3600") c.Data(http.StatusOK, "", data) } // ManifestExport 供测试使用 func (h *Handler) ManifestExport() Manifest { return h.manifest } func (h *Handler) FirstSlug() string { return h.order[0] } var ( hRe = regexp.MustCompile(`(?s)<(h[23])>(.*?)`) tagRe = regexp.MustCompile(`<[^>]+>`) ) // injectHeadingIDs 给 h2/h3 加锚点 id,并产出目录 func injectHeadingIDs(html string) (string, []TocItem) { var toc []TocItem counter := 0 out := hRe.ReplaceAllStringFunc(html, func(m string) string { sub := hRe.FindStringSubmatch(m) tag, inner := sub[1], sub[2] counter++ id := fmt.Sprintf("sec-%d", counter) text := strings.TrimSpace(tagRe.ReplaceAllString(inner, "")) level := 2 if tag == "h3" { level = 3 } toc = append(toc, TocItem{ID: id, Level: level, Text: text}) return fmt.Sprintf(`<%s id="%s">%s`, tag, id, inner, tag) }) return out, toc } var mdNoise = regexp.MustCompile("(?s)```.*?```|`[^`]*`|!\\[[^\\]]*\\]\\([^)]*\\)|\\[[^\\]]*\\]\\([^)]*\\)|[#>*_\\-|]+") // plainText 供搜索索引的纯文本 func plainText(src string) string { s := mdNoise.ReplaceAllString(src, " ") s = strings.Join(strings.Fields(s), " ") if len(s) > 6000 { s = s[:6000] } return s } func notFoundHTML(homeSlug string) string { return fmt.Sprintf(`404 · OpsXShell 文档

404

文档页面不存在或已被移动。

返回文档首页
`, homeSlug) }