diff --git a/.gitignore b/.gitignore index 970905d..c219cae 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ PRD.md docs/prd/ TASKS.md docs/acceptance.md +/site/dist/ diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..6219008 --- /dev/null +++ b/site/app.js @@ -0,0 +1,81 @@ +// MujicaUI docs: theme toggle, copy buttons, mobile nav, index search. No network requests. +(function () { + var root = document.documentElement; + + function effectiveTheme() { + if (root.dataset.theme) return root.dataset.theme; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + var themeBtn = document.querySelector(".theme"); + function labelTheme() { + if (themeBtn) themeBtn.textContent = effectiveTheme() === "dark" ? "浅色" : "深色"; + } + if (themeBtn) { + labelTheme(); + themeBtn.addEventListener("click", function () { + var next = effectiveTheme() === "dark" ? "light" : "dark"; + root.dataset.theme = next; + try { localStorage.setItem("mujica-theme", next); } catch (e) {} + labelTheme(); + }); + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", labelTheme); + } + + document.querySelectorAll(".code .copy").forEach(function (btn) { + btn.addEventListener("click", function () { + var code = btn.parentElement.querySelector("code").textContent; + function done(ok) { + btn.textContent = ok ? "已复制" : "复制失败"; + setTimeout(function () { btn.textContent = "复制"; }, 1500); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(code).then(function () { done(true); }, function () { done(false); }); + } else { + done(false); + } + }); + }); + + var toggle = document.querySelector(".navtoggle"); + var side = document.getElementById("sidenav"); + if (toggle && side) { + toggle.addEventListener("click", function () { + var open = side.classList.toggle("open"); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + var cur = side.querySelector('[aria-current="page"]'); + if (cur && window.matchMedia("(min-width: 821px)").matches) cur.scrollIntoView({ block: "center" }); + } + + var dataEl = document.getElementById("search-data"); + var q = document.getElementById("q"); + if (!dataEl || !q) return; + var items = JSON.parse(dataEl.textContent); + var list = document.getElementById("results"); + var count = document.getElementById("qcount"); + function render() { + var term = q.value.trim().toLowerCase(); + list.textContent = ""; + if (!term) { count.textContent = ""; return; } + var hits = items.filter(function (it) { + return it.n.toLowerCase().indexOf(term) >= 0 || it.z.indexOf(term) >= 0 || + it.id.indexOf(term) >= 0 || it.s.indexOf(term) >= 0 || it.c.toLowerCase().indexOf(term) >= 0; + }); + count.textContent = hits.length + " 个结果"; + hits.slice(0, 60).forEach(function (it) { + var li = document.createElement("li"); + var a = document.createElement("a"); + a.href = "/c/" + it.s + "/"; + [["num", it.id], ["n", it.n], ["zh", it.z], ["c", it.c]].forEach(function (p) { + var span = document.createElement("span"); + span.className = p[0]; + span.textContent = p[1]; + a.appendChild(span); + }); + li.appendChild(a); + list.appendChild(li); + }); + } + q.addEventListener("input", render); + render(); +})(); diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..f376c38 --- /dev/null +++ b/site/style.css @@ -0,0 +1,206 @@ +/* MujicaUI docs: court-gothic tokens from PRD 3.2. No web fonts, gradients, glow or glass. */ +:root { + color-scheme: light; + --bg: #F2EBDD; + --surface: #FBF7EF; + --hover: #E8DFD0; + --text: #211C1D; + --muted: #6A5B55; + --border: #D2C3AF; + --control: #877662; + --accent: #7D2034; + --accent-hover: #651729; + --on-accent: #FFF8EE; + --accent-text: #7D2034; + --selection: #EAD7D9; + --ornament: #AD9164; + --code-bg: #EFE6D6; + --serif: Georgia, "Songti SC", "STSong", "Noto Serif CJK SC", "Source Han Serif SC", serif; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --bg: #191618; + --surface: #242024; + --hover: #342C32; + --text: #F2EBDD; + --muted: #BCAEAA; + --border: #51424A; + --control: #968278; + --accent: #9C3C53; + --accent-hover: #AA425C; + --accent-text: #E4A0AE; + --selection: #492A36; + --ornament: #BDA172; + --code-bg: #211D20; + } +} +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #191618; + --surface: #242024; + --hover: #342C32; + --text: #F2EBDD; + --muted: #BCAEAA; + --border: #51424A; + --control: #968278; + --accent: #9C3C53; + --accent-hover: #AA425C; + --accent-text: #E4A0AE; + --selection: #492A36; + --ornament: #BDA172; + --code-bg: #211D20; +} + +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.6 var(--sans); + overflow-wrap: anywhere; +} +::selection { background: var(--selection); } +a { color: var(--accent-text); text-underline-offset: 2px; } +a:focus-visible, button:focus-visible, input:focus-visible, summary:focus-visible { + outline: 2px solid var(--accent-text); + outline-offset: 2px; +} +h1, h2, h3, h4 { font-family: var(--serif); font-weight: 600; line-height: 1.3; text-wrap: balance; } +h1 { font-size: 1.9rem; margin: 0 0 .6rem; } +h2 { font-size: 1.4rem; margin: 2rem 0 .6rem; } +h3 { font-size: 1.15rem; margin: 1.5rem 0 .5rem; } +img { max-width: 100%; height: auto; } + +.skip { position: absolute; left: -999px; top: 0; background: var(--surface); padding: 8px 12px; z-index: 10; } +.skip:focus { left: 8px; top: 8px; } + +/* Ornament: thin gold rule with a centered diamond. */ +.rule { position: relative; height: 1px; background: var(--ornament); margin: 1.2rem auto; max-width: 320px; } +.rule span { position: absolute; left: 50%; top: 50%; width: 9px; height: 9px; background: var(--bg); border: 1px solid var(--ornament); transform: translate(-50%, -50%) rotate(45deg); } + +.top { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; + padding: 10px 16px; border-bottom: 1px solid var(--ornament); background: var(--bg); +} +.brand { font-family: var(--serif); font-size: 1.25rem; font-weight: 600; color: var(--text); text-decoration: none; display: inline-flex; align-items: center; gap: 10px; } +.lozenge { width: 9px; height: 9px; border: 1px solid var(--ornament); transform: rotate(45deg); display: inline-block; } +.toplinks { display: flex; flex-wrap: wrap; gap: 4px 14px; flex: 1; } +.toplinks a { color: var(--text); text-decoration: none; font-size: .92rem; padding: 4px 0; } +.toplinks a:hover { color: var(--accent-text); text-decoration: underline; } +button { font: inherit; } +.theme, .copy, .navtoggle { + background: var(--surface); color: var(--text); border: 1px solid var(--control); + border-radius: 2px; padding: 4px 12px; min-height: 32px; cursor: pointer; +} +.theme:hover, .copy:hover, .navtoggle:hover { background: var(--hover); } + +.layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); max-width: 1280px; margin: 0 auto; } +.navtoggle { display: none; } +.side { + position: sticky; top: 0; align-self: start; max-height: 100vh; overflow-y: auto; + padding: 16px 12px 32px; border-right: 1px solid var(--border); font-size: .9rem; +} +.side details { margin-bottom: 4px; } +.side summary { font-family: var(--serif); font-weight: 600; cursor: pointer; padding: 4px 6px; border-radius: 2px; } +.side summary:hover { background: var(--hover); } +.side ul { list-style: none; margin: 2px 0 8px; padding: 0 0 0 10px; border-left: 1px solid var(--border); } +.side li a { display: block; padding: 3px 8px; color: var(--text); text-decoration: none; border-radius: 2px; } +.side li a:hover { background: var(--hover); } +.side li a[aria-current="page"] { background: var(--accent); color: var(--on-accent); } +.side li a[aria-current="page"] .zh { color: var(--on-accent); } +.zh { color: var(--muted); font-size: .85em; } +.count { color: var(--muted); font-family: var(--sans); font-weight: 400; font-size: .8em; } + +main { min-width: 0; padding: 24px 32px 48px; } +main.solo { max-width: 920px; margin: 0 auto; } +.crumb { margin: 0 0 12px; color: var(--muted); font-size: .9rem; } + +.doc { max-width: 860px; } +.doc h1 { padding-bottom: .5rem; border-bottom: 1px solid var(--ornament); } +.doc h2 { padding-bottom: .25rem; border-bottom: 1px solid var(--border); } +.doc code { font-family: var(--mono); font-size: .88em; background: var(--code-bg); border: 1px solid var(--border); border-radius: 2px; padding: 0 4px; overflow-wrap: anywhere; } +.doc ul, .doc ol { padding-left: 1.4rem; } +.doc li { margin: .2rem 0; } +.doc blockquote { margin: 1rem 0; padding: .2rem 1rem; border-left: 2px solid var(--ornament); color: var(--muted); } +.doc hr { border: 0; border-top: 1px solid var(--ornament); margin: 2rem 0; } +.code { position: relative; margin: 1rem 0; } +.code pre { margin: 0; padding: 14px 16px; padding-right: 72px; background: var(--code-bg); border: 1px solid var(--border); border-radius: 4px; overflow-x: auto; font: 13px/1.55 var(--mono); } +.code pre code { background: none; border: 0; padding: 0; font-size: inherit; overflow-wrap: normal; } +.copy { position: absolute; top: 6px; right: 6px; min-height: 28px; padding: 2px 10px; font-size: .8rem; } +.table { overflow-x: auto; margin: 1rem 0; border: 1px solid var(--border); border-radius: 4px; } +table { border-collapse: collapse; width: 100%; font-size: .9rem; } +th, td { text-align: left; vertical-align: top; padding: 8px 10px; border-bottom: 1px solid var(--border); } +th { background: var(--surface); font-family: var(--serif); font-weight: 600; white-space: nowrap; } +tbody tr:last-child td { border-bottom: 0; } +td code { overflow-wrap: normal; white-space: nowrap; } + +/* Snapshots: both shown; the one matching the page theme comes first. */ +.snaps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 0 0 24px; max-width: 860px; } +.snap { margin: 0; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); padding: 8px; } +.snap img { display: block; margin: 0 auto; } +.snap figcaption { font-size: .8rem; color: var(--muted); text-align: center; margin-top: 6px; } +.nosnap { padding: 32px 8px; text-align: center; color: var(--muted); } +.snap-dark { order: 2; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .snap-dark { order: 0; } + :root:not([data-theme="light"]) .snap-light { order: 1; } +} +:root[data-theme="dark"] .snap-dark { order: 0; } +:root[data-theme="dark"] .snap-light { order: 1; } + +.pager { display: flex; justify-content: space-between; gap: 12px; margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--ornament); max-width: 860px; } +.pager .next { margin-left: auto; text-align: right; } + +/* Index */ +.hero { text-align: center; padding: 32px 0 8px; } +.hero h1 { font-size: 2.6rem; margin: 0; letter-spacing: .02em; } +.eyebrow { font-family: var(--serif); font-style: italic; color: var(--muted); margin: 0 0 6px; } +.lede { max-width: 620px; margin: 0 auto 1rem; } +.honest { max-width: 680px; margin: 1rem auto; padding: 12px 16px; border: 1px solid var(--ornament); border-radius: 4px; background: var(--surface); text-align: left; font-size: .92rem; } +.ctas { display: flex; justify-content: center; flex-wrap: wrap; gap: 12px; margin-top: 1.2rem; } +.btn { display: inline-block; padding: 8px 18px; min-height: 36px; border-radius: 2px; background: var(--accent); color: var(--on-accent); text-decoration: none; border: 1px solid var(--accent); } +.btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +.btn.ghost { background: transparent; color: var(--accent-text); border-color: var(--control); } +.btn.ghost:hover { background: var(--hover); } +.search { margin: 32px 0 8px; } +.search label { display: block; font-family: var(--serif); font-weight: 600; font-size: 1.15rem; margin-bottom: 6px; } +.search input { width: 100%; font: inherit; padding: 8px 12px; min-height: 40px; border: 1px solid var(--control); border-radius: 2px; background: var(--surface); color: var(--text); } +.qcount { color: var(--muted); font-size: .85rem; margin: 6px 0; min-height: 1.2em; } +.results { list-style: none; padding: 0; margin: 0; } +.results li a { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 6px 10px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); } +.results li a:hover { background: var(--hover); } +.results .c { color: var(--muted); font-size: .8rem; margin-left: auto; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; } +.cat { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; padding: 12px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; text-decoration: none; color: var(--text); } +.cat:hover { border-color: var(--ornament); background: var(--hover); } +.cname { font-family: var(--serif); font-weight: 600; } +.catlist h2 { scroll-margin-top: 12px; } +.cards { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; } +.cards a { display: flex; flex-wrap: wrap; gap: 2px 8px; align-items: baseline; padding: 8px 10px; border: 1px solid var(--border); border-radius: 2px; text-decoration: none; color: var(--text); background: var(--surface); } +.cards a:hover { background: var(--hover); } +.num { font-family: var(--mono); font-size: .75rem; color: var(--muted); } +.n { font-weight: 600; } + +.foot { padding: 8px 16px 32px; text-align: center; color: var(--muted); font-size: .85rem; border-top: 1px solid var(--border); margin-top: 24px; } +.foot p { max-width: 720px; margin: .5rem auto; } + +@media (max-width: 820px) { + .layout { grid-template-columns: minmax(0, 1fr); } + .navtoggle { display: block; margin: 12px 16px 0; } + .side { display: none; position: static; max-height: none; border-right: 0; border-bottom: 1px solid var(--border); } + .side.open { display: block; } + main { padding: 16px 16px 40px; } +} +@media (max-width: 520px) { + .snaps { grid-template-columns: minmax(0, 1fr); } + .hero h1 { font-size: 2rem; } + h1 { font-size: 1.55rem; } + .theme { margin-left: auto; } +} +@media (prefers-reduced-motion: no-preference) { + html { scroll-behavior: smooth; } +} diff --git a/tools/site/main.go b/tools/site/main.go new file mode 100644 index 0000000..570a1f7 --- /dev/null +++ b/tools/site/main.go @@ -0,0 +1,243 @@ +// 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 +} diff --git a/tools/site/markdown.go b/tools/site/markdown.go new file mode 100644 index 0000000..a08eb0d --- /dev/null +++ b/tools/site/markdown.go @@ -0,0 +1,269 @@ +package main + +import ( + "html" + "regexp" + "strconv" + "strings" +) + +var ( + headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*?)\s*#*\s*$`) + listRe = regexp.MustCompile(`^(\s*)([-*+]|\d+[.)])\s+(.*)$`) + sepRe = regexp.MustCompile(`^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$`) + linkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`) + boldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`) + slugRe = regexp.MustCompile(`[^\p{L}\p{N}]+`) +) + +// markdown converts the subset of Markdown used by the docs to HTML; link rewrites relative hrefs. +type markdown struct { + link func(href string) string +} + +func (m markdown) render(src string) string { + lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n") + var b strings.Builder + m.blocks(&b, lines) + return b.String() +} + +func (m markdown) blocks(b *strings.Builder, lines []string) { + for i := 0; i < len(lines); { + line := lines[i] + trim := strings.TrimSpace(line) + switch { + case trim == "": + i++ + case strings.HasPrefix(trim, "```"): + i = m.fence(b, lines, i) + case headingRe.MatchString(trim): + g := headingRe.FindStringSubmatch(trim) + n := len(g[1]) + text := m.inline(g[2]) + if n == 1 { + b.WriteString("

" + text + "

\n") + } else { + id := slug(g[2]) + b.WriteString("` + text + "\n") + } + i++ + case trim == "---" || trim == "***": + b.WriteString("
\n") + i++ + case strings.HasPrefix(trim, "|") && i+1 < len(lines) && sepRe.MatchString(lines[i+1]): + i = m.table(b, lines, i) + case listRe.MatchString(line): + i = m.list(b, lines, i) + case strings.HasPrefix(trim, ">"): + var inner []string + for i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), ">") { + inner = append(inner, strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(lines[i]), ">"), " ")) + i++ + } + b.WriteString("
\n") + m.blocks(b, inner) + b.WriteString("
\n") + default: + var para []string + for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !startsBlock(lines, i) { + para = append(para, strings.TrimSpace(lines[i])) + i++ + } + if len(para) == 0 { + para = append(para, trim) + i++ + } + b.WriteString("

" + m.inline(strings.Join(para, "\n")) + "

\n") + } + } +} + +func startsBlock(lines []string, i int) bool { + t := strings.TrimSpace(lines[i]) + return strings.HasPrefix(t, "```") || headingRe.MatchString(t) || listRe.MatchString(lines[i]) || + strings.HasPrefix(t, ">") || (strings.HasPrefix(t, "|") && i+1 < len(lines) && sepRe.MatchString(lines[i+1])) +} + +func (m markdown) fence(b *strings.Builder, lines []string, i int) int { + open := strings.TrimSpace(lines[i]) + lang := strings.TrimSpace(strings.TrimLeft(open, "`")) + var code []string + i++ + for i < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i]), "```") { + code = append(code, lines[i]) + i++ + } + class := "" + if lang != "" { + class = ` class="lang-` + html.EscapeString(lang) + `"` + } + b.WriteString(`
" + html.EscapeString(strings.Join(code, "\n")) + "
\n") + return i + 1 +} + +func (m markdown) table(b *strings.Builder, lines []string, i int) int { + head := splitRow(lines[i]) + b.WriteString("
\n") + for _, c := range head { + b.WriteString("") + } + b.WriteString("\n\n") + i += 2 + for i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "|") { + b.WriteString("") + for _, c := range splitRow(lines[i]) { + b.WriteString("") + } + b.WriteString("\n") + i++ + } + b.WriteString("
" + m.inline(c) + "
" + m.inline(c) + "
\n") + return i +} + +// splitRow splits a table row on pipes outside code spans; `\|` is a literal pipe. +func splitRow(line string) []string { + s := strings.TrimSpace(line) + s = strings.TrimPrefix(s, "|") + if strings.HasSuffix(s, "|") && !strings.HasSuffix(s, `\|`) { + s = s[:len(s)-1] + } + var cells []string + var cur strings.Builder + inCode := false + for i := 0; i < len(s); i++ { + ch := s[i] + switch { + case ch == '\\' && i+1 < len(s) && s[i+1] == '|': + cur.WriteByte('|') + i++ + case ch == '`': + inCode = !inCode + cur.WriteByte(ch) + case ch == '|' && !inCode: + cells = append(cells, strings.TrimSpace(cur.String())) + cur.Reset() + default: + cur.WriteByte(ch) + } + } + return append(cells, strings.TrimSpace(cur.String())) +} + +func (m markdown) list(b *strings.Builder, lines []string, i int) int { + first := listRe.FindStringSubmatch(lines[i]) + indent := len(first[1]) + ordered := first[2][0] >= '0' && first[2][0] <= '9' + tag := "ul" + if ordered { + tag = "ol" + } + b.WriteString("<" + tag + ">\n") + for i < len(lines) { + g := listRe.FindStringSubmatch(lines[i]) + if g == nil || len(g[1]) != indent { + break + } + body := []string{g[3]} + i++ + for i < len(lines) { + l := lines[i] + if strings.TrimSpace(l) == "" { + if i+1 < len(lines) && leading(lines[i+1]) > indent { + body = append(body, "") + i++ + continue + } + break + } + if leading(l) <= indent { + break + } + body = append(body, strings.TrimLeft(l, " \t")) + i++ + } + b.WriteString("
  • ") + if len(body) == 1 || !hasBlock(body) { + b.WriteString(m.inline(strings.Join(body, "\n"))) + } else { + var inner strings.Builder + m.blocks(&inner, body) + b.WriteString(inner.String()) + } + b.WriteString("
  • \n") + if i < len(lines) && strings.TrimSpace(lines[i]) == "" && i+1 < len(lines) { + if g := listRe.FindStringSubmatch(lines[i+1]); g != nil && len(g[1]) == indent { + i++ + } + } + } + b.WriteString("\n") + return i +} + +func hasBlock(body []string) bool { + for i := range body { + if i > 0 && (strings.TrimSpace(body[i]) == "" || startsBlock(body, i)) { + return true + } + } + return false +} + +func leading(s string) int { + return len(s) - len(strings.TrimLeft(s, " \t")) +} + +// inline renders code spans, links and bold; everything else is escaped text. +func (m markdown) inline(s string) string { + var b strings.Builder + for { + start := strings.IndexByte(s, '`') + if start < 0 { + break + } + ticks := 1 + for start+ticks < len(s) && s[start+ticks] == '`' { + ticks++ + } + delim := strings.Repeat("`", ticks) + end := strings.Index(s[start+ticks:], delim) + if end < 0 { + break + } + b.WriteString(m.text(s[:start])) + code := strings.TrimSpace(s[start+ticks : start+ticks+end]) + b.WriteString("" + html.EscapeString(code) + "") + s = s[start+ticks+end+ticks:] + } + b.WriteString(m.text(s)) + return b.String() +} + +func (m markdown) text(s string) string { + s = html.EscapeString(s) + s = linkRe.ReplaceAllStringFunc(s, func(x string) string { + g := linkRe.FindStringSubmatch(x) + href := html.UnescapeString(g[2]) + if m.link != nil { + href = m.link(href) + } + attr := "" + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + attr = ` rel="noopener"` + } + return `" + g[1] + "" + }) + s = boldRe.ReplaceAllString(s, "$1") + return strings.ReplaceAll(s, "\n", " ") +} + +func slug(s string) string { + s = strings.Trim(slugRe.ReplaceAllString(strings.ToLower(s), "-"), "-") + if s == "" { + return "section" + } + return s +} diff --git a/tools/site/render.go b/tools/site/render.go new file mode 100644 index 0000000..726e47c --- /dev/null +++ b/tools/site/render.go @@ -0,0 +1,277 @@ +package main + +import ( + "encoding/json" + "fmt" + "html" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" +) + +type site struct { + root, out string + comps []*component + cats []*category +} + +// extraPages are repo documents published as their own pages. +var extraPages = []struct{ Slug, Src, Title string }{ + {"readme", "README.md", "README"}, + {"compat", "docs/compat.md", "兼容与平台依赖"}, + {"upstream", "docs/upstream.md", "上游归并"}, +} + +var compLinkRe = regexp.MustCompile(`^(?:\.\./|docs/components/|\./)?(\d{3}-[a-z0-9-]+)\.md(#.*)?$`) + +// link rewrites repository-relative hrefs to site routes or GitHub URLs. +func (s *site) link(from string) func(string) string { + return func(href string) string { + if strings.Contains(href, "://") || strings.HasPrefix(href, "#") || strings.HasPrefix(href, "mailto:") { + return href + } + if g := compLinkRe.FindStringSubmatch(href); g != nil { + return "/c/" + g[1] + "/" + g[2] + } + resolved := path.Clean(path.Join(path.Dir(from), href)) + for _, p := range extraPages { + if resolved == p.Src { + return "/" + p.Slug + "/" + } + } + if resolved == "docs/components" { + return "/#components" + } + return repoURL + "/blob/main/" + resolved + } +} + +func (s *site) write() error { + if err := copyDir(filepath.Join(s.root, "site"), s.out, func(name string) bool { + return strings.HasSuffix(name, ".css") || strings.HasSuffix(name, ".js") + }); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(s.out, "img"), 0o755); err != nil { + return err + } + if err := s.writeFile("index.html", s.index()); err != nil { + return err + } + if err := s.writeFile("404.html", s.page("页面不存在", "", `

    页面不存在

    找不到这个地址。返回组件目录。

    `)); err != nil { + return err + } + for _, p := range extraPages { + raw, err := os.ReadFile(filepath.Join(s.root, p.Src)) + if err != nil { + return err + } + body := `
    ` + markdown{link: s.link(p.Src)}.render(string(raw)) + `
    ` + if err := s.writeFile(p.Slug+"/index.html", s.page(p.Title, "", body)); err != nil { + return err + } + } + for _, c := range s.comps { + for _, src := range []string{c.Light, c.Dark} { + if src != "" { + if err := copyFile(src, filepath.Join(s.out, "img", filepath.Base(src))); err != nil { + return err + } + } + } + if err := s.writeFile("c/"+c.Slug+"/index.html", s.componentPage(c)); err != nil { + return err + } + } + return nil +} + +func (s *site) writeFile(rel, content string) error { + p := filepath.Join(s.out, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + return os.WriteFile(p, []byte(content), 0o644) +} + +const themeBoot = `` + +func (s *site) page(title, current, main string) string { + e := html.EscapeString + var b strings.Builder + b.WriteString(``) + b.WriteString(`` + e(title) + ` · MujicaUI`) + b.WriteString(`` + themeBoot + ``) + b.WriteString(`
    MujicaUI`) + b.WriteString(`
    `) + if current != "" { + b.WriteString(`
    `) + b.WriteString(s.sidebar(current)) + b.WriteString(`
    ` + main + `
    `) + } else { + b.WriteString(`
    ` + main + `
    `) + } + b.WriteString(``) + b.WriteString(``) + return b.String() +} + +func (s *site) sidebar(current string) string { + e := html.EscapeString + var b strings.Builder + b.WriteString(``) + return b.String() +} + +func (s *site) componentPage(c *component) string { + e := html.EscapeString + var b strings.Builder + fmt.Fprintf(&b, `

    %s · #%s

    `, e(slug(c.Category)), e(c.Category), c.ID) + b.WriteString(`
    `) + for _, v := range []struct{ src, mode, label string }{{c.Light, "light", "浅色"}, {c.Dark, "dark", "深色"}} { + fmt.Fprintf(&b, `
    `, v.mode) + if v.src != "" { + fmt.Fprintf(&b, `%s %s主题渲染快照`, e(filepath.Base(v.src)), e(c.Name), v.label) + } else { + b.WriteString(`
    暂无快照
    `) + } + fmt.Fprintf(&b, `
    %s · 渲染快照
    `, v.label) + } + b.WriteString(`
    `) + b.WriteString(`
    ` + markdown{link: s.link("docs/components/" + c.Slug + ".md")}.render(c.Doc) + `
    `) + b.WriteString(s.pager(c)) + return s.page(c.Name+" "+c.ZhName, c.Slug, b.String()) +} + +func (s *site) pager(c *component) string { + var prev, next *component + for i, x := range s.comps { + if x == c { + if i > 0 { + prev = s.comps[i-1] + } + if i+1 < len(s.comps) { + next = s.comps[i+1] + } + } + } + var b strings.Builder + b.WriteString(``) + return b.String() +} + +type searchItem struct { + ID string `json:"id"` + Slug string `json:"s"` + Name string `json:"n"` + Zh string `json:"z"` + Cat string `json:"c"` +} + +func (s *site) index() string { + e := html.EscapeString + var b strings.Builder + shots := 0 + for _, c := range s.comps { + if c.Light != "" || c.Dark != "" { + shots++ + } + } + b.WriteString(`

    Court-gothic components for MyGo

    MujicaUI

    `) + b.WriteString(``) + fmt.Fprintf(&b, `

    宫廷哥特风格的 Go 桌面组件库:象牙白与炭黑底色,酒红主色,旧金细线,衬线标题。共 %d 个组件,%d 个分类。

    `, len(s.comps), len(s.cats)) + b.WriteString(`

    说明:这些组件是原生 Go(MyGo)控件,在桌面窗口里由 GPU 自绘。本站展示的是渲染快照图片,浏览器不运行任何组件代码。
    `) + b.WriteString(`Note: the components are native Go (MyGo) widgets. The images are rendered snapshots; your browser runs no component code.

    `) + b.WriteString(`

    开始使用GitHub 源码

    `) + + b.WriteString(``) + + b.WriteString(`

    分类

    `) + for _, cat := range s.cats { + fmt.Fprintf(&b, `%s%d`, e(slug(cat.Name)), e(cat.Name), len(cat.Items)) + } + b.WriteString(`
    `) + for _, cat := range s.cats { + fmt.Fprintf(&b, `

    %s %d

    `) + } + items := make([]searchItem, 0, len(s.comps)) + for _, c := range s.comps { + items = append(items, searchItem{c.ID, c.Slug, c.Name, c.ZhName, c.Category}) + } + data, err := json.Marshal(items) + if err != nil { + panic(err) // plain strings always marshal + } + b.WriteString(``) + return s.page("组件", "", b.String()) +} + +func copyDir(src, dst string, keep func(string) bool) error { + files, err := os.ReadDir(src) + if err != nil { + return err + } + for _, f := range files { + if f.IsDir() || !keep(f.Name()) { + continue + } + if err := copyFile(filepath.Join(src, f.Name()), filepath.Join(dst, f.Name())); err != nil { + return err + } + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + out, err := os.Create(dst) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} diff --git a/tools/site/site_test.go b/tools/site/site_test.go new file mode 100644 index 0000000..a0f8739 --- /dev/null +++ b/tools/site/site_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFixture(t *testing.T, root, rel, content string) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestBuildFixture(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, "docs/components/001-oauth-buttons.md", "# 001 OAuthButtons 第三方登录按钮\n\n## 参数\n\n"+ + "| 字段 | 类型 |\n| --- | --- |\n| `Value` | `*T` |\n| `Pipe` | `a \\| b` |\n\n"+ + "- 一项,见 [兼容](../compat.md)\n- 二项\n\n```go\nx := a < b\n```\n") + writeFixture(t, root, "examples/gallery/pages_fixture.go", "package main\n\nconst fixCategory = \"Fixtures\"\n\n"+ + "var fixPages = []page{\n\t{ID: \"001\", Name: \"OAuthButtons\", ZhName: \"第三方登录按钮\", Category: fixCategory, Code: fixCode[\"x\"]},\n}\n") + writeFixture(t, root, "pkg/auth/testdata/snapshots/darwin/oauth_buttons-light.png", "png") + writeFixture(t, root, "pkg/auth/testdata/snapshots/darwin/oauth_buttons-dark.png", "png") + writeFixture(t, root, "README.md", "# MujicaUI\n\n见 [兼容](docs/compat.md)。\n") + writeFixture(t, root, "docs/compat.md", "# 兼容\n") + writeFixture(t, root, "docs/upstream.md", "# 上游\n") + writeFixture(t, root, "site/style.css", "body{}") + writeFixture(t, root, "site/app.js", "") + + out := filepath.Join(root, "site", "dist") + n, err := build(root, out) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("pages = %d, want 1", n) + } + raw, err := os.ReadFile(filepath.Join(out, "c", "001-oauth-buttons", "index.html")) + if err != nil { + t.Fatal(err) + } + page := string(raw) + for _, want := range []string{ + "字段类型", + "Value*T", + "a | b", + `兼容`, + `x := a < b`, + `class="copy"`, + `
  • OAuthButtons`, + `src="/img/oauth_buttons-light.png"`, + `src="/img/oauth_buttons-dark.png"`, + "Fixtures", + } { + if !strings.Contains(page, want) { + t.Errorf("component page missing %q", want) + } + } + for _, rel := range []string{"index.html", "404.html", "readme/index.html", "compat/index.html", "upstream/index.html", "img/oauth_buttons-dark.png", "style.css", "app.js"} { + if _, err := os.Stat(filepath.Join(out, rel)); err != nil { + t.Errorf("missing %s: %v", rel, err) + } + } + index, err := os.ReadFile(filepath.Join(out, "index.html")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(index), `"n":"OAuthButtons","z":"第三方登录按钮"`) { + t.Error("index search data missing the component") + } +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..2df81c1 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + // Static docs site built by `go run ./tools/site`; no Worker script. + "name": "mujicaui", + "compatibility_date": "2026-10-01", + "assets": { + "directory": "./site/dist", + "not_found_handling": "404-page" + } +}