From ff50f9b35a66b9369949749ee4c2ec281b5a2bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 17 Oct 2026 01:55:25 -0400 Subject: [PATCH] Add gallery core and command: routes, sidebar, search, code panel, browser hook Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 5 +- cmd/gallery/hook.go | 7 ++ cmd/gallery/hook_js.go | 30 +++++++ cmd/gallery/main.go | 28 ++++++ gallery/app.go | 109 +++++++++++++++++++++++ gallery/chrome.go | 106 +++++++++++++++++++++++ gallery/demos.go | 3 + gallery/gallery_test.go | 185 ++++++++++++++++++++++++++++++++++++++++ gallery/pages.go | 119 ++++++++++++++++++++++++++ gallery/registry.go | 160 ++++++++++++++++++++++++++++++++++ 10 files changed, 750 insertions(+), 2 deletions(-) create mode 100644 cmd/gallery/hook.go create mode 100644 cmd/gallery/hook_js.go create mode 100644 cmd/gallery/main.go create mode 100644 gallery/app.go create mode 100644 gallery/chrome.go create mode 100644 gallery/demos.go create mode 100644 gallery/gallery_test.go create mode 100644 gallery/pages.go create mode 100644 gallery/registry.go diff --git a/AGENTS.md b/AGENTS.md index a3018df..7db1352 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,8 +70,9 @@ health, home, marketing, content, desktop, geo · 6 gallery · 7 cmd/gallery ## Gallery entry -Each component adds one `Entry` in `gallery/demos_*.go`: ID, Name, Category, Summary, Demo. The demo reports -every event through `log`. The code panel shows the demo function's own source. +Each component adds one `Entry` in `gallery/demos_*.go`: ID, Name, Category, Summary, Demo. Each file's list +joins `entries` in `gallery/demos.go`. The demo is a top-level function and reports every event through `log`. The +code panel shows the demo function's own source, cut from the embedded `demos*.go`. ## Tests diff --git a/cmd/gallery/hook.go b/cmd/gallery/hook.go new file mode 100644 index 0000000..00dd931 --- /dev/null +++ b/cmd/gallery/hook.go @@ -0,0 +1,7 @@ +//go:build !(js && wasm) + +package main + +import "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/gallery" + +func hook(*gallery.App) {} diff --git a/cmd/gallery/hook_js.go b/cmd/gallery/hook_js.go new file mode 100644 index 0000000..4dd7464 --- /dev/null +++ b/cmd/gallery/hook_js.go @@ -0,0 +1,30 @@ +//go:build js && wasm + +package main + +import ( + "syscall/js" + + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/gallery" + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound" +) + +// hook exports window.__crychic for Playwright. +func hook(app *gallery.App) { + obj := js.Global().Get("Object").New() + js.Global().Get("Object").Call("defineProperty", obj, "ready", map[string]any{ + "get": js.FuncOf(func(js.Value, []js.Value) any { return app.Snapshot().Built }), + }) + obj.Set("page", js.FuncOf(func(js.Value, []js.Value) any { return app.Snapshot().Page })) + obj.Set("stats", js.FuncOf(func(js.Value, []js.Value) any { + return map[string]any{"components": len(gallery.Entries()), "categories": len(gallery.Categories)} + })) + obj.Set("recent", js.FuncOf(func(js.Value, []js.Value) any { + var out []any + for _, c := range sound.Recent() { + out = append(out, c.String()) + } + return out + })) + js.Global().Set("__crychic", obj) +} diff --git a/cmd/gallery/main.go b/cmd/gallery/main.go new file mode 100644 index 0000000..4a8994a --- /dev/null +++ b/cmd/gallery/main.go @@ -0,0 +1,28 @@ +// Command gallery opens the CrychicUI gallery on desktops and browsers. +package main + +import ( + "log" + + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/gallery" + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +func main() { + app := gallery.New("/") + hook(app) + mygo.App.WhenReady(func() { + mygo.NewWindow(mygo.WindowOptions{ + Title: "CrychicUI", + Width: 1280, + Height: 820, + MinWidth: 720, + MinHeight: 480, + Content: ui.View(app.View), + }) + }) + if err := mygo.App.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/gallery/app.go b/gallery/app.go new file mode 100644 index 0000000..7b1fdd1 --- /dev/null +++ b/gallery/app.go @@ -0,0 +1,109 @@ +package gallery + +import ( + "log/slog" + "strings" + "sync" + + "github.com/egoist/mygo/ui" + "github.com/egoist/mygo/ui/editor" +) + +const keepEvents = 6 + +// App is the gallery that desktop and browser share. +type App struct { + router *ui.Router + query string + mode int + events []string + codes map[string]*editor.State + + mu sync.Mutex + snap Snapshot +} + +// Snapshot is what the browser hook reads between frames. +type Snapshot struct { + Page string + Built bool +} + +// New opens the gallery at path. +func New(path string) *App { + return &App{router: ui.NewRouter(path), codes: map[string]*editor.State{}} +} + +// Snapshot returns the state of the last built frame. +func (a *App) Snapshot() Snapshot { + a.mu.Lock() + defer a.mu.Unlock() + return a.snap +} + +func (a *App) View(c *ui.Context) { + switch a.mode { + case 1: + c.SetTheme(ui.LightTheme()) + case 2: + c.SetTheme(ui.DarkTheme()) + } + ui.Column(c).Fill().Children(func() { + a.topBar(c) + ui.Row(c).Grow(1).AlignItems(ui.Stretch).Children(func() { + a.sidebar(c) + ui.Column(c).Grow(1).Children(func() { + if strings.TrimSpace(a.query) != "" { + a.results(c) + return + } + a.pages(c) + }) + }) + a.statusBar(c) + }) + a.mu.Lock() + a.snap = Snapshot{Page: a.router.Location(), Built: true} + a.mu.Unlock() +} + +// Go opens path and clears the search. +func (a *App) Go(path string) { + a.query = "" + if path != a.router.Location() { + slog.Debug("crychic: gallery go", "path", path) + a.router.Push(path) + } +} + +// logger keeps a demo's events for the status strip. +func (a *App) logger(e Entry) func(string) { + return func(ev string) { + // The strip shows the event; the log never does. + slog.Debug("crychic: gallery event", "entry", e.Name) + a.events = append(a.events, e.Name+": "+ev) + a.events = a.events[max(0, len(a.events)-keepEvents):] + } +} + +func (a *App) code(e Entry) *editor.State { + st, ok := a.codes[e.Path()] + if !ok { + buf := editor.NewBuffer(e.Code()) + buf.SetLanguage(editor.LanguageFor("demo.go")) + st = &editor.State{Buffer: buf, ReadOnly: true} + a.codes[e.Path()] = st + } + return st +} + +func matches(q string) []Entry { + q = strings.ToLower(strings.TrimSpace(q)) + var out []Entry + for _, e := range entries { + if strings.Contains(strings.ToLower(e.Name+" "+e.Category+" "+e.Summary), q) { + out = append(out, e) + } + } + return out +} diff --git a/gallery/chrome.go b/gallery/chrome.go new file mode 100644 index 0000000..354a733 --- /dev/null +++ b/gallery/chrome.go @@ -0,0 +1,106 @@ +package gallery + +import ( + "fmt" + "log/slog" + "strings" + + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit" + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound" + "github.com/egoist/mygo/ui" +) + +var modes = []string{"System", "Light", "Dark"} + +func (a *App) topBar(c *ui.Context) { + t := c.Theme() + ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(4)).AlignItems(ui.Center). + Border(1, t.Border).Children(func() { + ui.Text(c, "CrychicUI").Bold().FontSize(t.Rem(1.15)) + ui.Spacer(c) + ui.SearchField(c, &a.query).Label("Search components").Width(240) + if ui.Segmented(c, &a.mode, modes...).Label("Appearance").Changed() { + slog.Debug("crychic: gallery appearance", "mode", modes[a.mode]) + } + on, glyph := sound.Enabled(), "volume-x" + if on { + glyph = "volume-2" + } + ui.Icon(c, icon.Must(glyph)).Size(16, 16) + if ui.Switch(c, &on).Label("Sound").Changed() { + sound.SetEnabled(on) + slog.Debug("crychic: gallery sound", "on", on) + } + }) +} + +func (a *App) sidebar(c *ui.Context) { + page := "/" + if parts := strings.Split(a.router.Path(), "/"); len(parts) > 2 && parts[1] == "c" { + page = "/c/" + parts[2] + } + if ui.Sidebar(c, &page, func() { + ui.SidebarItem(c, "/", icon.Must("house"), "Home") + ui.SidebarSection(c, "Components", nil, func() { + for _, k := range Categories { + ui.SidebarItem(c, "/c/"+k.Key, icon.Must(k.Icon), k.Name).Children(func() { + ui.Badge(c, fmt.Sprint(len(inCategory(k.Key)))) + }) + } + }) + }).Width(220).Changed() { + a.Go(page) + } +} + +func (a *App) statusBar(c *ui.Context) { + t := c.Theme() + ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(1.5), t.Space(4)).Border(1, t.Border). + Children(func() { + kit.Muted(c, "Log") + ui.Text(c, strings.Join(a.events, " · ")).MaxLines(1).Grow(1).Shrink(1) + kit.Muted(c, "Cues") + var cues []string + for _, q := range recentCues(8) { + cues = append(cues, q.String()) + } + ui.Text(c, strings.Join(cues, " ")).MaxLines(1) + }) +} + +func recentCues(n int) []sound.Cue { + r := sound.Recent() + return r[max(0, len(r)-n):] +} + +func (a *App) results(c *ui.Context) { + t := c.Theme() + found := matches(a.query) + ui.Scroll(c).Grow(1).Children(func() { + ui.Column(c).FillWidth().Gap(t.Space(1)).Padding(t.Space(6)).Children(func() { + ui.Text(c, fmt.Sprintf("%d results", len(found))).Bold() + for _, e := range found { + a.entryRow(c, e) + } + }) + }) +} + +func (a *App) entryRow(c *ui.Context, e Entry) { + t := c.Theme() + row := ui.Row(c).Key(e.Path()).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(3)).Radius(t.Radius). + Label(e.Name) + bg := t.Background + if row.Hovered() { + bg = t.SurfaceHover + } + row.Background(row.AnimateColor("bg", bg, ui.Snappy)).Children(func() { + kit.Muted(c, fmt.Sprintf("%04d", e.ID)).Width(t.Rem(3)) + ui.Text(c, e.Name).Bold().Width(t.Rem(12)) + kit.Muted(c, e.Summary).MaxLines(1).Grow(1).Shrink(1) + }) + if row.Clicked() { + a.Go(e.Path()) + } +} diff --git a/gallery/demos.go b/gallery/demos.go new file mode 100644 index 0000000..6594623 --- /dev/null +++ b/gallery/demos.go @@ -0,0 +1,3 @@ +package gallery + +var entries []Entry diff --git a/gallery/gallery_test.go b/gallery/gallery_test.go new file mode 100644 index 0000000..8b0437c --- /dev/null +++ b/gallery/gallery_test.go @@ -0,0 +1,185 @@ +package gallery + +import ( + "strings" + "testing" + "testing/fstest" + "time" + + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound" + "github.com/egoist/mygo/ui" +) + +func pingDemo(c *ui.Context, log func(string)) { + if ui.Button(c, "Ping").Clicked() { + log("pinged") + } +} + +var pingEntry = Entry{ID: 1, Name: "PingBox", Category: "layout", Summary: "Answers a ping.", Demo: pingDemo} + +// withEntry registers pingEntry for one test. +func withEntry(t *testing.T) { + t.Helper() + old, oldCode := entries, codes["pingDemo"] + entries, codes["pingDemo"] = []Entry{pingEntry}, "func pingDemo() {}\n" + t.Cleanup(func() { entries, codes["pingDemo"] = old, oldCode }) +} + +func open(path string) (*App, *ui.Tester) { + a := New(path) + return a, ui.NewTester(a.View, 1280, 820) +} + +func TestHome(t *testing.T) { + withEntry(t) + a, tt := open("/") + for _, s := range []string{"Components", "Categories", "Sound cues", "Layout", "Geo", "1 components"} { + if !tt.HasText(s) { + t.Errorf("home lacks %q", s) + } + } + if s := a.Snapshot(); !s.Built || s.Page != "/" { + t.Errorf("snapshot %+v", s) + } + if err := tt.Click("Layout"); err != nil { + t.Fatal(err) + } + tt.Advance(time.Second) + if a.Snapshot().Page != "/c/layout" || !tt.HasText("PingBox") { + t.Errorf("page %q, texts %q", a.Snapshot().Page, tt.Texts()) + } +} + +func TestComponentPage(t *testing.T) { + withEntry(t) + a, tt := open(pingEntry.Path()) + for _, s := range []string{"PingBox", "Answers a ping.", "0001 · layout", "Code"} { + if !tt.HasText(s) { + t.Errorf("page lacks %q", s) + } + } + if got := a.codes[pingEntry.Path()].Buffer.String(); got != codes["pingDemo"] { + t.Errorf("code = %q", got) + } + if err := tt.Click("Ping"); err != nil { + t.Fatal(err) + } + if !tt.HasText("PingBox: pinged") { + t.Errorf("log strip lacks the event: %q", tt.Texts()) + } +} + +func TestNotFound(t *testing.T) { + withEntry(t) + for _, p := range []string{"/c/nope", "/c/layout/nope", "/x"} { + if _, tt := open(p); !tt.HasText("Not found") { + t.Errorf("%s: no Not found", p) + } + } +} + +func TestSearch(t *testing.T) { + withEntry(t) + a, tt := open("/") + a.query = "ping" + tt.Frame() + if !tt.HasText("1 results") { + t.Fatalf("texts %q", tt.Texts()) + } + if err := tt.Click("PingBox"); err != nil { + t.Fatal(err) + } + tt.Advance(time.Second) + if a.query != "" || a.Snapshot().Page != pingEntry.Path() { + t.Errorf("query %q, page %q", a.query, a.Snapshot().Page) + } +} + +func TestAppearance(t *testing.T) { + _, tt := open("/") + px := func() ui.Color { p := tt.Image().RGBAAt(1200, 400); return ui.Color{R: p.R, G: p.G, B: p.B, A: 255} } + if err := tt.Click("Dark"); err != nil { + t.Fatal(err) + } + tt.Advance(time.Second) + if got, want := px(), ui.DarkTheme().Background; got.R != want.R || got.G != want.G || got.B != want.B { + t.Errorf("dark background %v, want %v", got, want) + } + if err := tt.Click("Light"); err != nil { + t.Fatal(err) + } + tt.Advance(time.Second) + if got, want := px(), ui.LightTheme().Background; got.R != want.R || got.G != want.G || got.B != want.B { + t.Errorf("light background %v, want %v", got, want) + } +} + +func TestSoundSwitch(t *testing.T) { + t.Cleanup(func() { sound.SetEnabled(false) }) + _, tt := open("/") + if err := tt.Click("Sound"); err != nil { + t.Fatal(err) + } + if !sound.Enabled() { + t.Fatal("switch did not enable sound") + } + if err := tt.Click("Sound"); err != nil { + t.Fatal(err) + } + if sound.Enabled() { + t.Error("switch did not mute sound") + } +} + +func TestCueStrip(t *testing.T) { + sound.Play(sound.Success) + if _, tt := open("/"); !strings.Contains(strings.Join(tt.Texts(), " "), "success") { + t.Errorf("cue strip lacks success: %q", tt.Texts()) + } +} + +func TestSources(t *testing.T) { + raw := "func demoR() string {\n\treturn `a\n}\nb`\n}" + got := sources(fstest.MapFS{"demos_x.go": {Data: []byte( + "package gallery\r\n\r\n// A doc.\r\nfunc demoA(c int) {\r\n\tx()\r\n}\r\n\r\nfunc demoB() { x() }\r\n\r\n" + + "func (e Entry) M() {\r\n}\r\n\r\n" + strings.ReplaceAll(raw, "\n", "\r\n") + "\r\n")}}) + want := map[string]string{"demoA": "func demoA(c int) {\n\tx()\n}", "demoB": "func demoB() { x() }", "demoR": raw} + if len(got) != len(want) { + t.Errorf("sources = %q", got) + } + for k, v := range want { + if got[k] != v { + t.Errorf("%s = %q, want %q", k, got[k], v) + } + } + defer func() { + if recover() == nil { + t.Error("broken file did not panic") + } + }() + sources(fstest.MapFS{"demos_y.go": {Data: []byte("package gallery\nfunc demoB() {\n")}}) +} + +func TestSlug(t *testing.T) { + for in, want := range map[string]string{"Stack": "stack", "KPIGrid": "kpi-grid", "Base64Tool": "base64-tool"} { + if got := (Entry{Name: in}).Slug(); got != want { + t.Errorf("slug(%s) = %s, want %s", in, got, want) + } + } +} + +func TestCategories(t *testing.T) { + seen := map[string]bool{} + for _, k := range Categories { + icon.Must(k.Icon) + if seen[k.Key] { + t.Errorf("category %s twice", k.Key) + } + seen[k.Key] = true + } + if len(Categories) != 38 { + t.Errorf("categories = %d, want 38", len(Categories)) + } +} diff --git a/gallery/pages.go b/gallery/pages.go new file mode 100644 index 0000000..c29a6d4 --- /dev/null +++ b/gallery/pages.go @@ -0,0 +1,119 @@ +package gallery + +import ( + "fmt" + + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit" + "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound" + "github.com/egoist/mygo/ui" + "github.com/egoist/mygo/ui/editor" +) + +func (a *App) pages(c *ui.Context) { + a.router.View(c, func(r *ui.Route) { + switch { + case r.Match("/"): + r.Title("CrychicUI") + a.home(c) + case r.Match("/c/{cat}"): + k, ok := category(r.Param("cat")) + if !ok { + notFound(c) + return + } + r.Title(k.Name) + a.categoryPage(c, k) + case r.Match("/c/{cat}/{slug}"): + for _, e := range inCategory(r.Param("cat")) { + if e.Slug() == r.Param("slug") { + r.Title(e.Name) + a.entryPage(c, e) + return + } + } + notFound(c) + default: + notFound(c) + } + }) +} + +func page(c *ui.Context, fn func()) { + t := c.Theme() + ui.Scroll(c).Grow(1).Children(func() { + ui.Column(c).FillWidth().MaxWidth(t.Rem(64)).Gap(t.Space(4)).Padding(t.Space(8)).Children(fn) + }) +} + +func notFound(c *ui.Context) { + page(c, func() { + ui.Text(c, "Not found").Bold().FontSize(c.Theme().Rem(1.5)) + kit.Muted(c, "No component lives at this address.") + }) +} + +func (a *App) home(c *ui.Context) { + t := c.Theme() + page(c, func() { + ui.Text(c, "CrychicUI").Bold().FontSize(t.Rem(2)) + kit.Muted(c, "Components for MyGo native UI, in MyGo's own look, with motion and sound.") + ui.Row(c).Gap(t.Space(4)).Children(func() { + stat(c, "Components", len(entries)) + stat(c, "Categories", len(Categories)) + stat(c, "Sound cues", len(sound.Cues())) + }) + ui.Row(c).FillWidth().Wrap().Gap(t.Space(3)).Children(func() { + for _, k := range Categories { + a.categoryCard(c, k) + } + }) + }) +} + +func stat(c *ui.Context, label string, n int) { + t := c.Theme() + kit.Card(c).Padding(t.Space(3), t.Space(4)).Gap(t.Space(1)).Children(func() { + kit.Muted(c, label) + ui.Text(c, fmt.Sprint(n)).Bold().FontSize(t.Rem(1.5)) + }) +} + +func (a *App) categoryCard(c *ui.Context, k Category) { + t := c.Theme() + card := kit.Press(kit.Lift(kit.Card(c).Key(k.Key).Width(t.Rem(13)).Padding(t.Space(3)).Gap(t.Space(1)). + Label(k.Name))) + card.Children(func() { + ui.Icon(c, icon.Must(k.Icon)).Size(16, 16) + ui.Text(c, k.Name).Bold() + kit.Muted(c, fmt.Sprintf("%d components", len(inCategory(k.Key)))) + }) + if card.Clicked() { + a.Go("/c/" + k.Key) + } +} + +func (a *App) categoryPage(c *ui.Context, k Category) { + page(c, func() { + ui.Text(c, k.Name).Bold().FontSize(c.Theme().Rem(1.5)) + list := inCategory(k.Key) + kit.Muted(c, fmt.Sprintf("%d components", len(list))) + for _, e := range list { + a.entryRow(c, e) + } + }) +} + +func (a *App) entryPage(c *ui.Context, e Entry) { + t := c.Theme() + page(c, func() { + kit.Muted(c, fmt.Sprintf("%04d · %s", e.ID, e.Category)) + ui.Text(c, e.Name).Bold().FontSize(t.Rem(1.5)) + kit.Muted(c, e.Summary) + kit.Card(c).Key(e.ID).FillWidth().Padding(t.Space(6)).Children(func() { + e.Demo(c, a.logger(e)) + }) + ui.Text(c, "Code").Bold() + editor.View(c, a.code(e)).FillWidth().Height(t.Rem(22)) + }) +} diff --git a/gallery/registry.go b/gallery/registry.go new file mode 100644 index 0000000..5d6368b --- /dev/null +++ b/gallery/registry.go @@ -0,0 +1,160 @@ +// Package gallery shows every component live. +package gallery + +import ( + "embed" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "reflect" + "runtime" + "strings" + "unicode" + + "github.com/egoist/mygo/ui" +) + +// Entry is one component's page. +type Entry struct { + ID int + Name string + Category string + Summary string + Demo func(c *ui.Context, log func(string)) +} + +// Category is a component package shown in the sidebar. +type Category struct { + Key, Name, Icon string +} + +var Categories = []Category{ + {"layout", "Layout", "layout-grid"}, + {"typography", "Typography", "type"}, + {"button", "Button", "mouse-pointer-click"}, + {"display", "Display", "id-card"}, + {"input", "Input", "text-cursor-input"}, + {"choice", "Choice", "list-checks"}, + {"feedback", "Feedback", "message-square-warning"}, + {"motion", "Motion", "move"}, + {"fx", "Effects", "sparkles"}, + {"form", "Form", "clipboard-list"}, + {"datetime", "Date & Time", "calendar-clock"}, + {"color", "Color", "palette"}, + {"navigation", "Navigation", "compass"}, + {"overlay", "Overlay", "layers"}, + {"data", "Data", "table"}, + {"chart", "Chart", "chart-column"}, + {"media", "Media", "image"}, + {"chat", "Chat", "message-circle"}, + {"agent", "Agent", "bot"}, + {"code", "Code", "code"}, + {"files", "Files", "folder"}, + {"shop", "Shop", "shopping-cart"}, + {"finance", "Finance", "wallet"}, + {"dashboard", "Dashboard", "layout-dashboard"}, + {"schedule", "Schedule", "calendar-days"}, + {"project", "Project", "kanban"}, + {"mail", "Mail", "mail"}, + {"social", "Social", "users"}, + {"account", "Account", "user-round"}, + {"devtools", "Devtools", "terminal"}, + {"game", "Game", "gamepad-2"}, + {"learn", "Learn", "graduation-cap"}, + {"health", "Health", "heart-pulse"}, + {"home", "Home", "house"}, + {"marketing", "Marketing", "megaphone"}, + {"content", "Content", "book-open"}, + {"desktop", "Desktop", "monitor"}, + {"geo", "Geo", "map-pin"}, +} + +// Entries lists every registered component in ID order. +func Entries() []Entry { return entries } + +func category(key string) (Category, bool) { + for _, k := range Categories { + if k.Key == key { + return k, true + } + } + return Category{}, false +} + +func inCategory(key string) []Entry { + var out []Entry + for _, e := range entries { + if e.Category == key { + out = append(out, e) + } + } + return out +} + +// Slug turns KPIGrid into kpi-grid. +func (e Entry) Slug() string { + r := []rune(e.Name) + var b strings.Builder + for i, c := range r { + if i > 0 && unicode.IsUpper(c) { + prev := r[i-1] + endsAcronym := unicode.IsUpper(prev) && i+1 < len(r) && unicode.IsLower(r[i+1]) + if unicode.IsLower(prev) || unicode.IsDigit(prev) || endsAcronym { + b.WriteByte('-') + } + } + b.WriteRune(unicode.ToLower(c)) + } + return b.String() +} + +func (e Entry) Path() string { return "/c/" + e.Category + "/" + e.Slug() } + +//go:embed demos*.go +var demoFiles embed.FS + +var codes = sources(demoFiles) + +// Code is the source of the entry's demo function. +func (e Entry) Code() string { + name := funcName(e.Demo) + src, ok := codes[name] + if !ok { + panic("gallery: no source for " + name) + } + return src +} + +func funcName(f any) string { + n := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() + return n[strings.LastIndex(n, ".")+1:] +} + +// sources indexes every top-level function by name. +func sources(fsys fs.FS) map[string]string { + names, err := fs.Glob(fsys, "*.go") + if err != nil { + panic(err) + } + out := map[string]string{} + for _, f := range names { + b, err := fs.ReadFile(fsys, f) + if err != nil { + panic(err) + } + // A Windows checkout may bring CRLF. + src := strings.ReplaceAll(string(b), "\r\n", "\n") + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, f, src, 0) + if err != nil { + panic(err) + } + for _, d := range file.Decls { + if fn, ok := d.(*ast.FuncDecl); ok && fn.Recv == nil { + out[fn.Name.Name] = src[fset.Position(fn.Pos()).Offset:fset.Position(fn.End()).Offset] + } + } + } + return out +}