526 lines
24 KiB
Go
526 lines
24 KiB
Go
// Package app holds the kjol-website site's Go/WASM 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"
|
||
|
||
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: kjøl 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 {
|
||
// The lockup names the LAYER you are standing in, not the site. On the front page
|
||
// that is Kjøl itself; inside /wasm it is Kjøl Wasm Web; inside /c it is Kjøl C —
|
||
// Wordmark, not Name, because up here "C" alone names a language rather than the thing
|
||
// you are reading. A wordmark that says the same thing everywhere is one more thing the
|
||
// reader has to keep track of himself.
|
||
name, sub := "Kjøl", "a shared base layer"
|
||
if l := CurrentLayer(d.Path()); l != nil {
|
||
name, sub = l.Wordmark(), l.Sub
|
||
}
|
||
|
||
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
|
||
// text-white, not text-surface: the flag is the same in both themes, so the boat on
|
||
// top of it has to be too. text-surface inverts to near-black in dark mode, which
|
||
// would hide the boat against the navy cross. The flag itself carries a dark scrim
|
||
// (see .flag-no) so this plain white boat reads without a shadow of its own.
|
||
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white"),
|
||
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(name)),
|
||
Span(Attr("class", "text-sm text-ink-faint"), Text(sub)),
|
||
),
|
||
)
|
||
}
|
||
|
||
// 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")),
|
||
|
||
// The nav, the content and the footer are ONE column, and the way to get that is for
|
||
// all three to be built the same way: gutter on the outside, measure on the inside.
|
||
//
|
||
// <div class="px-4"> <div class="mx-auto max-w-3xl"> …
|
||
//
|
||
// This used to be `mx-auto max-w-3xl px-4` on the nav's inner div — measure and gutter
|
||
// on the SAME element. On a wide screen the gutter has nothing to do (the centring has
|
||
// already pushed the box in much further), so all it did was inset the nav's contents
|
||
// by another 16px: the wordmark sat a finger's width to the right of the headline
|
||
// underneath it. Close enough to look like a mistake, far enough to see.
|
||
//
|
||
// The footer was worse — it was max-w-2xl, a different measure entirely.
|
||
Nav(Attr("class", "site-nav border-b border-line"),
|
||
Div(Attr("class", "px-4"),
|
||
Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 py-4"),
|
||
wordmark(d, "/"),
|
||
Div(Attr("class", "ml-auto flex items-center gap-1"),
|
||
layersMenu(d),
|
||
compositionsMenu(d),
|
||
Ul(Attr("class", "flex items-center gap-1"),
|
||
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", "px-4 pb-14"),
|
||
Div(Attr("class", "mx-auto max-w-3xl"),
|
||
P(Attr("class", "text-sm text-ink-faint"),
|
||
Text("Kjøl is a shared base layer, factored out of several applications so they stay in "+
|
||
"sync. It 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{"/wasm/components": 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")),
|
||
Div(Attr("class", "ml-auto flex items-center gap-2"),
|
||
layersMenu(d),
|
||
compositionsMenu(d),
|
||
Ul(Attr("class", "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.
|
||
// sidebarNav is the sidebar's contents, which depend on WHICH LAYER you are reading.
|
||
//
|
||
// AppLayout is shared by every documentation page in this binary, and those pages are no
|
||
// longer all about the same thing: /wasm/* documents the Go→WebAssembly engine, /c
|
||
// documents the C base layer. A sidebar listing the engine's chapters while you are
|
||
// reading about arenas would be worse than no sidebar at all.
|
||
func sidebarNav(path string) []docsGroup {
|
||
switch {
|
||
case path == "/c" || strings.HasPrefix(path, "/c/"):
|
||
return cNav()
|
||
case path == "/go" || strings.HasPrefix(path, "/go/"):
|
||
return goNav()
|
||
default:
|
||
return docsNav()
|
||
}
|
||
}
|
||
|
||
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 sidebarNav(d.Path()) {
|
||
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...)
|
||
}
|
||
|
||
// No icon: the sidebar is a list of words, and a glyph on every row is noise the reader has
|
||
// to look past to read the label. The label is the navigation. (docsItem still carries an
|
||
// Icon — it is used on the /docs index cards, where a larger tile earns one.)
|
||
func sidebarLink(d Deps, it docsItem) *VNode {
|
||
base, frag, isAnchor := strings.Cut(it.Path, "#")
|
||
|
||
cls := "block rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||
|
||
// A section link is NEVER "active", and that is deliberate. It cannot be: it would
|
||
// have to know which section you had scrolled to, which means measuring all fifteen of
|
||
// them on every scroll frame, and the only way to act on the answer is a signal write
|
||
// — which re-renders this entire page. Sixty times a second, to move a highlight.
|
||
//
|
||
// (Marking them active by PAGE instead lights up all fifteen at once, which is worse
|
||
// than no highlight: it tells you nothing and looks broken.)
|
||
active := !isAnchor && d.Path() == it.Path
|
||
if active {
|
||
cls = "active block rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
|
||
}
|
||
|
||
click := navigate(d, it.Path)
|
||
if isAnchor {
|
||
click = navigateAnchor(d, base, frag)
|
||
}
|
||
|
||
return A(Attr("class", cls), Attr("href", it.Path), click,
|
||
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 a column of plain text and two lists.
|
||
//
|
||
// It used to carry the Wasm Web engine's own highlights: the two-runtime demo, a list of
|
||
// SSR/hydration/server-component features, the build transcript. All of it was true, and
|
||
// none of it belonged HERE — the front page is kjøl's, and kjøl is not the Go/WebAssembly
|
||
// engine any more than it is the C arena allocator. A reader landing on it should learn
|
||
// what the thing IS, not be pitched one of its five parts.
|
||
//
|
||
// So the demo moved to /wasm, where it is the first thing that section shows, and the
|
||
// front page says what is actually true of the whole: here are the languages, here are the
|
||
// frameworks assembled out of them, go and read one.
|
||
//
|
||
//gowasm:page / static layout=public
|
||
func HomePage(d Deps) func() *VNode {
|
||
return func() *VNode {
|
||
return Div(Attr("class", "mx-auto max-w-3xl"),
|
||
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
|
||
Text("Kjøl")),
|
||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||
Text("A shared base layer, factored out of several applications so they stay in sync. "+
|
||
"Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
|
||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||
Text("It is not one library. It is a set of them, in several languages, and a couple of "+
|
||
"frameworks assembled out of those. Each one is documented here, and every page of that "+
|
||
"documentation runs the code it documents.")),
|
||
|
||
// ---- layers: the languages ----
|
||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Layers")),
|
||
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
|
||
Text("What Kjøl is written in, and what it gives you in each. A layer is a directory of "+
|
||
"code you can use on its own — the Go base does not know the C one exists.")),
|
||
layerGrid(Languages()),
|
||
|
||
// ---- compositions: the frameworks ----
|
||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Compositions")),
|
||
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
|
||
Text("What the layers become when they are assembled into something that does a job. A "+
|
||
"composition is not another language: Kjøl Wasm Web is Go all the way down, and Kjøl JS "+
|
||
"Web is TypeScript compiled by a Go toolchain. These are the two you can click into.")),
|
||
layerGrid(Compositions()),
|
||
|
||
// ---- close ----
|
||
P(Attr("class", "mt-12 border-t border-line pt-6 leading-relaxed text-ink-soft"),
|
||
Text("There is not a screenshot of a component anywhere on this site. Every example is the "+
|
||
"real thing, running — which is the only way a documentation page can tell you when it "+
|
||
"has gone stale. "),
|
||
A(Attr("class", "text-accent underline underline-offset-4"),
|
||
Attr("href", "/about"), navigate(d, "/about"), Text("Why this exists")),
|
||
Text("."),
|
||
),
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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<")
|
||
}
|
||
|
||
// The real transcript. It is on the front page, so it is the first thing anybody copies —
|
||
// which makes it the first thing to notice when it goes stale.
|
||
const buildTranscript = `$ go run ./server -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)
|
||
==> bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)
|
||
==> copying Go's wasm_exec.js shim into wwwroot/
|
||
|
||
$ 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("Kjøl is a shared base layer, factored out of several applications so they stay in sync. "+
|
||
"(Kjøl 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, and it still is — that is Kjøl JS Web, and it "+
|
||
"is what those applications run today. Kjøl Wasm Web is the same kit written a second time "+
|
||
"in Go and compiled to WebAssembly: the same components, the same Tailwind, no JavaScript "+
|
||
"build at all. One language across the server and the browser, and a table you could share "+
|
||
"with a native app, because it is a Go function rather than a JSX file.")),
|
||
|
||
P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
|
||
Text("Neither of them is Kjøl. They are two compositions of it — two uses of the layers "+
|
||
"underneath, which are just directories of Go, TypeScript, C and Jai. The front page lists "+
|
||
"both, and does not argue for either.")),
|
||
|
||
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 /wasm/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"),
|
||
),
|
||
)
|
||
}
|
||
}`
|