Static docs site generator and wrangler config

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:59:26 -04:00
1 parent 7ef6ed9865
commit 806451800c
8 files changed
+1163

No files matched your search

+1
View File
@@ -6,3 +6,4 @@ PRD.md
docs/prd/
TASKS.md
docs/acceptance.md
/site/dist/
+81
View File
@@ -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();
})();
+206
View File
@@ -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; }
}
+243
View File
@@ -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
}
+269
View File
@@ -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("<h1>" + text + "</h1>\n")
} else {
id := slug(g[2])
b.WriteString("<h" + strconv.Itoa(n) + ` id="` + id + `">` + text + "</h" + strconv.Itoa(n) + ">\n")
}
i++
case trim == "---" || trim == "***":
b.WriteString("<hr>\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("<blockquote>\n")
m.blocks(b, inner)
b.WriteString("</blockquote>\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("<p>" + m.inline(strings.Join(para, "\n")) + "</p>\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(`<div class="code"><button type="button" class="copy" aria-label="复制代码">复制</button><pre><code` +
class + ">" + html.EscapeString(strings.Join(code, "\n")) + "</code></pre></div>\n")
return i + 1
}
func (m markdown) table(b *strings.Builder, lines []string, i int) int {
head := splitRow(lines[i])
b.WriteString("<div class=\"table\"><table>\n<thead><tr>")
for _, c := range head {
b.WriteString("<th>" + m.inline(c) + "</th>")
}
b.WriteString("</tr></thead>\n<tbody>\n")
i += 2
for i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "|") {
b.WriteString("<tr>")
for _, c := range splitRow(lines[i]) {
b.WriteString("<td>" + m.inline(c) + "</td>")
}
b.WriteString("</tr>\n")
i++
}
b.WriteString("</tbody></table></div>\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("<li>")
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("</li>\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("</" + tag + ">\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("<code>" + html.EscapeString(code) + "</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 `<a href="` + html.EscapeString(href) + `"` + attr + ">" + g[1] + "</a>"
})
s = boldRe.ReplaceAllString(s, "<strong>$1</strong>")
return strings.ReplaceAll(s, "\n", " ")
}
func slug(s string) string {
s = strings.Trim(slugRe.ReplaceAllString(strings.ToLower(s), "-"), "-")
if s == "" {
return "section"
}
return s
}
+277
View File
@@ -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("页面不存在", "", `<article class="doc"><h1>页面不存在</h1><p>找不到这个地址。<a href="/">返回组件目录</a>。</p></article>`)); err != nil {
return err
}
for _, p := range extraPages {
raw, err := os.ReadFile(filepath.Join(s.root, p.Src))
if err != nil {
return err
}
body := `<article class="doc">` + markdown{link: s.link(p.Src)}.render(string(raw)) + `</article>`
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 = `<script>try{var t=localStorage.getItem("mujica-theme");if(t)document.documentElement.dataset.theme=t}catch(e){}</script>`
func (s *site) page(title, current, main string) string {
e := html.EscapeString
var b strings.Builder
b.WriteString(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`)
b.WriteString(`<meta name="color-scheme" content="light dark"><title>` + e(title) + ` · MujicaUI</title>`)
b.WriteString(`<link rel="stylesheet" href="/style.css">` + themeBoot + `</head><body>`)
b.WriteString(`<a class="skip" href="#main">跳到正文</a><header class="top"><a class="brand" href="/"><span class="lozenge" aria-hidden="true"></span>MujicaUI</a><nav class="toplinks">`)
b.WriteString(`<a href="/#components">组件</a><a href="/readme/">README</a><a href="/compat/">兼容</a><a href="/upstream/">上游</a><a href="` + repoURL + `" rel="noopener">GitHub</a></nav>`)
b.WriteString(`<button type="button" class="theme" aria-label="切换浅色或深色主题">主题</button></header>`)
if current != "" {
b.WriteString(`<div class="layout"><button type="button" class="navtoggle" aria-expanded="false" aria-controls="sidenav">组件目录</button>`)
b.WriteString(s.sidebar(current))
b.WriteString(`<main id="main">` + main + `</main></div>`)
} else {
b.WriteString(`<main id="main" class="solo">` + main + `</main>`)
}
b.WriteString(`<footer class="foot"><div class="rule" aria-hidden="true"><span></span></div><p>MujicaUI 的组件是原生 Go(MyGo)控件,在桌面窗口里由 GPU 自绘。本站图片是这些控件渲染出的快照,浏览器不运行任何组件代码。</p>`)
b.WriteString(`<p>MujicaUI components are native Go (MyGo) widgets. The images here are rendered snapshots; this site runs no component code. Source: <a href="` + repoURL + `" rel="noopener">github.com/ZacharyZhang-NY/MujicaUI</a></p></footer>`)
b.WriteString(`<script src="/app.js"></script></body></html>`)
return b.String()
}
func (s *site) sidebar(current string) string {
e := html.EscapeString
var b strings.Builder
b.WriteString(`<nav class="side" id="sidenav" aria-label="组件目录">`)
for _, cat := range s.cats {
open := ""
for _, c := range cat.Items {
if c.Slug == current {
open = " open"
}
}
fmt.Fprintf(&b, `<details%s><summary>%s <span class="count">%d</span></summary><ul>`, open, e(cat.Name), len(cat.Items))
for _, c := range cat.Items {
aria := ""
if c.Slug == current {
aria = ` aria-current="page"`
}
fmt.Fprintf(&b, `<li><a href="/c/%s/"%s>%s <span class="zh">%s</span></a></li>`, c.Slug, aria, e(c.Name), e(c.ZhName))
}
b.WriteString(`</ul></details>`)
}
b.WriteString(`</nav>`)
return b.String()
}
func (s *site) componentPage(c *component) string {
e := html.EscapeString
var b strings.Builder
fmt.Fprintf(&b, `<p class="crumb"><a href="/#%s">%s</a> · #%s</p>`, e(slug(c.Category)), e(c.Category), c.ID)
b.WriteString(`<section class="snaps" aria-label="渲染快照">`)
for _, v := range []struct{ src, mode, label string }{{c.Light, "light", "浅色"}, {c.Dark, "dark", "深色"}} {
fmt.Fprintf(&b, `<figure class="snap snap-%s">`, v.mode)
if v.src != "" {
fmt.Fprintf(&b, `<img src="/img/%s" alt="%s %s主题渲染快照" loading="lazy">`, e(filepath.Base(v.src)), e(c.Name), v.label)
} else {
b.WriteString(`<div class="nosnap">暂无快照</div>`)
}
fmt.Fprintf(&b, `<figcaption>%s · 渲染快照</figcaption></figure>`, v.label)
}
b.WriteString(`</section>`)
b.WriteString(`<article class="doc">` + markdown{link: s.link("docs/components/" + c.Slug + ".md")}.render(c.Doc) + `</article>`)
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(`<nav class="pager" aria-label="相邻组件">`)
if prev != nil {
fmt.Fprintf(&b, `<a href="/c/%s/" rel="prev">← %s</a>`, prev.Slug, html.EscapeString(prev.Name))
}
if next != nil {
fmt.Fprintf(&b, `<a class="next" href="/c/%s/" rel="next">%s →</a>`, next.Slug, html.EscapeString(next.Name))
}
b.WriteString(`</nav>`)
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(`<section class="hero"><p class="eyebrow">Court-gothic components for MyGo</p><h1>MujicaUI</h1>`)
b.WriteString(`<div class="rule" aria-hidden="true"><span></span></div>`)
fmt.Fprintf(&b, `<p class="lede">宫廷哥特风格的 Go 桌面组件库:象牙白与炭黑底色,酒红主色,旧金细线,衬线标题。共 %d 个组件,%d 个分类。</p>`, len(s.comps), len(s.cats))
b.WriteString(`<p class="honest"><strong>说明:</strong>这些组件是原生 Go(MyGo)控件,在桌面窗口里由 GPU 自绘。本站展示的是渲染快照图片,浏览器不运行任何组件代码。<br>`)
b.WriteString(`<strong>Note:</strong> the components are native Go (MyGo) widgets. The images are rendered snapshots; your browser runs no component code.</p>`)
b.WriteString(`<p class="ctas"><a class="btn" href="/readme/">开始使用</a><a class="btn ghost" href="` + repoURL + `" rel="noopener">GitHub 源码</a></p></section>`)
b.WriteString(`<section class="search" aria-label="搜索组件"><label for="q">搜索组件</label><input id="q" type="search" autocomplete="off" placeholder="名称、中文名或编号,如 Button、按钮、042"><p id="qcount" class="qcount" aria-live="polite"></p><ul id="results" class="results"></ul></section>`)
b.WriteString(`<section id="components" class="cats"><h2>分类</h2><div class="grid">`)
for _, cat := range s.cats {
fmt.Fprintf(&b, `<a class="cat" href="#%s"><span class="cname">%s</span><span class="count">%d</span></a>`, e(slug(cat.Name)), e(cat.Name), len(cat.Items))
}
b.WriteString(`</div></section>`)
for _, cat := range s.cats {
fmt.Fprintf(&b, `<section class="catlist" id="%s"><h2>%s <span class="count">%d</span></h2><ul class="cards">`, e(slug(cat.Name)), e(cat.Name), len(cat.Items))
for _, c := range cat.Items {
fmt.Fprintf(&b, `<li><a href="/c/%s/"><span class="num">%s</span><span class="n">%s</span><span class="zh">%s</span></a></li>`, c.Slug, c.ID, e(c.Name), e(c.ZhName))
}
b.WriteString(`</ul></section>`)
}
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(`<script id="search-data" type="application/json">` + string(data) + `</script>`)
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()
}
+77
View File
@@ -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{
"<th>字段</th><th>类型</th>",
"<td><code>Value</code></td><td><code>*T</code></td>",
"<td><code>a | b</code></td>",
`<a href="/compat/">兼容</a>`,
`<code class="lang-go">x := a &lt; b</code>`,
`class="copy"`,
`<li><a href="/c/001-oauth-buttons/" aria-current="page">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")
}
}
+9
View File
@@ -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"
}
}