Update kjol website with C documentation

This commit is contained in:
2026-07-14 13:05:12 -04:00
parent 02a6dc6c48
commit 7d7b7354df
66 changed files with 23884 additions and 2551 deletions

View File

@@ -1,7 +1,14 @@
package app
import (
"strings"
"kjol/lexer" // syntax highlighting for the code blocks — a string in, HTML out
. "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. This page uses it for exactly
// one thing: reading the clock when hydration commits.
"kjol/wasmruntime"
ui "kjol/webui"
)
@@ -31,18 +38,38 @@ type docsItem struct {
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 Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."},
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, server-drawn as SVG."},
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",
@@ -50,14 +77,7 @@ func docsNav() []docsGroup {
},
}, {
Title: "Components",
Items: []docsItem{
{Path: "/wasm/kit", Label: "UI kit", Icon: "squares",
Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
{Path: "/wasm/overlays", Label: "Overlays", Icon: "layers",
Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
{Path: "/wasm/table", Label: "AutoTable", Icon: "table",
Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
},
Items: items,
}}
}
@@ -71,7 +91,7 @@ func docPage(eyebrow, title, lede string, sections ...*VNode) *VNode {
Div(Attr("class", "border-b border-line pb-6"),
P(Attr("class", "text-xs 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 max-w-3xl text-ink-muted leading-relaxed"), Text(lede)),
P(Attr("class", "mt-3 text-ink-muted leading-relaxed"), Text(lede)),
),
)
for _, s := range sections {
@@ -93,11 +113,15 @@ func docSection(id, title string, body ...*VNode) *VNode {
return El("section", mods...)
}
// prose is a paragraph of explanation. Constrained to a reading measure: a line of body
// text that runs the full width of a wide screen is genuinely harder to read, and the
// demos beside it are allowed to be as wide as they like.
// 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 max-w-3xl text-ink-soft leading-relaxed"), Text(text))
return P(Attr("class", "mt-3 text-ink-soft leading-relaxed"), Text(text))
}
// ---- code ---------------------------------------------------------------
@@ -109,23 +133,20 @@ func prose(text string) *VNode {
// 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 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.
// 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.
//
// Go blocks are syntax-highlighted (webui.HighlightGo); the others are shown verbatim.
// A shell transcript put through a Go lexer comes out with `serving` painted as an
// identifier and quotes as string literals — highlighting the wrong language is more
// distracting than not highlighting 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 {
var body *VNode
if lang == "Go" {
// Raw, not Text: HighlightGo returns HTML. It escapes every run of source on the
// way out, so the snippets that contain markup stay inert.
body = El("code", Raw(ui.HighlightGo(src)))
} else {
body = El("code", Text(src))
}
// 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"),
@@ -156,7 +177,7 @@ func demo(title string, body ...*VNode) *VNode {
// 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 max-w-3xl rounded-default border border-primary-border bg-primary-subtle px-4 py-3"),
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)),
)
@@ -180,7 +201,10 @@ func apiTable(rows ...apiRow) *VNode {
for _, b := range body {
rowMods = append(rowMods, b)
}
return Div(Attr("class", "mt-4 max-w-5xl overflow-x-auto"),
// 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...),
),
@@ -191,7 +215,38 @@ func apiTable(rows ...apiRow) *VNode {
//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")}
@@ -213,10 +268,39 @@ func DocsPage(d Deps) func() *VNode {
}
return docPage("Introduction", "Overview",
"Kjol Web is kjol'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.",
"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 "+
@@ -230,15 +314,35 @@ func DocsPage(d Deps) func() *VNode {
"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), navigate(d, it.Path),
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, "")),