fix: 顶栏文案去重与文档/连库体验更新
顶栏定为:连接 · 新建连接 · 新建查询 · 查询历史 · 快捷键; 同步 docs/落地页、对象树与 connection-types 注册表整理。
This commit is contained in:
1 parent
f8740d27f7
commit
26f459d1b8
49 files changed
+2682
-492
No files matched your search
@@ -1,89 +1,264 @@
|
||||
// Package docs:用户文档骨架(嵌入 HTML,侧栏+正文密度对齐工作台)。
|
||||
// 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 page struct {
|
||||
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
|
||||
Body string
|
||||
}
|
||||
|
||||
var order = []page{
|
||||
{Slug: "install", Title: "安装与启动"},
|
||||
{Slug: "connect", Title: "连库入门"},
|
||||
{Slug: "workbench", Title: "工作台简介"},
|
||||
{Slug: "faq", Title: "常见问题"},
|
||||
type Page struct {
|
||||
Slug string
|
||||
Title string
|
||||
Group string
|
||||
Updated string
|
||||
HTML string
|
||||
TOC []TocItem
|
||||
Prev *NavLink
|
||||
Next *NavLink
|
||||
}
|
||||
|
||||
// Handler 文档服务
|
||||
type Handler struct {
|
||||
tpl string
|
||||
manifest Manifest
|
||||
pages map[string]*Page
|
||||
order []string
|
||||
searchData []byte
|
||||
tpl string
|
||||
}
|
||||
|
||||
func New() *Handler {
|
||||
raw, err := files.ReadFile("template.html")
|
||||
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())
|
||||
}
|
||||
return &Handler{tpl: string(raw)}
|
||||
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/install") })
|
||||
r.GET("/docs/:slug", h.Page)
|
||||
r.GET("/docs/assets/*file", h.Asset)
|
||||
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")
|
||||
bodyBytes, err := files.ReadFile("content/" + slug + ".html")
|
||||
if err != nil {
|
||||
page, ok := h.pages[slug]
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound)
|
||||
_, _ = c.Writer.WriteString(notFoundHTML(h.order[0])) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
title := slug
|
||||
nav := strings.Builder{}
|
||||
for _, p := range order {
|
||||
cls := "nav-link"
|
||||
if p.Slug == slug {
|
||||
cls += " active"
|
||||
title = p.Title
|
||||
}
|
||||
nav.WriteString(`<a class="` + cls + `" href="/docs/` + p.Slug + `">` + p.Title + `</a>`)
|
||||
}
|
||||
html := h.tpl
|
||||
html = strings.ReplaceAll(html, "{{TITLE}}", title)
|
||||
html = strings.ReplaceAll(html, "{{NAV}}", nav.String())
|
||||
html = strings.ReplaceAll(html, "{{BODY}}", string(bodyBytes))
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
_, _ = c.Writer.WriteString(html)
|
||||
_, _ = 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 := strings.TrimPrefix(c.Param("file"), "/")
|
||||
name := path.Clean(c.Param("file"))
|
||||
if strings.Contains(name, "..") {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := files.ReadFile("assets/" + name)
|
||||
data, err := files.ReadFile("assets" + name)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ct := "application/octet-stream"
|
||||
if strings.HasSuffix(name, ".css") {
|
||||
ct = "text/css; charset=utf-8"
|
||||
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("Content-Type", ct)
|
||||
c.Data(http.StatusOK, ct, data)
|
||||
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])>(.*?)</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</%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(home string) string {
|
||||
return fmt.Sprintf(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>404 · OpsxDB 文档</title>
|
||||
<link rel="stylesheet" href="/docs/assets/docs.css"></head><body class="doc-404">
|
||||
<div class="wrap"><h1>404</h1><p>文档页面不存在或已被移动。</p><a class="btn" href="%s">返回文档首页</a></div>
|
||||
</body></html>`, home)
|
||||
}
|
||||
Reference in new issue
Block a user