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,127 @@
# kjol-website — the kjol website
The landing page and documentation for the whole codebase. It is also the thing it
documents: every page runs the code it describes, and there is not a screenshot of a
component anywhere on the site.
It is **one server running two entirely different front-ends**, and that is the point of
it. kjol has two web layers — one written in Go and compiled to WebAssembly, one written
in Solid and bundled by a Go toolchain — and the only honest way to document both is to
build the site out of both.
## Run it
```sh
cd go/cmd/kjol-website
go run ./server -build # cold build, then exit
go run ./server # build, then SSR + /rsc + hot reload at http://localhost:8085
```
`go run ./server` performs that same build on every save and hot-swaps the result into
the browser, so day to day it is the only command you need. A `.go` save rebuilds the
wasm and swaps it in without a reload or a flash, preserving page state; a `.css` save
recompiles only Tailwind; a compile error lands in a browser overlay rather than in a
terminal you were not looking at.
## The shape of the site
| Path | Rendered by | What it is |
|---|---|---|
| `/`, `/about` | Go → WebAssembly, SSR'd | The landing page. The **Layers** menu is the site's primary navigation. |
| `/wasm/*` | Go → WebAssembly | **Kjol Wasm Web** — the gowasm engine: SSR + hydration, server components, data fetching, and the whole `webui` kit on one page (`/wasm/components`). |
| `/js/*` | Solid → esbuild, client-rendered | **Kjol JS Web** — the Solid kit, all of it on one page (`/js/components`), with a sidebar that jumps to each group. |
| `/js/ssr` | Solid → **goja, at request time** | A public page server-rendered with live data injected — the ISR path. |
Crossing between `/wasm` and `/js` is a real page load. They are different binaries, and
pretending otherwise would mean shipping both to every visitor.
## Layout
```
app/ Kjol Wasm Web — pages as plain Go functions returning a *VNode
pages.go Deps + Shell + the public/app layouts + the landing page
layers.go the Layers, as data. MIRRORED in frontend/src/layers.ts.
docs.go the docs chrome; docsNav() is the sidebar AND the index
components.go THE WHOLE KIT, on one page. componentGroups() is the single source
for both the sections and the sidebar that jumps to them.
table.go the AutoTable's data + controller (shared with its tests)
chart.go data.go server_counter.go
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, RSC stubs)
wasm/ the js/wasm client entry (main_native.go is a host stub)
css/app.css its Tailwind entry — kjol/tw scans the .go files for class names
frontend/ Kjol JS Web — .tsx pages written against @ui/*
css/style.css brand ONLY. kjol's theme.css is prepended by the bundler, and it
is the one that does `@import "tailwindcss"`.
vendor/ pdf-lib + pdfjs-dist. @ui/AutoTable imports them at the TOP LEVEL,
so a bundle without them does not degrade — it fails to evaluate.
src/app.ts the SPA entry. It is .ts, not .tsx, because the bundler resolves
the entry as src/app.ts and nothing else — so it can hold no JSX.
src/layers.ts the Layers again. Keep in step with app/layers.go.
server/ ONE Go server: serves wwwroot, SSRs the wasm routes, hosts /rsc,
mounts the Solid SPA at /js/*, serves the SSR'd public pages, and
carries the -build flag.
build/ the build pipeline, in Go rather than a shell script, so the cold build
and the watch loop call the SAME functions and cannot drift. It is a
library, not a command: the server imports it, and Go will not let you
import a main — hence `go run ./server -build` rather than `./build`.
internal/handlers/ the app side of the public-page inversion: kjol generates the
registry; this owns the type and the document shell.
wwwroot/ both layers write here. They never collide: app.css / app.wasm for one,
bundle.min.* for the other. One static dir, one server.
```
## Theming
One theme, both layers. The kits are themed by **semantic tokens** — components say
`bg-surface`, `text-ink`, `border-line` and never name a colour — so dark mode
re-points about a dozen CSS variables and not one component knows it happened.
The choice is stored under a single `kjol-theme` key that **both** front-ends read, so
switching to dark in `/wasm` and walking over to `/js` keeps it dark. A ten-line boot
script in the document head applies the class before first paint; without it every
dark-mode reader would get a white page until the bundle landed, and then have it
snatched away.
The only places a `dark:` variant survives are the two a re-pointed token cannot fix: a
coloured tint (a `red-50` wash is invisible on a near-black surface) and a fill that has
to invert (the neutral button, whose label must go dark when the fill goes pale).
## Adding things
**A Kjol Wasm Web page:** write the function, mark it `//gowasm:page /wasm/thing layout=app`,
build. `wasmgen` regenerates the routing. Add `static` to have it server-rendered.
**A component:** add a section to `app/components.go` (Go) or `frontend/src/pages/Components.tsx`
(Solid), and one entry to `componentGroups()` / `COMPONENT_GROUPS`. That single list drives the
sections, the sidebar that jumps to them, and the index — so none of the three can drift.
**A layer:** one entry in `app/layers.go` *and* one in `frontend/src/layers.ts`. They are
two files because nothing is upstream of both a WebAssembly binary and an esbuild bundle;
keeping each to a flat list of plain data is what makes that duplication survivable.
## How it maps onto the engine
| Package | Role | This app's use |
|---|---|---|
| `kjol/vdom` | neutral virtual DOM (native + wasm): `VNode`, `Signal`, `RenderHTML` | pages build `*VNode`; the server SSRs with `vdom.RenderHTML` |
| `kjol/wasmruntime` | wasm client runtime: reconcile, `Run`/`Hydrate`, router, fetch | `wasm/main.go` calls `Hydrate`/`Run` |
| `kjol/rsc` | stateless server components over HTTP | `//gowasm:server` + its generated client stub |
| `kjol/wasmdevserver` | reusable dev server: SSR, `/rsc`, hot reload, error overlay | `server/main.go` fills a `wasmdevserver.Config` |
| `kjol/webui` | the Go component kit | every `/wasm/*` page |
| `kjol/jsbundler` | TSX → Solid → esbuild, the Tailwind driver, the SSR bake | `build.JS`, and the ISR render at request time |
| `kjol/jsruntime` | the Solid kit, the vendored runtime, the icons, `theme.css` | everything under `/js/*` |
| `kjol/tw` | the Tailwind v4 engine, in Go | both stylesheets — it scans `.go` for one and `.tsx` for the other |
The **golden rule** holds throughout: no kjol package imports application code. The app
injects `Build`, `Render` and `Document` into `wasmdevserver`; it owns the `publicPage`
type that kjol's generated registry is written against. The dependency only ever points
one way.
## Its own module
`go.mod` declares module `kjolwebsite` with `replace kjol => ../..`, so its dependencies —
go-chart for the server-drawn charts, plus esbuild and goja by way of the bundler — stay
out of kjol, whose engine packages are stdlib-only. `go build ./...` at the kjol root
does not descend into this nested module; build it from here.

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{}

View File

@@ -0,0 +1,177 @@
// Package build is the build pipeline: directive codegen, Tailwind, the wasm binary,
// the Solid bundle, and Go's JS shim.
//
// It used to be two packages — a `buildsteps` library and a `build` command that did
// nothing but loop over it and print. There was nothing for that split to be: the
// library had exactly two importers, both in this directory tree, and a package with no
// outside consumers is an import statement pretending to be a boundary. It is one
// package now.
//
// It cannot be `package main`, because the dev server imports it and Go will not let you
// import a main. So the cold build is a flag on the server rather than a second binary:
//
// go run ./server -build # build once and exit
// go run ./server # build, then watch and serve
//
// It is Go rather than a shell script for three reasons. The dev server has to call
// these steps on every save and cannot shell out to bash on Windows. Editors need to run
// them as tasks, and a task that only works on one platform is a task half the team
// cannot use. And the cold build and the watch build must be the SAME steps — the moment
// they are two scripts they drift, and the bug only shows up in whichever one you use
// less.
package build
import (
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"kjol/jsbundler"
)
// kjolRoot is the kjol Go module root, relative to the app. The Tailwind and codegen
// commands are run FROM there so the engine's dependencies resolve in kjol's own go.mod,
// and this app's stays lean.
const kjolRoot = "../.."
// Wwwroot is where every build artefact lands, and what the server serves.
const Wwwroot = "wwwroot"
// Step is one named stage. Naming them lets the cold build narrate itself without the
// watch loop having to care what they are called.
type Step struct {
Name string
Run func() ([]byte, error)
}
func Steps() []Step {
return []Step{
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", Codegen},
{"compiling Tailwind CSS -> wwwroot/app.css", Tailwind},
{"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", Wasm},
{"bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)", JS},
{"copying Go's wasm_exec.js shim into wwwroot/", Shim},
}
}
// All is the full build, in order. It is what the dev server runs on a code change.
//
// The returned bytes are the failing command's combined stdout+stderr, which the dev
// server puts straight into the browser's error overlay — so a compile error lands in
// front of you rather than in a terminal you were not looking at.
func All() ([]byte, error) {
for _, s := range Steps() {
if out, err := s.Run(); err != nil {
return out, err
}
}
return nil, nil
}
// Cold runs every step once, narrating as it goes, and exits non-zero on the first
// failure. This is `go run ./server -build`: CI, a cold start, or an editor's pre-launch
// task — anywhere there is nobody watching a browser overlay.
func Cold() {
log.SetFlags(0)
for _, s := range Steps() {
log.Println("==>", s.Name)
if out, err := s.Run(); err != nil {
os.Stderr.Write(out)
log.Fatalln("build failed:", err)
}
}
log.Println("==> Done. Serve it with: go run ./server")
}
// Codegen regenerates app/*.gen.go from the //gowasm: directives — the routes, the
// layouts, and the client stubs for server components. It runs FIRST: everything after
// it compiles the code it writes.
func Codegen() ([]byte, error) {
return exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput()
}
// Tailwind compiles css/app.css to wwwroot/app.css, scanning the webui kit, the lexer's
// palette and this app's Go markup for utility candidates.
//
// It scans .go files, which is the whole point of kjol's native engine: the markup is
// written in Go, so that is where the class names are. Nothing in this stage involves
// JavaScript.
//
// EVERY package whose class names have to exist has to be listed here. That is not a
// warning about carelessness — it is the failure mode: a package left off this list still
// compiles, still renders, and just comes out unstyled, because the class it asked for was
// never generated. kjol/lexer is here for exactly that reason; its whole output is class
// names, and nothing else in the tree mentions text-teal-300.
func Tailwind() ([]byte, error) {
cmd := exec.Command("go", "run", "./cmd/twcss",
"-entry", "cmd/kjol-website/css/app.css",
"-out", "cmd/kjol-website/wwwroot/app.css",
"-base", ".",
"webui/**/*.go",
"lexer/**/*.go",
"cmd/kjol-website/app/**/*.go",
"cmd/kjol-website/server/**/*.go",
)
cmd.Dir = kjolRoot
return cmd.CombinedOutput()
}
// Wasm compiles ./wasm to wwwroot/app.wasm.
func Wasm() ([]byte, error) {
cmd := exec.Command("go", "build", "-o", filepath.Join(Wwwroot, "app.wasm"), "./wasm")
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
return cmd.CombinedOutput()
}
// JS builds the Solid app: the SPA under /js, the server-rendered public pages, and
// their stylesheet. It is kjol/jsbundler — TSX compiled to Solid by a Go program, bundled
// by esbuild's Go API, styled by kjol/tw — run in-process rather than shelled out to, so
// a compile error comes back as a Go error and lands in the browser's error overlay like
// every other failure.
//
// It writes bundle.min.{js,css} and public.bundle.min.{js,css} into the SAME wwwroot as
// the wasm build. They never collide: different filenames, one static dir, one server.
//
// WebDir points at the shared tree — the kit, the vendored Solid runtime, the icon SVGs
// and the @theme scaffold all live there.
func JS() ([]byte, error) {
err := jsbundler.Build(jsbundler.Config{
AppFrontend: "frontend",
WebDir: filepath.Join(kjolRoot, "jsruntime"),
Output: Wwwroot,
GenTSDir: filepath.Join("frontend", "src", "ui", "generated"),
})
if err != nil {
return []byte(err.Error()), err
}
return nil, nil
}
// Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to start
// a Go wasm binary, it ships with the toolchain, and it must match the compiler that
// produced the binary — so it is copied from GOROOT rather than vendored.
func Shim() ([]byte, error) {
out, err := exec.Command("go", "env", "GOROOT").Output()
if err != nil {
return out, err
}
goroot := strings.TrimSpace(string(out))
for _, src := range []string{
filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24
filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23
} {
b, err := os.ReadFile(src)
if err != nil {
continue
}
dst := filepath.Join(Wwwroot, "wasm_exec.js")
// The GOROOT copy is read-only, and so is the copy we made last time. Remove it
// first, or the write fails with a permission error that says nothing useful.
os.Remove(dst)
return nil, os.WriteFile(dst, b, 0o644)
}
return nil, os.ErrNotExist
}

View File

@@ -0,0 +1,229 @@
@import "tailwindcss";
/* ---------------------------------------------------------------------------
Dark mode: `dark:` as a CLASS, not a media query.
---------------------------------------------------------------------------
Tailwind's built-in dark variant follows the operating system. A site with its own
theme switch cannot use it: the OS says one thing, the switch says another, and the
media query wins — so the switch appears to do nothing.
This redefines it against a class on <html>, which webui.Theme toggles. The OS is
still respected: it is the DEFAULT (see the boot script in server/main.go), just no
longer the last word.
--------------------------------------------------------------------------- */
@custom-variant dark (&:where(.dark, .dark *));
/* ---------------------------------------------------------------------------
Open Sans — self-hosted (files in wwwroot/fonts). One variable file per subset
carries weights 400700 upright and italic, so the four faces below cover the
whole UI. unicode-range keeps the browser to the one subset a glyph needs, and
font-display: swap paints text in the fallback first rather than blocking on the
download. The same four blocks live in the /js side's frontend/css/style.css so
both front-ends render in one typeface. --font-sans (below) points at it. */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-ext-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-italic.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-ext-italic.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
values live with the app; the kit stays generic.
The surface/line/ink tokens are the kit's THEME CONTRACT (see webui.ThemeTokens):
components say bg-surface / border-line / text-ink and never name a colour, so the
whole kit changes theme by changing these ten values rather than by carrying a dark:
variant on four hundred class strings. */
@theme {
--radius-default: 0.375rem;
/* The UI typeface. --font-sans is what Tailwind's preflight points html at, and
the kit's utilities (font-sans) resolve to, so this one line moves the whole
site onto Open Sans; the fallbacks cover the swap window and any glyph outside
the vendored subsets. */
--font-sans: "Open Sans", ui-sans-serif, system-ui, sans-serif;
/* Navy and red — the flag, muted. kjøl is a Norwegian word and the palette says so.
Both are dark and low-key: the page is mostly prose, code and tables, and the brand's
job is to mark the few things you can act on, not to compete with them for attention.
(The previous sky blue did the same job, but said nothing.) */
--color-primary: #1e3a63; /* muted navy — FILLS; they carry white text */
--color-primary-hover: #16294a;
--color-primary-subtle: #eef2f8; /* a navy wash — tinted panels, badges, callouts */
--color-primary-border: #c5d1e2;
/* accent is for TEXT and icons — links, the eyebrow, an active sidebar row. It is still a
separate token from primary, because the two have opposite constraints: a fill must be
dark enough for white text on TOP of it, and accent text must be readable ON the
surface. Here they are the same hue — navy — but not the same value: the accent is a
touch deeper so a navy link on white is unmistakably a link. (The red is gone; the
brand is navy throughout now. Only the flag keeps its red field.) */
--color-accent: #1c3a66; /* navy — accent TEXT */
/* Surfaces, lines, ink — the kit's theme contract. */
--color-surface: #ffffff;
--color-surface-muted: #fafafa;
--color-surface-raised: #f5f5f5;
--color-surface-strong: #e5e5e5;
--color-line: #e5e5e5;
--color-line-strong: #d4d4d4;
--color-ink: #171717;
--color-ink-soft: #525252;
--color-ink-muted: #737373;
--color-ink-faint: #a3a3a3;
--color-text-heading: #111827;
--color-text-on-dark: #f9fafb;
--color-text-on-dark-muted: #9ca3af;
}
/* ---------------------------------------------------------------------------
Grid background
---------------------------------------------------------------------------
Plain CSS, not a utility: Tailwind's arbitrary-value syntax cannot carry a
background-image with commas in it without becoming unreadable, and this is a
single named thing rather than a composition of atoms. kjol's Tailwind engine
passes rules it does not recognise straight through, so this lands in the output
untouched.
The grid is drawn with two 1px gradients — a vertical set and a horizontal set —
tiled at --grid-size. It is deliberately faint: it should register as texture, not
as graph paper you have to read the page through.
--------------------------------------------------------------------------- */
:root {
--grid-line: rgba(30, 58, 99, 0.06); /* the navy, at the edge of visible */
}
/* ---------------------------------------------------------------------------
The dark theme.
---------------------------------------------------------------------------
Only the token VALUES change. Not one component knows this block exists — they ask
for bg-surface and text-ink, and here is where those come to mean something else.
This is a plain rule, not another @theme block: @theme generates utilities, and these
are overrides of utilities that already exist.
The surfaces are not pure black. Black gives a dark UI a hard, glaring edge against
white text and makes every border invisible; a very dark grey leaves room for the
raised surfaces and lines above it to actually be seen. --------------------------- */
.dark {
--color-surface: #101013;
--color-surface-muted: #17171b;
--color-surface-raised: #1f1f24;
--color-surface-strong: #2c2c33;
--color-line: #2a2a30;
--color-line-strong: #3d3d45;
--color-ink: #f2f2f3;
--color-ink-soft: #c6c6cc;
--color-ink-muted: #9a9aa3;
--color-ink-faint: #71717a;
/* Both brand tokens move in the dark, and both for the same reason now: navy is too dark
to read on a near-black page, so each climbs to a lighter blue.
The accent (TEXT) climbs furthest — a link has to be legible at body-text weight, so it
goes to a soft sky. The fill climbs less: it only has to look like a button and still
carry white text (~7:1), so it lifts to a steel blue and stops there, well below where
the accent lands. */
--color-accent: #9fc1ec;
--color-primary: #2b4f80;
--color-primary-hover: #37619b;
--color-primary-subtle: #182234;
--color-primary-border: #2c3e5c;
--color-text-heading: #f5f5f5;
/* The grid is drawn in ink, not in shadow, once the page is dark. */
--grid-line: rgba(226, 232, 240, 0.05);
}
/* The page's own background — painted before the app mounts, and behind it afterwards.
Without this, a dark app sits in a white window. */
html {
background-color: var(--color-surface);
color: var(--color-ink);
font-family: var(--font-sans);
}
/* ---------------------------------------------------------------------------
The wordmark's logo tile: a muted Norwegian flag.
---------------------------------------------------------------------------
kjøl is a Norwegian word — the mark says so. It is the flag's Scandinavian cross: a
navy cross with an off-white outline on a red field, offset LEFT as the real flag is
(the vertical bar sits at ~38% rather than centre). Muted, not the flag's full
saturation — it is a 32px tile next to body text, not a banner.
Drawn in layered gradients over a red base rather than as an <img>, so it inherits the
tile's rounded corners and border, needs no asset request, and cannot 404. Layers paint
TOP-first, so the reading is: dim scrim, then navy cross, off-white cross, red field. Each
band is transparent outside its stripe (hard stops via doubled positions) so the layer
beneath shows through. The bands are constant across light and dark — a flag does not
change with the page theme.
The topmost layer is a flat dark scrim, and it is there so the WHITE sailboat on top of
the tile reads on its own — no outline, no shadow on the glyph. The boat is a thin white
stroke and the off-white cross is exactly the band it would vanish into; rather than trace
the boat in shadow, the whole flag is dimmed until white stands out against every band of
it, the pale cross included. A dimmer, moodier flag with a crisp white boat, on purpose.
The cross geometry, as percentages of the tile:
vertical (offset left, centre 38%): off-white 2749%, navy 32.543.5%
horizontal (centred, centre 50%): off-white 3961%, navy 44.555.5% */
.flag-no {
background-color: #a83f4c; /* the red field, muted (then dimmed by the scrim above) */
background-image:
linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42)), /* the dim scrim, on top */
linear-gradient(180deg, transparent 44.5%, #294c76 44.5%, #294c76 55.5%, transparent 55.5%),
linear-gradient(90deg, transparent 32.5%, #294c76 32.5%, #294c76 43.5%, transparent 43.5%),
linear-gradient(180deg, transparent 39%, #ece6da 39%, #ece6da 61%, transparent 61%),
linear-gradient(90deg, transparent 27%, #ece6da 27%, #ece6da 49%, transparent 49%);
}
.bg-grid {
background-image:
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
/* Written out rather than as var(--size) var(--size): the CSS minifier drops the
space between two adjacent var() calls, and while that is still legal CSS, a
background-size that depends on how a minifier tokenises is not worth the cleverness. */
background-size: 56px 56px;
background-position: center top;
}
/* Fades the grid out towards the bottom, so it frames the hero and then gets out of
the way of the content below rather than running under it the whole page. */
.grid-fade {
-webkit-mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
}
/* (The hero glow that used to live here went with the hero. A coloured wash behind an
oversized headline is the most recognisable gesture in framework marketing, and this
page is not making that argument any more.) */

View File

@@ -0,0 +1,114 @@
/* ---------------------------------------------------------------------------
kjol-website — brand stylesheet for the Kjol JS Web section (/js/*).
---------------------------------------------------------------------------
There is deliberately no `@import "tailwindcss"` here. The bundler PREPENDS
kjol's shared scaffold (go/jsruntime/styles/theme.css) to this file, and that
scaffold does the import — an @import has to come first, and this file no
longer is. See jsbundler/css.go and the header of theme.css.
What is left is only what is genuinely this app's: the brand.
The values below match the Go/WASM section's css/app.css on purpose — same
Open Sans, same navy accent — so that crossing between /wasm and /js reads as
two parts of ONE site rather than two demos that happen to share a domain. The
two sections are built by completely different pipelines; they should not look
like it.
--------------------------------------------------------------------------- */
/* Open Sans — self-hosted (files in wwwroot/fonts). One variable file per subset
carries weights 400700 upright and italic. unicode-range keeps the browser to the
one subset a glyph needs; font-display: swap paints the fallback first. The same four
blocks live in the /wasm side's css/app.css so both front-ends share one typeface. */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-ext-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-italic.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-ext-italic.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@theme {
/* The UI typeface — see the @font-face blocks above. This one line points the kit's
font-sans utilities and the html rule below onto Open Sans. */
--font-sans: "Open Sans", ui-sans-serif, system-ui, sans-serif;
/* The brand: navy throughout, a muted flag-navy. These are the SAME values the Go/WASM
section's css/app.css sets, and under the same names — so the Layers menu, the
sidebar highlight and the callouts are the same navy on both sides of the site rather
than two navies that happen to be close.
primary FILLS (white text sits on it); accent is TEXT and icons (it has to be
readable on the surface) and is a slightly deeper navy so a link reads as one;
primary-subtle/-border are the tinted panel. Only the flag tile keeps a red. */
--color-primary: #1e3a63; /* muted navy — fills; they carry white text */
--color-primary-hover: #16294a;
--color-primary-subtle: #eef2f8; /* a navy wash — tinted panels, callouts */
--color-primary-border: #c5d1e2;
--color-accent: #1c3a66; /* navy — accent TEXT (links, eyebrow, active rows) */
}
/* Only the font. The page's background and text colour come from the shared
scaffold's `html` rule, which paints them from --color-surface / --color-ink —
the tokens the .dark block re-points. Setting them here would pin the page to
white and leave a dark app sitting in a white window. */
html {
font-family: var(--font-sans);
}
/* The dark values for the brand.
---------------------------------------------------------------------------
Navy is too dark to read on a near-black page, so both brand tokens climb to a lighter
blue. The accent (TEXT) climbs furthest, to a soft sky a link stays legible in; the fill
climbs less, to a steel blue that still looks like a button and still carries white text.
And the tinted panel inverts outright, because a pale wash on #101013 is not a tint, it
is a white box.
Same values, same names, as the Go/WASM section's css/app.css. */
.dark {
--color-accent: #9fc1ec;
--color-primary: #2b4f80;
--color-primary-hover: #37619b;
--color-primary-subtle: #182234;
--color-primary-border: #2c3e5c;
}
/* The wordmark's logo tile: a muted Norwegian flag. Identical to the Go/WASM section's
css/app.css — the two front-ends share one mark. See there for the full note; in short
it is the flag's Scandinavian cross (navy cross, off-white outline, red field, offset
left) drawn in layered gradients over a red base, constant across themes, with a flat
dark scrim on top so the plain white sailboat reads on it without an outline. */
.flag-no {
background-color: #a83f4c;
background-image:
linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42)),
linear-gradient(180deg, transparent 44.5%, #294c76 44.5%, #294c76 55.5%, transparent 55.5%),
linear-gradient(90deg, transparent 32.5%, #294c76 32.5%, #294c76 43.5%, transparent 43.5%),
linear-gradient(180deg, transparent 39%, #ece6da 39%, #ece6da 61%, transparent 61%),
linear-gradient(90deg, transparent 27%, #ece6da 27%, #ece6da 49%, transparent 49%);
}

View File

@@ -0,0 +1,59 @@
// SPA entry for the Kjøl JS Web section (/js/*).
//
// This file is .ts and NOT .tsx on purpose — it is not a style choice. The bundler
// resolves the SPA entry as src/app.ts (falling back to src/app.js) and nothing
// else, so the entry cannot contain JSX. Hence createComponent() here, and JSX in
// the pages it points at.
//
// The section is mounted under a base path rather than at the root: the front page
// and the whole /wasm section are served by Kjøl Wasm Web, a different binary, which
// this bundle knows nothing about. `base: "/js"` keeps every route in here relative
// to that, so a link to "/components" resolves to /js/components and the two SPAs
// never fight over a URL.
//
// Crossing OUT of /js (to the front page, or into /wasm) is a plain <a href> and a
// real page load — the rest of the site is a different binary. That is the
// honest cost of running two front-ends behind one server, and it is one navigation.
import { render, createComponent } from "solid-js/web";
import { Router } from "@solidjs/router";
import type { RouteDefinition } from "@solidjs/router";
import { Shell } from "./layout/Shell.tsx";
import { Overview } from "./pages/Overview.tsx";
import { Components } from "./pages/Components.tsx";
import { NotFound } from "./pages/NotFound.tsx";
// Routes as plain data: solid-router accepts RouteDefinition[] as `children`, which
// is what lets a JSX-free entry declare a full route tree.
//
// There are only two. The kit used to be spread across /kit, /forms, /table and
// /theming — a split along the lines of the SOURCE FILES rather than along anything a
// reader wants: a person looking for a date picker does not know, and should not have
// to guess, whether it was filed under forms or under overlays. It is one page now,
// and the sidebar jumps you down it.
const routes: RouteDefinition[] = [
{ path: "/", component: Overview },
{ path: "/components", component: Components },
// The catch-all, and it is not optional. The SERVER answers every /js/* URL with this
// shell — it has no idea which paths the router knows about — so without a fallback an
// unknown one renders the chrome around an empty <main>: a blank page, with a 200, and
// nothing to tell you why. Kjøl Wasm Web has the same catch-all for the same reason.
{ path: "*", component: NotFound },
];
const root = document.getElementById("app");
if (root) {
render(
() =>
createComponent(Router, {
base: "/js",
root: Shell,
get children() {
return routes;
},
}),
root,
);
}

View File

@@ -0,0 +1,36 @@
// The component groups: one entry per section of /js/components, AND one line in the
// sidebar that jumps to it.
//
// Declaring them once, as data, is what keeps those two in step — the sidebar cannot
// offer a jump to a section that does not exist, and a section cannot go missing from
// the sidebar.
//
// This is the JS mirror of app/components.go's componentGroups(). The ids match, so the
// two sections of the site have the same shape and a reader crossing between them lands
// in the same place. The ICONS differ, and have to: this side names FontAwesome, the Go
// side names webui's own hand-drawn registry, and where the two have no glyph in common
// the names diverge.
export interface ComponentGroup {
id: string;
label: string;
icon: string;
}
export const COMPONENT_GROUPS: ComponentGroup[] = [
{ id: "buttons", label: "Buttons", icon: "check" },
{ id: "badges", label: "Badges & alerts", icon: "circle-info" },
{ id: "cards", label: "Cards & layout", icon: "table-columns" },
{ id: "icons", label: "Icons", icon: "star" },
{ id: "forms", label: "Forms & inputs", icon: "pen-to-square" },
{ id: "selects", label: "Selects & comboboxes", icon: "sliders" },
{ id: "toggles", label: "Toggles & signature", icon: "check" },
{ id: "dates", label: "Dates", icon: "calendar" },
{ id: "tables", label: "Tables", icon: "table" },
{ id: "overlays", label: "Overlays", icon: "copy" },
{ id: "feedback", label: "Toasts & tours", icon: "bell" },
{ id: "navigation", label: "Tabs & navigation", icon: "bars" },
{ id: "search", label: "Fuzzy search", icon: "magnifying-glass" },
{ id: "charts", label: "Charts", icon: "chart-column" },
{ id: "theming", label: "Theming", icon: "palette" },
];

View File

@@ -0,0 +1,94 @@
// 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 JS mirror of app/layers.go. The ids, the order and the taglines match; only
// the ICONS diverge, and they have to — see below.
export interface Layer {
name: string;
href: string;
tagline: string;
/** Live = you can click into worked examples. Reference = documented, no demo. */
live: boolean;
/**
* The ONE field that does not match app/layers.go, and cannot: the two kits have
* different icon sets. This side names FontAwesome; the Go side names webui's own
* hand-drawn registry, which has no FontAwesome in it at all. Where the two have no
* glyph in common the names diverge.
*
* Both sides fail loudly rather than quietly — a name neither registry knows renders
* an empty box, and app/icons_test.go fails the build over it.
*/
icon: string;
}
/** Languages: what kjøl is written in. */
export const LANGUAGES: Layer[] = [
{
name: "Go",
href: "/go",
tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
live: false,
icon: "server",
},
{
name: "TypeScript",
href: "/ts",
tagline: "The Solid component kit, the vendored runtime, and the generic scaffolding apps import as @kjol/*.",
live: false,
icon: "code",
},
{
name: "C",
href: "/c",
tagline: "Arena allocator, counted strings, math, a lexer, a platform layer — and a build system that is a C file.",
live: true,
icon: "bolt",
},
{
name: "Jai",
href: "/jai",
tagline: "Console rendering. Early.",
live: false,
icon: "cube",
},
];
/** Compositions: what the languages are assembled into. */
export const COMPOSITIONS: 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.",
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.",
live: true,
icon: "table-columns",
},
];
/** The layer or composition the current path belongs to, or undefined on the front page. */
export function currentLayer(path: string): Layer | undefined {
return [...COMPOSITIONS, ...LANGUAGES].find(
(l) => path === l.href || path.startsWith(l.href + "/"),
);
}

View File

@@ -0,0 +1,33 @@
// A worked example: the code on one side, that same code RUNNING on the other.
//
// The code string is written by hand rather than extracted from the source, and that
// is a known compromise — a hand-copied snippet can drift from the component beside
// it. The alternative (a build step that slices the real source) buys accuracy at the
// cost of a second thing to maintain, and the snippets here are short enough to read
// against the live demo in one glance. If they start getting long, that trade flips.
import { JSXElement } from "solid-js";
import { CodeBox } from "@ui/General";
export function Demo(props: { title: string; code: string; children?: JSXElement }) {
return (
<section class="mt-10">
<h2 class="text-lg font-semibold text-ink">{props.title}</h2>
<div class="mt-3 overflow-hidden rounded-default border border-line">
{/* The live half. It sits on the plain surface, not in a tinted "preview"
box, because a component that only looks right against a special
background is a component that will look wrong in the app. */}
<div class="border-b border-line px-4 py-2">
<span class="font-mono text-[11px] uppercase tracking-widest text-ink-faint">running</span>
</div>
<div class="px-4 py-6">{props.children}</div>
<div class="border-t border-line bg-surface-muted px-4 py-2">
<span class="font-mono text-[11px] uppercase tracking-widest text-ink-faint">source</span>
</div>
<CodeBox code={props.code} class="rounded-none border-0" />
</div>
</section>
);
}

View File

@@ -0,0 +1,245 @@
// The Kjøl JS Web shell: top bar (wordmark + Layers menu), sidebar, content.
//
// It is deliberately a near-copy of the Go/WASM section's AppLayout. Two front-ends,
// one site: if the chrome drifted, crossing from /wasm to /js would feel like leaving
// for somebody else's website. The components underneath are completely different —
// these are Solid components from the kit, those are Go functions returning a VNode —
// and the page should not betray that.
import { For, Show } from "solid-js";
import { A, useLocation, useNavigate } from "@solidjs/router";
import { Icon } from "@ui/Icons";
import { Menu, MenuTrigger, MenuContent } from "@ui/Menu";
import { ThemeToggle, initTheme } from "@ui/Theme";
import { LANGUAGES, COMPOSITIONS, currentLayer, Layer } from "../layers.ts";
import { COMPONENT_GROUPS } from "../componentGroups.ts";
interface NavItem {
path: string;
label: string;
icon: string;
}
// The section's own pages. Paths are relative to the router base (/js).
const NAV: NavItem[] = [
{ path: "/", label: "Overview", icon: "circle-info" },
{ path: "/components", label: "Components", icon: "table-columns" },
];
// jumpTo scrolls a section into view, routing there first if we are somewhere else.
//
// A plain <a href="#forms"> would work if the reader were already on the components
// page, and would do nothing useful from anywhere else. The router's <A> is no good
// either — it would try to navigate to a route called "#forms".
//
// The queueMicrotask is not superstition: after navigate() the target section does not
// exist yet, because the page it lives on has not rendered. Scrolling on the next tick
// is the earliest moment the element is actually there to scroll to.
function jumpTo(navigate: (to: string) => void, onComponentsPage: boolean, id: string) {
const scroll = () => document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
if (onComponentsPage) {
scroll();
return;
}
navigate("/components");
queueMicrotask(scroll);
}
// The site's primary navigation, as TWO dropdowns: Layers (the languages) and
// Compositions (the frameworks assembled out of them — see layers.ts).
//
// Two menus, not one with two headings inside it. They answer 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.
//
// Anything not `live` still appears, greyed, with the reason. A menu that silently omits
// half the library teaches the reader that the library is half the size it is.
//
// Same shape as the Go side (app/layers.go: layersMenu / compositionsMenu).
function Dropdown(props: { label: string; rows: Layer[] }) {
const location = useLocation();
const current = () => currentLayer(location.pathname);
return (
// bottom-end, because the triggers sit at the right-hand end of the bar and a 24rem
// panel hanging off the left edge of one would run past the window.
<Menu placement="bottom-end">
<MenuTrigger>
<span 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">
{props.label}
<Icon icon="chevron-down" size={11} class="text-ink-faint" />
</span>
</MenuTrigger>
<MenuContent class="w-96">
<For each={props.rows}>{(layer) => <LayerItem layer={layer} current={current()} />}</For>
</MenuContent>
</Menu>
);
}
// LayerItem is one row of the menu.
//
// The markup is deliberately the same shape and the same classes as app/layers.go's
// layerItem. Two front-ends, one menu: if they drifted, this is where it would show,
// because it is the one component a reader sees on both sides within seconds of each
// other.
function LayerItem(props: { layer: Layer; current?: Layer }) {
return (
<Show
when={props.layer.live}
fallback={
<div class="flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55">
<span class="flex items-center gap-2 text-sm font-medium text-ink-muted">
{props.layer.name}
<span class="rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted">
reference
</span>
</span>
<span class="text-xs text-ink-muted">{props.layer.tagline}</span>
</div>
}
>
{/* A plain <a href>, not the router's <A>: the other side is served by a
different binary, so crossing to it has to be a real navigation, not a
client-side route the router would try to handle itself. */}
<a
href={props.layer.href}
class={
"flex flex-col gap-0.5 px-3 py-2 no-underline hover:bg-surface-raised " +
(props.current?.href === props.layer.href ? "bg-primary-subtle" : "")
}
>
<span class="text-sm font-medium text-ink">{props.layer.name}</span>
<span class="text-xs text-ink-muted">{props.layer.tagline}</span>
</a>
</Show>
);
}
function Wordmark() {
// A plain <a href>, not a router <A>: "/" is the front page, which belongs to the
// Go/WASM binary. Routing to it inside this SPA would resolve to /js and land you
// back where you started.
return (
<a href="/" class="flex items-center gap-2.5 no-underline">
{/* text-white, not a theme token: the flag tile is the same in both themes, so
the boat on top of it has to be too. The flag carries a dark scrim (.flag-no)
so the plain white boat reads without a shadow of its own. */}
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white">
<Icon icon="sailboat" size={17} />
</span>
<span class="flex items-baseline gap-1.5">
<span class="text-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
</span>
</a>
);
}
function Sidebar() {
const location = useLocation();
const navigate = useNavigate();
// The router's pathname is absolute (/js/components); NAV paths are base-relative.
const active = (path: string) => location.pathname === "/js" + (path === "/" ? "" : path);
const onComponents = () => location.pathname === "/js/components";
// The same active treatment the Go sidebar uses (app/pages.go: sidebarLink) — a
// tinted panel and accent text, not a grey fill. Now that the Solid theme carries the
// primary-subtle / accent tokens, the two sidebars are the same sidebar.
//
// No icons, also matching the Go sidebar: the sidebar is a list of words, and a glyph on
// every row is noise to read past. So `block`, not `flex items-center gap-2`.
const linkCls = (on: boolean) =>
on
? "block rounded-default bg-primary-subtle px-2 py-1.5 text-sm font-medium text-accent no-underline"
: "block rounded-default px-2 py-1.5 text-sm text-ink-soft no-underline hover:bg-surface-raised hover:text-ink";
return (
<aside class="sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block">
<p class="px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">Kjøl JS Web</p>
<ul class="mt-2 space-y-0.5">
<For each={NAV}>
{(item) => (
<li>
<A href={item.path} end={item.path === "/"} class={linkCls(active(item.path))}>
{item.label}
</A>
</li>
)}
</For>
</ul>
{/* The component groups are not pages — they are anchors into the one components
page, and clicking one scrolls you there.
They are not highlighted by which section you have scrolled to. Finding that
out means measuring all fifteen of them on every scroll frame, and the only
way to act on the answer is a state write, which re-renders. Sixty times a
second, to move a highlight. The highlight is not worth the page. */}
<p class="mt-8 px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
Components
</p>
<ul class="mt-2 space-y-0.5">
<For each={COMPONENT_GROUPS}>
{(g) => (
<li>
<a
href={"/js/components#" + g.id}
class={linkCls(false)}
onclick={(e) => {
e.preventDefault();
jumpTo(navigate, onComponents(), g.id);
}}
>
{g.label}
</a>
</li>
)}
</For>
</ul>
</aside>
);
}
export function Shell(props: { children?: any }) {
// Once, at the root. The boot script in the document head has ALREADY put the right
// class on <html> — this only syncs the toggle's signals with it and starts
// following the OS while the mode is "system". Calling it late is harmless; not
// calling it just leaves the button showing the wrong icon.
initTheme();
return (
<div class="min-h-screen bg-surface">
{/* bg-surface/90, not bg-white/90: the translucent sticky bar has to be
translucent over whatever the surface currently IS. */}
<nav class="sticky top-0 z-20 border-b border-line bg-surface/90 backdrop-blur">
<div class="mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3">
<Wordmark />
<span class="rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
Docs
</span>
<div class="ml-auto flex items-center gap-2">
<Dropdown label="Layers" rows={LANGUAGES} />
<Dropdown label="Compositions" rows={COMPOSITIONS} />
<a
href="/"
class="rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:bg-surface-raised hover:text-ink"
>
Home
</a>
<ThemeToggle small />
</div>
</div>
</nav>
<div class="mx-auto flex max-w-[110rem] gap-8 px-6">
<Sidebar />
<main class="min-w-0 flex-1 py-10">{props.children}</main>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
// The fallback for any /js/* URL the router does not know.
//
// The server cannot 404 these: it answers every /js/* path with the same SPA shell,
// because it has no idea which routes the bundle contains. So the router has to be the
// one to say so — and if it does not, an unknown URL renders the chrome around an empty
// <main>, which is a blank page with a 200 and no explanation.
//
// It names the paths that MOVED, because that is what a stale bookmark most likely wants:
// /js/kit, /js/forms, /js/table and /js/theming were four pages, and are now four
// sections of one.
import { useLocation, useNavigate } from "@solidjs/router";
import { ButtonUI, BUTTON_COLOR_PRIMARY, BUTTON_COLOR_LIGHT_NEUTRAL } from "@ui/Buttons";
export function NotFound() {
const location = useLocation();
const navigate = useNavigate();
return (
<div class="max-w-2xl py-10">
<h1 class="text-2xl font-semibold tracking-tight text-ink">Page not found</h1>
<p class="mt-2 leading-relaxed text-ink-soft">
No route matches <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">{location.pathname}</code>.
</p>
<p class="mt-3 leading-relaxed text-ink-soft">
The kit used to be spread across several pages <code class="font-mono">/js/kit</code>,{" "}
<code class="font-mono">/js/forms</code>, <code class="font-mono">/js/table</code>,{" "}
<code class="font-mono">/js/theming</code>. It is one page now, and they are sections of it.
</p>
<div class="mt-6 flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => navigate("/components")}>
Go to Components
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={() => navigate("/")}>
Overview
</ButtonUI>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
// /js — what the JS layer is, and how it is built.
import { For } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { Card, CardHeader } from "@ui/Cards";
import { AlertBlue } from "@ui/Alerts";
import { CodeBox } from "@ui/General";
import { Icon } from "@ui/Icons";
import { COMPONENT_GROUPS } from "../componentGroups.ts";
export function Overview() {
const navigate = useNavigate();
return (
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">
A Solid kit, built by a Go toolchain
</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
This layer is the original one: a Solid.js component kit forms, tables, modals, menus,
tooltips, charts that the applications shared before any of it was rewritten in Go. It is
still what those applications run.
</p>
<p class="mt-3 leading-relaxed text-ink-soft">
What is unusual is the build. There is no Node, no Vite, no Babel, and no{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">node_modules</code>.
The TSX is compiled to Solid's runtime calls by a Go program, the CSS by a Go implementation
of Tailwind v4, and the whole thing is bundled by esbuild's Go API. The toolchain is a Go
package you import.
</p>
<h2 class="mt-10 text-lg font-semibold text-ink">The pipeline</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
One command builds this section. Every stage of it is Go:
</p>
<div class="mt-4 space-y-3">
<Stage
n="1"
title="TSX → Solid"
body="kjol/jsbundler compiles each .tsx into dom-expressions calls — the same output Babel's Solid preset produces. It is checked against Babel by a render-equivalence test: both are compiled, both are rendered, and the HTML must match."
/>
<Stage
n="2"
title="Solid → bundle"
body="esbuild's Go API bundles it. Vendored packages resolve out of a pinned manifest rather than their own exports maps, because solid-js's bare entry mis-resolves to its SSR build — where every effect is a silent no-op."
/>
<Stage
n="3"
title="Tailwind"
body="kjol/tw scans the sources for candidate class names and compiles the stylesheet. It is a Go implementation, so it can just as happily scan .go files — which is exactly what the Wasm Web layer needs it to do."
/>
</div>
<CodeBox class="mt-5" code={"$ go run ./server -build\nGenerating FA icon subset...\n FA icons: 94 defs for 47 names\nGenerating public routes...\n /js/ssr Ssr (rendered, 2498 bytes)\nBundling JS + CSS...\n\nBundle Files Size Time\n-------------------------------------------------------\nbundle.min.js 1 1.4 MB 198ms\nbundle.min.css 2500 74.6 KB 30ms"} />
<AlertBlue header="One reactive instance, always" class="mt-8">
The single hardest invariant in this build is that there is exactly one copy of solid-js. Two
copies do not error they render fine and then silently stop flushing effects, so onMount
never fires and nothing updates. kjol's vendor manifest is searched before the app's for
precisely this reason.
</AlertBlue>
<h2 class="mt-10 text-lg font-semibold text-ink">The kit</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
All of it is on one page. It used to be three Components, Forms, AutoTable which is a
split along the lines of the source files rather than along anything a reader wants: a person
looking for a date picker does not know, and should not have to guess, whether it was filed
under forms or under overlays.
</p>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<For each={COMPONENT_GROUPS}>
{(g) => (
<a
href={"/js/components#" + g.id}
class="group flex items-center gap-2 rounded-default border border-line bg-surface px-3 py-2.5 no-underline shadow-xs transition hover:border-primary hover:shadow-sm"
onclick={(e) => {
e.preventDefault();
navigate("/components");
queueMicrotask(() =>
document.getElementById(g.id)?.scrollIntoView({ behavior: "smooth", block: "start" }),
);
}}
>
<Icon icon={g.icon} size={14} class="shrink-0 text-primary" />
<span class="text-sm font-medium text-ink">{g.label}</span>
<Icon
icon="arrow-right"
size={11}
class="ml-auto text-ink-faint transition group-hover:text-primary"
/>
</a>
)}
</For>
</div>
</div>
);
}
function Stage(props: { n: string; title: string; body: string }) {
return (
<div class="flex gap-4 border-l-2 border-line pl-4">
<span class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-surface-raised text-xs font-semibold text-ink-soft">
{props.n}
</span>
<div>
<h3 class="font-semibold text-ink">{props.title}</h3>
<p class="mt-1 text-sm leading-relaxed text-ink-soft">{props.body}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
// The chrome around every server-rendered public page.
//
// The bundler's SSR entry is hardcoded to import { PublicLayout } from this exact
// path and to call it with { currentPath, children } — it is a contract, not a
// convention. The client takeover (public.tsx) wraps the same body in the same
// layout with the same currentPath, which is what makes the server markup and the
// post-takeover markup identical. If they diverged, the page would visibly rebuild
// itself the moment the bundle landed.
//
// Deliberately plain. This renders inside goja against a DOM shim at BUILD time,
// where there is no layout, no getBoundingClientRect and no window — so nothing in
// here may measure the page. That rules out the kit's floating components (Menu,
// Tooltip, Popover), which is why the Layers menu is a row of links here and a real
// menu everywhere else.
import { JSXElement } from "solid-js";
export function PublicLayout(props: { currentPath: string; children?: JSXElement }) {
return (
<div class="min-h-screen bg-surface">
<nav class="border-b border-line">
<div class="mx-auto flex max-w-2xl items-center gap-2 px-4 py-4">
<a href="/" class="flex items-center gap-2.5 no-underline">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default bg-fill-neutral text-on-fill-neutral">
{/* The boat is the point of the name: kjol is Norwegian for KEEL. Inlined
rather than pulled from the icon kit, because the kit's <Icon> reads a
CSS custom property at runtime to pick its style — and under SSR there
is no computed style to read. */}
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11.25 3.75v12M11.25 15.75H4.5l6.75-12M14.25 15.75h4.5l-4.5-7.5zM2.25 18.75h19.5l-2.4 3H4.65z" />
</svg>
</span>
<span class="flex items-baseline gap-1.5">
<span class="text-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
</span>
</a>
<ul class="ml-auto flex items-center gap-1">
<li>
<a
href="/js"
class={
props.currentPath === "/js"
? "rounded-default bg-surface-raised px-3 py-1.5 text-sm font-medium text-ink no-underline"
: "rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:bg-surface-raised hover:text-ink"
}
>
Docs
</a>
</li>
<li>
<a
href="/wasm"
class="rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:bg-surface-raised hover:text-ink"
>
Wasm Web
</a>
</li>
</ul>
</div>
</nav>
<main>{props.children}</main>
<footer class="mx-auto max-w-2xl px-4 pb-14">
<p class="text-sm text-ink-faint">
Kjøl JS Web is one layer of kjol a shared base layer. kjol is Norwegian for keel.
</p>
</footer>
</div>
);
}

View File

@@ -0,0 +1,77 @@
// A server-rendered public page.
//
// The SPA under /js/* is client-only: the browser gets an empty #app and Solid fills
// it. That is fine for a docs section behind a click, and wrong for anything a search
// engine or a slow phone has to read.
//
// This page takes the other route. The bundler renders it at BUILD time — the real
// component, executed in goja against a DOM shim — and bakes the resulting HTML into
// a Go registry (internal/handlers/public_pages.gen.go). The server ships that HTML
// directly, so the page is complete before any JavaScript loads. The client bundle
// then re-renders the same component over the top and it becomes interactive.
//
// serverData() is what makes it more than a static file: the handler can inject data
// for a request, and the SAME component renders it — on the server at request time,
// and again in the browser after takeover, from the same inlined JSON. No refetch, no
// flash of a skeleton.
import { serverData } from "@kjol/ssr/serverData.ts";
interface BuildInfo {
renderedAt: string;
stage: string;
}
export function Ssr() {
// Read inside the reactive body, never captured at module load — the value has to
// be observed at render time, and there are three different render times.
const info = () => serverData<BuildInfo>();
return (
<div class="page-ssr mx-auto max-w-2xl px-4 py-14">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">
This page was rendered by Go
</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
Not by a Node renderer, and not in your browser. A Go program executed this Solid component
in an embedded JavaScript engine, serialized the DOM it produced, and compiled the result
into the server binary. View source: the markup arrived complete.
</p>
{/* No data → the skeleton. This is exactly what the build-time bake sees, because
the bake injects nothing; it is also what a crawler sees. With data injected at
request time, the same three lines render the real values instead. */}
{!info() ? (
<div class="mt-8 animate-pulse rounded-default border border-line p-5">
<div class="h-3 w-40 rounded bg-surface-strong" />
<div class="mt-3 h-3 w-64 rounded bg-surface-raised" />
</div>
) : (
<dl class="mt-8 rounded-default border border-line p-5">
<div class="flex justify-between text-sm">
<dt class="text-ink-muted">rendered at</dt>
<dd class="font-mono text-ink">{info()!.renderedAt}</dd>
</div>
<div class="mt-2 flex justify-between text-sm">
<dt class="text-ink-muted">stage</dt>
<dd class="font-mono text-ink">{info()!.stage}</dd>
</div>
</dl>
)}
<p class="mt-8 leading-relaxed text-ink-soft">
The skeleton above is the honest default. The bake runs with no data, so a component that
cannot render without data cannot be baked which is a useful constraint to discover at build
time rather than in production.
</p>
<p class="mt-8 text-sm text-ink-muted">
<a href="/js" class="text-primary underline underline-offset-4">
Back to Kjøl JS Web
</a>
</p>
</div>
);
}

View File

@@ -0,0 +1,30 @@
// SINGLE SOURCE OF TRUTH for server-rendered public pages.
//
// Add an entry here, then write the component it points at, then run the bundler.
// It regenerates:
// - internal/handlers/public_pages.gen.go Go registry: route → <title> + baked HTML
// - frontend/src/pages/public/routes.gen.ts client takeover map: route → component
//
// Both generated files are read back by code that is committed, so neither is
// optional — but neither is hand-edited either.
export interface PublicPageDef {
path: string; // URL pathname
module: string; // component file, relative to frontend/src
component: string; // exported component name
title: string; // <title> text
dynamic?: boolean; // ISR: also bake the render bundle so the server can render
// this page with live data at request time
}
export const publicPages: PublicPageDef[] = [
{
path: "/js/ssr",
module: "pages/public/Ssr.tsx",
component: "Ssr",
title: "Server-rendered — Kjøl JS Web",
// dynamic: the server may inject data for this route at request time, so bake
// the render bundle too, not just the static skeleton.
dynamic: true,
},
];

View File

@@ -0,0 +1,17 @@
// Code generated by cmd/bundle; DO NOT EDIT.
// Source: frontend/src/pages/public/pages.ts
import { JSXElement } from "solid-js";
import { Ssr } from "./Ssr.tsx";
// Body component for each public route, keyed by URL pathname. The client
// router (public.ts) renders these when navigating without a full reload.
export const publicRoutes: Record<string, () => JSXElement> = {
"/js/ssr": Ssr,
};
// <title> for each public route, applied by the client router on navigation
// (the first load gets its title from the server-rendered shell).
export const publicTitles: Record<string, string> = {
"/js/ssr": "Server-rendered — Kjøl JS Web",
};

View File

@@ -0,0 +1,35 @@
// Client takeover for the server-rendered public pages.
//
// The server ships each page's HTML inside #page-root — fast first paint, readable by
// a crawler, works with JavaScript off. This boots the same component and swaps it in,
// making the page interactive.
//
// It wraps the body in the SAME PublicLayout with the SAME currentPath the build-time
// bake used (see jsbundler/genssr.go: ssrEntrySolid). That is not tidiness — if the two
// trees differed, the page would visibly rebuild itself the instant this bundle landed.
//
// It is a re-render takeover, not attach-hydration: Solid renders the client tree into
// a detached node FIRST, then replaces #page-root's children in one step. The server
// markup stays on screen until identical client markup is ready to replace it, so there
// is no window in which the page is half-built.
import { render } from "solid-js/web";
import { PublicLayout } from "./pages/public/PublicLayout.tsx";
import { publicRoutes } from "./pages/public/routes.gen.ts";
const root = document.getElementById("page-root");
const path = window.location.pathname;
const Body = root ? publicRoutes[path] : undefined;
if (root && Body) {
const staging = document.createElement(root.tagName);
render(
() => (
<PublicLayout currentPath={path}>
<Body />
</PublicLayout>
),
staging,
);
root.replaceChildren(...staging.childNodes);
}

View File

@@ -0,0 +1,8 @@
// Ambient shims for tsserver only. The vendored pdf-lib / pdfjs-dist here are trimmed to
// the runtime files the bundler pins (frontend/vendor/vendor.json), so their `.d.ts` type
// trees are absent and each package.json `types` field points at a file that was not
// vendored. @ui/AutoTable imports both; the bundler resolves them at build time, but the
// editor needs a declaration or it reports "cannot find module". These make them `any`,
// which is all this example needs — it does not exercise the PDF export path itself.
declare module "pdf-lib";
declare module "pdfjs-dist";

View File

@@ -0,0 +1,589 @@
/*!
* @kurkle/color v0.3.4
* https://github.com/kurkle/color#readme
* (c) 2024 Jukka Kurkela
* Released under the MIT License
*/
function round(v) {
return v + 0.5 | 0;
}
const lim = (v, l, h) => Math.max(Math.min(v, h), l);
function p2b(v) {
return lim(round(v * 2.55), 0, 255);
}
function b2p(v) {
return lim(round(v / 2.55), 0, 100);
}
function n2b(v) {
return lim(round(v * 255), 0, 255);
}
function b2n(v) {
return lim(round(v / 2.55) / 100, 0, 1);
}
function n2p(v) {
return lim(round(v * 100), 0, 100);
}
const map$1 = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, a: 10, b: 11, c: 12, d: 13, e: 14, f: 15};
const hex = [...'0123456789ABCDEF'];
const h1 = b => hex[b & 0xF];
const h2 = b => hex[(b & 0xF0) >> 4] + hex[b & 0xF];
const eq = b => ((b & 0xF0) >> 4) === (b & 0xF);
const isShort = v => eq(v.r) && eq(v.g) && eq(v.b) && eq(v.a);
function hexParse(str) {
var len = str.length;
var ret;
if (str[0] === '#') {
if (len === 4 || len === 5) {
ret = {
r: 255 & map$1[str[1]] * 17,
g: 255 & map$1[str[2]] * 17,
b: 255 & map$1[str[3]] * 17,
a: len === 5 ? map$1[str[4]] * 17 : 255
};
} else if (len === 7 || len === 9) {
ret = {
r: map$1[str[1]] << 4 | map$1[str[2]],
g: map$1[str[3]] << 4 | map$1[str[4]],
b: map$1[str[5]] << 4 | map$1[str[6]],
a: len === 9 ? (map$1[str[7]] << 4 | map$1[str[8]]) : 255
};
}
}
return ret;
}
const alpha = (a, f) => a < 255 ? f(a) : '';
function hexString(v) {
var f = isShort(v) ? h1 : h2;
return v
? '#' + f(v.r) + f(v.g) + f(v.b) + alpha(v.a, f)
: undefined;
}
const HUE_RE = /^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;
function hsl2rgbn(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = (n, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return [f(0), f(8), f(4)];
}
function hsv2rgbn(h, s, v) {
const f = (n, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0);
return [f(5), f(3), f(1)];
}
function hwb2rgbn(h, w, b) {
const rgb = hsl2rgbn(h, 1, 0.5);
let i;
if (w + b > 1) {
i = 1 / (w + b);
w *= i;
b *= i;
}
for (i = 0; i < 3; i++) {
rgb[i] *= 1 - w - b;
rgb[i] += w;
}
return rgb;
}
function hueValue(r, g, b, d, max) {
if (r === max) {
return ((g - b) / d) + (g < b ? 6 : 0);
}
if (g === max) {
return (b - r) / d + 2;
}
return (r - g) / d + 4;
}
function rgb2hsl(v) {
const range = 255;
const r = v.r / range;
const g = v.g / range;
const b = v.b / range;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h, s, d;
if (max !== min) {
d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
h = hueValue(r, g, b, d, max);
h = h * 60 + 0.5;
}
return [h | 0, s || 0, l];
}
function calln(f, a, b, c) {
return (
Array.isArray(a)
? f(a[0], a[1], a[2])
: f(a, b, c)
).map(n2b);
}
function hsl2rgb(h, s, l) {
return calln(hsl2rgbn, h, s, l);
}
function hwb2rgb(h, w, b) {
return calln(hwb2rgbn, h, w, b);
}
function hsv2rgb(h, s, v) {
return calln(hsv2rgbn, h, s, v);
}
function hue(h) {
return (h % 360 + 360) % 360;
}
function hueParse(str) {
const m = HUE_RE.exec(str);
let a = 255;
let v;
if (!m) {
return;
}
if (m[5] !== v) {
a = m[6] ? p2b(+m[5]) : n2b(+m[5]);
}
const h = hue(+m[2]);
const p1 = +m[3] / 100;
const p2 = +m[4] / 100;
if (m[1] === 'hwb') {
v = hwb2rgb(h, p1, p2);
} else if (m[1] === 'hsv') {
v = hsv2rgb(h, p1, p2);
} else {
v = hsl2rgb(h, p1, p2);
}
return {
r: v[0],
g: v[1],
b: v[2],
a: a
};
}
function rotate(v, deg) {
var h = rgb2hsl(v);
h[0] = hue(h[0] + deg);
h = hsl2rgb(h);
v.r = h[0];
v.g = h[1];
v.b = h[2];
}
function hslString(v) {
if (!v) {
return;
}
const a = rgb2hsl(v);
const h = a[0];
const s = n2p(a[1]);
const l = n2p(a[2]);
return v.a < 255
? `hsla(${h}, ${s}%, ${l}%, ${b2n(v.a)})`
: `hsl(${h}, ${s}%, ${l}%)`;
}
const map = {
x: 'dark',
Z: 'light',
Y: 're',
X: 'blu',
W: 'gr',
V: 'medium',
U: 'slate',
A: 'ee',
T: 'ol',
S: 'or',
B: 'ra',
C: 'lateg',
D: 'ights',
R: 'in',
Q: 'turquois',
E: 'hi',
P: 'ro',
O: 'al',
N: 'le',
M: 'de',
L: 'yello',
F: 'en',
K: 'ch',
G: 'arks',
H: 'ea',
I: 'ightg',
J: 'wh'
};
const names$1 = {
OiceXe: 'f0f8ff',
antiquewEte: 'faebd7',
aqua: 'ffff',
aquamarRe: '7fffd4',
azuY: 'f0ffff',
beige: 'f5f5dc',
bisque: 'ffe4c4',
black: '0',
blanKedOmond: 'ffebcd',
Xe: 'ff',
XeviTet: '8a2be2',
bPwn: 'a52a2a',
burlywood: 'deb887',
caMtXe: '5f9ea0',
KartYuse: '7fff00',
KocTate: 'd2691e',
cSO: 'ff7f50',
cSnflowerXe: '6495ed',
cSnsilk: 'fff8dc',
crimson: 'dc143c',
cyan: 'ffff',
xXe: '8b',
xcyan: '8b8b',
xgTMnPd: 'b8860b',
xWay: 'a9a9a9',
xgYF: '6400',
xgYy: 'a9a9a9',
xkhaki: 'bdb76b',
xmagFta: '8b008b',
xTivegYF: '556b2f',
xSange: 'ff8c00',
xScEd: '9932cc',
xYd: '8b0000',
xsOmon: 'e9967a',
xsHgYF: '8fbc8f',
xUXe: '483d8b',
xUWay: '2f4f4f',
xUgYy: '2f4f4f',
xQe: 'ced1',
xviTet: '9400d3',
dAppRk: 'ff1493',
dApskyXe: 'bfff',
dimWay: '696969',
dimgYy: '696969',
dodgerXe: '1e90ff',
fiYbrick: 'b22222',
flSOwEte: 'fffaf0',
foYstWAn: '228b22',
fuKsia: 'ff00ff',
gaRsbSo: 'dcdcdc',
ghostwEte: 'f8f8ff',
gTd: 'ffd700',
gTMnPd: 'daa520',
Way: '808080',
gYF: '8000',
gYFLw: 'adff2f',
gYy: '808080',
honeyMw: 'f0fff0',
hotpRk: 'ff69b4',
RdianYd: 'cd5c5c',
Rdigo: '4b0082',
ivSy: 'fffff0',
khaki: 'f0e68c',
lavFMr: 'e6e6fa',
lavFMrXsh: 'fff0f5',
lawngYF: '7cfc00',
NmoncEffon: 'fffacd',
ZXe: 'add8e6',
ZcSO: 'f08080',
Zcyan: 'e0ffff',
ZgTMnPdLw: 'fafad2',
ZWay: 'd3d3d3',
ZgYF: '90ee90',
ZgYy: 'd3d3d3',
ZpRk: 'ffb6c1',
ZsOmon: 'ffa07a',
ZsHgYF: '20b2aa',
ZskyXe: '87cefa',
ZUWay: '778899',
ZUgYy: '778899',
ZstAlXe: 'b0c4de',
ZLw: 'ffffe0',
lime: 'ff00',
limegYF: '32cd32',
lRF: 'faf0e6',
magFta: 'ff00ff',
maPon: '800000',
VaquamarRe: '66cdaa',
VXe: 'cd',
VScEd: 'ba55d3',
VpurpN: '9370db',
VsHgYF: '3cb371',
VUXe: '7b68ee',
VsprRggYF: 'fa9a',
VQe: '48d1cc',
VviTetYd: 'c71585',
midnightXe: '191970',
mRtcYam: 'f5fffa',
mistyPse: 'ffe4e1',
moccasR: 'ffe4b5',
navajowEte: 'ffdead',
navy: '80',
Tdlace: 'fdf5e6',
Tive: '808000',
TivedBb: '6b8e23',
Sange: 'ffa500',
SangeYd: 'ff4500',
ScEd: 'da70d6',
pOegTMnPd: 'eee8aa',
pOegYF: '98fb98',
pOeQe: 'afeeee',
pOeviTetYd: 'db7093',
papayawEp: 'ffefd5',
pHKpuff: 'ffdab9',
peru: 'cd853f',
pRk: 'ffc0cb',
plum: 'dda0dd',
powMrXe: 'b0e0e6',
purpN: '800080',
YbeccapurpN: '663399',
Yd: 'ff0000',
Psybrown: 'bc8f8f',
PyOXe: '4169e1',
saddNbPwn: '8b4513',
sOmon: 'fa8072',
sandybPwn: 'f4a460',
sHgYF: '2e8b57',
sHshell: 'fff5ee',
siFna: 'a0522d',
silver: 'c0c0c0',
skyXe: '87ceeb',
UXe: '6a5acd',
UWay: '708090',
UgYy: '708090',
snow: 'fffafa',
sprRggYF: 'ff7f',
stAlXe: '4682b4',
tan: 'd2b48c',
teO: '8080',
tEstN: 'd8bfd8',
tomato: 'ff6347',
Qe: '40e0d0',
viTet: 'ee82ee',
JHt: 'f5deb3',
wEte: 'ffffff',
wEtesmoke: 'f5f5f5',
Lw: 'ffff00',
LwgYF: '9acd32'
};
function unpack() {
const unpacked = {};
const keys = Object.keys(names$1);
const tkeys = Object.keys(map);
let i, j, k, ok, nk;
for (i = 0; i < keys.length; i++) {
ok = nk = keys[i];
for (j = 0; j < tkeys.length; j++) {
k = tkeys[j];
nk = nk.replace(k, map[k]);
}
k = parseInt(names$1[ok], 16);
unpacked[nk] = [k >> 16 & 0xFF, k >> 8 & 0xFF, k & 0xFF];
}
return unpacked;
}
let names;
function nameParse(str) {
if (!names) {
names = unpack();
names.transparent = [0, 0, 0, 0];
}
const a = names[str.toLowerCase()];
return a && {
r: a[0],
g: a[1],
b: a[2],
a: a.length === 4 ? a[3] : 255
};
}
const RGB_RE = /^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;
function rgbParse(str) {
const m = RGB_RE.exec(str);
let a = 255;
let r, g, b;
if (!m) {
return;
}
if (m[7] !== r) {
const v = +m[7];
a = m[8] ? p2b(v) : lim(v * 255, 0, 255);
}
r = +m[1];
g = +m[3];
b = +m[5];
r = 255 & (m[2] ? p2b(r) : lim(r, 0, 255));
g = 255 & (m[4] ? p2b(g) : lim(g, 0, 255));
b = 255 & (m[6] ? p2b(b) : lim(b, 0, 255));
return {
r: r,
g: g,
b: b,
a: a
};
}
function rgbString(v) {
return v && (
v.a < 255
? `rgba(${v.r}, ${v.g}, ${v.b}, ${b2n(v.a)})`
: `rgb(${v.r}, ${v.g}, ${v.b})`
);
}
const to = v => v <= 0.0031308 ? v * 12.92 : Math.pow(v, 1.0 / 2.4) * 1.055 - 0.055;
const from = v => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
function interpolate(rgb1, rgb2, t) {
const r = from(b2n(rgb1.r));
const g = from(b2n(rgb1.g));
const b = from(b2n(rgb1.b));
return {
r: n2b(to(r + t * (from(b2n(rgb2.r)) - r))),
g: n2b(to(g + t * (from(b2n(rgb2.g)) - g))),
b: n2b(to(b + t * (from(b2n(rgb2.b)) - b))),
a: rgb1.a + t * (rgb2.a - rgb1.a)
};
}
function modHSL(v, i, ratio) {
if (v) {
let tmp = rgb2hsl(v);
tmp[i] = Math.max(0, Math.min(tmp[i] + tmp[i] * ratio, i === 0 ? 360 : 1));
tmp = hsl2rgb(tmp);
v.r = tmp[0];
v.g = tmp[1];
v.b = tmp[2];
}
}
function clone(v, proto) {
return v ? Object.assign(proto || {}, v) : v;
}
function fromObject(input) {
var v = {r: 0, g: 0, b: 0, a: 255};
if (Array.isArray(input)) {
if (input.length >= 3) {
v = {r: input[0], g: input[1], b: input[2], a: 255};
if (input.length > 3) {
v.a = n2b(input[3]);
}
}
} else {
v = clone(input, {r: 0, g: 0, b: 0, a: 1});
v.a = n2b(v.a);
}
return v;
}
function functionParse(str) {
if (str.charAt(0) === 'r') {
return rgbParse(str);
}
return hueParse(str);
}
class Color {
constructor(input) {
if (input instanceof Color) {
return input;
}
const type = typeof input;
let v;
if (type === 'object') {
v = fromObject(input);
} else if (type === 'string') {
v = hexParse(input) || nameParse(input) || functionParse(input);
}
this._rgb = v;
this._valid = !!v;
}
get valid() {
return this._valid;
}
get rgb() {
var v = clone(this._rgb);
if (v) {
v.a = b2n(v.a);
}
return v;
}
set rgb(obj) {
this._rgb = fromObject(obj);
}
rgbString() {
return this._valid ? rgbString(this._rgb) : undefined;
}
hexString() {
return this._valid ? hexString(this._rgb) : undefined;
}
hslString() {
return this._valid ? hslString(this._rgb) : undefined;
}
mix(color, weight) {
if (color) {
const c1 = this.rgb;
const c2 = color.rgb;
let w2;
const p = weight === w2 ? 0.5 : weight;
const w = 2 * p - 1;
const a = c1.a - c2.a;
const w1 = ((w * a === -1 ? w : (w + a) / (1 + w * a)) + 1) / 2.0;
w2 = 1 - w1;
c1.r = 0xFF & w1 * c1.r + w2 * c2.r + 0.5;
c1.g = 0xFF & w1 * c1.g + w2 * c2.g + 0.5;
c1.b = 0xFF & w1 * c1.b + w2 * c2.b + 0.5;
c1.a = p * c1.a + (1 - p) * c2.a;
this.rgb = c1;
}
return this;
}
interpolate(color, t) {
if (color) {
this._rgb = interpolate(this._rgb, color._rgb, t);
}
return this;
}
clone() {
return new Color(this.rgb);
}
alpha(a) {
this._rgb.a = n2b(a);
return this;
}
clearer(ratio) {
const rgb = this._rgb;
rgb.a *= 1 - ratio;
return this;
}
greyscale() {
const rgb = this._rgb;
const val = round(rgb.r * 0.3 + rgb.g * 0.59 + rgb.b * 0.11);
rgb.r = rgb.g = rgb.b = val;
return this;
}
opaquer(ratio) {
const rgb = this._rgb;
rgb.a *= 1 + ratio;
return this;
}
negate() {
const v = this._rgb;
v.r = 255 - v.r;
v.g = 255 - v.g;
v.b = 255 - v.b;
return this;
}
lighten(ratio) {
modHSL(this._rgb, 2, ratio);
return this;
}
darken(ratio) {
modHSL(this._rgb, 2, -ratio);
return this;
}
saturate(ratio) {
modHSL(this._rgb, 1, ratio);
return this;
}
desaturate(ratio) {
modHSL(this._rgb, 1, -ratio);
return this;
}
rotate(deg) {
rotate(this._rgb, deg);
return this;
}
}
function index_esm(input) {
return new Color(input);
}
export { Color, b2n, b2p, index_esm as default, hexParse, hexString, hsl2rgb, hslString, hsv2rgb, hueParse, hwb2rgb, lim, n2b, n2p, nameParse, p2b, rgb2hsl, rgbParse, rgbString, rotate, round };

View File

@@ -0,0 +1,77 @@
{
"name": "@kurkle/color",
"type": "module",
"version": "0.3.4",
"description": "css color parsing, manupulation and conversion",
"sideEffects": false,
"main": "dist/color.cjs",
"module": "dist/color.esm.js",
"types": "dist/color.d.ts",
"exports": {
"types": "./dist/color.d.ts",
"import": "./dist/color.esm.js",
"require": "./dist/color.cjs"
},
"scripts": {
"build": "node util/copy_dist.js && rollup -c",
"lint": "eslint src/*.js test/*.js util/*.js",
"test": "node test/index.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/kurkle/color.git"
},
"files": [
"dist/*",
"dist/color.d.ts"
],
"keywords": [
"color",
"colour",
"css",
"hsl",
"hex",
"rgb",
"rgba",
"hwb",
"hsv",
"cmyk"
],
"author": "Jukka Kurkela",
"license": "MIT",
"bugs": {
"url": "https://github.com/kurkle/color/issues"
},
"homepage": "https://github.com/kurkle/color#readme",
"devDependencies": {
"@rollup/plugin-terser": "^0.4.0",
"assert": "^2.0.0",
"benchmark": "^2.1.4",
"chartjs-color": "^2.4.1",
"chartjs-color-string": "^0.6.0",
"child_process": "^1.0.2",
"chroma-js": "^3.1.1",
"color-name": "^2.0.0",
"color-names": "^2.0.0",
"color-parse": "^2.0.2",
"color-parser": "^0.1.0",
"color-string": "^1.5.5",
"csscolorparser": "^1.0.3",
"eslint": "^9.15.0",
"eslint-config-chartjs": "^0.3.0",
"eslint-config-defaults": "^9.0.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-react": "^7.22.0",
"fs": "0.0.1-security",
"perf_hooks": "0.0.1",
"rollup": "^4.25.0",
"rollup-plugin-analyzer": "^4.0.0",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-istanbul": "^5.0.0",
"rollup-plugin-visualizer": "^5.8.3",
"tinycolor2": "^1.4.2",
"typedoc": "^0.26.7",
"typescript": "^5.6.2",
"util": "^0.12.3"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,139 @@
{
"name": "chart.js",
"homepage": "https://www.chartjs.org",
"description": "Simple HTML5 charts using the canvas element.",
"version": "4.5.1",
"license": "MIT",
"type": "module",
"sideEffects": [
"./auto/auto.js",
"./auto/auto.cjs",
"./dist/chart.umd.min.js",
"./dist/chart.umd.js"
],
"jsdelivr": "./dist/chart.umd.min.js",
"unpkg": "./dist/chart.umd.min.js",
"main": "./dist/chart.cjs",
"module": "./dist/chart.js",
"exports": {
".": {
"types": "./dist/types.d.ts",
"import": "./dist/chart.js",
"require": "./dist/chart.cjs"
},
"./auto": {
"types": "./auto/auto.d.ts",
"import": "./auto/auto.js",
"require": "./auto/auto.cjs"
},
"./helpers": {
"types": "./helpers/helpers.d.ts",
"import": "./helpers/helpers.js",
"require": "./helpers/helpers.cjs"
}
},
"types": "./dist/types.d.ts",
"keywords": [
"canvas",
"charts",
"data",
"graphs",
"html5",
"responsive"
],
"repository": {
"type": "git",
"url": "https://github.com/chartjs/Chart.js.git"
},
"bugs": {
"url": "https://github.com/chartjs/Chart.js/issues"
},
"files": [
"auto/**",
"dist/**",
"!dist/docs/**",
"helpers/**"
],
"scripts": {
"autobuild": "rollup -c -w",
"copyDeclarations": "node -e \"fs.cpSync('./src/types/', './dist/types/', {recursive:true})\"",
"emitDeclarations": "tsc --emitDeclarationOnly && pnpm copyDeclarations",
"build": "rollup -c && pnpm emitDeclarations",
"dev": "karma start ./karma.conf.cjs --auto-watch --no-single-run --browsers chrome --grep",
"dev:ff": "karma start ./karma.conf.cjs --auto-watch --no-single-run --browsers firefox --grep",
"docs": "pnpm run build && pnpm --filter \"./docs/**\" build",
"docs:dev": "pnpm run build && pnpm --filter \"./docs/**\" dev",
"lint-js": "eslint \"src/**/*.{js,ts}\" \"test/**/*.js\" \"docs/**/*.js\" --cache",
"lint-md": "eslint \"**/*.md\" --cache",
"lint-types": "pnpm build && node test/types/autogen.js && tsc -p test/types",
"lint": "concurrently \"pnpm:lint-*\"",
"test": "pnpm lint && pnpm test-ci",
"test-ci": "concurrently \"pnpm:test-ci-*\"",
"test-ci-karma": "cross-env NODE_ENV=test karma start ./karma.conf.cjs --auto-watch --single-run --coverage --grep",
"test-ci-integration": "pnpm --filter \"./test/integration/**\" test"
},
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-inject": "^5.0.2",
"@rollup/plugin-json": "^5.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@swc/core": "^1.3.18",
"@types/estree": "^1.0.0",
"@types/offscreencanvas": "^2019.7.0",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"chartjs-adapter-luxon": "^1.2.0",
"chartjs-adapter-moment": "^1.0.0",
"chartjs-test-utils": "^0.4.0",
"concurrently": "^7.3.0",
"coveralls": "^3.1.1",
"cross-env": "^7.0.3",
"eslint": "^8.21.0",
"eslint-config-chartjs": "^0.3.0",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-html": "^7.1.0",
"eslint-plugin-markdown": "^3.0.0",
"esm": "^3.2.25",
"glob": "^8.0.3",
"jasmine": "^3.7.0",
"jasmine-core": "^3.7.1",
"karma": "^6.3.2",
"karma-chrome-launcher": "^3.1.0",
"karma-coverage": "^2.0.3",
"karma-edge-launcher": "^0.4.2",
"karma-firefox-launcher": "^2.1.0",
"karma-jasmine": "^4.0.1",
"karma-jasmine-html-reporter": "^1.5.4",
"karma-rollup-preprocessor": "7.0.7",
"karma-safari-private-launcher": "^1.0.0",
"karma-spec-reporter": "0.0.32",
"luxon": "^3.0.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.34",
"pixelmatch": "^5.3.0",
"rollup": "^3.3.0",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-istanbul": "^4.0.0",
"rollup-plugin-swc3": "^0.7.0",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.7.4",
"yargs": "^17.5.1"
},
"engines": {
"pnpm": ">=8"
},
"packageManager": "pnpm@8.13.0",
"pnpm": {
"overrides": {
"html-entities": "1.4.0"
},
"peerDependencyRules": {
"ignoreMissing": [
"chart.js"
]
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,141 @@
{
"name": "pdf-lib",
"version": "1.17.1",
"description": "Create and modify PDF files with JavaScript",
"author": "Andrew Dillon <andrew.dillon.j@gmail.com>",
"contributors": [
"jerp (https://github.com/jerp)",
"Greg Bacchus (https://github.com/gregbacchus)",
"Mickael Lecoq (https://github.com/mlecoq)",
"Philip Murphy (https://github.com/philipjmurphy)",
"Dmitry Kozliuk (https://github.com/PlushBeaver)",
"Said Amezyane (https://github.com/samezyane)",
"Georges Gabereau (https://github.com/multiplegeorges)",
"Gerard Smit (https://github.com/GerardSmit)",
"jlmessenger (https://github.com/jlmessenger)",
"thebenlamm (https://github.com/thebenlamm)",
"cshenks (https://github.com/cshenks)",
"James Woodrow (https://github.com/jwoodrow)",
"Guillaume Grossetie (https://github.com/Mogztter)",
"Philipp Tessenow (https://github.com/tessi)",
"Tim Kräuter (https://github.com/timKraeuter)",
"Richard Bateman (https://github.com/taxilian)",
"Sebastian Martinez (https://github.com/sebastinez)",
"soadzoor (https://github.com/soadzoor)",
"Slobodan Babic (https://github.com/bockoblur)",
"Zach Toben (https://github.com/ztoben)",
"Zack Sheppard (https://github.com/zackdotcomputer)",
"DkDavid (https://github.com/DkDavid)",
"Bj Tecu (https://github.com/btecu)",
"Brent McSharry (https://github.com/mcshaz)",
"Tim Knapp (https://github.com/duffyd)",
"Ching Chang (https://github.com/ChingChang9)"
],
"scripts": {
"release:latest": "yarn publish --tag latest && yarn pack && yarn release:tag",
"release:next": "yarn publish --tag next",
"release:prep": "yarn clean && yarn lint && yarn typecheck && yarn test && yarn build",
"release:tag": "TAG=\"v$(yarn --silent get:version)\" && git tag $TAG && git push origin $TAG",
"get:version": "node --eval 'console.log(require(`./package.json`).version)'",
"clean": "rimraf ts3.4 build cjs dist es scratchpad/build coverage tsBuildInfo.json apps/node-build apps/node/tsBuildInfo.json isolate*.log flamegraph.html out.pdf",
"typecheck": "tsc --noEmit --incremental false --tsBuildInfoFile null",
"test": "jest --config jest.json --runInBand",
"testw": "jest --config jest.json --watch",
"testc": "jest --config jest.json --coverage && open coverage/index.html",
"lint": "yarn lint:prettier && yarn lint:tslint:src && yarn lint:tslint:tests",
"lint:tslint:src": "tslint --project tsconfig.json --fix",
"lint:tslint:tests": "tslint --project tests/tsconfig.json --fix",
"lint:prettier": "prettier --write \"./{src,tests,apps}/**/*.{ts,js,json,html,css}\" --loglevel error",
"build": "yarn build:cjs && yarn build:es && yarn build:esm && yarn build:esm:min && yarn build:umd && yarn build:umd:min && yarn build:downlevel-dts",
"build:cjs": "ttsc --module commonjs --outDir cjs",
"build:es": "ttsc --module ES2015 --outDir es",
"build:esm": "rollup --config rollup.config.js --file dist/pdf-lib.esm.js --environment MODULE_TYPE:es",
"build:esm:min": "rollup --config rollup.config.js --file dist/pdf-lib.esm.min.js --environment MINIFY,MODULE_TYPE:es",
"build:umd": "rollup --config rollup.config.js --file dist/pdf-lib.js --environment MODULE_TYPE:umd",
"build:umd:min": "rollup --config rollup.config.js --file dist/pdf-lib.min.js --environment MINIFY,MODULE_TYPE:umd",
"build:downlevel-dts": "rimraf ts3.4 && yarn downlevel-dts . ts3.4 && rimraf ts3.4/scratchpad",
"scratchpad:start": "ttsc --build scratchpad/tsconfig.json --watch",
"scratchpad:run": "node scratchpad/build/scratchpad/index.js",
"scratchpad:flame": "rimraf isolate*.log && node --prof scratchpad/build/scratchpad/index.js && node --prof-process --preprocess -j isolate*.log | flamebearer",
"apps:node": "ttsc --build apps/node/tsconfig.json && node apps/node-build/index.js",
"apps:deno": "deno run --allow-read --allow-write --allow-run apps/deno/index.ts",
"apps:web": "http-server -c-1 .",
"apps:web:mac": "bash -c 'sleep 1 && open http://localhost:8080/apps/web/test1.html' & yarn apps:web",
"apps:rn:ios": "cd apps/rn && yarn add ./../.. --force && react-native run-ios",
"apps:rn:android": "yarn apps:rn:emulator & cd apps/rn && yarn add ./../.. --force && react-native run-android",
"apps:rn:emulator": "emulator -avd \"$(emulator -list-avds | head -n 1)\" & bash -c 'sleep 5 && adb reverse tcp:8080 tcp:8080 && adb reverse tcp:8081 tcp:8081'"
},
"main": "cjs/index.js",
"module": "es/index.js",
"unpkg": "dist/pdf-lib.min.js",
"types": "cjs/index.d.ts",
"typesVersions": {
"<=3.5": {
"*": [
"ts3.4/*"
]
}
},
"files": [
"cjs/",
"dist/",
"es/",
"src/",
"ts3.4",
"LICENSE.md",
"package.json",
"README.md",
"yarn.lock"
],
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
},
"devDependencies": {
"@pdf-lib/fontkit": "^1.1.0",
"@rollup/plugin-commonjs": "^13.0.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^8.0.1",
"@types/jest": "^26.0.0",
"@types/node-fetch": "^2.5.7",
"@types/pako": "^1.0.1",
"@zerollup/ts-transform-paths": "^1.7.18",
"downlevel-dts": "^0.5.0",
"flamebearer": "^1.1.3",
"http-server": "^0.12.3",
"jest": "^26.0.1",
"node-fetch": "^2.6.0",
"prettier": "^2.0.5",
"rimraf": "^3.0.2",
"rollup": "^2.17.1",
"rollup-plugin-terser": "^6.1.0",
"ts-jest": "^26.1.0",
"tslint": "^6.1.2",
"tslint-config-prettier": "^1.18.0",
"ttypescript": "^1.5.10",
"typescript": "^3.9.5"
},
"license": "MIT",
"private": false,
"homepage": "https://pdf-lib.js.org",
"repository": "git+https://github.com/Hopding/pdf-lib.git",
"bugs": {
"url": "https://github.com/Hopding/pdf-lib/issues"
},
"keywords": [
"pdf-lib",
"pdf",
"document",
"create",
"modify",
"creation",
"modification",
"edit",
"editing",
"typescript",
"javascript",
"library"
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,34 @@
{
"name": "pdfjs-dist",
"version": "5.5.207",
"main": "build/pdf.mjs",
"types": "types/src/pdf.d.ts",
"description": "Generic build of Mozilla's PDF.js library.",
"keywords": [
"Mozilla",
"pdf",
"pdf.js"
],
"homepage": "https://mozilla.github.io/pdf.js/",
"bugs": "https://github.com/mozilla/pdf.js/issues",
"license": "Apache-2.0",
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.95",
"node-readable-to-web-readable-stream": "^0.4.2"
},
"browser": {
"canvas": false,
"fs": false,
"http": false,
"https": false,
"url": false
},
"repository": {
"type": "git",
"url": "git+https://github.com/mozilla/pdf.js.git"
},
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"scripts": {}
}

View File

@@ -0,0 +1,12 @@
{
"//": "This app's vendored packages, MERGED on top of kjol's base manifest (go/jsruntime/runtime/vendor.json), which pins solid-js, solid-js/web, solid-js/html, solid-js/store, @solidjs/router and solid-refresh. kjol's manifest is searched FIRST, so its solid-js wins and there is exactly one reactive instance — a split instance does not error, it silently stops flushing effects, so onMount never fires and nothing updates.",
"//2": "pdf-lib and pdfjs-dist are here because @ui/AutoTable imports them at the TOP LEVEL for PDF export. That makes them a hard dependency of the kit, not an optional extra: leave them out and esbuild emits a bare `import ... from \"pdf-lib\"`, the browser cannot resolve it, and the entire bundle fails to evaluate — you get an empty page and one line in the console. Any app that uses AutoTable must vendor these two.",
"//3": "Only the files the bundler actually pins are vendored, not the whole npm packages — 3.5 MB rather than 62 MB of type definitions, CJS builds and documentation. If a subpath import is ever added that reaches outside dist/ or build/, this is the first place it will fail.",
"entrypoints": {
"pdf-lib": "pdf-lib/dist/pdf-lib.esm.js",
"pdfjs-dist": "pdfjs-dist/build/pdf.mjs"
}
}

View File

@@ -0,0 +1,28 @@
// The example is its own module so its go-chart dependency (and freetype /
// x/image) stays out of the kjol module — kjol's engine packages are
// stdlib-only. kjol is resolved locally via the replace below (no publish step).
module kjolwebsite
go 1.26.3
require (
github.com/wcharczuk/go-chart/v2 v2.1.2
kjol v0.0.0
)
require (
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 // indirect
github.com/evanw/esbuild v0.28.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/tdewolff/minify/v2 v2.24.13 // indirect
github.com/tdewolff/parse/v2 v2.8.13 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
replace kjol => ../..

View File

@@ -0,0 +1,93 @@
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -0,0 +1,117 @@
// Package handlers serves the server-rendered public pages of the Kjøl JS Web
// section.
//
// This file is the APP side of a coupling inversion. kjol's bundler renders each
// public page at build time and generates public_pages.gen.go — a list of routes,
// titles, baked HTML, and (for dynamic pages) the render bundle. It does not know
// what a page is served as: no document shell, no stylesheet paths, no data. That
// is all here, because all of it is the application's business.
//
// The generated file declares `var publicPages = []publicPage{...}` and nothing
// else. The TYPE is ours — which is what lets the shape of a page be an app concern
// while the rendering of one stays the framework's.
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"kjol/jsbundler"
"kjol/webui"
)
// publicPage is the app-side shape the generated registry is written against.
// Field names and order are the generator's contract (jsbundler/genssr.go).
type publicPage struct {
route string // URL path, e.g. "/js/ssr"
title string // <title> text
module string // page module relative to frontend/src (informational)
component string // exported body component name (informational)
html string // pre-rendered, data-free page body (PublicLayout + page content)
renderJS string // bundled render entry; baked ONLY for dynamic (ISR) pages
}
// buildInfo is the payload injected into the /js/ssr page. It mirrors the
// `BuildInfo` interface the component reads via serverData<T>() — the two have to
// agree, and the JSON tags are the whole of that agreement.
type buildInfo struct {
RenderedAt string `json:"renderedAt"`
Stage string `json:"stage"`
}
// RegisterPublicPages binds every generated public page to its route.
//
// A page with a render bundle is rendered PER REQUEST with live data (the ISR
// path). A page without one serves the skeleton that was baked at build time. Both
// ship complete HTML; the difference is only whether the numbers in it are fresh.
func RegisterPublicPages(mux *http.ServeMux) {
for _, p := range publicPages {
mux.HandleFunc("GET "+p.route, servePublicPage(p))
}
}
func servePublicPage(p publicPage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body := p.html
data := ""
// The ISR path. The SAME Solid component that was baked at build time is run
// again here, in goja, with data injected — so the server's markup is not a
// template with holes punched in it, it is the component's own output.
if p.renderJS != "" {
payload, err := json.Marshal(buildInfo{
RenderedAt: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
Stage: "request time, in goja",
})
if err != nil {
log.Printf("public page %s: marshalling data: %v", p.route, err)
} else if rendered, err := jsbundler.RenderBundleWithData(p.renderJS, string(payload)); err != nil {
// Fall through to the baked skeleton rather than 500. A page that cannot
// render with data is still a page; serving nothing helps no one.
log.Printf("public page %s: ISR render failed, serving skeleton: %v", p.route, err)
} else {
body, data = rendered, string(payload)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, document(p.title, body, data))
}
}
// document wraps a rendered body in the page shell.
//
// __SERVER_DATA__ is inlined BEFORE the bundle, and it is the same JSON the server
// just rendered with. That is what makes the client takeover silent: public.tsx
// re-renders the identical component against the identical data and produces the
// identical markup, so the swap is invisible. Omit it and the page would render, then
// visibly collapse back to its loading skeleton the moment the bundle loaded.
func document(title, body, data string) string {
serverData := ""
if data != "" {
serverData = "\n<script>window.__SERVER_DATA__ = " + data + ";</script>"
}
// webui.ThemeBootScript is the Go/WASM kit's — reused verbatim, because it reads the
// same "kjol-theme" key the Solid kit's controller writes. One script, one key, and a
// reader's choice of theme survives crossing between two front-ends that share
// nothing else. It goes BEFORE the stylesheet, or a dark-mode reader gets a white
// page until the CSS lands.
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>` + title + `</title>
` + webui.ThemeBootScript + `
<link rel="stylesheet" href="/public.bundle.min.css" />
</head>
<body class="antialiased">
<div id="page-root">` + body + `</div>` + serverData + `
<script type="module" src="/public.bundle.min.js"></script>
</body>
</html>`
}

View File

@@ -0,0 +1,150 @@
// Command server runs the kjol-website site on kjol's reusable wasmdevserver:
// it SSRs the app's static routes, hosts the /rsc server-component endpoint, and
// hot-swaps the wasm into the browser on change. It shows the coupling
// inversion — the framework (wasmdevserver) imports no app code; the app injects
// Build/Render/Document here.
//
// Run it from THIS directory (the relative paths below are resolved against it):
//
// go run ./server # from go/cmd/kjol-website
package main
import (
"flag"
"fmt"
"log"
"net/http"
"kjol/httputil"
"kjol/vdom"
"kjol/wasmdevserver"
"kjol/webui"
"kjolwebsite/app"
"kjolwebsite/build"
"kjolwebsite/internal/handlers"
)
func main() {
addr := flag.String("addr", ":8085", "listen address")
watch := flag.Bool("watch", true, "watch sources, rebuild wasm, hot-reload")
buildOnly := flag.Bool("build", false, "run the full build once and exit (no server)")
flag.Parse()
if *buildOnly {
build.Cold()
return
}
log.Fatal(wasmdevserver.Serve(wasmdevserver.Config{
Addr: *addr,
Dir: "./wwwroot",
Watch: *watch,
WatchDirs: []string{
"app", "wasm", "css", // the Go/WASM app
"frontend", // the Solid app — a .tsx save rebuilds the JS bundle
"../../webui", "../../vdom", "../../wasmruntime", "../../rsc", // the wasm engine
"../../lexer", // the code-block highlighter
"../../jsruntime/uikit", "../../jsruntime/styles", // the Solid kit + the shared theme
},
Build: build.All,
BuildCSS: build.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
Render: render,
Document: document,
Handle: routes,
}))
}
// routes registers everything the WASM app does not own.
//
// Order does not matter here — Go's ServeMux picks the most specific pattern, not the
// first — but the shape does: /js/* belongs to a completely different front-end, and it
// is claimed BEFORE the wasm app's "/" catch-all ever sees it. Two SPAs, one server, no
// argument about who owns a URL.
func routes(mux *http.ServeMux) {
handlers.RegisterPublicPages(mux) // the SSR'd public pages (/js/ssr)
mux.HandleFunc("GET /js/", serveJSApp)
mux.HandleFunc("GET /js", serveJSApp)
// /api/quotes responds with a gob-encoded []app.Quote (via httputil.RespondGob) —
// the /wasm/data page fetches and decodes it on the client with encoding/gob (Go
// types end to end, no JSON).
mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
httputil.RespondGob(w, http.StatusOK, sampleQuotes())
})
}
// serveJSApp ships the shell for the Solid SPA. Every /js/* route gets the SAME empty
// document — the client router reads the URL and decides what to render, which is what
// makes it a single-page app.
//
// It carries no server-rendered markup, and that is a real difference from Kjøl Wasm Web
// rather than an oversight: this is a docs section behind a click, where a blank first
// frame costs nothing. Where it WOULD cost something, the public-page path (see
// internal/handlers) renders on the server instead — /js/ssr is that, and it is
// registered above, so it never reaches this handler.
func serveJSApp(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kjøl JS Web</title>
`+webui.ThemeBootScript+`
<link rel="stylesheet" href="/bundle.min.css" />
</head>
<body class="antialiased">
<div id="app"></div>
<script type="module" src="/bundle.min.js"></script>
</body>
</html>`)
}
func sampleQuotes() []app.Quote {
return []app.Quote{
{Author: "Rob Pike", Text: "A little copying is better than a little dependency."},
{Author: "Rob Pike", Text: "Don't communicate by sharing memory; share memory by communicating."},
{Author: "Ken Thompson", Text: "When in doubt, use brute force."},
{Author: "Alan Kay", Text: "The best way to predict the future is to invent it."},
}
}
// render SSRs a static route's #app inner HTML; ok=false ships an empty #app
// (client-rendered). It is the same neutral render the client runs, so the client
// hydrates it.
func render(path string) (string, bool) {
if !app.StaticPaths[path] {
return "", false
}
deps := app.Deps{Path: func() string { return path }} // Navigate is nil on the server
return vdom.RenderHTML(app.Shell(deps, app.Routes(deps))), true
}
// document wraps the server-rendered inner HTML in the page shell. No whitespace
// between <div id="app"> and the markup, so hydration's childNodes line up. The
// dev server injects the livereload script before </body> in watch mode.
func document(inner string) string {
// The theme boot script comes FIRST — before the stylesheet, before any markup.
//
// The server cannot read localStorage, so it cannot know which theme to render. If
// the dark class were applied by the WebAssembly once it loads, a dark-mode user
// would be shown a white page for as long as the binary takes to download and then
// have it snatched away. This runs synchronously, before the first paint, so the
// first paint is already right. It is the only JavaScript in the project.
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>kjøl — a shared base layer</title>
` + webui.ThemeBootScript + `
<link rel="stylesheet" href="/app.css" />
</head>
<body class="bg-surface text-ink antialiased">
<div id="app">` + inner + `</div>
<script src="/wasm_exec.js"></script>
<script src="/wasmboot.js"></script>
</body>
</html>`
}

View File

@@ -0,0 +1,32 @@
{
// TS config for the kjol-website front-end. The bundler resolves the @ui / @kjol / @appgen
// aliases at build time; the editor's TypeScript server needs them declared here or it
// reports every `import … from "@ui/…"` as an unresolved module. Mirrors the shape of a
// consuming app's root tsconfig, with the paths made relative to this nested example
// (the shared JS tree lives two levels up at go/jsruntime).
"compilerOptions": {
"target": "ES2025",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"noEmit": true,
"allowJs": true,
"checkJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"strict": false,
"lib": ["ES2025", "DOM", "DOM.Iterable"],
"paths": {
"@ui/*": ["../../jsruntime/uikit/*"],
"@kjol/*": ["../../jsruntime/*"],
"@appgen/*": ["./frontend/src/ui/generated/*"],
"*": ["../../jsruntime/runtime/*", "./frontend/vendor/*"]
}
},
"include": ["frontend/**/*", "../../jsruntime/**/*"],
"exclude": ["frontend/css/**/*", "frontend/vendor/**/*", "../../jsruntime/runtime/**/*", "../../jsruntime/types.d.ts"]
}

View File

@@ -0,0 +1,53 @@
//go:build js && wasm
// Command wasm is the client entry point: it wires client capabilities into the
// (generated) routes, then hydrates the server-rendered DOM or renders fresh.
package main
import (
"kjolwebsite/app"
"kjol/vdom"
"kjol/wasmruntime"
)
func main() {
// (The client transport for httputil.FetchGob / FetchJSON needs no wiring —
// importing wasmruntime installs it. On the server it stays nil, so those
// fetches no-op during SSR and the page ships its loading state.)
//
// Collect the signals created during setup so their values can be preserved
// across an in-place hot swap. On a normal load RestoreState is nil (fresh
// state); after a dev hot-reload it carries the previous instance's values,
// which NewSignal restores by creation order.
collector := vdom.BeginCollect(wasmruntime.RestoreState())
router := wasmruntime.NewRouter()
deps := app.Deps{Path: router.Path, Navigate: router.Navigate}
routes := app.Routes(deps)
vdom.EndCollect() // signals created later (during renders) aren't preserved
wasmruntime.PreserveState(collector)
render := func() *vdom.VNode { return app.Shell(deps, routes) }
if wasmruntime.HasServerContent() {
wasmruntime.Hydrate(render) // static route: adopt the server-rendered DOM
} else {
wasmruntime.Run(render) // client-rendered route
}
// Adopt the theme AFTER mounting. The class is already on <html> — the document's
// boot script put it there before the first paint — so this is not what makes the
// page dark; it is what makes the SWITCH know which way it is pointing, and what
// keeps the page following the OS if the user never touched the switch.
//
// The server rendered this button not knowing the theme (it cannot: the preference is
// in localStorage), so it drew the wrong icon. Init's signal write re-renders it, and
// the reconciler patches the one button. That mismatch is inherent, not a bug — it is
// the same reason the boot script has to exist at all.
//
// This line used to be unreachable: Hydrate ended in a `select {}` and never returned.
// See wasmruntime.Wait.
app.Theme.Init()
// Last line, always: a Go/wasm main that returns is a dead program, and every event
// handler on the page dies with it.
wasmruntime.Wait()
}

View File

@@ -0,0 +1,9 @@
//go:build !(js && wasm)
// The wasm client entry point (main.go) builds only under GOOS=js GOARCH=wasm.
// This native placeholder keeps the package buildable on the host so a plain
// `go build ./...` succeeds; the real client is built by ./build (or the dev
// server) with GOOS=js GOARCH=wasm.
package main
func main() {}

View File

@@ -0,0 +1,24 @@
// bootstrap.js — boots the Go/Wasm client, and supports flash-free hot swaps.
//
// On first load the server has already rendered the page's HTML into #app and
// the wasm app hydrates it (see wasm/main.go). The dev server's livereload
// script hot-swaps a freshly built module WITHOUT a full page reload or a blank
// flash: it calls __gowasmPrepare() to fetch + compile the new module while the
// current page is still visible, then (in one synchronous step) __gowasmDispose()
// to tear down the old instance and start() to run the new one.
(function () {
// prepare fetches + compiles the module and returns a SYNCHRONOUS start()
// thunk. Separating the async work (network + compile) from start (which
// renders synchronously) is what lets a swap avoid an intermediate blank #app.
async function prepare() {
const go = new Go();
// cache:no-store so a hot swap always fetches the freshly built bytes.
const resp = await fetch("/app.wasm", { cache: "no-store" });
const result = await WebAssembly.instantiateStreaming(resp, go.importObject);
return function start() { go.run(result.instance); }; // runs main() (renders), then parks on select{}
}
async function boot() { (await prepare())(); }
window.__gowasmPrepare = prepare;
window.__gowasmBoot = boot;
boot();
})();