add WASM blazor-like thing
This commit is contained in:
81
go/cmd/examples/go-wasm-web/app/chart.go
Normal file
81
go/cmd/examples/go-wasm-web/app/chart.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"math/rand"
|
||||
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
)
|
||||
|
||||
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
|
||||
// fixed initial data so the server SSR and the client's first render match.
|
||||
func fixedChartData() []int { return []int{42, 17, 63, 28, 55, 9, 71} }
|
||||
|
||||
func randomValues() []int {
|
||||
v := make([]int, len(chartLabels))
|
||||
for i := range v {
|
||||
v[i] = rand.Intn(95) + 5
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func renderSVG(c interface {
|
||||
Render(chart.RendererProvider, io.Writer) error
|
||||
}) string {
|
||||
var buf bytes.Buffer
|
||||
if c.Render(chart.SVG, &buf) != nil {
|
||||
return "<p class=\"text-danger m-0\">chart error</p>"
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func barSVG(values []int) string {
|
||||
bars := make([]chart.Value, len(values))
|
||||
for i, v := range values {
|
||||
bars[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
|
||||
}
|
||||
return renderSVG(&chart.BarChart{
|
||||
Title: "Weekly values (bar)",
|
||||
TitleStyle: chart.Style{FontSize: 15},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 16, Right: 16, Bottom: 16}},
|
||||
Height: 320, BarWidth: 48, Bars: bars,
|
||||
})
|
||||
}
|
||||
|
||||
func pieSVG(values []int) string {
|
||||
vs := make([]chart.Value, len(values))
|
||||
for i, v := range values {
|
||||
vs[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
|
||||
}
|
||||
return renderSVG(&chart.PieChart{
|
||||
Title: "Share by day (pie)",
|
||||
TitleStyle: chart.Style{FontSize: 15},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48}},
|
||||
Width: 320, Height: 320, Values: vs,
|
||||
})
|
||||
}
|
||||
|
||||
//gowasm:page /chart static layout=app
|
||||
func ChartPage(d Deps) func() *VNode {
|
||||
data := NewSignal(fixedChartData())
|
||||
return func() *VNode {
|
||||
values := data.Get()
|
||||
return Div(
|
||||
H2(Attr("class", "h4 mb-3"), Text("Charts — go-chart (SSR + hydrate)")),
|
||||
P(Attr("class", "text-secondary"),
|
||||
Text("Rendered to SVG on the server, hydrated on the client; Shuffle re-renders client-side.")),
|
||||
Button(Attr("class", "btn btn-primary mb-3"),
|
||||
On(EVENT_CLICK, func() { data.Set(randomValues()) }), Text("Shuffle Data")),
|
||||
Div(Attr("class", "row"),
|
||||
Div(Attr("class", "col-12 col-lg-7 mb-3"),
|
||||
Div(Attr("class", "border rounded p-2 bg-white overflow-auto"), Raw(barSVG(values)))),
|
||||
Div(Attr("class", "col-12 col-lg-5 mb-3"),
|
||||
Div(Attr("class", "border rounded p-2 bg-white overflow-auto"), Raw(pieSVG(values)))),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
13
go/cmd/examples/go-wasm-web/app/client.gen.go
Normal file
13
go/cmd/examples/go-wasm-web/app/client.gen.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Code generated by gowasmgen. DO NOT EDIT.
|
||||
|
||||
//go:build js && wasm
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"kjol/rsc"
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// ServerCounter is a generated client stub for the server component of the same name.
|
||||
func ServerCounter() func() *vdom.VNode { return rsc.Mount("ServerCounter") }
|
||||
193
go/cmd/examples/go-wasm-web/app/pages.go
Normal file
193
go/cmd/examples/go-wasm-web/app/pages.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// 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 <path> [static] [layout=<name>]
|
||||
// marks a page factory as a route. `static`
|
||||
// pre-renders it on the server (SSR);
|
||||
// `layout=<name>` wraps it in a //gowasm:layout.
|
||||
// //gowasm:layout <name> 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
40
go/cmd/examples/go-wasm-web/app/routes.gen.go
Normal file
40
go/cmd/examples/go-wasm-web/app/routes.gen.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Code generated by gowasmgen. DO NOT EDIT.
|
||||
package app
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Routes maps each //gowasm:page path to its instantiated render function.
|
||||
func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
return map[string]func() *vdom.VNode{
|
||||
"/": HomePage(d),
|
||||
"/about": AboutPage(d),
|
||||
"/chart": ChartPage(d),
|
||||
"/server": ServerPage(d),
|
||||
}
|
||||
}
|
||||
|
||||
// StaticPaths are the routes the server pre-renders (SSR); others render client-side.
|
||||
var StaticPaths = map[string]bool{
|
||||
"/": true,
|
||||
"/about": true,
|
||||
"/chart": true,
|
||||
}
|
||||
|
||||
// RouteLayout maps each route to the name of the layout that wraps it.
|
||||
var RouteLayout = map[string]string{
|
||||
"/": "public",
|
||||
"/about": "public",
|
||||
"/chart": "app",
|
||||
"/server": "app",
|
||||
}
|
||||
|
||||
// LayoutFor wraps a page's content in the layout declared for its route.
|
||||
func LayoutFor(d Deps, path string, content *vdom.VNode) *vdom.VNode {
|
||||
switch RouteLayout[path] {
|
||||
case "app":
|
||||
return AppLayout(d, content)
|
||||
case "public":
|
||||
return PublicLayout(d, content)
|
||||
}
|
||||
return AppLayout(d, content)
|
||||
}
|
||||
11
go/cmd/examples/go-wasm-web/app/server.gen.go
Normal file
11
go/cmd/examples/go-wasm-web/app/server.gen.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Code generated by gowasmgen. DO NOT EDIT.
|
||||
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package app
|
||||
|
||||
import "kjol/rsc"
|
||||
|
||||
func init() {
|
||||
rsc.Register("ServerCounter", ServerCounter)
|
||||
}
|
||||
118
go/cmd/examples/go-wasm-web/app/server_counter.go
Normal file
118
go/cmd/examples/go-wasm-web/app/server_counter.go
Normal file
@@ -0,0 +1,118 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
)
|
||||
|
||||
// ServerCounter is a SERVER component — note it's written exactly like a client
|
||||
// component (same builders, signals, On handlers). The //gowasm:server directive
|
||||
// makes the build generate a client stub so calling ServerCounter() on the
|
||||
// frontend is identical to calling any component; the state and this render run
|
||||
// on the server (its chart is computed there with go-chart), and clicks
|
||||
// round-trip over /rsc.
|
||||
//
|
||||
// The chart plots the counter value against the wall-clock time of each click
|
||||
// (milliseconds since the first click), so spacing clicks out spreads the data
|
||||
// points along the x-axis. Because the component is stateless on the server, the
|
||||
// click points live in a signal that round-trips with the rest of its state
|
||||
// (a plain slice would reset on every request).
|
||||
//
|
||||
//gowasm:server
|
||||
func ServerCounter() func() *VNode {
|
||||
count := NewSignal(0)
|
||||
points := NewSignal([]clickPoint{})
|
||||
bump := func(delta int) {
|
||||
count.Set(count.Get() + delta)
|
||||
points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()}))
|
||||
}
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "card"),
|
||||
Div(Attr("class", "card-body"),
|
||||
Div(Attr("class", "d-flex align-items-center gap-2 mb-2"),
|
||||
Span(Text("Server counter: ")),
|
||||
Strong(Attr("class", "badge text-bg-success fs-6"), Text(strconv.Itoa(count.Get()))),
|
||||
Button(Attr("class", "btn btn-sm btn-outline-secondary"),
|
||||
On(EVENT_CLICK, func() { bump(-1) }), Text("−")),
|
||||
Button(Attr("class", "btn btn-sm btn-success"),
|
||||
On(EVENT_CLICK, func() { bump(1) }), Text("+")),
|
||||
),
|
||||
Div(Attr("class", "border rounded p-2 bg-white overflow-auto"),
|
||||
Raw(clickChartSVG(points.Get()))),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// clickPoint records one click: its wall-clock time and the resulting counter
|
||||
// value. Exported fields so the signal's JSON snapshot round-trips it.
|
||||
type clickPoint struct {
|
||||
T int64 // click time, Unix milliseconds
|
||||
V int // counter value after the click
|
||||
}
|
||||
|
||||
// clickChartSVG plots counter value vs. time-of-click (ms since the first
|
||||
// click) as a line graph. Explicit axis ranges keep it valid for the tricky
|
||||
// cases (a single click, or several clicks within the same millisecond).
|
||||
func clickChartSVG(points []clickPoint) string {
|
||||
if len(points) == 0 {
|
||||
return `<span class="text-muted">Click + / − to plot the counter over time (ms since the first click).</span>`
|
||||
}
|
||||
t0 := points[0].T
|
||||
xs := make([]float64, len(points))
|
||||
ys := make([]float64, len(points))
|
||||
minY, maxY := 0.0, 0.0 // keep the zero baseline in view for context
|
||||
for i, p := range points {
|
||||
xs[i] = float64(p.T - t0)
|
||||
ys[i] = float64(p.V)
|
||||
if ys[i] < minY {
|
||||
minY = ys[i]
|
||||
}
|
||||
if ys[i] > maxY {
|
||||
maxY = ys[i]
|
||||
}
|
||||
}
|
||||
maxX := xs[len(xs)-1]
|
||||
if maxX <= 0 {
|
||||
maxX = 1 // rapid or single clicks: avoid a zero-width x-range
|
||||
}
|
||||
if minY == maxY {
|
||||
maxY++ // avoid a zero-height y-range
|
||||
}
|
||||
graph := chart.Chart{
|
||||
Title: "Counter over time (computed on the server)",
|
||||
TitleStyle: chart.Style{FontSize: 14},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 20, Right: 20, Bottom: 40}},
|
||||
Height: 260,
|
||||
XAxis: chart.XAxis{
|
||||
Name: "ms since first click",
|
||||
Range: &chart.ContinuousRange{Min: 0, Max: maxX},
|
||||
},
|
||||
YAxis: chart.YAxis{
|
||||
Name: "counter",
|
||||
Range: &chart.ContinuousRange{Min: minY, Max: maxY},
|
||||
},
|
||||
Series: []chart.Series{
|
||||
chart.ContinuousSeries{
|
||||
XValues: xs,
|
||||
YValues: ys,
|
||||
Style: chart.Style{
|
||||
StrokeColor: chart.ColorGreen, StrokeWidth: 2,
|
||||
DotColor: chart.ColorGreen, DotWidth: 4, // a dot at each click
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if graph.Render(chart.SVG, &buf) != nil {
|
||||
return `<span class="text-danger">chart error</span>`
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
Reference in New Issue
Block a user