// Package app holds the application's pages and components as standalone // functions (no central App struct). It is platform-neutral, so the SAME code // renders on the server (SSR) and hydrates on the client. // // Directives (processed by cmd/gowasmgen at build time): // // //gowasm:page [static] [layout=] // marks a page factory as a route. `static` // pre-renders it on the server (SSR); // `layout=` wraps it in a //gowasm:layout. // //gowasm:layout marks a func(Deps, *VNode) *VNode as a named // layout that wraps a page's content. // //gowasm:server (see server_counter.go) marks a component // that runs on the server; the generated // client stub makes calling it identical to // calling any other component. // // The Routes() map, StaticPaths set, and RouteLayout/LayoutFor dispatch are all // generated from these directives. package app //go:generate go run kjol/cmd/gowasmgen . import ( "strconv" . "kjol/vdom" ) // Deps are the client-only capabilities, injected so the pages stay neutral. type Deps struct { Path func() string Navigate func(string) } func itoa(n int) string { return strconv.Itoa(n) } // Layout wraps a page's rendered content with shared chrome (nav, footer, …). // Layouts are declared with //gowasm:layout and selected per route via a page's // `layout=` directive; the generated LayoutFor dispatches by name. type Layout func(d Deps, content *VNode) *VNode // Shell renders the current route's page inside its declared layout. `routes` is // the generated route table; `d.Path()` selects both the page and its layout. func Shell(d Deps, routes map[string]func() *VNode) *VNode { path := d.Path() var content *VNode if page := routes[path]; page != nil { content = page() } else { content = notFound(path) } return LayoutFor(d, path, content) } func notFound(path string) *VNode { return Div( H2(Attr("class", "h4 mb-3"), Text("Page not found")), P(Attr("class", "text-secondary"), Text("No route matches "+path+".")), ) } // --- layouts (selected per route via `layout=` in //gowasm:page) --------- // PublicLayout is the chrome for public/marketing pages: a light navbar with a // call-to-action into the app, and a footer. The func(Deps, *VNode) *VNode shape // is what //gowasm:layout expects. // //gowasm:layout public func PublicLayout(d Deps, content *VNode) *VNode { return Div( Nav(Attr("class", "navbar navbar-expand bg-light border-bottom mb-4"), Div(Attr("class", "container"), A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")), Ul(Attr("class", "navbar-nav ms-auto align-items-center"), navItem(d, "/", "Home"), navItem(d, "/about", "About"), Li(Attr("class", "nav-item ms-2"), A(Attr("class", "btn btn-sm btn-primary"), Attr("href", "/chart"), navigate(d, "/chart"), Text("Open app →"))), ))), Main(Attr("class", "container"), content, Footer(Attr("class", "text-secondary small border-top mt-5 pt-3"), Text("gowasm public site — a tiny Blazor-like engine in Go.")), ), ) } // AppLayout is the chrome for the application itself: a dark app navbar listing // the app's sections, plus a link back to the public site. // //gowasm:layout app func AppLayout(d Deps, content *VNode) *VNode { return Div( Nav(Attr("class", "navbar navbar-expand navbar-dark bg-dark mb-4"), Div(Attr("class", "container"), A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")), Ul(Attr("class", "navbar-nav me-auto"), navItem(d, "/chart", "Chart"), navItem(d, "/server", "Server")), Ul(Attr("class", "navbar-nav"), navItem(d, "/", "Home")), )), Main(Attr("class", "container"), content), ) } // navItem is a nav link that carries an active state on the current route. func navItem(d Deps, path, label string) *VNode { cls := "nav-link" if d.Path() == path { cls += " active" } return Li(Attr("class", "nav-item"), A(Attr("class", cls), Attr("href", path), navigate(d, path), Text(label))) } // navigate intercepts a link click for client-side SPA navigation. On the server // Navigate is nil, so the anchor falls back to a normal navigation. func navigate(d Deps, path string) Mod { return OnEvent(EVENT_CLICK, func(e Event) { if d.Navigate != nil { e.PreventDefault() d.Navigate(path) } }) } // Counter is a presentational client component; state is owned by the caller. func Counter(label string, count *Signal[int]) *VNode { return Div(Attr("class", "counter card mb-2"), Div(Attr("class", "card-body py-2 d-flex align-items-center"), Span(Attr("class", "me-2 fw-semibold"), Text(label+": ")), Strong(Attr("class", "badge text-bg-primary me-2"), Text(itoa(count.Get()))), Div(Attr("class", "btn-group btn-group-sm ms-auto"), Button(Attr("class", "btn btn-outline-secondary"), On(EVENT_CLICK, func() { count.Update(func(v int) int { return v - 1 }) }), Text("−")), Button(Attr("class", "btn btn-outline-primary"), On(EVENT_CLICK, func() { count.Update(func(v int) int { return v + 1 }) }), Text("+")), ))) } //gowasm:page / static layout=public func HomePage(d Deps) func() *VNode { a := NewSignal(0) b := NewSignal(0) return func() *VNode { return Div( H2(Attr("class", "h4 mb-3"), Text("Home — component composition")), P(Attr("class", "text-secondary"), Text("Two counters; the total is derived across them. Server-rendered, then hydrated.")), Counter("Apples", a), Counter("Bananas", b), Div(Attr("class", "alert alert-info d-flex justify-content-between align-items-center mt-3"), Span(Text("Combined total: ")), Strong(Attr("class", "fs-5"), Text(itoa(a.Get()+b.Get())))), ) } } //gowasm:page /about static layout=public func AboutPage(d Deps) func() *VNode { return func() *VNode { return Div( H2(Attr("class", "h4 mb-3"), Text("About")), P(Attr("class", "lead"), Text("Components are standalone functions; calling a server component looks "+ "identical to calling a client one — the //gowasm:server directive and the "+ "build-time codegen wire up the round-trip. Static routes are SSR'd; the rest "+ "render on the client.")), ) } } //gowasm:page /server layout=app func ServerPage(d Deps) func() *VNode { // ServerCounter is a server component — but calling it is just like calling // any component. On the client this resolves to a generated stub that mounts // it over /rsc; on the server it's the real function. (Not `static`: the // client renders the stub, which round-trips, so there's nothing stable to // pre-render + hydrate.) counter := ServerCounter() return func() *VNode { return Div( H2(Attr("class", "h4 mb-3"), Text("Server component")), P(Attr("class", "text-secondary"), Text("This counter runs on the server. Its state lives there; clicks round-trip "+ "and the returned render merges into the DOM. The call site is identical to a "+ "client component.")), counter(), ) } }