Files
MujicaUI/tools/site/main.go
T

244 lines
6.1 KiB
Go

// Command site builds the static MujicaUI documentation site into site/dist.
package main
import (
"flag"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
const repoURL = "https://github.com/ZacharyZhang-NY/MujicaUI"
// component is one documented component and everything its page needs.
type component struct {
ID, Slug, Name, ZhName, Category string
Doc string // raw Markdown
Light, Dark string // absolute PNG paths, empty when missing
}
type category struct {
Name string
Items []*component
}
func main() {
root := flag.String("root", ".", "repository root")
out := flag.String("out", "site/dist", "output directory")
flag.Parse()
n, err := build(*root, *out)
if err != nil {
log.Fatal(err)
}
fmt.Printf("site: %d component pages written to %s\n", n, *out)
}
// build reads the repo at root and writes the site to out, returning the component page count.
func build(root, out string) (int, error) {
comps, err := loadComponents(root)
if err != nil {
return 0, err
}
if err := os.RemoveAll(out); err != nil {
return 0, err
}
s := &site{root: root, out: out, comps: comps, cats: groupCategories(comps)}
if err := s.write(); err != nil {
return 0, err
}
return len(comps), nil
}
var (
docNameRe = regexp.MustCompile(`^(\d{3})-([a-z0-9-]+)\.md$`)
entryIDRe = regexp.MustCompile(`\bID:\s*"([^"]+)"`)
fieldRe = regexp.MustCompile(`\b(Name|ZhName|Category):\s*("(?:[^"\\]|\\.)*"|[A-Za-z_][A-Za-z0-9_]*)`)
constRe = regexp.MustCompile(`(?m)^\s*(?:const\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"`)
titleSplitR = regexp.MustCompile(`^#\s+(?:\d{3}\s+)?(\S+)\s*(.*)$`)
)
type entry struct{ Name, ZhName, Category string }
func loadComponents(root string) ([]*component, error) {
entries, err := loadGallery(root)
if err != nil {
return nil, err
}
snaps, err := findSnapshots(root)
if err != nil {
return nil, err
}
dir := filepath.Join(root, "docs", "components")
files, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var comps []*component
for _, f := range files {
g := docNameRe.FindStringSubmatch(f.Name())
if g == nil {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, f.Name()))
if err != nil {
return nil, err
}
c := &component{ID: g[1], Slug: strings.TrimSuffix(f.Name(), ".md"), Doc: string(raw), Category: "Other"}
if t := titleSplitR.FindStringSubmatch(firstHeading(c.Doc)); t != nil {
c.Name, c.ZhName = t[1], t[2]
}
if e, ok := entries[c.ID]; ok {
c.Name, c.ZhName, c.Category = e.Name, e.ZhName, e.Category
}
if c.Name == "" {
return nil, fmt.Errorf("site: %s has no heading and no gallery entry", f.Name())
}
key := normKey(c.Name)
c.Light, c.Dark = snaps[key+"-light"], snaps[key+"-dark"]
comps = append(comps, c)
}
if len(comps) == 0 {
return nil, fmt.Errorf("site: no component docs in %s", dir)
}
return comps, nil
}
func firstHeading(doc string) string {
for _, l := range strings.Split(doc, "\n") {
if strings.HasPrefix(l, "# ") {
return strings.TrimSpace(l)
}
}
return ""
}
// loadGallery scans examples/gallery/pages_*.go for page entries without importing the package.
func loadGallery(root string) (map[string]entry, error) {
files, err := filepath.Glob(filepath.Join(root, "examples", "gallery", "pages_*.go"))
if err != nil {
return nil, err
}
consts := map[string]string{}
var srcs []string
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
return nil, err
}
src := string(b)
srcs = append(srcs, src)
for _, m := range constRe.FindAllStringSubmatch(src, -1) {
consts[m[1]] = m[2]
}
}
out := map[string]entry{}
for _, src := range srcs {
locs := entryIDRe.FindAllStringSubmatchIndex(src, -1)
for i, loc := range locs {
end := len(src)
if i+1 < len(locs) {
end = locs[i+1][0]
}
id := src[loc[2]:loc[3]]
var e entry
for _, f := range fieldRe.FindAllStringSubmatch(src[loc[1]:end], -1) {
v := f[2]
if strings.HasPrefix(v, `"`) {
if u, err := strconv.Unquote(v); err == nil {
v = u
}
} else if cv, ok := consts[v]; ok {
v = cv
} else {
continue // a variable such as Code: fooCode is not a field we want
}
switch f[1] {
case "Name":
if e.Name == "" {
e.Name = v
}
case "ZhName":
if e.ZhName == "" {
e.ZhName = v
}
case "Category":
if e.Category == "" {
e.Category = v
}
}
}
if e.Name != "" && e.Category != "" {
out[id] = e
}
}
}
return out, nil
}
// findSnapshots indexes every PNG under any testdata/snapshots/darwin directory by normalized base name.
func findSnapshots(root string) (map[string]string, error) {
out := map[string]string{}
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
name := d.Name()
if p != root && (strings.HasPrefix(name, ".") || name == "node_modules" || name == "dist") {
return filepath.SkipDir
}
if filepath.ToSlash(p) != "testdata/snapshots/darwin" && !strings.HasSuffix(filepath.ToSlash(p), "/testdata/snapshots/darwin") {
return nil
}
pngs, err := filepath.Glob(filepath.Join(p, "*.png"))
if err != nil {
return err
}
sort.Strings(pngs)
for _, f := range pngs {
base := strings.TrimSuffix(filepath.Base(f), ".png")
if strings.HasSuffix(base, ".got") {
continue
}
i := strings.LastIndex(base, "-")
if i < 0 {
continue
}
key := normKey(base[:i]) + base[i:]
if _, ok := out[key]; !ok {
out[key] = f
}
}
return filepath.SkipDir
})
return out, err
}
// normKey folds snake_case and CamelCase names to one key, so OAuthButtons meets oauth_buttons.
func normKey(s string) string {
return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(s))
}
func groupCategories(comps []*component) []*category {
idx := map[string]*category{}
var cats []*category
for _, c := range comps {
cat, ok := idx[c.Category]
if !ok {
cat = &category{Name: c.Category}
idx[c.Category] = cat
cats = append(cats, cat)
}
cat.Items = append(cat.Items, c)
}
return cats
}