Files
kjol/go/cmd/examples/go-wasm-web/app/pages.go

504 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package app holds the go-wasm-web example's pages and components as
// standalone, platform-neutral functions (SSR on the server, hydrate on the
// client). UI is built from the kjol webui kit + Tailwind utility classes.
//
// Directives (processed by kjol/cmd/wasmgen at build time):
//
// //gowasm:page <path> [static] [layout=<name>] a route (static => SSR'd)
// //gowasm:layout <name> a func(Deps, *VNode) *VNode wrapper
// //gowasm:server (see server_counter.go) a server component
package app
//go:generate go run kjol/cmd/wasmgen .
import (
"strconv"
"strings"
. "kjol/vdom"
// The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
// code can measure the browser and still server-render. The landing page uses it for
// exactly one thing: reading the clock when hydration commits.
"kjol/wasmruntime"
ui "kjol/webui"
)
// Deps are the client-only capabilities, injected so pages stay neutral.
type Deps struct {
Path func() string
Navigate func(string)
}
// Theme is the site-wide theme controller. One per site, created once — the switch in
// the header and the class on <html> have to be the same object, or the button and the
// page disagree about what theme you are in.
//
// The client calls Theme.Init() after mounting (see wasm/main.go); on the server it is
// inert, and the document's boot script has already put the right class on <html>.
var Theme = ui.NewTheme()
func itoa(n int) string { return strconv.Itoa(n) }
// Layout wraps a page's content with shared chrome (declared with //gowasm:layout,
// selected per route via `layout=`; 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.
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(Attr("class", "py-10"),
H2(Attr("class", "text-xl font-semibold text-ink mb-2"), Text("Page not found")),
P(Attr("class", "text-ink-muted"), Text("No route matches "+path+".")),
)
}
// --- layouts (Tailwind chrome) -------------------------------------------
// wordmark is the brand lockup, shared by both layouts so they cannot drift.
//
// The boat is the point of the name: kjol is Norwegian for KEEL — the spine of a hull,
// the thing every other part is built onto. Which is what this library is meant to be
// for the applications that share it.
func wordmark(d Deps, href string) *VNode {
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"),
ui.IconInline("sailboat", 17, "")),
Span(Attr("class", "flex items-baseline gap-1.5"),
Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text("Kjol Web")),
Span(Attr("class", "text-sm text-ink-faint"), Text("Go + WASM")),
),
)
}
// PublicLayout is deliberately plain: a line of navigation, a column of content, a line
// of footer. No hero, no glow, no full-bleed anything.
//
// The grid stays, faintly, because it is the one piece of decoration that is not trying
// to sell you something — it is texture, and it costs nothing to read past.
//
//gowasm:layout public
func PublicLayout(d Deps, content *VNode) *VNode {
return Div(Attr("class", "relative min-h-screen"),
// Behind everything, masked to fade out down the page. aria-hidden +
// pointer-events-none because it is decoration: not tabbable, not clickable, not
// read aloud.
Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),
Nav(Attr("class", "site-nav border-b border-line"),
Div(Attr("class", "mx-auto flex max-w-2xl items-center gap-2 px-4 py-4"),
wordmark(d, "/"),
Ul(Attr("class", "ml-auto flex items-center gap-1"),
navItem(d, "/docs", "Docs", false),
navItem(d, "/about", "About", false),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
))),
Main(Attr("class", "px-4 py-14"), content),
Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
P(Attr("class", "text-sm text-ink-faint"),
Text("Kjol Web is part of kjol — a shared base layer. kjol is Norwegian for keel.")),
),
ui.ModalHost(),
)
}
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
// and three calculated columns has no business being squeezed into a reading-width
// column; prose pages still are.
var wideRoutes = map[string]bool{"/table": true}
// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on
// the right. The app routes are the framework's docs — each one explains a capability,
// shows the Go that implements it, and then runs that Go on the page — so they are
// framed like documentation rather than like a demo carousel.
//
//gowasm:layout app
func AppLayout(d Deps, content *VNode) *VNode {
// The content column is wide, and the PROSE inside it is what gets held to a reading
// measure (see prose()). Constraining the whole column to reading width instead left
// code blocks, demos and reference tables cramped into a third of the screen with a
// desert to the right of them — the text was comfortable and everything else paid
// for it.
width := "max-w-6xl"
if wideRoutes[d.Path()] {
// The table's own chrome is the demo; a measure would hide the column management
// that is the whole point of it.
width = "max-w-none"
}
return Div(Attr("class", "min-h-screen bg-surface"),
Nav(Attr("class", "app-nav sticky top-0 z-20 border-b border-line bg-surface/90 backdrop-blur"),
Div(Attr("class", "mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3"),
wordmark(d, "/"),
Span(Attr("class", "rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint"), Text("Docs")),
Ul(Attr("class", "ml-auto flex items-center gap-2"),
navItem(d, "/", "Home", false),
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
),
)),
Div(Attr("class", "mx-auto flex max-w-[110rem] gap-8 px-6"),
docsSidebar(d),
Main(Attr("class", "min-w-0 flex-1 py-10"),
Div(Attr("class", width), content),
),
),
// The host for webui.OpenModal — content opened imperatively, by code that
// owns no component in the tree, is portaled out of here. Render it ONCE,
// near the root. It is an empty portal when nothing is open.
ui.ModalHost(),
)
}
// docsSidebar is the section list. Sticky, so it stays put while a long page scrolls —
// on a documentation site the nav is how you know where you are, and a nav that scrolls
// away leaves you nowhere.
func docsSidebar(d Deps) *VNode {
mods := []Mod{Attr("class", "sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block")}
for _, g := range docsNav() {
items := []Mod{Attr("class", "mt-2 space-y-0.5")}
for _, it := range g.Items {
items = append(items, Li(sidebarLink(d, it)))
}
mods = append(mods,
Div(Attr("class", "mb-6"),
P(Attr("class", "px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint"), Text(g.Title)),
Ul(items...),
),
)
}
return El("aside", mods...)
}
func sidebarLink(d Deps, it docsItem) *VNode {
cls := "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"
iconCls := "text-ink-faint"
if d.Path() == it.Path {
cls = "active flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
iconCls = "text-accent"
}
return A(Attr("class", cls), Attr("href", it.Path), navigate(d, it.Path),
ui.IconInline(it.Icon, 14, iconCls),
Text(it.Label),
)
}
// navItem is a nav link with an active state; dark switches to on-dark colors.
func navItem(d Deps, path, label string, dark bool) *VNode {
active := d.Path() == path
var cls string
switch {
case dark && active:
cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-white/10 text-white"
case dark:
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-ink-faint hover:bg-white/5 hover:text-white"
case active:
cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-surface-raised text-ink"
default:
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink"
}
return Li(A(Attr("class", cls+" no-underline"), Attr("href", path), navigate(d, path), Text(label)))
}
// navigate intercepts a link click for client-side SPA navigation (Navigate is
// nil on the server, 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 flex items-center gap-3 rounded-default border border-line bg-surface px-4 py-3 shadow-xs"),
Span(Attr("class", "font-medium text-ink-soft"), Text(label+": ")),
Strong(Attr("class", "badge inline-flex min-w-8 items-center justify-center rounded-full bg-primary px-2.5 py-0.5 text-sm font-semibold text-white"), Text(itoa(count.Get()))),
Div(Attr("class", "ml-auto flex gap-1"),
ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "", OnClick: func() { count.Update(func(v int) int { return v - 1 }) }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "+", OnClick: func() { count.Update(func(v int) int { return v + 1 }) }}),
),
)
}
// ---- landing ------------------------------------------------------------
// The landing page is one narrow column of plain text, a demo, and a list.
//
// It used to be a framework marketing page: an oversized headline, a hero glow, feature
// cards in a grid, numbered chapters, a call to action repeated at both ends. All of it
// was arguing. None of it was showing. A library this small does not need to argue — it
// needs to say what it is, show that it works, and get out of the way, and a reader who
// wants to be convinced can click into the docs and find every page running the code it
// documents.
//
// What survives is the part that could not be faked: the same Go function rendered twice
// at once, as live DOM and as the HTML string the server sends.
//
//gowasm:page / static layout=public
func HomePage(d Deps) func() *VNode {
clicks := NewSignal(0)
// The one measurement on the page: performance.now() when the client's first render
// commits. Zero until then — which is what the SERVER renders, and what the client
// renders on its first pass, so the two agree and hydration stays clean.
hydratedAt := NewSignal(0.0)
wasmruntime.AfterRender(func() {
if hydratedAt.Get() == 0 {
hydratedAt.Set(wasmruntime.Now())
}
})
// demoTree is called TWICE per render below — once for the DOM, once for the HTML.
// That is the point: the two panes cannot drift, because there is only one of them.
demoTree := func() *VNode {
return Div(Attr("class", "flex items-center gap-3"),
ui.Button(ui.ButtonProps{
Color: ui.ButtonPrimary, Text: "Click me",
OnClick: func() { clicks.Set(clicks.Get() + 1) },
}),
Span(Attr("class", "text-ink-soft"), Text("clicked "+itoa(clicks.Get())+" times")),
)
}
return func() *VNode {
markup := RenderHTML(demoTree())
return Div(Attr("class", "mx-auto max-w-2xl"),
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
Text("Kjol Web")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("A small library for writing web interfaces in Go. Components are ordinary functions "+
"returning a virtual DOM. The server renders them to HTML, and the same code compiles "+
"to WebAssembly and takes over in the browser.")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("There is no JavaScript build step, and nothing outside the standard library.")),
// ---- the demonstration ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")),
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+
"the DOM and you can use it. On the right is the HTML the same function produces when "+
"the server renders it — the markup that reached your browser before any WebAssembly "+
"had loaded. Click the button; both move.")),
Div(Attr("class", "mt-5 border border-line sm:grid sm:grid-cols-2"),
Div(Attr("class", "border-b border-line sm:border-b-0 sm:border-r"),
paneLabel("in your browser"),
Div(Attr("class", "px-4 py-8"), demoTree()),
),
Div(
paneLabel(itoa(len(markup))+" bytes of HTML"),
Pre(Attr("class", "whitespace-pre-wrap px-4 py-4 font-mono text-[12px] leading-relaxed text-ink-muted"),
El("code", Text(prettyHTML(markup))),
),
),
),
P(Attr("class", "mt-3 text-sm leading-relaxed text-ink-muted"),
Text("The right pane is not a picture of the source. It is vdom.RenderHTML, called on the "+
"very tree the left pane is showing, recomputed on every click.")),
// ---- what is in it ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("What is in it")),
Ul(Attr("class", "mt-3 space-y-1.5 leading-relaxed text-ink-soft"),
item("Server-side rendering and client hydration, from one codebase."),
item("Server components: mark a function and its code and state stay on the server."),
item("A component kit — forms, tabs, modals, tooltips, toasts — with dark mode."),
item("A table with filtering, sorting, column management, formulas, and CSV and PDF export."),
item("Tailwind, compiled by a Go program that reads your Go."),
),
// ---- building ----
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Building it")),
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
Text("Two commands. The first produced the page you are reading; the second serves it and "+
"rebuilds on save.")),
codeLang("terminal", "sh", buildTranscript),
// ---- close ----
P(Attr("class", "mt-12 border-t border-line pt-6 leading-relaxed text-ink-soft"),
Text("Every page of the documentation runs the code it documents — there are no screenshots "+
"of components anywhere on this site. "),
A(Attr("class", "text-accent underline underline-offset-4"),
Attr("href", "/docs"), navigate(d, "/docs"), Text("Read the docs")),
Text(", or "),
A(Attr("class", "text-accent underline underline-offset-4"),
Attr("href", "/kit"), navigate(d, "/kit"), Text("look at the components")),
Text("."),
),
P(Attr("class", "mt-4 text-sm text-ink-muted"),
Text(hydrationNote(hydratedAt.Get()))),
)
}
}
// item is one bullet.
func item(text string) *VNode {
return Li(Attr("class", "flex gap-2.5"),
Span(Attr("class", "select-none text-ink-faint"), Text("—")),
Span(Text(text)),
)
}
// paneLabel captions one half of the two-runtime demo.
func paneLabel(title string) *VNode {
return Div(Attr("class", "border-b border-line px-4 py-2"),
Span(Attr("class", "font-mono text-[11px] uppercase tracking-widest text-ink-faint"), Text(title)),
)
}
// hydrationNote is the page's one measurement, written as a sentence rather than
// displayed on a dashboard. It is a fact about this page, not a boast about the library,
// and it reads better as the former.
func hydrationNote(ms float64) string {
if ms == 0 {
return "This page was rendered by Go on the server. WebAssembly is still loading."
}
return "This page was rendered by Go on the server; WebAssembly took over " +
strconv.FormatFloat(ms, 'f', 0, 64) + " ms later."
}
// prettyHTML puts each element of a rendered tree on its own line. The markup shown is
// otherwise byte-for-byte what RenderHTML produced — long class lists and all, because
// tidying them for the demo would make the pane a lie.
func prettyHTML(s string) string {
return strings.ReplaceAll(s, "><", ">\n<")
}
const buildTranscript = `$ go run ./build
==> generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)
==> compiling Tailwind CSS -> wwwroot/app.css
==> compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)
$ go run ./server
serving "./wwwroot" on http://localhost:8085`
// ---- about --------------------------------------------------------------
//gowasm:page /about static layout=public
func AboutPage(d Deps) func() *VNode {
return func() *VNode {
return Div(Attr("class", "mx-auto max-w-3xl py-4"),
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text("About")),
H1(Attr("class", "mt-2 text-4xl font-semibold tracking-tight text-text-heading"), Text("Why this exists")),
P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"),
Text("Kjol Web is one part of kjol — a shared base layer factored out of several applications "+
"so they stay in sync. (Kjol is Norwegian for KEEL: the spine of a hull, the thing every "+
"other part is built onto.) The applications had drifted: the same table, the same forms, "+
"the same charts, each subtly different in each app, each fixed twice.")),
P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
Text("The UI kit began as Solid.js components. Kjol Web is the same kit, written in Go and "+
"compiled to WebAssembly — the same components, the same Tailwind, no JavaScript build. "+
"That means one language across the server and the browser, and a table you can share "+
"between a web app and a native one because it is a Go function, not a JSX file.")),
H2(Attr("class", "mt-12 text-2xl font-semibold tracking-tight text-text-heading"), Text("The rules it keeps")),
Div(Attr("class", "mt-6 space-y-4"),
principle("The framework never imports application code",
"Where kjol needs something app-specific, the app injects it — an interface, a registration "+
"call, a config struct. The dependency only ever points one way."),
principle("Standard library only",
"vdom, the reconciler, the component kit, the Tailwind compiler, the PDF writer: no "+
"third-party Go packages. A dependency in the engine is a dependency in every app that "+
"consumes it."),
principle("The same code on both sides",
"A component that cannot render on the server is a component that cannot be server-rendered. "+
"The browser APIs components need are dual-build: real under WebAssembly, no-ops "+
"natively — so one component measures the DOM and still SSRs."),
),
Div(Attr("class", "mt-12 rounded-default border border-primary-border bg-primary-subtle p-5"),
P(Attr("class", "font-semibold text-text-heading"), Text("This page is the proof, not a claim about it")),
P(Attr("class", "mt-1 leading-relaxed text-ink-soft"),
Text("Its HTML was rendered by Go on the server, and the same Go is running in your browser "+
"now. View the source: the markup arrived complete.")),
),
)
}
}
func principle(title, body string) *VNode {
return Div(Attr("class", "border-l-2 border-line pl-4"),
H3(Attr("class", "font-semibold text-text-heading"), Text(title)),
P(Attr("class", "mt-1 leading-relaxed text-ink-soft"), Text(body)),
)
}
// ---- server components --------------------------------------------------
//gowasm:page /server layout=app
func ServerPage(d Deps) func() *VNode {
// ServerCounter is a server component — 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.
counter := ServerCounter()
return func() *VNode {
return docPage("Rendering", "Server components",
"A server component's code and state never reach the browser. Mark a function with "+
"//gowasm:server and the codegen replaces it, on the client, with a stub that renders it "+
"over an HTTP round-trip — so calling one looks exactly like calling any other component.",
docSection("declaring", "Declaring one",
prose("The directive is the whole API. The function stays an ordinary component: it takes "+
"whatever it needs, and returns a VNode tree."),
code("app/server_counter.go", serverSnippet),
note("Why the state stays put",
"The counter's value lives in a map on the server, keyed by instance. Nothing about it is "+
"shipped to the client — the browser holds an id and a rendered fragment, and every "+
"click asks the server what the next fragment should be."),
),
docSection("try-it", "Try it",
prose("Each click below is a POST to /rsc. The server runs the component again and returns the "+
"new markup, which is merged into the DOM in place — the page is not reloaded and nothing "+
"else on it is re-rendered."),
demo("A counter whose state lives on the server", counter()),
),
docSection("when", "When to reach for one",
prose("When the component needs something the browser must not have: a database handle, a "+
"secret, a large dataset you do not want to ship. The cost is a round-trip per interaction, "+
"so it is the wrong tool for anything that has to feel instant."),
apiTable(
apiRow{"//gowasm:server", "Marks a component as server-side. The codegen writes a client stub in its place."},
apiRow{"POST /rsc", "The endpoint the stub calls. Registered by the dev server; wire it into your own server with rsc.Handler."},
apiRow{"rsc.Handler", "The http.HandlerFunc that runs the component and returns its rendered fragment."},
),
),
)
}
}
const serverSnippet = `//gowasm:server
func ServerCounter() func() *VNode {
id := newInstanceID() // this state never leaves the server
return func() *VNode {
return Div(
Span(Text("count: "+itoa(counts[id]))),
Button(
On(EVENT_CLICK, func() { counts[id]++ }), // runs SERVER-side
Text("+1"),
),
)
}
}`