页面不存在
找不到这个地址。返回组件目录。
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("
\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")) + "| " + m.inline(c) + " | ") + } + b.WriteString("
|---|
| " + m.inline(c) + " | ") + } + 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("页面不存在", "", `找不到这个地址。返回组件目录。
%s · #%s
`, e(slug(c.Category)), e(c.Category), c.ID) + b.WriteString(`Court-gothic components for MyGo
宫廷哥特风格的 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.
Value*Ta | bx := a < b`,
+ `class="copy"`,
+ `