Add landing page for kjol, documentation

This commit is contained in:
2026-07-13 16:51:21 -04:00
parent 5230bd6702
commit fec8ef4a3e
54 changed files with 3529 additions and 547 deletions

View File

@@ -13,8 +13,14 @@ package app
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"
)
@@ -24,6 +30,14 @@ type Deps struct {
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,
@@ -44,31 +58,59 @@ func Shell(d Deps, routes map[string]func() *VNode) *VNode {
func notFound(path string) *VNode {
return Div(Attr("class", "py-10"),
H2(Attr("class", "text-xl font-semibold text-neutral-800 mb-2"), Text("Page not found")),
P(Attr("class", "text-neutral-500"), Text("No route matches "+path+".")),
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(
Nav(Attr("class", "site-nav sticky top-0 z-10 border-b border-neutral-200 bg-white"),
Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
A(Attr("class", "text-lg font-semibold tracking-tight text-text-heading no-underline"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
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, "/", "Home", false),
navItem(d, "/docs", "Docs", false),
navItem(d, "/about", "About", false),
Li(Attr("class", "ml-2"),
A(Attr("class", "inline-flex items-center gap-1 rounded-default bg-primary px-3 py-1.5 text-sm font-medium text-white no-underline hover:bg-primary-hover"),
Attr("href", "/chart"), navigate(d, "/chart"), Text("Open app →"))),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
))),
Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"),
content,
Footer(Attr("class", "mt-12 border-t border-neutral-200 pt-4 text-sm text-neutral-400"),
Text("gowasm — pure-Go components compiled to WebAssembly.")),
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(),
)
}
@@ -77,30 +119,42 @@ func PublicLayout(d Deps, content *VNode) *VNode {
// 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 {
width := "max-w-5xl"
// 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()] {
width = "max-w-[100rem]"
// 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"),
Nav(Attr("class", "app-nav border-b border-neutral-800 bg-neutral-900"),
// The nav tracks the content's width, so the logo stays flush with the page
// rather than floating in from the left on the wide routes.
Div(Attr("class", "mx-auto flex "+width+" items-center gap-2 px-4 py-3"),
A(Attr("class", "text-lg font-semibold tracking-tight text-text-on-dark no-underline"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
Ul(Attr("class", "ml-4 flex items-center gap-1"),
navItem(d, "/chart", "Chart", true),
navItem(d, "/server", "Server", true),
navItem(d, "/data", "Data", true),
navItem(d, "/table", "Table", true),
navItem(d, "/overlays", "Overlays", true),
navItem(d, "/kit", "UI Kit", true)),
Ul(Attr("class", "ml-auto flex items-center"),
navItem(d, "/", "Home", true)),
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})),
),
)),
Main(Attr("class", "mx-auto "+width+" px-4 py-8"), content),
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,
@@ -109,6 +163,39 @@ func AppLayout(d Deps, content *VNode) *VNode {
)
}
// 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
@@ -117,11 +204,11 @@ func navItem(d Deps, path, label string, dark bool) *VNode {
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-neutral-300 hover:bg-white/5 hover:text-white"
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-neutral-100 text-neutral-900"
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-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
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)))
}
@@ -139,8 +226,8 @@ func navigate(d Deps, path string) Mod {
// 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-neutral-200 bg-white px-4 py-3 shadow-xs"),
Span(Attr("class", "font-medium text-neutral-700"), Text(label+": ")),
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 }) }}),
@@ -149,74 +236,268 @@ func Counter(label string, count *Signal[int]) *VNode {
)
}
// ---- 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 {
a := NewSignal(0)
b := NewSignal(0)
dark := NewSignal(false)
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 {
return Div(Attr("class", "space-y-8"),
Div(
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Home — component composition")),
P(Attr("class", "mt-1 text-neutral-500"), Text("Two counters; the total is derived across them. Server-rendered, then hydrated.")),
),
Div(Attr("class", "grid gap-3 sm:grid-cols-2"),
Counter("Apples", a),
Counter("Bananas", b),
),
ui.Alert(ui.AlertBlue, "",
Span(Text("Combined total: ")),
Strong(Attr("class", "font-semibold"), Text(itoa(a.Get()+b.Get()))),
),
ui.Card("",
ui.CardHeader("", Text("webui kit")),
Div(Attr("class", "flex flex-wrap items-center gap-2"),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Icon: "check", Text: "Small"}),
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
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(Attr("class", "mt-4"),
ui.ToggleSwitch(dark.Get(), func(v bool) { dark.Set(v) }, "Dark mode", "Just a demo toggle", false, "")),
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.")),
),
)
}
}
//gowasm:page /about static layout=public
func AboutPage(d Deps) func() *VNode {
return func() *VNode {
return Div(Attr("class", "space-y-6"),
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("About")),
ui.Card("",
P(Attr("class", "text-neutral-600 leading-relaxed"),
Text("Components are standalone Go 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. The UI kit (kjol/webui) is a Go port of the Solid.js "+
"component kit, styled with Tailwind.")),
),
ui.Alert(ui.AlertGreen, "Neutral + isomorphic",
Text("This page's markup runs on the server (SSR) and hydrates on the client from the same Go code.")),
)
}
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 Div(Attr("class", "space-y-6"),
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Server component")),
P(Attr("class", "text-neutral-500"),
Text("This counter runs on the server. Its state lives there; clicks round-trip "+
"and the returned render merges into the DOM.")),
counter(),
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"),
),
)
}
}`