package app import ( "strings" "kjol/lexer" // syntax highlighting for the code blocks — a string in, HTML out . "kjol/wasmruntime/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. This page uses it for exactly // one thing: reading the clock when hydration commits. "kjol/wasmruntime" ui "kjol/webui" ) // Documentation chrome. // // The app routes are the framework's documentation, so they are built from one small // vocabulary rather than each page inventing its own headings and spacing: a page has a // title and a lede, then sections; a section explains something in prose, shows the Go // that does it, and then RUNS that Go on the page you are reading. The last part is the // point — a docs page for a UI framework that only shows screenshots of its components // is a docs page that cannot tell you when it has gone stale. // docsNav is the sidebar: the sections of the documentation, in reading order. // // It is data, not markup, because it is consumed twice — once by the sidebar and once // by the /docs index, which lists the same pages as cards. Two hand-written copies of a // nav is two copies to forget to update. type docsGroup struct { Title string Items []docsItem } type docsItem struct { Path string Label string Blurb string // shown on the /docs index; too long for the sidebar Icon string } // The Components group is not a list of PAGES — it is a list of anchors into the one // components page. There used to be three pages there ("UI kit", "Overlays", // "AutoTable"), which split the kit along the lines of its source files rather than // along anything a reader wants: a person hunting for a date picker does not know, and // should not have to guess, whether it was filed under forms or under overlays. // // So the whole kit is one page, and the sidebar jumps you down it. The groups come from // componentGroups(), which is also what BUILDS the sections — so the sidebar cannot // offer a jump to a section that does not exist, and a section cannot go missing from // the sidebar. func docsNav() []docsGroup { items := make([]docsItem, 0, len(componentGroups())) for _, g := range componentGroups() { items = append(items, docsItem{ Path: "/wasm/components#" + g.ID, Label: g.Label, Icon: g.Icon, Blurb: g.Blurb, }) } return []docsGroup{{ Title: "Introduction", Items: []docsItem{ {Path: "/wasm", Label: "Overview", Icon: "book-open", Blurb: "What Kjøl Wasm Web is, how a page becomes a WebAssembly binary, and what runs where."}, }, }, { Title: "Rendering", Items: []docsItem{ {Path: "/wasm/chart", Label: "SSR & hydration", Icon: "chart-column", Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, drawn by the WebAssembly."}, {Path: "/wasm/server", Label: "Server components", Icon: "server", Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."}, {Path: "/wasm/data", Label: "Data fetching", Icon: "cloud-arrow-down", Blurb: "gob to your own server (Go types end to end, no JSON) and JSON to a third-party API."}, }, }, { Title: "Components", Items: items, }} } // ---- page scaffolding --------------------------------------------------- // docPage is the frame every documentation page shares: an eyebrow, a title, a lede, // and then its sections. func docPage(eyebrow, title, lede string, sections ...*VNode) *VNode { mods := []Mod{Attr("class", "pb-16")} mods = append(mods, Div(Attr("class", "border-b border-line pb-6"), P(Attr("class", "text-ss font-semibold uppercase tracking-widest text-accent"), Text(eyebrow)), H1(Attr("class", "mt-2 text-3xl font-semibold tracking-tight text-text-heading"), Text(title)), P(Attr("class", "mt-3 text-ink-muted leading-relaxed"), Text(lede)), ), ) for _, s := range sections { mods = append(mods, s) } return Div(mods...) } // docSection is a titled slab of the page. The id is what the "on this page" links and // the tour steps anchor to. func docSection(id, title string, body ...*VNode) *VNode { mods := []Mod{Attr("id", id), Attr("class", "mt-12 scroll-mt-24")} mods = append(mods, H2(Attr("class", "text-xl font-semibold tracking-tight text-text-heading"), Text(title)), ) for _, b := range body { mods = append(mods, b) } return El("section", mods...) } // prose is a paragraph of explanation. // // It used to be pinned to a reading measure (max-w-3xl). It is not any more: on a // documentation page the paragraphs sit directly above demos, tables and code blocks // that are as wide as the column, and a narrow ribbon of text over a full-width panel // reads as a mistake rather than as typographic care. The column itself (max-w-6xl, set // by AppLayout) is the measure now. func prose(text string) *VNode { return P(Attr("class", "mt-3 text-ink-soft leading-relaxed"), Text(text)) } // ---- code --------------------------------------------------------------- // code is a Go snippet, captioned with where it comes from. // // The caption is a real file path in this example, not a decoration: every snippet on // these pages is copied from code that actually runs, and saying where from is what // lets you go and check. func code(caption, src string) *VNode { return codeLang(caption, "Go", src) } // codeLang is code() for a block that is not Go — a C header, a shell session, a formula. // The label in the corner says what you are looking at, and a shell command labelled "Go" // is worse than no label at all. // // The label is ALSO what picks the lexer, so the two cannot disagree: a block cannot be // labelled C and painted as Go. A language kjol/lexer does not know comes back escaped and // unpainted, which is what should happen — a shell transcript put through a Go lexer comes // out with `serving` painted as an identifier and quotes as string literals, and // highlighting the WRONG language is more distracting than not highlighting at all. func codeLang(caption, lang, src string) *VNode { // Raw, not Text: the lexer returns HTML. It escapes every run of source on the way out // — including for a language it does not know — so the snippets that contain markup, // and every C snippet, which is all pointers and shifts, stay inert. body := El("code", Raw(lexer.Highlight(lang, src))) return Div(Attr("class", "mt-4 overflow-hidden rounded-default border border-neutral-800 bg-neutral-900"), Div(Attr("class", "flex items-center gap-2 border-b border-neutral-800 px-4 py-2"), Span(Attr("class", "text-ss font-medium text-ink-faint font-mono"), Text(caption)), Span(Attr("class", "ml-auto rounded-full bg-white/5 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-faint"), Text(lang)), ), Pre(Attr("class", "overflow-x-auto px-4 py-3 text-[13px] leading-relaxed text-neutral-100 font-mono"), body), ) } // ---- demos -------------------------------------------------------------- // demo is the panel a section's example sits in, captioned with what it is showing. func demo(title string, body ...*VNode) *VNode { mods := []Mod{Attr("class", "mt-4 rounded-default border border-line bg-surface shadow-xs")} mods = append(mods, Div(Attr("class", "border-b border-line px-4 py-2"), Span(Attr("class", "text-ss text-ink-muted"), Text(title)), ), ) inner := []Mod{Attr("class", "p-4")} for _, b := range body { inner = append(inner, b) } mods = append(mods, Div(inner...)) return Div(mods...) } // note is an aside — a caveat, a gotcha, the reason something is the way it is. func note(title, body string) *VNode { return Div(Attr("class", "mt-4 rounded-default border border-primary-border bg-primary-subtle px-4 py-3"), P(Attr("class", "text-sm font-semibold text-text-heading"), Text(title)), P(Attr("class", "mt-1 text-sm text-ink-soft leading-relaxed"), Text(body)), ) } // ---- reference tables --------------------------------------------------- type apiRow struct{ Name, Desc string } // apiTable is the reference half of a page: the names, and what each one does. func apiTable(rows ...apiRow) *VNode { body := make([]*VNode, 0, len(rows)) for _, r := range rows { body = append(body, El("tr", Attr("class", "border-t border-line"), El("td", Attr("class", "py-2 pr-4 align-top whitespace-nowrap"), El("code", Attr("class", "rounded bg-surface-raised px-1.5 py-0.5 text-[13px] font-mono text-ink"), Text(r.Name))), El("td", Attr("class", "py-2 text-sm text-ink-soft leading-relaxed"), Text(r.Desc)), )) } rowMods := []Mod{} for _, b := range body { rowMods = append(rowMods, b) } // Full width, like everything else on the page. A reference table pinned to max-w-5xl // inside a max-w-6xl column is not narrower for a reason — it is narrower by an inch, // which reads as a misalignment rather than as a decision. return Div(Attr("class", "mt-4 overflow-x-auto"), El("table", Attr("class", "w-full border-collapse text-left"), Tbody(rowMods...), ), ) } // ---- the docs index ----------------------------------------------------- //gowasm:page /wasm static layout=app func DocsPage(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. // // This demo used to be on the front page. It does not belong there — it is the Wasm // Web engine's single best argument, and the front page is kjøl's, not this engine's. // Here it is the first thing the section shows, which is where an argument like this // one earns its place. 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()) var groups []*VNode for _, g := range docsNav() { grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")} for _, it := range g.Items { if it.Path == "/wasm" { continue // don't list this page on itself } grid = append(grid, docsCard(d, it)) } if len(grid) == 1 { continue // the group held nothing but this page } groups = append(groups, Div(Attr("class", "mt-10"), H2(Attr("class", "text-sm font-semibold uppercase tracking-widest text-ink-faint"), Text(g.Title)), Div(grid...), ), ) } return docPage("Introduction", "Overview", "Kjøl Wasm Web is Kjøl's Go→WebAssembly UI engine. You write components as ordinary Go "+ "functions returning a virtual DOM; the server renders them to HTML and the same code "+ "hydrates them in the browser. There is no JavaScript build step, and the engine depends "+ "on nothing outside the standard library.", // ---- the demonstration ---- // // The one thing on this site that cannot be faked: the same Go function, rendered // twice at once, as live DOM and as the HTML string the server sent. docSection("two-runtimes", "One function, two runtimes", prose("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.")), P(Attr("class", "mt-2 text-sm text-ink-muted"), Text(hydrationNote(hydratedAt.Get()))), ), docSection("what-runs-where", "What runs where", prose("A page is Go, compiled twice. On the server it renders to an HTML string, so the first "+ "paint needs no WebAssembly at all. In the browser the same functions run again, adopt the "+ "markup that is already there, and from then on a signal write re-renders and reconciles into "+ "the live DOM."), code("app/pages.go", ssrSnippet), note("The host API is dual-build", "Components measure the DOM — a tooltip has to know where its trigger is. Those calls are "+ "real under js/wasm and no-ops natively, which is what lets one component both SSR and "+ "position itself, without a branch in the component."), ), docSection("what-is-in-it", "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."), ), prose("Two commands build it. The first produced the page you are reading; the second serves "+ "it and rebuilds on save."), codeLang("terminal", "sh", buildTranscript), ), appendNodes(Div(Attr("class", "mt-14 border-t border-line pt-2")), groups...), ) } } func docsCard(d Deps, it docsItem) *VNode { // A component card is a jump into the components page, not a page of its own — so it // routes there and scrolls, exactly as the sidebar does. click := navigate(d, it.Path) if base, frag, ok := strings.Cut(it.Path, "#"); ok { click = navigateAnchor(d, base, frag) } return A( Attr("class", "group block rounded-default border border-line bg-surface p-4 no-underline shadow-xs transition hover:border-primary-border hover:shadow-sm"), Attr("href", it.Path), click, Div(Attr("class", "flex items-center gap-2"), Span(Attr("class", "inline-flex h-7 w-7 items-center justify-center rounded-default bg-primary-subtle text-accent"), ui.IconInline(it.Icon, 14, "")), Span(Attr("class", "font-semibold text-text-heading"), Text(it.Label)), Span(Attr("class", "ml-auto text-ink-faint transition group-hover:text-accent"), ui.IconInline("arrow-right", 12, "")), ), P(Attr("class", "mt-2 text-sm text-ink-muted leading-relaxed"), Text(it.Blurb)), ) } // appendNodes adds children to a node after the fact — the shape a few of these pages // need, where the section list is computed rather than written out. func appendNodes(parent *VNode, children ...*VNode) *VNode { parent.Children = append(parent.Children, children...) return parent } const ssrSnippet = `//gowasm:page /wasm static layout=app func DocsPage(d Deps) func() *VNode { count := NewSignal(0) // state lives in the closure return func() *VNode { // the render: pure, called again on every change return Div(Attr("class", "space-y-2"), H1(Text("Overview")), Button( Attr("class", "btn"), On(EVENT_CLICK, func() { count.Set(count.Get() + 1) }), Text("clicked "+itoa(count.Get())+" times"), ), ) } } // static => the server pre-renders this route to HTML. // The same function then hydrates it in the browser.`