Update 3d chart mode, add US heatmap, move kjol-web -> kjol-website

This commit is contained in:
2026-07-16 12:40:49 -04:00
parent 550e97aa9b
commit 2477c2d6a2
75 changed files with 701 additions and 416 deletions

View File

@@ -0,0 +1,217 @@
package app
import (
"bytes"
"io"
"math/rand"
chart "github.com/wcharczuk/go-chart/v2"
. "kjol/vdom"
"kjol/wasmruntime"
ui "kjol/webui"
)
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
// fixed initial data so the server SSR and the client's first render match.
func fixedChartData() []int { return []int{42, 17, 63, 28, 55, 9, 71} }
func randomValues() []int {
v := make([]int, len(chartLabels))
for i := range v {
v[i] = rand.Intn(95) + 5
}
return v
}
func renderSVG(c interface {
Render(chart.RendererProvider, io.Writer) error
}) string {
var buf bytes.Buffer
if c.Render(chart.SVG, &buf) != nil {
return "<p class=\"text-danger m-0\">chart error</p>"
}
return buf.String()
}
func barSVG(values []int) string {
bars := make([]chart.Value, len(values))
for i, v := range values {
bars[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
}
return renderSVG(&chart.BarChart{
Title: "Weekly values (bar)",
TitleStyle: chart.Style{FontSize: 15},
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 16, Right: 16, Bottom: 16}},
Height: 320, BarWidth: 48, Bars: bars,
})
}
func pieSVG(values []int) string {
vs := make([]chart.Value, len(values))
for i, v := range values {
vs[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
}
return renderSVG(&chart.PieChart{
Title: "Share by day (pie)",
TitleStyle: chart.Style{FontSize: 15},
Background: chart.Style{Padding: chart.Box{Top: 48}},
Width: 320, Height: 320, Values: vs,
})
}
// chartSkeleton is what the SERVER puts where a chart is going to be: a box of the right
// height, so nothing jumps when the real one arrives.
func chartSkeleton(height string) *VNode {
return Div(Attr("class", "flex animate-pulse items-center justify-center rounded-default bg-surface-muted "+height),
Span(Attr("class", "text-xs text-ink-faint"), Text("drawing…")),
)
}
// newChartDrawing returns a signal that is FALSE on the server and on the client's first
// render, and true from the moment the WebAssembly has committed that first render.
//
// It is what keeps the charts CLIENT-DRAWN. go-chart is ordinary Go and would run just as
// happily on the server — it used to, and this page's markup carried two finished SVGs.
// Two reasons not to:
//
// - It is work the server does on every single request for a picture that only matters
// once the page is alive. Drawing it in the browser costs the server nothing and the
// reader nothing they can see.
// - It is the more honest demonstration. A Go charting library, compiled to WebAssembly,
// drawing an SVG in the browser is the thing this layer claims it can do. Shipping a
// server-rendered picture of one proves the opposite point.
//
// The false-on-first-render part is not optional: hydration walks the server's DOM
// alongside the client's first tree, so that tree has to be the SAME tree. Draw the charts
// on the client's first pass and the two disagree, and the reconciler has to rebuild what
// it should have adopted.
func newChartDrawing() *Signal[bool] {
drawn := NewSignal(false)
wasmruntime.AfterRender(func() {
if !drawn.Get() {
drawn.Set(true) // a write re-renders; the second pass draws for real
}
})
return drawn
}
// chartBox renders one chart, or the placeholder standing in for it. draw is a closure so
// that on the server go-chart is never called at all — not called and discarded, but never
// entered.
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
if !drawn {
return Div(Attr("class", class), chartSkeleton(height))
}
return Div(Attr("class", class), Raw(draw()))
}
//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData())
drawn := newChartDrawing()
return func() *VNode {
values := data.Get()
return docPage("Rendering", "SSR & hydration",
"A static route is rendered to HTML by the server, so the page is complete before any "+
"WebAssembly has downloaded. The same component then runs in the browser, adopts the markup "+
"that is already there, and takes over. One function, two runtimes.",
docSection("the-directive", "Marking a route static",
prose("static on the page directive is what puts a route in the server's pre-render set. Leave "+
"it off and the route renders on the client only — which is the right choice when the page "+
"is behind a login, or its content depends on something only the browser knows."),
code("app/chart.go", chartSnippet),
note("Hydration adopts, it does not rebuild",
"The client renders the same tree the server did and walks the existing DOM alongside it, "+
"wiring event handlers to the nodes that are already on the page. If the two trees "+
"disagree, the CLIENT wins — a stale server binary should not be able to pin a wrong "+
"class onto the page forever."),
),
docSection("charts", "A worked example: charts",
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing "+
"about the browser. They are drawn by the WEBASSEMBLY, in your browser, and never by the "+
"server: what the server sends is the two placeholders you may have seen for a moment, "+
"and the WebAssembly replaces them on its first commit."),
prose("That is the demonstration. A Go charting library, compiled to wasm, drawing an SVG in "+
"the browser is exactly what this layer claims it can do — and a server-rendered picture "+
"of a chart would prove the opposite point while looking identical. Shuffle redraws them, "+
"and no request is made."),
prose("The rest of the page IS server-rendered — the headings, the prose, the code you are "+
"reading. Static and client-drawn are not opposites: a route can be pre-rendered and still "+
"leave the expensive, browser-only parts of itself for the client."),
Div(Attr("class", "mt-4"),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data",
OnClick: func() { data.Set(randomValues()) }}),
),
Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-12"),
chartBox("lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
"h-[260px]", drawn.Get(), func() string { return barSVG(values) }),
chartBox("lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
"h-[320px]", drawn.Get(), func() string { return pieSVG(values) }),
),
note("go-chart lives in the EXAMPLE, not in kjol",
"The engine is standard-library-only. This example is its own Go module precisely so a "+
"charting dependency it happens to want does not become a dependency of everyone who "+
"uses the framework."),
),
docSection("api", "Reference",
apiTable(
apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."},
apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."},
apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."},
apiRow{"vdom.Raw", "Insert markup verbatim — how the SVG the WebAssembly drew gets in. The reconciler clears it correctly when the element is reused."},
),
),
)
}
}
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData())
drawn := NewSignal(false) // false on the server AND on the first client render
// AfterRender is the post-commit hook. It fires once the WebAssembly has put its
// first tree on the page — the earliest moment at which drawing is a client act.
wasmruntime.AfterRender(func() {
if !drawn.Get() {
drawn.Set(true) // a write re-renders; the second pass draws
}
})
return func() *VNode {
return Div(
ui.Button(ui.ButtonProps{
Text: "Shuffle data",
OnClick: func() { data.Set(randomValues()) },
}),
// The server never enters barSVG: chartBox takes a CLOSURE, and calls it only
// once drawn is true. It renders the placeholder instead, and the WebAssembly
// swaps in the real chart on its first commit.
//
// drawn must be FALSE on the client's first render too. Hydration walks the
// server's DOM alongside the client's first tree, so the two have to BE the
// same tree; draw on that first pass and the reconciler rebuilds what it
// should have adopted.
chartBox("", "h-[260px]", drawn.Get(),
func() string { return barSVG(data.Get()) }),
)
}
}
// chartBox is the whole trick, and it is four lines.
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
if !drawn {
return Div(Attr("class", class), chartSkeleton(height))
}
return Div(Attr("class", class), Raw(draw()))
}`

View File

@@ -0,0 +1,600 @@
package app
import (
. "kjol/vdom"
)
// The C layer's documentation.
//
// One page, like the component pages: a reader looking for the arena API should not have
// to guess which of six pages it was filed under. The sidebar calls the sections
// SUBSYSTEMS, which is what they are — each is a directory of C, and each has a different
// idea of what it depends on.
//
// Everything here is read off the source. Where a subsystem needs something the reader has
// to supply — a unity translation unit, a vendored ImGui, a resource script pointed at his
// own product — the page says what it needs and what to write, rather than filing it as a
// defect. This is a base layer: it is COPIED INTO a project and compiled with it, so "what
// you have to provide" is not a caveat, it is the interface.
type subsystem struct {
ID string
Label string
Icon string
Blurb string
}
// cSubsystems is the single source for the page's sections AND the sidebar that jumps to
// them — so the sidebar cannot offer a jump to a section that does not exist.
func cSubsystems() []subsystem {
return []subsystem{
{ID: "base", Label: "base", Icon: "cube",
Blurb: "The vocabulary: fixed-width types, a bump allocator, counted strings, and 2D rectangle math."},
{ID: "build", Label: "build", Icon: "bolt",
Blurb: "A single-header build system. Your build script is a C program that recompiles itself."},
{ID: "platform", Label: "platform", Icon: "server",
Blurb: "A window, its input, the clipboard. Win32, and it hands every message to Dear ImGui first."},
{ID: "lexer", Label: "lexer", Icon: "code",
Blurb: "Syntax highlighting. Five languages, thirteen themes, and a paint array rather than a token list."},
{ID: "config", Label: "config", Icon: "file",
Blurb: "An INI file beside the executable. Program-managed state, not user-authored settings."},
{ID: "installer", Label: "installer", Icon: "download",
Blurb: "A Win32 wizard that carries the product inside itself as a resource."},
}
}
// cNav is the sidebar while you are reading /c. The group is called Subsystems.
func cNav() []docsGroup {
items := make([]docsItem, 0, len(cSubsystems()))
for _, s := range cSubsystems() {
items = append(items, docsItem{
Path: "/c#" + s.ID,
Label: s.Label,
Icon: s.Icon,
Blurb: s.Blurb,
})
}
return []docsGroup{
{
Title: "Introduction",
Items: []docsItem{
{Path: "/c", Label: "Overview", Icon: "book-open",
Blurb: "What this layer is, and where it came from."},
{Path: "/c#compiling", Label: "Compiling it", Icon: "bolt",
Blurb: "The unity translation unit: what you write, and what each subsystem needs from you."},
},
},
{Title: "Subsystems", Items: items},
}
}
//gowasm:page /c static layout=app
func CPage(d Deps) func() *VNode {
return func() *VNode {
return docPage("Layers", "Kjøl C",
"A base layer in C — a bump allocator, counted strings, a syntax highlighter, a Win32 window, "+
"and a build system that is itself a C program. It was lifted out of codeMAX, a code editor, "+
"which is why the pieces are the pieces an editor needs.",
docSection("what-this-is", "What this is",
prose("Not a library you link against. A set of headers and translation units you COPY INTO "+
"a project and compile with it — the base-layer style you will recognise if you have read "+
"Ryan Fleury's RAD Debugger or watched Handmade Hero. The headers say as much: base_core, "+
"base_arena and base_strings all credit raddebugger, and the naming (U32, Str8, Rng2F32) "+
"comes straight from it."),
prose("There is no package manager, no version, and no ABI to keep stable — because there is "+
"nothing to keep stable BETWEEN. The code and its consumer are compiled together. Which is "+
"also why the next section is about the file YOU write: the layer does not build on its "+
"own, by design. It builds as part of your program."),
note("It arrived from codeMAX, and still carries its names",
"The whole directory came into Kjøl in one commit, out of codeMAX, a code editor. Its "+
"fingerprints are still on it and they are worth knowing before you go looking: the "+
"Win32 window class is codemax_wc, the installer installs codeMAX, and installer.rc "+
"names codeMAX's icon, manifest and payload. Those are the strings to change when you "+
"adopt it — they are the product's name, not the layer's."),
),
cCompiling(),
cBase(),
cBuild(),
cPlatform(),
cLexer(),
cConfig(),
cInstaller(),
)
}
}
// ---- compiling -----------------------------------------------------------
// How you actually compile the thing. It is second on the page, before any API, because a
// base layer's first question is "how does this get into my program" and the answer here is
// not the one a reader arriving from a package-manager language expects.
func cCompiling() *VNode {
return docSection("compiling", "Compiling it",
prose("One translation unit. You write a single .c that #includes the layer's .c files in order, "+
"and hand THAT to the compiler — the whole layer is one TU, compiled with your program. "+
"base/base_inc.c is the pattern in miniature: three lines, pulling in the arena and the "+
"strings. Your own unity file is the same idea, one level up."),
codeLang("app_inc.c — the file you write", "c", unityTUSnippet),
prose("This is not a workaround for the absence of a build system; it IS the build system. There is "+
"no per-file compilation, so there are no object files, no link order and no header guard "+
"archaeology — and the compiler sees the whole layer at once, which is what makes every helper "+
"in it `internal` and every call to one a candidate for inlining."),
docSubheading("What each subsystem needs from you"),
prose("They are not equally self-contained, and the differences are worth knowing before you pick "+
"the ones you want:"),
apiTable(
apiRow{"base", "Nothing. It stands on its own — base_inc.c is a complete TU, and everything above depends on it."},
apiRow{"lexer", "Include lexer.c AND the five backends in the same TU. Their helpers are `internal`, so separate compilation would leave lexer.c's dispatch with nothing to call."},
apiRow{"config", "Include config.h above config.c. config.c uses Config without including its own header — which is exactly what a unity build lets it do."},
apiRow{"platform", "Dear ImGui, vendored, and a C++ compiler for it. See the subsystem below — the window procedure hands it every message before it looks at any of them."},
apiRow{"installer", "Its own program and its own .rc, pointed at your icon, your manifest and your payload."},
apiRow{"build", "Nothing — it is the thing that runs the compiler. Write a build.c, compile it once, and never compile it again."},
),
note("There is no build.c in Kjøl yet, and that is the next job",
"build.h is here, complete and self-contained, and nothing in the repository includes it — so "+
"the layer currently has a build system and no build. Writing that build.c is what turns "+
"this directory into something you can type one command at, and it is the single most "+
"useful thing anyone could add to the C layer. The build subsystem below documents the "+
"header it would be written against, and the snippet there is a working sketch of it."),
)
}
// ---- base ----------------------------------------------------------------
func cBase() *VNode {
return docSection("base", "base",
prose("The vocabulary. Fixed-width integers with short names, a bump allocator, a counted string, "+
"and the rectangle math a user interface actually spends its time on. Everything else in the "+
"layer is written in these — which is why there is no malloc below this line, and no char* "+
"pretending to be a string."),
docSubheading("The types (base_core.h)"),
prose("U8 through U64, S8 through S64, B32 for a boolean, F32 and F64 — upper case, not the u8/i32 "+
"of the Rust-adjacent style. Sizes are written KB(4) and MB(64) rather than as a number of "+
"zeroes you have to count. And the three meanings of `static` in C get three different names: "+
"`internal` for a file-local function, `global` for a translation-unit variable, `local_persist` "+
"for a local that survives the call. They all expand to `static`; they do not all mean the same "+
"thing, and the code says which one it meant."),
codeLang("c/base/base_core.h", "c", baseCoreSnippet),
prose("There is more in the header than the types: Min/Max/Clamp, the nil-aware doubly and singly "+
"linked-list macros from raddebugger (DLLPushBack, SLLQueuePush and friends), DeferLoop — which "+
"is `defer` written as a for-loop, because C has no defer — and an Assert that traps on MSVC "+
"with __debugbreak."),
docSubheading("The arena (base_arena.h)"),
prose("One allocator, and it is a bump pointer: arena_push moves a cursor and hands you the memory. "+
"There is no free. You release the whole arena, or you roll it back to a saved position — which "+
"is what Temp is. A function that wants scratch space opens a Temp, allocates as freely as it "+
"likes, and closes it. Nothing has to be individually released, so nothing can be individually "+
"forgotten."),
codeLang("c/base/base_arena.h", "c", baseArenaSnippet),
note("It is malloc-backed and fixed-capacity — and that has a sharp edge",
"The header says so: this is raddebugger's arena with the virtual-memory reserve/commit taken "+
"out. arena_alloc is one malloc of the capacity you asked for, and that capacity is final — "+
"the arena does not grow and does not chain. Overflow hits Assert(!\"Arena overflow\") and "+
"returns NULL. But Assert compiles to nothing unless _DEBUG is defined, so in a release build "+
"an over-full arena hands you a silent NULL. Alignment is a hard-coded 8 bytes, so there is "+
"no SIMD guarantee. And there is no thread-local scratch pool — if you came here expecting "+
"raddebugger's scratch_begin, it is not in this snapshot."),
docSubheading("Strings (base_strings.h)"),
prose("Str8 is a pointer and a length. Not NUL-terminated, not owned, does not allocate — so a "+
"substring is free, and a Str8 can point into the middle of a file you mapped. The two "+
"operations that MUST allocate take the arena, and so they say so in their signature. That is "+
"the whole API: eight functions. It is early, and there is no split, join, trim or "+
"case-insensitive compare yet."),
codeLang("c/base/base_strings.h", "c", baseStringsSnippet),
docSubheading("Math (base_math.h)"),
prose("Look at the shape of this file and it tells you what it is for. Vec3F32 has a constructor "+
"and no operations. Rng2F32 — a rectangle — has nine: width, height, dim, center, contains, "+
"pad, shift, intersect. There are no matrices, no quaternions, no Mat4. This is 2D interface "+
"math, and a layout written in it is a sequence of rectangle operations rather than eight lines "+
"of x + w arithmetic with an off-by-one hiding in them."),
prose("The Axis2 / Side / Corner enums plus v2f32_axis are the raddebugger trick for axis-generic "+
"code: one code path handles both X and Y by indexing into the vector instead of naming .x "+
"and .y, so a horizontal and a vertical layout are the same function with a different argument."),
apiTable(
apiRow{"arena_alloc / arena_release", "One malloc, fixed capacity. It does not grow."},
apiRow{"arena_push / arena_push_no_zero", "Bump the cursor. Zeroed by default; the fast one has to be asked for by name."},
apiRow{"temp_begin / temp_end", "A scratch scope: save the position, allocate freely, roll it all back."},
apiRow{"push_array(arena, T, n)", "Typed sugar over arena_push. The macro you will actually use."},
apiRow{"str8 / str8_cstr / str8_lit", "Make a counted string. None of them allocate."},
apiRow{"str8_pushf / str8_push_copy", "The two that DO allocate — and so they take the arena."},
apiRow{"Rng2F32 + rng2f32_intersect / _pad / _shift", "Rectangles, and the three things a layout does to them."},
),
)
}
// ---- build ---------------------------------------------------------------
func cBuild() *VNode {
return docSection("build", "build",
prose("The build system is a C program, and the build system's build system is a C compiler. "+
"build.h is a single header in the stb style: define BUILD_IMPLEMENTATION in one file, include "+
"it, and write your build as a main() that shells out to a compiler. The lineage is nob.h — the "+
"self-rebuild macro here is called GO_REBUILD_URSELF, which is a straight nod to it."),
docSubheading("Bootstrapping"),
prose("You compile the build script once, by hand. After that you never compile it again, because "+
"the first thing it does when it runs is compare its own source against its own executable — "+
"and if the source is newer, it rebuilds itself, swaps the binary, and re-executes. Edit the "+
"build script and just run it. It will notice."),
codeLang("terminal", "sh", buildBootstrapSnippet),
note("On Windows you cannot overwrite a running .exe — so it doesn't",
"go_rebuild_urself RENAMES the current binary to build.exe.old, compiles the new one in its "+
"place, and re-execs. If the compile FAILS, it renames the old one back. That is the whole "+
"reason the dance exists, and it is the difference between a build script you can edit and "+
"one that bricks itself the first time you make a typo."),
docSubheading("Writing one"),
prose("GO_REBUILD_URSELF is the first line of main. Cmd is a growable argv you append to and run. "+
"needs_rebuild compares timestamps, so a target whose inputs have not changed is skipped. That "+
"is the whole of it — no rule syntax, no DSL, no dependency graph, because a C program already "+
"has if-statements and for-loops and you already know how to write them."),
codeLang("build.c — the file you write", "c", buildUsageSnippet),
note("What it deliberately does NOT do",
"There is no compiler detection: the compiler is cl.exe on Windows and cc everywhere else, "+
"hard-coded, and only for the self-rebuild — for your own targets you assemble the command "+
"yourself. There is no parallelism: cmd_run is synchronous, start to finish. There is no "+
"globbing and no header-dependency scanning: needs_rebuild compares mtimes against the input "+
"list YOU pass it, so if you edit a header that is not in that list, nothing rebuilds. Know "+
"that going in and it is a fine tool; expect make and you will be bitten."),
docSubheading("What else is in the header"),
prose("More than a build system strictly needs, and all of it is there because a real build wanted "+
"it. A temp allocator — an 8 MB ring buffer that silently wraps, so temp_sprintf can hand you a "+
"formatted path that you never free. A string builder. File I/O that reports its own errors. "+
"And two functions that are squarely about shipping a graphical program: embed_file, which "+
"turns any binary into a C array in a header, and compile_shader / embed_spirv, which run glslc "+
"over a GLSL source and embed the SPIR-V the same way. That last pair is a fossil of a Vulkan "+
"renderer, and it is the clearest evidence in the layer of what codeMAX was becoming."),
apiTable(
apiRow{"GO_REBUILD_URSELF(argc, argv)", "First line of main. Rebuilds and re-executes the script if its source changed."},
apiRow{"Cmd + cmd_append + cmd_run", "A growable argv, appended variadically, run synchronously. cmd_run resets it for reuse."},
apiRow{"needs_rebuild(out, inputs, n)", "1 if any input is newer than the output, 0 if up to date, -1 on error. Mtimes only."},
apiRow{"temp_sprintf / temp_reset", "Formatted strings from a ring buffer. You never free them; you reset the ring."},
apiRow{"sb_read_file / write_entire_file", "Whole-file I/O into a String_Builder, and back out."},
apiRow{"embed_file(in, out, var)", "Turn a binary into a C header: an array, and its size."},
apiRow{"compile_shader / embed_spirv", "glslc a .glsl to .spv, then embed the .spv as a C array."},
),
)
}
// ---- platform ------------------------------------------------------------
func cPlatform() *VNode {
return docSection("platform", "platform",
prose("The operating system, behind one header — and it is a narrower header than the name "+
"suggests. There is no file I/O here, no clock, no threads, and no virtual memory (which is why "+
"the arena is malloc-backed). What there is: a window, the input that arrives at it, the "+
"clipboard, and a way to spawn a terminal."),
prose("Input comes in two shapes on purpose. PlatformRawInput is what the OS actually said — UTF-16 "+
"characters, virtual key codes, mouse coordinates. PlatformInput is what the application wants "+
"to hear. platform_adapt_input converts one into the other, and that function is the seam a "+
"second backend would be written behind."),
note("It expects Dear ImGui in the build, and Windows underneath",
"platform_win32.c is the only backend, and its window procedure forward-declares "+
"ImGui_ImplWin32_WndProcHandler and calls it on every message BEFORE it looks at any of "+
"them — so ImGui gets first refusal on the input, which is what makes an ImGui text field "+
"inside the window behave like a text field rather than a hole the editor's keybindings "+
"fall through. To use this subsystem you vendor ImGui and compile it (it is C++) alongside; "+
"to use it WITHOUT ImGui you cut that one call, and everything else in the file is C and "+
"stands. A second backend would replace the PKEY_ enum — whose values ARE Windows virtual "+
"key codes, passed straight through — and find a home for platform_spawn_terminal, which "+
"hard-codes cmd.exe /k."),
docSubheading("Two decisions worth stealing"),
prose("WM_SIZE calls the frame callback synchronously, from inside the window procedure. That looks "+
"wrong and is the standard Win32 fix for a real problem: while you drag a window's edge, "+
"Windows runs a modal resize loop that never returns to your main loop, so the window goes "+
"blank or smears. Rendering from inside the message handler is the only way to keep drawing. "+
"platform_set_frame_callback exists for exactly this."),
prose("And platform_get_input drains its accumulator on read — it memsets the buffered keys and "+
"characters to zero on the way out, and returns was_mouse_down beside mouse_down so the caller "+
"can see an edge without keeping its own copy of last frame."),
apiTable(
apiRow{"platform_create_window / _destroy_window", "A window from a PlatformWindowDesc; and its teardown."},
apiRow{"platform_poll_events", "Pump the message queue. False means the user wants to close."},
apiRow{"platform_get_input / platform_adapt_input", "The raw OS input for this frame; and the conversion that is the portability seam."},
apiRow{"platform_set_frame_callback", "Called per frame — including from inside a live resize, which is the point."},
apiRow{"platform_get_dpi_scale", "GetDpiForWindow / 96. The layer is per-monitor DPI aware throughout."},
apiRow{"platform_clipboard_get / _set", "Get returns a pointer into a static 64 KB buffer. Do not free it, do not keep it."},
apiRow{"platform_get_native_handle", "The HWND, for the one place that genuinely needs it."},
),
)
}
// ---- lexer ---------------------------------------------------------------
func cLexer() *VNode {
return docSection("lexer", "lexer",
prose("Syntax highlighting, and the most complete thing in this layer — about 2,800 lines of it. "+
"The interesting decision is the output shape, and the header credits it to the Focus editor: a "+
"tokenizer here does not return a list of tokens. It takes the buffer and an out_tokens array "+
"of the SAME LENGTH, and paints one token-type byte per source byte."),
codeLang("c/lexer/lexer.h", "c", lexerSnippet),
prose("That sounds wasteful and is the opposite. An editor never wants to know what the tokens are; "+
"it wants to know what colour to draw the character at offset N — and with a parallel array "+
"that is one indexed read, not a search through a token list. The enum value IS the index into "+
"the theme's colour table, which is why TOK_DEFAULT has to be zero. Painting is a memset. The "+
"cost is one byte per byte of source, for a file you already have in memory."),
note("It lets you rewrite the past, which is why it can find function names",
"All five backends share one trick. When the tokenizer emits a `(` and the PREVIOUS token was "+
"an identifier, it goes back and re-paints that identifier as TOK_FUNCTION. With a token "+
"list you would have to look ahead, or fix up afterwards. With a paint array the past is "+
"just an array range, and repainting it costs a memset."),
docSubheading("Languages"),
prose("Five backends — C, Go, JavaScript, Lua, SQL — plus plain text, each a single .c file behind "+
"the same LexerTokenizeFn signature, selected by a hard-coded switch. There is no registry and "+
"no plugin: adding a language means editing four things in lexer.c, deliberately."),
prose("They are not toys. The JavaScript one carries an explicit depth stack for nested tagged "+
"template literals, so an html`…${css`…${x}`}…` lexes correctly at arbitrary nesting, and it "+
"has a regex-versus-division heuristic — after a keyword or an operator a slash starts a regex, "+
"after an identifier it is a divide. The Lua one counts the equals signs in a long bracket so "+
"[==[ is closed by ]==] and not by ]]. The SQL one is case-insensitive and treats \"quoted\" as "+
"an identifier rather than a string."),
docSubheading("Themes"),
prose("A theme is a colour per token type, and there are thirteen: Default, Default Light, Focus, "+
"Handmade Hero, Witness Classic, Witness, VS Classic, RAD Debugger, 4coder, Ryan Fleury, Gruber "+
"Darker, VS Dark, Freshcut Contrast. The file is pure data — not one function in five hundred "+
"lines."),
note("The Theme struct is a fossil, and you can date the rock",
"Every theme carries a 24-bit colour table AND a 256-colour ANSI fallback, with a use_truecolor "+
"flag — because this started life in a TERMINAL editor, where you cannot assume truecolor. "+
"But the platform layer beside it is a DPI-aware Win32 GUI window driven by ImGui, and the "+
"struct has since grown status_bg, minibuffer and file-browser colours that mean nothing to "+
"a terminal. It is a terminal-era struct with GUI-era fields bolted on. That is a seam, not "+
"a design."),
apiTable(
apiRow{"LexerTokenizeFn(data, len, out_tokens)", "The one signature every backend implements. One byte of token type per byte of source."},
apiRow{"lexer_get_tokenize_fn(lang)", "The backend for a language. A switch, not a registry."},
apiRow{"lexer_detect_lang(filename)", "Language from the file extension."},
apiRow{"Tokenizer + tokenizer_init / _eat_whitespace", "The shared cursor the backends are written against."},
apiRow{"g_themes / theme_active()", "Thirteen built-in themes, and the active one."},
),
)
}
// ---- config --------------------------------------------------------------
func cConfig() *VNode {
return docSection("config", "config",
prose("An INI file — [sections], key = value — that lives NEXT TO THE EXECUTABLE rather than in a "+
"home directory, which makes the program portable: copy the folder, keep your settings. It "+
"parses into one global Config struct and saves back out of it."),
codeLang("c/config/config.h", "c", configSnippet),
prose("The struct is the schema, and it is a fixed-size one: ten recent directories, every path a "+
"flat 1024 bytes. Nothing in this subsystem allocates — which is what lets it be loaded before "+
"an arena exists. If the file is absent, config_load writes a default one."),
note("It stores what the PROGRAM knows, not what the user wants",
"The header is explicit about the split, and it is a good one: this file is managed by the "+
"program — your window size, your recent folders, your active project — and per-project "+
"settings belong in .editorconfig, where other tools can read them. A config file that tries "+
"to be both ends up being neither."),
note("It needs a writable directory beside the executable",
"That is the one requirement the design carries, and it is easy to miss: the file is written "+
"NEXT TO the .exe, and config_save does not check whether the write succeeded. Run out of a "+
"folder you can write to — which is what \"copy the folder, keep your settings\" means — and "+
"it does exactly what it says. Install the same binary into C:\\Program Files and a "+
"non-elevated process cannot write there, so the save quietly does nothing. If you ship it "+
"through the installer, either keep the config beside the exe and expect an elevated write, "+
"or point config_get_path at %APPDATA% and give up the portability."),
apiTable(
apiRow{"config_load / config_save", "Read the INI into g_config; write g_config back out. Writes defaults if the file is absent."},
apiRow{"config_get_path", "Beside the executable — GetModuleFileNameA, then strip the filename."},
apiRow{"config_push_recent_dir", "Dedupes case-insensitively, moves the entry to the front, caps at ten, and saves itself."},
apiRow{"g_config", "The one global. There is no second one."},
),
)
}
// ---- installer -----------------------------------------------------------
func cInstaller() *VNode {
return docSection("installer", "installer",
prose("A Windows installer AND uninstaller, in one executable, with the thing it installs embedded "+
"inside it as a resource. No NSIS, no WiX, no MSI: it is a Win32 property-sheet wizard — "+
"welcome, directory, progress, finish — that extracts the payload, copies itself to "+
"uninstall.exe, optionally appends itself to the system PATH, writes a Start Menu shortcut via "+
"COM, and registers under HKLM so it shows up in Add/Remove Programs."),
prose("installer.manifest is what makes Windows ask for elevation UP FRONT rather than failing on "+
"the first write to Program Files. installer.h exists solely so the resource script and the C "+
"agree on the resource ids."),
note("The wizard has no dialog resources — it writes the DLGTEMPLATEs by hand, at runtime",
"There is a small serializer in installer.c that assembles the Win32 DLGTEMPLATE and "+
"DLGITEMTEMPLATE binary layout byte by byte, alignment padding and all, and hands the result "+
"to the property sheet with PSP_DLGINDIRECT. Which means the installer's entire user "+
"interface needs no resource compiler: only the icon, the manifest and the payload go "+
"through the .rc. That is either magnificent or deranged, and it is certainly deliberate."),
note("A running .exe cannot delete itself, so the uninstaller doesn't",
"It spawns a detached cmd.exe that waits two seconds, deletes uninstall.exe, and removes the "+
"directory. The process outlives the program that started it, which is the only way this can "+
"be done on Windows."),
docSubheading("Pointing it at your own product"),
prose("The .rc is the part you edit. It names codeMAX's icon, codeMAX's manifest and codeMAX's "+
"payload, at the paths the original repository had them at — so adopting the installer means "+
"pointing those three lines at your icon, your manifest and your executable, and changing the "+
"product name and the HKLM key the uninstall entry is written under. Nothing else in installer.c "+
"knows what it is installing."),
prose("There is also visible residue of a two-binary product that was collapsed into one, and it is "+
"worth recognising rather than copying: IDR_EXE_TERMINAL is still declared though nothing embeds "+
"or extracts it, do_install's numbered steps skip step 3, and the uninstaller still removes a "+
"second executable and a shortcut that the installer no longer creates. Those are the lines to "+
"delete on the way in."),
)
}
// ---- helpers -------------------------------------------------------------
// docSubheading is a heading INSIDE a section. The subsystems each have several parts, and
// six sections with no internal structure is a wall.
func docSubheading(text string) *VNode {
return H3(Attr("class", "mt-8 text-base font-semibold text-text-heading"), Text(text))
}
// ---- snippets ------------------------------------------------------------
//
// Every one of these is copied from the source, except two — unityTUSnippet and
// buildUsageSnippet, which are the two files the layer expects its CONSUMER to write and
// so cannot be copied from a layer that has no consumer yet. Both are written against the
// real API and both are captioned as the file you write, not as a file that is here.
//
// If you change the source, change these. A snippet that has drifted from the code it
// claims to show is the most expensive documentation there is, because it is believed.
const unityTUSnippet = `// The whole layer, as one translation unit — the only .c you hand to the
// compiler. Order matters: it is textual inclusion, not linking.
#include "base/base_inc.c" // core, arena, strings. Everything below needs it.
// The five backends declare their helpers `+ "`internal`" + ` (file-static), so they
// belong in the SAME TU as the dispatch that calls them.
#include "lexer/lexer.c"
#include "lexer/lexer_c.c"
#include "lexer/lexer_go.c"
#include "lexer/lexer_js.c"
#include "lexer/lexer_lua.c"
#include "lexer/lexer_sql.c"
// config.c uses Config without including its own header — which is exactly the
// thing a unity build is for. Put the header above it.
#include "config/config.h"
#include "config/config.c"
// ...and then your own program, compiled with all of it:
#include "app/app.c"`
const baseCoreSnippet = `// The three meanings of `+ "`static`" + ` in C, given three names.
#define internal static // a function private to this file
#define global static // a variable owned by this translation unit
#define local_persist static // a local that survives the call
typedef uint8_t U8; typedef int8_t S8;
typedef uint32_t U32; typedef int32_t S32;
typedef uint64_t U64; typedef int64_t S64;
typedef S32 B32; // a boolean, sized so it packs predictably
typedef float F32; typedef double F64;
#define KB(n) (((U64)(n)) << 10)
#define MB(n) (((U64)(n)) << 20)`
const baseArenaSnippet = `typedef struct Arena { U8 *base; U64 pos; U64 cap; } Arena;
typedef struct Temp { Arena *arena; U64 pos; } Temp;
// One malloc, of exactly cap. It does not grow and it does not chain.
Arena *arena_alloc(U64 cap);
void *arena_push(Arena *arena, U64 size); // zeroed
void *arena_push_no_zero(Arena *arena, U64 size); // not
void arena_pop_to(Arena *arena, U64 pos);
#define push_array(arena, T, count) ((T *)arena_push((arena), sizeof(T) * (count)))
// A scratch scope. Allocate as freely as you like inside it; none of it needs
// releasing, because temp_end rolls the cursor back over all of it at once.
Temp scratch = temp_begin(arena);
Node *nodes = push_array(arena, Node, 1024);
temp_end(scratch);`
const baseStringsSnippet = `// A pointer and a length. Not NUL-terminated, not owned, does not allocate —
// so a substring is free, and a Str8 can point into a file you mapped.
typedef struct Str8 { const char *str; U64 size; } Str8;
static inline Str8 str8_lit(const char *s);
static inline B32 str8_match(Str8 a, Str8 b);
static inline B32 str8_is_empty(Str8 s);
// The two that must allocate take the arena, and so they say so:
Str8 str8_pushf(Arena *arena, const char *fmt, ...);
Str8 str8_push_copy(Arena *arena, Str8 s);`
const buildBootstrapSnippet = `# Once, by hand. (On Windows, from a Visual Studio developer prompt —
# it shells out to cl.exe.)
cl /nologo build.c # Windows
cc build.c -o build # macOS / Linux
# Ever after, just run it. If build.c is newer than the binary, the binary
# rebuilds itself, swaps in the new one, and re-executes:
./build`
const buildUsageSnippet = `#define BUILD_IMPLEMENTATION
#include "build.h"
int main(int argc, char **argv) {
// Renames the running binary to .old, recompiles, and re-execs. If the
// compile fails it puts the old one back — so a typo in your build
// script cannot brick your build script.
GO_REBUILD_URSELF(argc, argv);
mkdir_if_not_exists("out");
// needs_rebuild compares MTIMES against the list you pass it. There is
// no header scanning: if a .h you depend on is not in this list, editing
// it will not trigger a rebuild.
const char *srcs[] = {"base/base_inc.c", "lexer/lexer.c"};
if (needs_rebuild("out/app.exe", srcs, ARRAY_LEN(srcs)) > 0) {
Cmd cmd = {0};
cmd_append(&cmd, "cl", "/nologo", "/I.", "/Fe:out/app.exe");
cmd_append(&cmd, srcs[0], srcs[1]);
if (!cmd_run(&cmd)) return 1; // synchronous; resets cmd for reuse
cmd_free(&cmd);
}
build_log(LOG_INFO, "done");
return 0;
}`
const lexerSnippet = `// A tokenizer does not RETURN tokens. It paints one token-type byte per
// source byte, into an array the same length as the buffer.
//
// The editor never asks "what are the tokens". It asks "what colour is the
// character at offset N" — and this answers that with one indexed read.
// The enum value IS the index into the theme's colour table, which is why
// TOK_DEFAULT has to be 0.
typedef void (*LexerTokenizeFn)(const char *data, S32 length, U8 *out_tokens);
typedef enum Lang { LANG_PLAIN_TEXT, LANG_C, LANG_GO, LANG_JS, LANG_LUA, LANG_SQL } Lang;
LexerTokenizeFn lexer_get_tokenize_fn(Lang lang); // a switch, not a registry
Lang lexer_detect_lang(const char *filename);`
const configSnippet = `// The struct IS the schema, and it is fixed-size throughout: this subsystem
// allocates nothing, so it can be loaded before an arena exists.
typedef struct Config {
char theme[64];
char recent_dirs[CONFIG_MAX_RECENT_DIRS][CONFIG_PATH_MAX]; // most recent first
S32 recent_dir_count;
B32 show_line_numbers;
B32 syntax_enabled;
F32 ui_scale;
char editor_font[64];
char active_project[CONFIG_PATH_MAX];
} Config;
extern Config g_config; // the one global
static void config_load(void); // <exe dir>/config.ini — not $HOME
static void config_save(void);`

View File

@@ -0,0 +1,13 @@
// Code generated by wasmgen. DO NOT EDIT.
//go:build js && wasm
package app
import (
"kjol/rsc"
"kjol/vdom"
)
// ServerCounter is a generated client stub for the server component of the same name.
func ServerCounter() func() *vdom.VNode { return rsc.Mount("ServerCounter") }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
package app
import (
"strconv"
"strings"
"kjol/httputil"
. "kjol/vdom"
ui "kjol/webui"
)
// Quote is the payload the /api/quotes endpoint returns. The server encodes a
// []Quote with httputil.RespondGob; the client decodes it straight back into
// []Quote — the SAME Go type, no JSON, no hand-written unmarshalling.
type Quote struct {
Author string
Text string
}
// repoInfo is a subset of GitHub's repo JSON (a third-party API), tagged for
// json decoding.
type repoInfo struct {
FullName string `json:"full_name"`
Description string `json:"description"`
Stars int `json:"stargazers_count"`
}
//gowasm:page /wasm/data layout=app static
func DataPage(d Deps) func() *VNode {
// (1) gob from our own server via httputil.RespondGob / FetchGob.
quotes := NewSignal([]Quote{})
qLoading := NewSignal(true)
qErr := NewSignal("")
// (2) JSON from a third-party API (GitHub), for a user-entered repo.
repo := NewSignal(repoInfo{})
rLoading := NewSignal(true)
rErr := NewSignal("")
repoQuery := NewSignal("golang/go")
started := false
// fetchRepo loads owner/name from the GitHub API into the repo signal.
fetchRepo := func(q string) {
q = strings.Trim(strings.TrimSpace(q), "/")
if q == "" {
rErr.Set("enter a repo as owner/name")
rLoading.Set(false)
return
}
rErr.Set("")
rLoading.Set(true)
httputil.FetchJSON("https://api.github.com/repos/"+q, func(r repoInfo, err error) {
if err != nil {
rErr.Set(err.Error())
} else {
repo.Set(r)
}
rLoading.Set(false)
})
}
return func() *VNode {
// Fire the initial fetches once, on the client (no transport on the server,
// so SSR ships the loading state and the client takes over).
if !started {
started = true
httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
if err != nil {
qErr.Set(err.Error())
} else {
quotes.Set(qs)
}
qLoading.Set(false)
})
fetchRepo(repoQuery.Get())
}
return docPage("Rendering", "Data fetching",
"Fetching happens in the browser, so a server-rendered page ships its LOADING state and the "+
"client fills it in. Two shapes are shown here: gob against your own server, where the same "+
"Go type crosses the wire untranslated, and JSON against somebody else's API.",
docSection("gob", "gob — the same Go type on both ends",
prose("Your server already speaks Go and so does your client, so there is no reason to translate "+
"through JSON in between. The handler answers with httputil.RespondGob([]Quote) and the "+
"client decodes straight back into []Quote — one type, declared once, with no tags and no "+
"hand-written unmarshalling to drift out of sync with it."),
code("app/data.go + server/main.go", gobSnippet),
demo("GET /api/quotes, decoded into []Quote",
quotesBody(qLoading.Get(), qErr.Get(), quotes.Get()),
),
),
docSection("json", "JSON — for everyone else's API",
prose("A third-party API does not speak gob, so httputil.FetchJSON decodes into a tagged struct "+
"the ordinary way. Enter a repository and the browser calls api.github.com directly."),
demo("GET api.github.com/repos/…, decoded into a tagged struct",
row("mb-4 flex items-end gap-2",
row("flex grow flex-col gap-1 max-w-sm",
ui.FormLabel(ui.FormLabelProps{}, Text("GitHub repo (owner/name)")),
ui.FormInput(ui.FormInputProps{
Value: repoQuery.Get(),
Placeholder: "golang/go",
OnInput: func(v string) { repoQuery.Set(v) },
}),
),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Fetch", OnClick: func() { fetchRepo(repoQuery.Get()) }}),
),
repoBody(rLoading.Get(), rErr.Get(), repo.Get()),
),
),
docSection("ssr", "What the server renders",
prose("This route is static, so the server pre-renders it — but there is no fetch on the server: "+
"no transport is installed there, and inventing one would mean the server quietly making "+
"requests on the user's behalf. So a fetch started during SSR does nothing at all, the page "+
"renders its spinner, and the client runs the fetch for real once it has hydrated."),
note("A fetch that fails on the server is a bug in the framework, not in your page",
"An earlier version of this returned an error from SSR, and every static page that fetched "+
"anything rendered \"no client transport installed\" into its own HTML. Loading is the "+
"correct server-side answer to \"have you fetched this yet?\"."),
apiTable(
apiRow{"httputil.RespondGob", "Server: write a Go value as gob."},
apiRow{"httputil.FetchGob", "Client: decode a gob response into a Go value."},
apiRow{"httputil.FetchJSON", "Client: decode a JSON response into a tagged struct."},
apiRow{"httputil.SetClientTransport", "Override the transport — a base URL, auth headers. The runtime installs a fetch-based one for you."},
),
),
)
}
}
const gobSnippet = `// One type. Both ends. No tags, no JSON.
type Quote struct {
Author string
Text string
}
// --- server ---
mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
httputil.RespondGob(w, http.StatusOK, sampleQuotes()) // []Quote
})
// --- client ---
httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
if err != nil { qErr.Set(err.Error()); return }
quotes.Set(qs) // []Quote
})`
func quotesBody(loading bool, failed string, quotes []Quote) *VNode {
switch {
case failed != "":
return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
case loading:
return ui.Loader()
default:
cards := make([]*VNode, 0, len(quotes))
for _, q := range quotes {
cards = append(cards, ui.BorderCard("",
P(Attr("class", "text-ink"), Text("“"+q.Text+"”")),
P(Attr("class", "mt-2 text-sm text-ink-muted"), Text("— "+q.Author)),
))
}
return row("grid gap-3 sm:grid-cols-2", cards...)
}
}
func repoBody(loading bool, failed string, r repoInfo) *VNode {
switch {
case failed != "":
return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
case loading:
return ui.Loader()
default:
return ui.BorderCard("",
row("flex items-center gap-2",
Strong(Attr("class", "text-ink"), Text(r.FullName)),
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber}, Text("★ "+strconv.Itoa(r.Stars))),
),
P(Attr("class", "mt-2 text-sm text-ink-soft"), Text(r.Description)),
)
}
}

View File

@@ -0,0 +1,380 @@
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"
)
// 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-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 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-xs 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-xs 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.`

View File

@@ -0,0 +1,303 @@
package app
import (
. "kjol/vdom"
ui "kjol/webui"
)
// The Go layer's documentation.
//
// One page, like /c and the component pages. The Go module is the biggest layer by far —
// a dozen small packages plus both web engines — so the job here is a MAP, not a manual:
// say what each subsystem is for and name the handful of identifiers you would reach for,
// and leave the exhaustive reference to go doc.
//
// The web engines get a deliberately short section. They are the two compositions, and
// each already has a whole documentation section of its own (/wasm, /js) — repeating it
// here would be two maps of the same ground, kept in sync by hand. So this page points at
// them and moves on.
//
// The organising idea is the same as the C page: every section is a SUBSYSTEM, and the
// sidebar lists them. A subsystem here is a small group of packages that answer one
// question — "how does it talk to a database", "how does it not trust its input" — rather
// than one package per section, which for thirteen packages would be a wall.
// goSubsystems is the single source for the page's sections AND the sidebar that jumps to
// them, so the sidebar cannot offer a jump to a section that does not exist.
func goSubsystems() []subsystem {
return []subsystem{
{ID: "config", Label: "Configuration", Icon: "bolt",
Blurb: "Where the binary learns its world: the environment baked in at compile time, and the config struct read at startup."},
{ID: "data", Label: "Data", Icon: "table",
Blurb: "A PostgreSQL query builder and row-to-struct automapper, and CSV in and out."},
{ID: "http", Label: "HTTP & email", Icon: "globe",
Blurb: "Dependency-free HTTP glue — CORS, responses, a typed client — and pluggable mail."},
{ID: "values", Label: "Values", Icon: "calculator",
Blurb: "The small stuff done once: generic helpers, UTC-first time, and money as integer cents."},
{ID: "trust", Label: "Trust & logging", Icon: "shield-check",
Blurb: "Crypto, input validation, and logging — the three that decide what the program will believe and remember."},
{ID: "text", Label: "Text", Icon: "code",
Blurb: "Syntax highlighting: source in, coloured HTML out. A lexer, not a parser."},
{ID: "engines", Label: "Web engines", Icon: "layers",
Blurb: "The two front-end frameworks are Go too — but documented on their own pages. This is only the pointer."},
{ID: "tooling", Label: "Tooling", Icon: "cube",
Blurb: "The command-line tools, most of them run by the build rather than by hand."},
}
}
// goNav is the sidebar while you are reading /go. The group is called Subsystems, as on /c.
func goNav() []docsGroup {
items := make([]docsItem, 0, len(goSubsystems()))
for _, s := range goSubsystems() {
items = append(items, docsItem{
Path: "/go#" + s.ID,
Label: s.Label,
Icon: s.Icon,
Blurb: s.Blurb,
})
}
return []docsGroup{
{
Title: "Introduction",
Items: []docsItem{
{Path: "/go", Label: "Overview", Icon: "book-open",
Blurb: "What the Go layer is, and the one rule that shapes all of it."},
},
},
{Title: "Subsystems", Items: items},
}
}
//gowasm:page /go static layout=app
func GoPage(d Deps) func() *VNode {
return func() *VNode {
return docPage("Layers", "Kjøl Go",
"The oldest and largest layer: configuration, a database toolkit, logging, HTTP helpers, "+
"mail, money math, validation — the parts an application needs that are not the application. "+
"Both web engines live here too, but those have their own pages; this is the base beneath them.",
docSection("what-this-is", "What this is",
prose("A single Go module, imported package by package as kjol/<name>. Each package is small, "+
"stdlib-first, and does one thing — there is no framework object to construct and no "+
"lifecycle to learn. You import config, or dbutil, or chrono, and call it."),
prose("One rule shapes the whole layer, and it is worth stating before the parts: the framework "+
"NEVER imports application code. Where a package needs something only the app knows — the "+
"names of its tables, where to write a log, its mail credentials, the shape of its config — "+
"the app hands that in, and the package is written against the gap. That is why dbutil has a "+
"Register, l4g a SetDatabaseWriter, snailmail a Configure, and config a generic Load[T]. The "+
"inversions are not decoration; they are the reason one base layer can sit under several "+
"different applications without knowing anything about any of them."),
note("This layer is consumed in place, not published",
"kjøl is a git submodule inside each app, wired up with a go.work file — so editing a file "+
"here takes effect in the consuming app immediately, with no version to bump and no go get. "+
"There is no ABI to keep stable because there is nothing to keep stable between: the layer "+
"and its consumer are built together."),
),
goConfig(),
goData(),
goHTTP(),
goValues(),
goTrust(),
goText(),
goEngines(),
goTooling(),
)
}
}
// ---- configuration -------------------------------------------------------
func goConfig() *VNode {
return docSection("config", "Configuration",
prose("Two packages, and they answer the same question — what world is this binary running in — at "+
"two different times. appenv answers it at COMPILE time: Environment is a const chosen by a build "+
"tag, so a production binary cannot be talked into thinking it is staging by a stray environment "+
"variable. The value is fixed the moment go build runs, and the bundler reads it to define the "+
"same constant for the JavaScript side."),
prose("config answers it at RUNTIME. Load[T] fills the app's own config struct from environment "+
"variables and an optional .env file, with real environment variables always winning over the "+
"file. The app defines the struct — its fields, its env tags, its defaults — and the package only "+
"provides the generic loading, so no two apps have to agree on what configuration means."),
apiTable(
apiRow{"appenv.Environment", "The deployment environment, a const fixed by build tag (-tags staging / production). No runtime path can change it."},
apiRow{"config.Load[T](file, *T)", "Fill your config struct from env + .env. Generic over the struct; environment variables beat the file."},
),
)
}
// ---- data ----------------------------------------------------------------
func goData() *VNode {
return docSection("data", "Data",
prose("dbutil is the largest package in the module, and it is two things: a PostgreSQL query builder "+
"and a reflection-based row-to-struct automapper. It is deliberately NOT an ORM — there are no "+
"migrations here (that is a separate tool) and no magic persistence. You bind a model type to a "+
"table, build parameterized SQL through chainable Select / InsertInto / Update / DeleteFrom, and "+
"scan the result straight into your structs by their db tags — including LEFT JOINs, which map a "+
"missing joined row to a nil pointer rather than a lie."),
prose("The table names come from a registry the app fills at startup (RegisterAll), which is the "+
"inversion at work: the builder resolves a Go type to a table without ever importing the app's "+
"models. Field references are type-safe — you pass a pointer to a struct field and the builder "+
"turns it into a column — so a renamed field is a compile error, not a wrong query at runtime."),
prose("csv is the small sibling: build CSV text from headers and rows, or straight from a slice of "+
"structs, and stream it to the browser as a download."),
apiTable(
apiRow{"dbutil.Init / ConnConfig", "Open the pooled *sql.DB and pin the session to UTC. Credentials are injected, never read from app config."},
apiRow{"dbutil.Register / RegisterAll", "Map a model type to its table name. The inversion: dbutil never imports your models."},
apiRow{"dbutil.Select / InsertInto / Update / DeleteFrom", "Chainable builders — Where, joins, order, paging — that emit $1,$2 parameterized SQL and its args."},
apiRow{"dbutil.ScanAll / QueryOne / QueryScalar[T]", "Result rows into your structs by db tag; joined rows that are all-NULL become nil pointers."},
apiRow{"dbutil.ParseFilterFromRequest / ApplyPagination", "Turn a request's query-string filters and paging into WHERE and LIMIT."},
apiRow{"csv.MakeCSV / StructToCSV / WriteCSVtoHTTP", "CSV from rows or from a slice of structs, and the headers to send it as an attachment."},
),
)
}
// ---- http & mail ---------------------------------------------------------
func goHTTP() *VNode {
return docSection("http", "HTTP & email",
prose("httputil is the HTTP glue, and it imports nothing of the app's. CorsMiddleware is "+
"constructor-style — you hand it the allowed domains and a function that reports the current "+
"bundle version, and it never reads those from config itself. Alongside it are the response "+
"writers (JSON, gob, error) and their client-side mirrors: FetchGob[T] and FetchJSON[T] do a typed "+
"GET-and-decode, and are a deliberate no-op during server rendering, so an SSR pass keeps its "+
"loading state instead of blocking on a network call."),
prose("snailmail sends mail through a provider chosen at startup — SMTP or Cloudflare — with the "+
"credentials injected via Configure and the actual branded message composed on the app side. The "+
"package takes an already-rendered Email and a type (text or HTML) and sends it; it does not know "+
"or care what the mail says."),
apiTable(
apiRow{"httputil.CorsMiddleware(CorsConfig)", "CORS as constructor-style middleware; allowed domains and the bundle-version source are injected."},
apiRow{"httputil.RespondJSON / RespondGob / RespondError", "Encode and write a response."},
apiRow{"httputil.FetchGob[T] / FetchJSON[T]", "Client-side typed GET+decode. A no-op during SSR, so the server keeps a loading state."},
apiRow{"snailmail.Configure(Settings)", "Pick SMTP or Cloudflare and hand it credentials, once at startup."},
apiRow{"snailmail.SendMail(Email, type)", "Send an already-rendered message. TYPE_TEXT or TYPE_HTML."},
),
)
}
// ---- values --------------------------------------------------------------
func goValues() *VNode {
return docSection("values", "Values",
prose("Three packages of the small stuff, done once so the apps do not each do it slightly "+
"differently. basic is the personal standard library: generic slice, map and pointer helpers, "+
"name capitalization that knows about Mc and O', number-to-string with commas, a reflection-based "+
"struct diff. chrono treats every stored time as UTC and only localizes at the edge — format a "+
"time in a timezone for display, parse an HTML date input back to UTC, render \"3 days ago\". "+
"finance keeps money as integer cents, never a float, and formats it back out with a symbol and "+
"grouping."),
apiTable(
apiRow{"basic.Reverse / IndexOf / RemoveDuplicates / MapMerge", "Generic slice and map helpers, stdlib-only."},
apiRow{"basic.NormalizeName / Int64ToStringWithCommas / CompareStructs", "Name casing, grouped numbers, and a field-by-field struct diff."},
apiRow{"chrono.FormatWithTz / DateToHTMLString", "A UTC time localized for display, or fed into an HTML date field."},
apiRow{"chrono.HTMLDateToTime / TimeSinceToString", "An HTML input parsed back to UTC; and \"Just now\" / \"3 days ago\"."},
apiRow{"finance.Int64ToMoneyWithCommas / MoneyToInt64", "Cents-as-int64 to a dollar string and back — no float ever touches the money."},
apiRow{"finance.MultiplyByPercentage / DaysToRateTerm", "Percentage math on cents, and a day count as a best-fit term string."},
),
)
}
// ---- trust & logging -----------------------------------------------------
func goTrust() *VNode {
return docSection("trust", "Trust & logging",
prose("Three packages that decide what the program will believe and what it will remember. security "+
"is the crypto: bcrypt for passwords, AES-256-GCM for secrets (with generic EncryptData[T] that "+
"gob-serializes then encrypts), hashing, base58/64, random keys, and a bluemonday HTML "+
"sanitization policy you initialize at startup. validation cleans and checks input — email, "+
"phone, US state, tax id, ZIP — and its validators return descriptive errors rather than a bare "+
"false, so the caller can say what was wrong."),
prose("l4g is logging, and it carries the same inversion as dbutil. It owns the Entry type — the "+
"framework's mirror of the app's log-entry model — and persists to the database through a function "+
"the app registers with SetDatabaseWriter. If none is registered it falls back to the terminal, so "+
"a line is never silently dropped. The main logger is terminal, file, or database, chosen by an "+
"environment variable."),
apiTable(
apiRow{"security.HashPassword / ComparePasswords", "bcrypt."},
apiRow{"security.EncryptData[T] / DecryptData[T]", "gob-serialize then AES-256-GCM, generic over the value."},
apiRow{"security.Init / SanitizationPolicy", "The bluemonday UGC policy (extended to allow svg/path). Initialize it before use."},
apiRow{"validation.SanitizeEmail / ValidatePhoneNumber / ValidateStateCode", "Clean and check US-centric input; validators return an error, not a bool."},
apiRow{"l4g.Init / Write / Fatal", "Terminal, file, or database logging, selected by LOGGER_TYPE."},
apiRow{"l4g.SetDatabaseWriter(func(Entry) error)", "The inversion: l4g owns Entry, the app owns the table it lands in."},
),
)
}
// ---- text ----------------------------------------------------------------
func goText() *VNode {
return docSection("text", "Text",
prose("lexer is syntax highlighting: source code in, HTML with coloured spans out. It is a lexer and "+
"not a parser on purpose — it degrades to escaped plain text on anything it does not understand "+
"rather than failing, so an unknown language is not an error and a half-written snippet still "+
"renders. It lives beside webui rather than inside it because it touches no DOM; it is a string in "+
"and a string out. It is what colours the C snippets over on the C page."),
apiTable(
apiRow{"lexer.Highlight(lang, src)", "Dispatch by language name. An unknown language comes back escaped and unpainted, not wrong."},
apiRow{"lexer.HighlightGo / HighlightC", "The two languages implemented so far."},
),
note("Its output is class names, so the stylesheet has to know it exists",
"The spans lexer emits carry Tailwind classes (text-emerald-300 and the like), so any stylesheet "+
"that renders a code block has to scan lexer/**/*.go for them. A build that forgets still "+
"compiles and just renders the snippet unstyled — which is exactly how it is wired into this "+
"site's Tailwind step."),
)
}
// ---- web engines ---------------------------------------------------------
// The short section, on purpose: these are the two compositions, and each is documented in
// full elsewhere. All this page owes them is a sentence and a door.
func goEngines() *VNode {
return docSection("engines", "Web engines",
prose("Both of kjøl's web frameworks are assembled out of this layer — and both have their own "+
"documentation, so this is only the map. The gowasm engine (the packages vdom, wasmruntime, rsc "+
"and wasmdevserver, plus the webui component kit) lets you write user interfaces as ordinary Go "+
"compiled to WebAssembly, server-rendered and then hydrated, with no JavaScript build at all. "+
"jsbundler and tw are the other road: the JavaScript build — TSX to Solid to esbuild — and a "+
"Tailwind v4 compiler written in Go, which is what styles both engines."),
P(Attr("class", "mt-4 flex flex-wrap gap-3"),
engineLink("/wasm", "code", "Kjøl Wasm Web"),
engineLink("/js", "table", "Kjøl JS Web"),
),
)
}
// engineLink is a pill that crosses into a composition's documentation. A plain href, not a
// client-side route: /js is a different binary's SPA, and even /wasm is reached most simply
// by letting the browser navigate rather than asking this page to swap itself out. No Deps,
// therefore — there is no navigate() to intercept.
func engineLink(href, icon, label string) *VNode {
return A(Attr("class", "inline-flex items-center gap-2 rounded-default border border-line px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:border-primary-border hover:bg-primary-subtle hover:text-accent"),
Attr("href", href),
ui.IconInline(icon, 14, "text-ink-faint"),
Text(label),
ui.IconInline("arrow-right", 12, "text-ink-faint"),
)
}
// ---- tooling -------------------------------------------------------------
func goTooling() *VNode {
return docSection("tooling", "Tooling",
prose("The module ships a handful of command-line programs under cmd/. Most of them are run by the "+
"build rather than typed by hand: wasmgen reads the //gowasm: directives and writes the route and "+
"layout glue, twcss compiles the Tailwind stylesheet, bundle drives the JavaScript build, and "+
"typecheck runs the TypeScript checker. The rest are operational: migrate applies database "+
"migrations, loc reports the lines of code across the repository, and passgen bcrypt-hashes a "+
"password from the command line."),
apiTable(
apiRow{"cmd/wasmgen", "Preprocesses the //gowasm: directives into glue: the route map, the layouts, the server-component calls."},
apiRow{"cmd/twcss", "The Tailwind v4 compiler as a CLI — scan the sources, write the stylesheet."},
apiRow{"cmd/bundle", "A thin CLI over jsbundler: TSX → Solid → esbuild, plus the SSR bake."},
apiRow{"cmd/typecheck", "Runs the frontend TypeScript checker (tsgo, the native-Go TypeScript compiler)."},
apiRow{"cmd/migrate", "PostgreSQL migrations — up and down, behind an advisory lock."},
apiRow{"cmd/loc", "A lines-of-code report over git-tracked files (gocloc), vendored code excluded."},
apiRow{"cmd/passgen", "bcrypt-hash a password given on the command line."},
),
)
}

View File

@@ -0,0 +1,82 @@
package app
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
ui "kjol/webui"
)
// Every icon name this app names must actually resolve.
//
// An unregistered name renders an empty, correctly-sized box. That is the right thing
// at runtime — a missing icon should not collapse the layout — but it means a typo is
// invisible: the icon is simply absent, and nothing says why. Two of them (shapes,
// layer-group, which the kit calls squares and layers) shipped in the sidebar looking
// like blank squares before this test existed.
//
// It scans the SOURCE rather than a hand-kept list, so an icon added to a page tomorrow
// is checked tomorrow, without anyone remembering to add it here.
func TestEveryIconNameResolves(t *testing.T) {
// ui.Icon("x", …) / ui.IconInline("x", …), and the Icon: "x" field on the props
// structs (buttons, menu items, docs nav).
patterns := []*regexp.Regexp{
regexp.MustCompile(`Icon(?:Inline)?\("([a-z0-9-]+)"`),
regexp.MustCompile(`\bIcon:\s*"([a-z0-9-]+)"`),
}
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
used := map[string][]string{} // icon name -> files that ask for it
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, re := range patterns {
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
used[m[1]] = append(used[m[1]], f)
}
}
}
if len(used) == 0 {
t.Fatal("scanned the package and found no icon names at all — the patterns have gone stale")
}
names := make([]string, 0, len(used))
for n := range used {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
if !ui.HasIcon(n) {
t.Errorf("icon %q is not registered (used in %s) — it will render as an empty box",
n, strings.Join(dedupe(used[n]), ", "))
}
}
t.Logf("checked %d icon names", len(names))
}
func dedupe(in []string) []string {
seen := map[string]bool{}
out := in[:0:0]
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}

View File

@@ -0,0 +1,262 @@
package app
import (
"strings"
. "kjol/vdom"
ui "kjol/webui"
)
// What kjøl is made of, as data.
//
// There are two kinds of thing here, and conflating them was the mistake this file used
// to make — one flat list called "the layers", holding both.
//
// LAYERS are LANGUAGES. What kjøl is written in, and what it gives you in each:
// the Go base, the TypeScript kit, the C base, the Jai modules. A layer is
// a directory of code you can use on its own.
//
// COMPOSITIONS are FRAMEWORKS. What you get when the layers are assembled into
// something that does a job — the two web engines. A composition is not
// another language; it is a use of them.
//
// Kjøl Wasm Web is Go, all the way down. Kjøl JS Web is TypeScript compiled by a Go
// toolchain — two layers, one framework. Listing that beside "C" as though they were the
// same kind of noun told the reader nothing about either.
//
// This is the Go mirror of frontend/src/layers.ts. The site has two front-ends built by
// two completely different pipelines, and these menus have to be identical in both — so
// each is a LIST, not markup, and the two lists are the only thing that has to be kept in
// step.
//
// (A shared source would be better than a mirrored one. There isn't one: this side
// compiles to WebAssembly and the other is bundled by esbuild, and nothing is upstream of
// both. Keeping each to a flat slice of plain data is what makes the duplication
// survivable — you can diff them by eye.)
type Layer struct {
Name string
Href string
Tagline string
// Sub is the half-line beside the wordmark while you are inside this layer. It says
// what you are standing in — "Go + WebAssembly", "arenas, strings, a lexer" — and a
// wordmark that says the same thing everywhere is one more thing the reader has to
// keep track of himself.
Sub string
// Live means you can click into worked examples. The others are documented but
// have no demo — they still appear, because a menu that silently omits half the
// library teaches the reader that the library is half the size it is.
Live bool
Icon string
}
// Wordmark is what the CHROME calls this layer — the top bar, and the page's own title.
// The menu calls it Name.
//
// They differ, and only for the languages. In a menu headed "Layers" the row says "C",
// because the row is answering "which language"; up in the top bar, alone, "C" is the name
// of a language rather than the name of the thing you are reading, and it has to say whose
// C this is. The compositions are already named "Kjøl Wasm Web" — the product's name is
// part of what they ARE, not a prefix bolted on — so they are returned unchanged.
func (l Layer) Wordmark() string {
if strings.HasPrefix(l.Name, "Kjøl") {
return l.Name
}
return "Kjøl " + l.Name
}
// Languages: what kjøl is written in.
func Languages() []Layer {
return []Layer{
{
Name: "Go",
Href: "/go",
Tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
Sub: "the base layer",
Live: true,
Icon: "server",
},
{
Name: "TypeScript",
Href: "/ts",
Tagline: "The Solid component kit, the vendored runtime, and the generic scaffolding apps import as @kjol/*.",
Icon: "squares",
},
{
Name: "C",
Href: "/c",
Tagline: "Arena allocator, counted strings, math, a lexer, a platform layer — and a build system that is a C file.",
Sub: "a base layer in C",
Live: true,
Icon: "bolt",
},
{
Name: "Jai",
Href: "/jai",
Tagline: "Console rendering. Early.",
Icon: "cube",
},
}
}
// Compositions: what the languages are assembled into.
func Compositions() []Layer {
return []Layer{
{
Name: "Kjøl Wasm Web",
Href: "/wasm",
Tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, and no JavaScript build at all.",
Sub: "Go + WebAssembly",
Live: true,
Icon: "code",
},
{
Name: "Kjøl JS Web",
Href: "/js",
Tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
Sub: "Solid + Go toolchain",
Live: true,
Icon: "table",
},
}
}
// CurrentLayer is the layer or composition the given path belongs to, or nil on the front
// page. The wordmark uses it to name where you are standing.
func CurrentLayer(path string) *Layer {
all := append(Compositions(), Languages()...)
for i, l := range all {
if path == l.Href || strings.HasPrefix(path, l.Href+"/") {
return &all[i]
}
}
return nil
}
// The two menus' controllers.
//
// They are created ONCE, here, at package level — not inside the functions below, which a
// layout calls on every single render. A floating component is a controller: it owns an
// open signal, a positioning engine and document listeners, and building a fresh one per
// render would leak all three and give you a menu that never opens. Same rule as Theme, a
// few lines up in pages.go.
//
// TWO menus, not one with two headings inside it. They are different questions — "what is
// this written in" and "what can I read" — and a reader who wants the second should not
// have to scroll past the first to find it. The kit's single-open manager means opening
// one closes the other, so they behave like one control with two halves.
var (
LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
CompositionsMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
)
// layersMenu lists the LANGUAGES. compositionsMenu, below, lists the frameworks.
//
// An entry that is Live is a link. One that is not is inert and dimmed, with the word
// "reference" on it — it exists, it is documented in the repository, there is simply
// nothing here to click.
//
// Crossing into another layer is a REAL navigation, not a client-side route: /js is a
// different binary's SPA and /wasm is this one. Hence a plain href and no navigate()
// interception — an intercepted click would ask this WebAssembly to render a page it
// does not have.
func layersMenu(d Deps) *VNode {
return dropdown(d, LayersMenuCtl, "Layers", Languages())
}
// compositionsMenu lists the FRAMEWORKS — the two things assembled out of the layers, and
// the two a reader can actually click into.
func compositionsMenu(d Deps) *VNode {
return dropdown(d, CompositionsMenuCtl, "Compositions", Compositions())
}
func dropdown(d Deps, ctl *ui.Menu, label string, rows []Layer) *VNode {
content := make([]*VNode, 0, len(rows))
for _, l := range rows {
content = append(content, layerItem(d, l))
}
return Div(Attr("class", "relative"),
ctl.Trigger(ui.MenuTriggerProps{
Class: "inline-flex items-center gap-1.5 rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink",
},
Text(label),
ui.IconInline("chevron-down", 11, "text-ink-faint"),
),
ctl.Content("w-96", content...),
)
}
// layerGrid is the front page's list — the same data as the menu, laid out to be read
// rather than navigated. A layer with no examples still gets a row: the point of the page
// is what kjøl IS, and half of it having no demo yet does not make that half not exist.
func layerGrid(rows []Layer) *VNode {
mods := []Mod{Attr("class", "mt-5 divide-y divide-line rounded-default border border-line")}
for _, l := range rows {
mods = append(mods, layerRow(l))
}
return Div(mods...)
}
// No icon beside the name, and none on the "Read the docs" link. The front page reads as a
// short list of what kjøl is, and a glyph next to every row — a boat, a table, a globe —
// asks to be decoded before the word beside it is read. The words are the point; they carry
// themselves. (The reference badge stays: it says something the name does not.)
func layerRow(l Layer) *VNode {
head := Span(Attr("class", "flex items-center gap-2"),
Span(Attr("class", "font-medium text-ink"), Text(l.Name)),
iff2(l.Live,
func() *VNode { return nil },
func() *VNode {
return Span(Attr("class", "rounded-full border border-line px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-faint"),
Text("reference"))
}),
)
body := P(Attr("class", "mt-1 text-sm leading-relaxed text-ink-muted"), Text(l.Tagline))
if !l.Live {
return Div(Attr("class", "px-5 py-4 opacity-75"), head, body)
}
// A real navigation: the next layer is a different binary.
return A(Attr("class", "block px-5 py-4 no-underline hover:bg-surface-muted"), Attr("href", l.Href),
head, body,
Span(Attr("class", "mt-2 inline-block text-sm font-medium text-accent"),
Text("Read the docs")),
)
}
// iff2 picks a node. Go has no ternary, and a four-line if statement inside a tree literal
// breaks the shape of the markup worse than this does.
func iff2(cond bool, a, b func() *VNode) *VNode {
if cond {
return a()
}
return b()
}
// No icon on the menu rows either — the name and its one-line tagline are the whole item,
// same as the front-page list and the sidebar. (The chevron on the menu TRIGGER stays: it
// is not a layer's glyph, it is the cue that the thing opens.)
func layerItem(d Deps, l Layer) *VNode {
active := CurrentLayer(d.Path()) != nil && CurrentLayer(d.Path()).Href == l.Href
if !l.Live {
return Div(Attr("class", "flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55"),
Span(Attr("class", "flex items-center gap-2 text-sm font-medium text-ink-muted"),
Text(l.Name),
Span(Attr("class", "rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted"),
Text("reference")),
),
Span(Attr("class", "text-xs text-ink-muted"), Text(l.Tagline)),
)
}
cls := "flex flex-col gap-0.5 px-3 py-2 no-underline hover:bg-surface-raised"
if active {
cls += " bg-primary-subtle"
}
return A(Attr("class", cls), Attr("href", l.Href),
Span(Attr("class", "text-sm font-medium text-ink"), Text(l.Name)),
Span(Attr("class", "text-xs text-ink-muted"), Text(l.Tagline)),
)
}

View File

@@ -0,0 +1,525 @@
// 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"),
),
)
}
}`

View File

@@ -0,0 +1,54 @@
// Code generated by wasmgen. DO NOT EDIT.
package app
import "kjol/vdom"
// Routes maps each //gowasm:page path to its instantiated render function.
func Routes(d Deps) map[string]func() *vdom.VNode {
return map[string]func() *vdom.VNode{
"/": HomePage(d),
"/about": AboutPage(d),
"/c": CPage(d),
"/go": GoPage(d),
"/wasm": DocsPage(d),
"/wasm/chart": ChartPage(d),
"/wasm/components": ComponentsPage(d),
"/wasm/data": DataPage(d),
"/wasm/server": ServerPage(d),
}
}
// StaticPaths are the routes the server pre-renders (SSR); others render client-side.
var StaticPaths = map[string]bool{
"/": true,
"/about": true,
"/c": true,
"/go": true,
"/wasm": true,
"/wasm/chart": true,
"/wasm/data": true,
}
// RouteLayout maps each route to the name of the layout that wraps it.
var RouteLayout = map[string]string{
"/": "public",
"/about": "public",
"/c": "app",
"/go": "app",
"/wasm": "app",
"/wasm/chart": "app",
"/wasm/components": "app",
"/wasm/data": "app",
"/wasm/server": "app",
}
// LayoutFor wraps a page's content in the layout declared for its route.
func LayoutFor(d Deps, path string, content *vdom.VNode) *vdom.VNode {
switch RouteLayout[path] {
case "app":
return AppLayout(d, content)
case "public":
return PublicLayout(d, content)
}
return AppLayout(d, content)
}

View File

@@ -0,0 +1,11 @@
// Code generated by wasmgen. DO NOT EDIT.
//go:build !(js && wasm)
package app
import "kjol/rsc"
func init() {
rsc.Register("ServerCounter", ServerCounter)
}

View File

@@ -0,0 +1,120 @@
//go:build !(js && wasm)
package app
import (
"bytes"
"strconv"
"time"
chart "github.com/wcharczuk/go-chart/v2"
. "kjol/vdom"
ui "kjol/webui"
)
// ServerCounter is a SERVER component — note it's written exactly like a client
// component (same builders, signals, On handlers). The //gowasm:server directive
// makes the build generate a client stub so calling ServerCounter() on the
// frontend is identical to calling any component; the state and this render run
// on the server (its chart is computed there with go-chart), and clicks
// round-trip over /rsc.
//
// The chart plots the counter value against the wall-clock time of each click
// (milliseconds since the first click), so spacing clicks out spreads the data
// points along the x-axis. Because the component is stateless on the server, the
// click points live in a signal that round-trips with the rest of its state
// (a plain slice would reset on every request).
//
//gowasm:server
func ServerCounter() func() *VNode {
count := NewSignal(0)
points := NewSignal([]clickPoint{})
bump := func(delta int) {
count.Set(count.Get() + delta)
points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()}))
}
// No card of its own: the component draws bare content and lets the caller frame it.
// The docs page already puts it in a demo panel, and a card inside a card gives you
// two borders and two shadows around the same thing.
return func() *VNode {
return Div(
Div(Attr("class", "flex items-center gap-2 mb-3"),
Span(Attr("class", "text-ink-soft"), Text("Server counter: ")),
Strong(Attr("class", "badge inline-flex items-center rounded-full bg-green-700 px-2.5 py-0.5 text-sm font-semibold text-white"), Text(strconv.Itoa(count.Get()))),
Div(Attr("class", "ml-auto flex gap-1"),
ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "", OnClick: func() { bump(-1) }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "+", OnClick: func() { bump(1) }}),
),
),
Div(Attr("class", "rounded-default border border-line bg-surface p-2 overflow-auto"),
Raw(clickChartSVG(points.Get()))),
)
}
}
// clickPoint records one click: its wall-clock time and the resulting counter
// value. Exported fields so the signal's JSON snapshot round-trips it.
type clickPoint struct {
T int64 // click time, Unix milliseconds
V int // counter value after the click
}
// clickChartSVG plots counter value vs. time-of-click (ms since the first
// click) as a line graph. Explicit axis ranges keep it valid for the tricky
// cases (a single click, or several clicks within the same millisecond).
func clickChartSVG(points []clickPoint) string {
if len(points) == 0 {
return `<span class="text-muted">Click + / to plot the counter over time (ms since the first click).</span>`
}
t0 := points[0].T
xs := make([]float64, len(points))
ys := make([]float64, len(points))
minY, maxY := 0.0, 0.0 // keep the zero baseline in view for context
for i, p := range points {
xs[i] = float64(p.T - t0)
ys[i] = float64(p.V)
if ys[i] < minY {
minY = ys[i]
}
if ys[i] > maxY {
maxY = ys[i]
}
}
maxX := xs[len(xs)-1]
if maxX <= 0 {
maxX = 1 // rapid or single clicks: avoid a zero-width x-range
}
if minY == maxY {
maxY++ // avoid a zero-height y-range
}
graph := chart.Chart{
Title: "Counter over time (computed on the server)",
TitleStyle: chart.Style{FontSize: 14},
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 20, Right: 20, Bottom: 40}},
Height: 260,
XAxis: chart.XAxis{
Name: "ms since first click",
Range: &chart.ContinuousRange{Min: 0, Max: maxX},
},
YAxis: chart.YAxis{
Name: "counter",
Range: &chart.ContinuousRange{Min: minY, Max: maxY},
},
Series: []chart.Series{
chart.ContinuousSeries{
XValues: xs,
YValues: ys,
Style: chart.Style{
StrokeColor: chart.ColorGreen, StrokeWidth: 2,
DotColor: chart.ColorGreen, DotWidth: 4, // a dot at each click
},
},
},
}
var buf bytes.Buffer
if graph.Render(chart.SVG, &buf) != nil {
return `<span class="text-danger">chart error</span>`
}
return buf.String()
}

View File

@@ -0,0 +1,179 @@
package app
import (
"bytes"
"os"
"strings"
"testing"
"kjol/vdom"
"kjol/webui"
)
func TestSSRPages(t *testing.T) {
for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/components"} {
deps := Deps{Path: func() string { return path }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal"))
if len(html) < 200 {
t.Errorf("%s rendered only %d bytes", path, len(html))
}
}
}
func TestSSRTablePage(t *testing.T) {
deps := Deps{Path: func() string { return "/wasm/components" }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
// The table persists a personal layout in localStorage, which the SERVER CANNOT
// READ. So the server renders a SKELETON, not the default table: if it rendered
// the default one, a user who had reordered their columns would watch them
// rearrange themselves once the wasm booted.
//
// This is a real cost — the page ships no table content — and it is the price of
// never showing the wrong table. See webui.RestoreLayout.
if !strings.Contains(html, `aria-busy="true"`) {
t.Error("SSR should render the AutoTable's loading skeleton, not a table")
}
if !strings.Contains(html, "animate-pulse") {
t.Error("the skeleton bars are missing")
}
// A salary, which ONLY the AutoTable renders.
//
// This used to look for "Ada Lovelace", which was a fine proxy back when the table
// had a page to itself. It is not one any more: the components page also demos
// PrettyTable, and PrettyTable's rows are Ada, Alan and Grace — so the old assertion
// failed on a page that was behaving perfectly. A test that names a value only the
// component under test can produce cannot be fooled by its neighbours.
if strings.Contains(html, "$1,610.25") {
t.Error("SSR rendered AutoTable CONTENT — a user with a saved layout would watch it rearrange")
}
}
// renderedTable drives the very table the page renders, past its skeleton. Natively
// there is nothing to restore, so RestoreLayout just marks the layout settled.
func renderedTable(t *testing.T) string {
t.Helper()
highlight := vdom.NewSignal("")
table := newEmployeeTable(highlight)
table.SetRows(employees())
table.RestoreLayout()
return vdom.RenderHTML(table.Render())
}
// Once the layout has settled, the table renders in full.
func TestTableRendersOnceSettled(t *testing.T) {
html := renderedTable(t)
for _, want := range []string{"Ada Lovelace", "Salary"} {
if !strings.Contains(html, want) {
t.Errorf("settled table missing %q", want)
}
}
// PerPage is 5, so page one holds 5 of the 12 rows.
if got := strings.Count(html, "@example.com"); got != 5 {
t.Errorf("rendered %d rows, want 5 (one page)", got)
}
// The Rank column is HiddenByDefault.
if strings.Contains(html, ">Rank<") {
t.Error("a HiddenByDefault column was rendered")
}
if !strings.Contains(html, "Page 1 of 3") {
t.Error("pagination did not compute 3 pages for 12 rows at 5/page")
}
}
// Calculated columns, end to end through the page, in all three shapes.
//
// Page 1 (declared order):
//
// salary 1200.50 1500.00 980.00 1340.00 1610.25
// bonus 150.00 300.00 0.00 220.00 400.00
func TestSSRCalculatedColumns(t *testing.T) {
html := renderedTable(t)
// BASIC: sum over the operand columns [Salary, Bonus], combined ACROSS each row.
// If this ever aggregated DOWN the column instead, every row would read the same
// number — which is exactly the bug these values are here to catch.
for _, want := range []string{"$1,350.50", "$1,800.00", "$980.00", "$1,560.00", "$2,010.25"} {
if !strings.Contains(html, want) {
t.Errorf("Total comp missing %s (a per-row Salary + Bonus)", want)
}
}
// ADVANCED: ([Salary] + [Bonus]) * 12.
for _, want := range []string{"$16,206.00", "$21,600.00", "$11,760.00"} {
if !strings.Contains(html, want) {
t.Errorf("Annual column missing %s", want)
}
}
// ADVANCED, position-dependent: SUM({Salary:1:ROW()}) accumulates down the rows.
for _, want := range []string{"$2,700.50", "$3,680.50", "$5,020.50", "$6,630.75"} {
if !strings.Contains(html, want) {
t.Errorf("running total missing %s", want)
}
}
// SUMMARY: aggregated DOWN the column, over ALL 12 filtered rows — not the 5 on
// this page. 1200.50+1500+980+1340+1610.25+1120+1275.75+1050+1400+860+1180+990.
if !strings.Contains(html, "$14,506.50") {
t.Error("footer did not total the whole filtered set ($14,506.50)")
}
if !strings.Contains(html, "Average salary") {
t.Error("summary row label missing")
}
}
// The export path, driven through the very table the /table page renders.
//
// Export must write what the FILTER selected — every matching row across every page
// — not the five rows on screen; the columns the user can SEE, in their order; and
// the calculated columns, with each row's own value.
func TestTableExport(t *testing.T) {
highlight := vdom.NewSignal("")
table := newEmployeeTable(highlight)
table.RestoreLayout() // nothing to restore natively; reveals the table over its skeleton
table.SetRows(employees())
// Filter to one team, then render (which resolves FilteredRows).
table.SetSearchValue("Team", "Research", true)
table.Render()
csv := string(webui.ExportCSV(table.ExportColumns(), table.FilteredRows(), nil))
// PerPage is 5 and Research has 4 members, but the point is that export ignores
// paging entirely: every filtered row, no one else's.
for _, want := range []string{"Alan Turing", "Katherine Johnson", "Barbara Liskov", "Evelyn Boyd Granville"} {
if !strings.Contains(csv, want) {
t.Errorf("CSV missing filtered row %q", want)
}
}
if strings.Contains(csv, "Ada Lovelace") {
t.Error("CSV contains a row the filter excluded")
}
// Rank is HiddenByDefault, so it must not be exported.
if strings.Contains(csv, "Item 10") {
t.Error("CSV exported a hidden column")
}
// The calculated columns come along, and the running total ACCUMULATES —
// $1,500.00 then $2,840.00 (Turing + Johnson), not the same number twice.
if !strings.Contains(csv, "Running total") || !strings.Contains(csv, "$2,840.00") {
t.Errorf("running total did not accumulate in the export:\n%s", csv)
}
// And the PDF: a real file, with the same filtered content.
pdf := table.ExportPDFBytes(webui.AutoTablePDFHeader{
Title: "Employees", ShowDate: true, Orientation: webui.PDF_ORIENTATION_LANDSCAPE,
})
if !bytes.HasPrefix(pdf, []byte("%PDF-")) || !bytes.Contains(pdf, []byte("%%EOF")) {
t.Fatalf("PDF is not a PDF (%d bytes)", len(pdf))
}
if out := os.Getenv("PDF_OUT"); out != "" {
if err := os.WriteFile(out, pdf, 0o644); err != nil {
t.Fatal(err)
}
t.Logf("wrote %s (%d bytes)", out, len(pdf))
}
}

View File

@@ -0,0 +1,202 @@
package app
import (
. "kjol/vdom"
ui "kjol/webui"
)
// Employee is a row in the table demo. Salary and Bonus are both money, so a
// calculated column has two numeric columns to combine ACROSS a row.
type Employee struct {
Name string
Email string
Team string
Status string
Salary string
Bonus string
Rank string
Note string
}
func employees() []any {
rows := []Employee{
{"Ada Lovelace", "ada@example.com", "Engineering", "active", "$1,200.50", "$150.00", "Item 2", "Wrote the first algorithm."},
{"Alan Turing", "alan@example.com", "Research", "active", "$1,500.00", "$300.00", "Item 10", "Decidability, and the machine."},
{"Grace Hopper", "grace@example.com", "Engineering", "inactive", "$980.00", "$0.00", "Item 1", "Found the first bug. Literally."},
{"Katherine Johnson", "katherine@example.com", "Research", "active", "$1,340.00", "$220.00", "Item 3", "Orbital mechanics, by hand."},
{"Margaret Hamilton", "margaret@example.com", "Engineering", "active", "$1,610.25", "$400.00", "Item 21", "Coined 'software engineering'."},
{"Barbara Liskov", "barbara@example.com", "Research", "inactive", "$1,120.00", "$90.00", "Item 7", "The substitution principle."},
{"Radia Perlman", "radia@example.com", "Networking", "active", "$1,275.75", "$180.00", "Item 12", "Spanning tree protocol."},
{"Karen Sparck Jones", "karen@example.com", "Research", "active", "$1,050.00", "$60.00", "Item 5", "Inverse document frequency."},
{"Frances Allen", "frances@example.com", "Engineering", "inactive", "$1,400.00", "$250.00", "Item 9", "Optimizing compilers."},
{"Jean Bartik", "jean@example.com", "Engineering", "active", "$860.00", "$40.00", "Item 4", "Programmed the ENIAC."},
{"Evelyn Boyd Granville", "evelyn@example.com", "Research", "active", "$1,180.00", "$130.00", "Item 15", "Trajectory analysis."},
{"Annie Easley", "annie@example.com", "Networking", "inactive", "$990.00", "$75.00", "Item 6", "Rocket propulsion code."},
}
out := make([]any, len(rows))
for i, r := range rows {
out[i] = r
}
return out
}
func emp(row any) Employee { return row.(Employee) }
func tableColumns() []ui.AutoTableColumn {
return []ui.AutoTableColumn{
{
Key: "name", DisplayName: "Name", Sortable: true, SortIdentifier: "Name",
CSV: true, CSVValue: func(r any) string { return emp(r).Name },
// No Toggleable: the name is what identifies a row, so it cannot be hidden.
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-ink", Text(emp(r).Name)) },
},
{
Key: "email", DisplayName: "Email", Sortable: true, SortIdentifier: "Email",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Email },
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-ink-muted", Text(emp(r).Email)) },
},
{
Key: "team", DisplayName: "Team", Sortable: true, SortIdentifier: "Team",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Team },
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("", Text(emp(r).Team)) },
},
{
Key: "status", DisplayName: "Status", Sortable: true, SortIdentifier: "Status",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Status },
Cell: func(r any) *VNode {
color := ui.BadgeGreen
if emp(r).Status != "active" {
color = ui.BadgeNeutral
}
return ui.AutoTableTdLeft("", ui.Badge(ui.BadgeProps{Color: color}, Text(emp(r).Status)))
},
},
{
// SortTypeMoney parses "$1,200.50" as a number — a plain string sort would
// put $1,200.50 before $980.00.
Key: "salary", DisplayName: "Salary", DisplayPosition: ui.COL_POS_RIGHT,
Sortable: true, SortIdentifier: "Salary", SortType: ui.SortTypeMoney,
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Salary },
Cell: func(r any) *VNode { return ui.AutoTableTdRight("tabular-nums", Text(emp(r).Salary)) },
},
{
Key: "bonus", DisplayName: "Bonus", DisplayPosition: ui.COL_POS_RIGHT,
Sortable: true, SortIdentifier: "Bonus", SortType: ui.SortTypeMoney,
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Bonus },
Cell: func(r any) *VNode { return ui.AutoTableTdRight("tabular-nums", Text(emp(r).Bonus)) },
},
{
// SortTypeNumeric sorts "Item 2" before "Item 10".
Key: "rank", DisplayName: "Rank", Sortable: true, SortIdentifier: "Rank",
SortType: ui.SortTypeNumeric, Toggleable: true, HiddenByDefault: true,
CSV: true, CSVValue: func(r any) string { return emp(r).Rank },
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("", Text(emp(r).Rank)) },
},
}
}
// newEmployeeTable builds the table controller.
//
// It is factored out of TablePage so a test can drive the very same table the page
// renders — the export test checks the bytes this exact configuration produces,
// rather than a second copy of it that could drift.
//
// The controller owns the search, sort, page, expansion and column state. Build it
// ONCE, never inside a render closure: rebuilding it per frame would reset every
// filter on each keystroke.
func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
return ui.NewAutoTableState(tableColumns(), ui.AutoTableStateOptions{
PerPage: 5,
// The table PAGES ITSELF to wherever the highlighted row landed after
// filtering and sorting.
HighlightMatch: func(r any) bool {
return highlight.Get() != "" && emp(r).Email == highlight.Get()
},
// Calculated columns come in two shapes, and the difference is the thing to
// understand:
//
// BASIC — a function over OPERAND COLUMNS, combined ACROSS each row.
// Sum over [Salary, Bonus] is this row's salary + bonus. It does
// NOT total the column. Operands are column KEYS (SortIdentifier),
// and subtract/divide are binary and ORDERED.
//
// ADVANCED — an Excel-style formula, which names columns by DISPLAY name:
// [Salary] is this row's cell, {Salary} is the whole column, and
// {Salary:1:ROW()} is everything up to this row — a running total.
//
// Either way they are evaluated against the FILTERED, SORTED rows, so filtering
// re-runs them. (ToCalcNumber parses "$1,200.50" for you.)
Calculated: []ui.UserCalculatedColumn{
{
// Basic: two columns, added together, per row.
ID: "comp", DisplayName: "Total comp", Fn: ui.CALC_FN_SUM,
Operands: []string{"Salary", "Bonus"},
DataType: ui.CALC_TYPE_MONEY, DisplayPosition: ui.COL_POS_RIGHT,
},
{
// Advanced: a formula.
ID: "annual", DisplayName: "Annual", Fn: ui.CALC_FN_CUSTOM,
Formula: "([Salary] + [Bonus]) * 12", DataType: ui.CALC_TYPE_MONEY,
DisplayPosition: ui.COL_POS_RIGHT,
},
{
// Advanced, and position-dependent: a running total down the page.
ID: "running", DisplayName: "Running total", Fn: ui.CALC_FN_CUSTOM,
Formula: "SUM({Salary:1:ROW()})", DataType: ui.CALC_TYPE_MONEY,
DisplayPosition: ui.COL_POS_RIGHT,
},
},
// A summary row goes the OTHER way: one column, aggregated DOWN the whole
// filtered set — not just the page on screen. Basic mode does that with a
// function + one operand; this one uses a formula for the same thing.
SummaryRows: []ui.UserSummaryRow{
{ID: "total", Label: "Total salary", Fn: ui.CALC_FN_SUM,
Operands: []string{"Salary"}, DataType: ui.CALC_TYPE_MONEY},
{ID: "avg", Label: "Average salary", Fn: ui.CALC_FN_CUSTOM,
Formula: "AVERAGE({Salary})", DataType: ui.CALC_TYPE_MONEY},
},
Accordion: true,
RowKey: func(r any) string { return emp(r).Email },
AccordionContent: func(r any) *VNode {
return P(Attr("class", "px-4 py-2 text-sm text-ink-soft"), Text(emp(r).Note))
},
Columns: ui.AutoTableColumnOptions{
Toggleable: true,
Draggable: true,
Resizable: true,
StorageKey: "gowasm-example-employees",
},
})
}
const tableSnippet = `// A controller: built ONCE, next to your signals — never inside the render.
table := ui.NewAutoTableState([]ui.AutoTableColumn{
{DisplayName: "Name", SortIdentifier: "Name", Sortable: true,
Cell: func(r any) *VNode { return Text(r.(Employee).Name) }},
{DisplayName: "Salary", SortIdentifier: "Salary", Sortable: true,
SortType: ui.SortTypeNumeric, // parses the currency: $980 < $1,200.50
Cell: func(r any) *VNode { return Text(money(r.(Employee).Salary)) }},
{DisplayName: "Rank", HiddenByDefault: true},
}, ui.AutoTableStateOptions{
PerPage: 5,
Columns: ui.AutoTableColumnOptions{
StorageKey: "employees", // order, widths, visibility — the user's, and persisted
},
})
table.SetRows(employees())`
const formulaSnippet = `A COLUMN combines operands ACROSS one row:
sum[Salary, Bonus] -> 1200.50 + 150.00 = 1350.50 (per person)
([Salary] + [Bonus]) * 12 -> the annualised figure
SUM({Salary:1:ROW()}) -> a running total, down the rows
A SUMMARY ROW aggregates ONE column DOWN the filtered rows:
avg[Salary] -> one number, printed in the footer`

View File

@@ -0,0 +1,123 @@
package app
import (
"strings"
"testing"
"kjol/vdom"
)
// The two-runtime demo.s whole claim is that its two panes are ONE function: the live
// component on the left, and the HTML string the server sends on the right. If they could
// drift, the page would be a lie told in the most embarrassing possible place.
//
// It lives on /wasm now, not on the front page — it is the Wasm Web engine.s argument,
// and the front page is kjøl.s. The test followed it.
//
// So: render it, click the button the way the browser would, render again, and check
// that BOTH panes moved. A pane rendered from a stale copy of the tree — or from a
// second, hand-written one — fails here.
func TestTwoRuntimePanesShareOneTree(t *testing.T) {
page := DocsPage(Deps{Path: func() string { return "/wasm" }})
html := vdom.RenderHTML(page())
if !strings.Contains(html, "clicked 0 times") {
t.Fatalf("the live pane did not render its initial state:\n%s", html)
}
// The right-hand pane is the ESCAPED HTML of the same tree, so the markup it shows
// appears in the page's own markup double-escaped: &lt;div ...
if !strings.Contains(html, "&lt;div class=") {
t.Fatal("the right-hand pane is not showing rendered HTML at all")
}
clickButton(t, page(), "Click me")
html = vdom.RenderHTML(page())
if strings.Count(html, "clicked 1 times") < 2 {
t.Errorf("after one click, %d panes say \"clicked 1 times\" — both should:\n%s",
strings.Count(html, "clicked 1 times"), html)
}
}
// The byte count under the right-hand pane is the length of the string actually shown,
// not a number typed in by hand — so it has to move when the markup does.
func TestTwoRuntimeByteCountIsReal(t *testing.T) {
page := DocsPage(Deps{Path: func() string { return "/wasm" }})
before := byteCountLabel(t, vdom.RenderHTML(page()))
clickButton(t, page(), "Click me")
// "clicked 0 times" -> "clicked 1 times" is the same length, so click into double
// digits, where the markup genuinely grows by one byte.
for i := 0; i < 10; i++ {
clickButton(t, page(), "Click me")
}
after := byteCountLabel(t, vdom.RenderHTML(page()))
if before == after {
t.Errorf("the markup grew by a digit but the byte count did not move (%s) — it is not measuring the string", before)
}
}
// byteCountLabel pulls the "N bytes of HTML" caption out of the rendered page.
func byteCountLabel(t *testing.T, html string) string {
t.Helper()
i := strings.Index(html, " bytes of HTML")
if i < 0 {
t.Fatal("no byte-count caption on the /wasm overview")
}
start := strings.LastIndexByte(html[:i], '>') + 1
return html[start : i+len(" bytes of HTML")]
}
// clickButton finds a button by its label and fires its click handler.
func clickButton(t *testing.T, n *vdom.VNode, label string) {
t.Helper()
if !findAndClickButton(n, label) {
t.Fatalf("no clickable button labelled %q on the page", label)
}
}
func findAndClickButton(n *vdom.VNode, label string) bool {
if n == nil {
return false
}
if n.Tag == "button" && strings.Contains(textOf(n), label) {
if h := n.Events[vdom.EVENT_CLICK]; h != nil {
h(clickEvent{})
return true
}
}
for _, c := range n.Children {
if findAndClickButton(c, label) {
return true
}
}
return false
}
func textOf(n *vdom.VNode) string {
if n.Tag == "" {
return n.Text
}
var b strings.Builder
for _, c := range n.Children {
b.WriteString(textOf(c))
}
return b.String()
}
// clickEvent is a vdom.Event with no DOM behind it — enough to invoke a handler.
type clickEvent struct{}
func (clickEvent) PreventDefault() {}
func (clickEvent) StopPropagation() {}
func (clickEvent) Value() string { return "" }
func (clickEvent) Checked() bool { return false }
func (clickEvent) Key() string { return "" }
func (clickEvent) ClientX() int { return 0 }
func (clickEvent) ClientY() int { return 0 }
func (clickEvent) Target() any { return nil }
func (clickEvent) SetData(_, _ string) {}
func (clickEvent) GetData(string) string { return "" }
var _ vdom.Event = clickEvent{}