// homepage-build:把 homepage/ 源(落地页 + 定价页 + 文档 md)构建为纯静态站点 homepage/dist/。 // 产物可直接部署到任意静态托管(nginx/Pages)——官网与后端二进制彻底解耦。 // 用法: go run . [-src homepage] [-out homepage/dist] package main import ( "encoding/json" "flag" "fmt" "html/template" "os" "path/filepath" "regexp" "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" gmhtml "github.com/yuin/goldmark/renderer/html" ) type PageDef struct { File string `json:"file"` Slug string `json:"slug"` Title string `json:"title"` Updated string `json:"updated,omitempty"` Hidden bool `json:"hidden,omitempty"` } type GroupDef struct { Name string `json:"name"` Pages []PageDef `json:"pages"` } type Manifest struct { ProductVersion string `json:"productVersion"` Groups []GroupDef `json:"groups"` } type TocItem struct { ID string Level int Text string } type SearchEntry struct { Slug, Title, Group, Text string } type NavLink struct { Slug, Title string } type Page struct { Slug, Title, Group, Updated string HTML template.HTML TOC []TocItem Prev, Next *NavLink } var ( hRe = regexp.MustCompile(`(?s)<(h[23])>(.*?)`) tagRe = regexp.MustCompile(`<[^>]+>`) mdNoise = regexp.MustCompile("(?s)```.*?```|`[^`]*`|!\\[[^\\]]*\\]\\([^)]*\\)|\\[[^\\]]*\\]\\([^)]*\\)|[#>*_\\-|]+") ) 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 } 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 must(err error, what string) { if err != nil { fmt.Fprintf(os.Stderr, "[homepage-build] %s: %v\n", what, err) os.Exit(1) } } func writeFile(path string, content []byte) { must(os.MkdirAll(filepath.Dir(path), 0o755), "mkdir "+path) must(os.WriteFile(path, content, 0o644), "write "+path) fmt.Println("[OK]", path) } // visibleManifest 返回去掉 hidden 页的分组清单:模板侧栏、上/下一页、搜索索引都只含可见页。 // Logan.Li func visibleManifest(m Manifest) Manifest { vm := Manifest{ProductVersion: m.ProductVersion} for _, g := range m.Groups { ng := GroupDef{Name: g.Name} for _, p := range g.Pages { if !p.Hidden { ng.Pages = append(ng.Pages, p) } } if len(ng.Pages) > 0 { vm.Groups = append(vm.Groups, ng) } } return vm } func main() { src := flag.String("src", "homepage", "源目录") out := flag.String("out", "homepage/dist", "产物目录") flag.Parse() // 幂等重建:清理上一轮生成的子目录(os.CopyFS 目标已存在会报错) for _, d := range []string{"site", "docs", "pricing", "home"} { must(os.RemoveAll(filepath.Join(*out, d)), "清理 "+d) } // 1) 落地页 + 别名 + 可选单页:纯 HTML 直接拷贝 raw, err := os.ReadFile(filepath.Join(*src, "index.html")) must(err, "读 index.html") writeFile(filepath.Join(*out, "index.html"), raw) // /home 别名:对齐 nginx try_files $uri/index.html,GUI openExternal("/home") 依赖它 writeFile(filepath.Join(*out, "home", "index.html"), raw) // 可选单页(pricing / terms / license):根 .html + /index.html // (对齐 nginx location = /pricing|/license → 目录内 index;纯静态服务器上 /pricing/ 亦为 200)// Logan.Li for _, name := range []string{"pricing.html", "terms.html", "license.html"} { b, e := os.ReadFile(filepath.Join(*src, name)) if e != nil { continue } writeFile(filepath.Join(*out, name), b) writeFile(filepath.Join(*out, strings.TrimSuffix(name, ".html"), "index.html"), b) } // 站点资源:落地页/定价页引用 /site/assets/* siteAssets := filepath.Join(*src, "assets") if st, _ := os.Stat(siteAssets); st != nil { must(os.CopyFS(filepath.Join(*out, "site", "assets"), os.DirFS(siteAssets)), "拷 site assets") fmt.Println("[OK]", filepath.Join(*out, "site", "assets")) } // 2) 文档:manifest → goldmark 渲染 → docs.html 模板(Go html/template) mraw, err := os.ReadFile(filepath.Join(*src, "content", "manifest.json")) must(err, "读 manifest") var manifest Manifest must(json.Unmarshal(mraw, &manifest), "解析 manifest") vis := visibleManifest(manifest) tplRaw, err := os.ReadFile(filepath.Join(*src, "docs.html")) must(err, "读 docs.html") tpl, err := template.New("doc").Parse(string(tplRaw)) must(err, "解析 docs 模板") md := goldmark.New(goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList), goldmark.WithRendererOptions(gmhtml.WithHardWraps())) // 可见页顺序(侧栏 / 上下一页 / 搜索) var order []string pages := map[string]*Page{} var search []SearchEntry var prev *NavLink for _, g := range vis.Groups { for _, def := range g.Pages { srcb, err := os.ReadFile(filepath.Join(*src, "content", def.File)) must(err, "读文档 "+def.File) var buf strings.Builder must(md.Convert(srcb, &buf), "渲染 "+def.File) body, toc := injectHeadingIDs(buf.String()) p := &Page{Slug: def.Slug, Title: def.Title, Group: g.Name, Updated: def.Updated, HTML: template.HTML(body), TOC: toc, Prev: prev} pages[def.Slug] = p order = append(order, def.Slug) prev = &NavLink{Slug: def.Slug, Title: def.Title} search = append(search, SearchEntry{Slug: def.Slug, Title: def.Title, Group: g.Name, Text: plainText(string(srcb))}) } } for i, slug := range order { if i+1 < len(order) { n := pages[order[i+1]] pages[slug].Next = &NavLink{Slug: n.Slug, Title: n.Title} } } render := func(p *Page) []byte { var sb strings.Builder must(tpl.Execute(&sb, map[string]any{"Manifest": vis, "Page": p, "TOC": p.TOC, "Content": p.HTML}), "渲染模板 "+p.Slug) return []byte(sb.String()) } // /docs/ 首页 = 第一篇(静态站无重定向,直接渲染) writeFile(filepath.Join(*out, "docs", "index.html"), render(pages[order[0]])) for _, slug := range order { writeFile(filepath.Join(*out, "docs", slug, "index.html"), render(pages[slug])) } // hidden 页:仍渲染为独立页面(不进侧栏/导航/搜索),供 /docs/ 直达 for _, g := range manifest.Groups { for _, def := range g.Pages { if !def.Hidden { continue } srcb, err := os.ReadFile(filepath.Join(*src, "content", def.File)) must(err, "读隐藏文档 "+def.File) var buf strings.Builder must(md.Convert(srcb, &buf), "渲染 "+def.File) body, toc := injectHeadingIDs(buf.String()) p := &Page{Slug: def.Slug, Title: def.Title, Group: g.Name, Updated: def.Updated, HTML: template.HTML(body), TOC: toc} writeFile(filepath.Join(*out, "docs", def.Slug, "index.html"), render(p)) } } sj, _ := json.Marshal(search) writeFile(filepath.Join(*out, "docs", "search-data.json"), sj) // docs assets(模板引用 /docs/assets/*) da := filepath.Join(*src, "docs-assets") if st, _ := os.Stat(da); st != nil { must(os.CopyFS(filepath.Join(*out, "docs", "assets"), os.DirFS(da)), "拷 docs assets") fmt.Println("[OK]", filepath.Join(*out, "docs", "assets")) } fmt.Printf("[DONE] 落地页 + %d 页文档(+隐藏页)→ %s(纯静态,可部署任意域名)\n", len(order), *out) }