381 lines
16 KiB
Go
381 lines
16 KiB
Go
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.`
|