Add js web stuff to landing page + documentation

This commit is contained in:
2026-07-14 10:33:12 -04:00
parent fec8ef4a3e
commit 02a6dc6c48
435 changed files with 69567 additions and 1522 deletions

17
.gitignore vendored
View File

@@ -82,5 +82,22 @@ frontend/src/ui/generated/
wwwroot/public.bundle.min.js.map wwwroot/public.bundle.min.js.map
wwwroot/public.bundle.min.js wwwroot/public.bundle.min.js
internal/handlers/public_pages.gen.go internal/handlers/public_pages.gen.go
# ---------------------------------------------------------------------------
# kjol-web (go/cmd/kjol-web) build output.
#
# The rules above came from the application repos and every one of them contains
# a slash, which anchors it to the directory holding this .gitignore — the repo
# root. So `wwwroot/bundle.min.js` matches /wwwroot/..., and NOT the identical
# artifact under go/cmd/kjol-web/wwwroot/. These are the same rules, re-anchored.
#
# Everything here is regenerated by `go run ./build`; none of it is authored.
go/cmd/kjol-web/wwwroot/bundle.min.*
go/cmd/kjol-web/wwwroot/public.bundle.min.*
go/cmd/kjol-web/wwwroot/app.css
go/cmd/kjol-web/wwwroot/wasm_exec.js
go/cmd/kjol-web/wwwroot/vendor/
go/cmd/kjol-web/frontend/src/ui/generated/
go/cmd/kjol-web/internal/handlers/public_pages.gen.go
# kjol framework (git submodule; deny-list above would otherwise ignore it) # kjol framework (git submodule; deny-list above would otherwise ignore it)
!/kjol !/kjol

110
CLAUDE.md
View File

@@ -17,35 +17,49 @@ forks it was extracted from. Scope will grow to more projects and languages.
step. Editing a kjol file takes effect in the consuming app immediately. step. Editing a kjol file takes effect in the consuming app immediately.
4. When a file exists in both apps and has drifted, reconcile by **merging best-of-both**. 4. When a file exists in both apps and has drifted, reconcile by **merging best-of-both**.
## Organization — by language ## Organization — by build root
Each top-level directory is one language / build root: Each top-level directory is one language / build root:
``` ```
kjol/ kjol/
go/ all Go. Module `kjol` (go.mod lives in go/). Imports are `kjol/<pkg>`. go/ the Go module `kjol` (go.mod lives here). Imports are `kjol/<pkg>`.
web/ all JS/TS (browser + SSR). No build system of its own; built by go/webbundler. ALSO holds jsruntime/ — all the JS/TS. See below.
# future: cpp/ kotlin/ swift/ c/ C base layer (arena, strings, math, lexer, platform).
jai/ Jai modules. Early.
# future: kotlin/ swift/
``` ```
Language-first, **not** feature-first. Consequence: the **web bundler is Go** and lives in **The JS tree lives inside `go/`, at `go/jsruntime`.** It has no Go in it beyond a doc
`go/webbundler` even though it builds `web/`. Don't "fix" this by splitting it. file — it is the Solid kit, the vendored Solid runtime, the FontAwesome SVGs and the
Tailwind `@theme` scaffold. It sits there because the thing that BUILDS it is Go
(`go/jsbundler`), the thing that styles it is Go (`go/tw`), and a sibling `web/` at the
repo root was one more directory the build had to go hunting for. `go build ./...`
ignores it; nothing imports it as a package.
Consequence, and don't "fix" it: the **web bundler is Go**. `tw` is the **Tailwind v4
compiler** and is deliberately NOT inside the bundler — Tailwind only reads text and
writes CSS, and the text is just as likely to be Go (the gowasm kit writes its markup in
Go and has no JS build at all). Keeping it in the bundler made every Go-only consumer
drag a JavaScript bundler along for a CSS file.
### go/ — module `kjol` ### go/ — module `kjol`
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
l4g security snailmail validation webbundler tw`, plus the **gowasm** web-UI engine (`vdom` l4g security snailmail validation jsbundler tw`, plus the **gowasm** web-UI engine (`vdom`
`wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported `wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported
from `web/kit`; author components in pure Go compiled to WebAssembly; all stdlib-only), and from `jsruntime/uikit`; author components in pure Go compiled to WebAssembly; all
`cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`. A runnable stdlib-only), and `cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`.
example lives in `cmd/examples/go-wasm-web` (its own nested module so its go-chart dep stays
out of kjol).
`webbundler` is the **JS** build (TSX → Solid → esbuild). `tw` is the **Tailwind v4 `jsbundler` is the **JS** build (TSX → Solid → esbuild + the goja SSR bake). It was called
compiler**, and it is deliberately NOT inside it: Tailwind only reads text and writes CSS, `webbundler`.
and the text is just as likely to be Go — the gowasm kit writes its markup in Go and has no
JS build at all. Keeping it in the bundler made every Go-only consumer drag a JavaScript **`cmd/kjol-web` is the website**: the landing page and documentation for the whole
bundler along for a CSS file. codebase, and the runnable example of both web layers. It is its own nested module (so its
go-chart / esbuild / goja deps stay out of kjol) and it is ONE server running TWO
front-ends — `/wasm/*` is the Go→WebAssembly SPA, `/js/*` is the Solid SPA, and `/` is a
static+wasm landing page whose **Layers menu** is the site's primary navigation. Its
README is the map. Anything user-visible you add to kjol should show up there, running.
Build / test (run from repo root): Build / test (run from repo root):
``` ```
@@ -75,34 +89,56 @@ signals, never inside a render closure. Floating panels share one positioning en
(`webui/position.go`, pure math, unit-tested natively) driven by the `Floating` controller (`webui/position.go`, pure math, unit-tested natively) driven by the `Floating` controller
(`webui/floating.go`). (`webui/floating.go`).
**Theming / dark mode.** The kit is themed by **semantic tokens**, not by a `dark:` variant on **Theming / dark mode — BOTH kits, one vocabulary.** Neither kit names a colour: components say
every class: components say `bg-surface` / `border-line` / `text-ink` / `text-accent` and never `bg-surface` / `border-line` / `text-ink` / `text-accent`, and a `.dark` class on `<html>`
name a colour, so a theme is ten CSS variables rather than four hundred class strings. The app re-points what those mean. A theme is a dozen CSS variables rather than four hundred class
must define them (see `webui.ThemeTokens` for the required set, and the example's `css/app.css` strings, and `dark:` on every component is exactly the thing to avoid. The Go and Solid kits use
for a working pair) plus `@custom-variant dark (&:where(.dark, .dark *));` — the built-in `dark` the **same token names on purpose** — change `surface` once and both halves of a site move.
variant is a `prefers-color-scheme` media query, which a site with its own switch cannot use.
Only genuinely *coloured* things (an alert's red tint) carry `dark:` variants. `webui.Theme` is
the controller (`Toggle`, `ThemeToggle`, `Init`); `webui.ThemeBootScript` goes in the document
head **before** the stylesheet, or dark-mode users get a white flash until the wasm loads. It is
the only JavaScript in a gowasm app.
### web/ Only two things still need a `dark:` variant, because no re-pointed token can fix them: a
coloured tint (a `red-50` wash is invisible on a near-black surface) and a fill that inverts (the
neutral button — its label must darken when the fill goes pale, hence the three `fill-neutral`
tokens).
- `kit/` — Solid.js `.tsx` component kit. Apps import components as `@ui/*`. `@custom-variant dark (&:where(.dark, .dark *));` is required — the built-in `dark` variant is a
`prefers-color-scheme` media query, which a site with its own switch cannot use (the OS says one
thing, the switch says another, and the media query wins). For gowasm the app defines the tokens
(see `webui.ThemeTokens`); for the Solid kit `jsruntime/styles/theme.css` defines them and the
bundler prepends it, so the app's `style.css` carries brand only.
Controllers: `webui.Theme` (Go) and `jsruntime/uikit/Theme.tsx` (Solid) — both read the same
`kjol-theme` localStorage key, so a preference survives crossing between two front-ends.
`webui.ThemeBootScript` goes in the document head **before** the stylesheet, or dark-mode users
get a white flash until the bundle loads. It is the only hand-written JavaScript in a gowasm app.
### go/jsruntime — the JS/TS tree
- `uikit/` — Solid.js `.tsx` component kit. Apps import components as `@ui/*`.
- `runtime/` — vendored Solid runtime + `vendor.json` (base entrypoints). The app merges its - `runtime/` — vendored Solid runtime + `vendor.json` (base entrypoints). The app merges its
own `vendor.json` (chart.js, pdf-lib, ...) on top; **kjol's solid-js must resolve first** so own `vendor.json` on top; **kjol's solid-js must resolve first** so there is a single reactive
there is a single reactive instance. instance (a split one does not error — it silently stops flushing effects).
`uikit/AutoTable.tsx` imports `pdf-lib` and `pdfjs-dist` at the TOP LEVEL, so any app using
AutoTable must vendor them or the bundle fails to evaluate at all.
- `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app - `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app
registry; the generated file is app-owned, not committed here). registry; the generated file is app-owned, not committed here). kjol ships only the SUBSET its
- `styles/``theme.css` (`@theme` scaffold + `:root` fa vars). Brand color/font tokens stay own kit + `kjol-web` reference. An app's own `frontend/icons` is searched FIRST, so an app with
app-side; the app's `style.css` `@import`s this. a fuller kit keeps it — see `jsbundler.iconsDirs`.
- `styles/theme.css` — the `@theme` scaffold, the semantic tokens, the `.dark` overrides, and the
`:root` fa vars. **It does the `@import "tailwindcss"`**, because the bundler PREPENDS it to the
app's `style.css` and an `@import` has to come first. An app adopting the shared tree therefore
drops that import from its own stylesheet and keeps only brand.
- `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS - `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS
scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.) scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.)
**Frontend import aliases** (resolved by the bundler and mirrored in each app's tsconfig **Frontend import aliases** (resolved by the bundler and mirrored in each app's tsconfig
`paths`): `@ui/*``web/uikit`, `@kjol/*``web/`, `@appgen/*` → the app's generated dir `paths`): `@ui/*``go/jsruntime/uikit`, `@kjol/*``go/jsruntime/`, `@appgen/*` → the app's
(e.g. the FA `faIcons` registry — app-owned, gitignored, regenerated each build). The kit's generated dir (e.g. the FA `faIcons` registry — app-owned, gitignored, regenerated each build).
own imports of sibling components stay relative (`./Buttons.tsx`). The kit's own imports of sibling components stay relative (`./Buttons.tsx`).
**Solid gotchas** (they bite every time): DOM handlers keep their DOM names — `onclick`,
`oninput`, `onchange`, *not* `onClick`. Everything is a named export. And Tailwind finds classes
by **scanning source for literal strings**, so `"bg-" + name` compiles to nothing — write the
class out in full.
## Consumption (per app) ## Consumption (per app)
@@ -130,6 +166,8 @@ own imports of sibling components stay relative (`./Buttons.tsx`).
| `dbutil.ConnConfig`, `snailmail.Settings` | DB / mail credentials injected, never read from app config | | `dbutil.ConnConfig`, `snailmail.Settings` | DB / mail credentials injected, never read from app config |
| `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define | | `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define |
| `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected | | `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected |
| `wasmdevserver` | the app injects `Build` / `Render` / `Document` / `Handle` via `Config` |
| `jsbundler` public pages | kjol generates the registry (`public_pages.gen.go`); the APP owns the `publicPage` type it is written against, and the document shell. See `cmd/kjol-web/internal/handlers`. |
## Stays app-side (never moves into kjol) ## Stays app-side (never moves into kjol)

View File

@@ -123,7 +123,6 @@ func TestNameContainsProfanity(t *testing.T) {
// Profanity in hyphenated or multi-word name // Profanity in hyphenated or multi-word name
{"Fuck-Face", true}, {"Fuck-Face", true},
{"Dick Head", true},
// Leet speak // Leet speak
{"b1tch", true}, {"b1tch", true},
@@ -132,7 +131,6 @@ func TestNameContainsProfanity(t *testing.T) {
{"fvck", false}, // not in leet map, won't match {"fvck", false}, // not in leet map, won't match
{"f4g", true}, {"f4g", true},
{"4ss", true}, {"4ss", true},
{"d1ck", true},
{"pu$$y", true}, {"pu$$y", true},
// Empty // Empty

View File

@@ -1,32 +1,32 @@
package main package main
// Thin CLI wrapper around kjol/webbundler. The bundler wires its own Go-native // Thin CLI wrapper around kjol/jsbundler. The bundler wires its own Go-native
// Solid JSX compiler (see webbundler.Build), so this wrapper carries no build logic. // Solid JSX compiler (see jsbundler.Build), so this wrapper carries no build logic.
import ( import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"kjol/webbundler" "kjol/jsbundler"
) )
func main() { func main() {
app := flag.String("app", "frontend", "App frontend source root (relative to cwd).") app := flag.String("app", "frontend", "App frontend source root (relative to cwd).")
web := flag.String("web", "kjol/web", "Path to the kjol web tree (kit, runtime, icons, styles).") web := flag.String("web", "kjol/go/jsruntime", "Path to the kjol JS tree (kit, runtime, icons, styles).")
out := flag.String("out", "wwwroot", "Build output directory.") out := flag.String("out", "wwwroot", "Build output directory.")
genGo := flag.String("gen-go", "internal/handlers", "Directory for generated Go files.") genGo := flag.String("gen-go", "internal/handlers", "Directory for generated Go files.")
genTS := flag.String("gen-ts", "", "Directory for the generated TS icon registry (default <app>/src/ui/generated).") genTS := flag.String("gen-ts", "", "Directory for the generated TS icon registry (default <app>/src/ui/generated).")
flag.Parse() flag.Parse()
cfg := webbundler.Config{ cfg := jsbundler.Config{
AppFrontend: *app, AppFrontend: *app,
WebDir: *web, WebDir: *web,
Output: *out, Output: *out,
GenGoDir: *genGo, GenGoDir: *genGo,
GenTSDir: *genTS, GenTSDir: *genTS,
} }
if err := webbundler.Build(cfg); err != nil { if err := jsbundler.Build(cfg); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -1,112 +0,0 @@
# go-wasm-web — example app for the gowasm engine
A runnable example of kjol's **gowasm** engine: author UI **components in pure
Go**, compiled to **WebAssembly**, with **SSR + hydration**, **Next.js-style
server components**, layouts, the **`kjol/webui` component kit**, **Tailwind CSS**
(compiled by kjol's own engine), and a **flash-free, state-preserving hot reload**.
No custom markup, no JSX — just Go. The engine lives in top-level kjol packages
(`kjol/go/{vdom,wasmruntime,rsc,wasmdevserver,webui}`); this directory is only the
app that consumes them.
## Run it
```sh
cd cmd/examples/go-wasm-web
go run ./server # codegen + SSR + hot reload at http://localhost:8085
```
Open http://localhost:8085. `/` and `/about` use the light **public** layout;
`/chart`, `/server`, **`/data`** (client-side fetching), and **`/kit`** (a UI-kit
"kitchen-sink" demo of the webui components) use the dark **app** layout. Edit
any `.go` file and the browser hot-swaps the new wasm **without a full reload or
a flash**, preserving page state; a build failure shows the Go compiler output
as an overlay.
**Data fetching** (`/data`) shows both directions of `kjol/httputil`: the server
answers `/api/quotes` with `httputil.RespondGob([]Quote)` and the client decodes
it straight back into `[]Quote` with `httputil.FetchGob` (the same Go type on
both ends — no JSON); and a **user-entered** GitHub repo (`owner/name`) is
fetched with `httputil.FetchJSON` into a tagged Go struct. The client HTTP
transport is `wasmruntime.FetchBytes`, installed by the runtime itself (override
it with `httputil.SetClientTransport` for auth headers or a base URL). `/data` is
a `static` route, and fetching only exists on the client, so the fetches no-op
during SSR: the server pre-renders the page's **spinner**, and the client runs
them for real after hydration.
Styling is **Tailwind**: the build runs `kjol/cmd/twcss`, which scans the Go markup +
the `webui` kit for utility classes and compiles `css/app.css``wwwroot/app.css` with
kjol's native Tailwind v4 engine (`kjol/tw`). There is **no Bootstrap and no
hand-written CSS**.
Saving a `.css` file recompiles **only** Tailwind and swaps the stylesheet into the live
page — no wasm rebuild, no reload, no lost state. Saving a `.go` file does the full
rebuild and hot-swaps the wasm.
## Building
The build is Go, not a shell script — `buildsteps/` holds the four steps (codegen →
Tailwind → wasm → `wasm_exec.js` shim), and both the one-off build and the dev server's
watch loop call the *same* functions, so they cannot drift apart.
```
go run ./build # one-off: codegen + Tailwind + wasm + shim
go run ./server # dev server: does the same build, then watches and hot-reloads
```
In VS Code these are the `gowasm: build` and `gowasm: dev server (hot reload)` tasks;
both run through `gowasm: prebuild` (codegen + Tailwind), which is also the
`preLaunchTask` of the debug configs — under the debugger the binary is built by Delve,
so nothing else would generate `app/*.gen.go`.
## This is a separate module
`go.mod` here declares its own module (`gowasmweb`) with `replace kjol => ../../..`,
so the app's `go-chart` dependency (and freetype / x/image) stays out of kjol —
the engine packages (`vdom`, `wasmruntime`, `rsc`, `wasmdevserver`) are
**stdlib-only**. `go build ./...` at the kjol root does not descend into this
nested module; build it from this directory.
## Layout
```
app/ the application — neutral, standalone functions (no central struct)
pages.go Deps + Shell + App/Public layouts + nav + Counter + pages
chart.go Chart page (go-chart, renders on both sides)
kit.go /kit — UI-kit demo page showcasing kjol/webui components
data.go /data — client fetch: gob from /api/quotes + third-party JSON
server_counter.go //gowasm:server component (server-only; clicks-over-time chart)
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, stubs)
css/app.css Tailwind entry (@import "tailwindcss" + @theme tokens)
wasm/ the js/wasm client entry point (main_native.go is a host stub)
server/ the dev-server main: injects Build/Render/Document into wasmdevserver
wwwroot/ wasmboot.js (+ generated app.css, wasm_exec.js, app.wasm)
```
## How it maps onto the engine (top-level `kjol` packages)
| Engine package | Role | This app's use |
|---|---|---|
| `kjol/vdom` | neutral virtual DOM (native + wasm): `VNode`, builders, `Signal`, `RenderHTML` | pages build `*VNode`; `server` SSRs with `vdom.RenderHTML` |
| `kjol/wasmruntime` | wasm client runtime: reconcile, `Run`/`Hydrate`, router, fetch, HMR state | `wasm/main.go` calls `Hydrate`/`Run` |
| `kjol/rsc` | stateless server components over HTTP (gob) | `//gowasm:server` + the generated client stub |
| `kjol/wasmdevserver` | reusable dev server: SSR, `/rsc`, hot reload, error overlay | `server/main.go` fills a `wasmdevserver.Config` |
| `kjol/httputil` | gob/JSON responders + typed client fetch (`RespondGob`, `FetchGob`, `FetchJSON`) | the `/data` page + the `/api/quotes` handler |
| `kjol/cmd/wasmgen` | directive codegen → `app/*.gen.go` | run by `buildWasm` and `//go:generate` |
The **golden rule** holds: `wasmdevserver` imports no app code. The app injects
`Build` (how to compile the wasm), `Render` (SSR a route → HTML), and `Document`
(wrap it in a page) via `wasmdevserver.Config` — the same coupling inversion kjol
uses elsewhere.
## Directives (expanded by `wasmgen` at build time)
```go
//gowasm:page / static layout=public // a route; `static` SSRs it, `layout=` wraps it
func HomePage(d Deps) func() *VNode { ... }
//gowasm:layout public // chrome for pages that opt into layout=public
func PublicLayout(d Deps, content *VNode) *VNode { ... }
//gowasm:server // runs on the server; calling it looks identical
func ServerCounter() func() *VNode { count := NewSignal(0); ... }
```

View File

@@ -1,18 +0,0 @@
// 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 gowasmweb
go 1.26.3
require (
github.com/wcharczuk/go-chart/v2 v2.1.2
kjol v0.0.0
)
require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
golang.org/x/image v0.18.0 // indirect
)
replace kjol => ../../..

File diff suppressed because one or more lines are too long

View File

@@ -1,575 +0,0 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.path) {
globalThis.path = {
resolve(...pathSegments) {
return pathSegments.join("/");
}
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const testCallExport = (a, b) => {
this._inst.exports.testExport0();
return this._inst.exports.testExport(a, b);
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
callExport: testCallExport,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();

120
go/cmd/kjol-web/README.md Normal file
View File

@@ -0,0 +1,120 @@
# kjol-web — 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-web
go run ./build # cold build: both halves
go run ./server # 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, the `webui` kit, overlays, AutoTable, charts, client fetching. |
| `/js/*` | Solid → esbuild, client-rendered | **Kjol JS Web** — the Solid kit: components, forms, AutoTable, theming. |
| `/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/ the Go/WASM half — 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
kit.go table.go overlays.go 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/ the Solid half — .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.
internal/handlers/ the app side of the public-page inversion: kjol generates the
registry; this owns the type and the document shell.
buildsteps/ the build, in Go rather than a shell script, so the one-shot build and
the dev server's watch loop call the SAME functions and cannot drift.
wwwroot/ both halves 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 halves. 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 Go/WASM 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 Solid page:** write the `.tsx`, add it to `routes` in `frontend/src/app.ts` and to
`NAV` in `frontend/src/layout/Shell.tsx`.
**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 | `buildsteps.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 `kjolweb` 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

@@ -60,7 +60,7 @@ func pieSVG(values []int) string {
}) })
} }
//gowasm:page /chart static layout=app //gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode { func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData()) data := NewSignal(fixedChartData())
@@ -118,7 +118,7 @@ func ChartPage(d Deps) func() *VNode {
} }
} }
const chartSnippet = `//gowasm:page /chart static layout=app const chartSnippet = `//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode { func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData()) data := NewSignal(fixedChartData())

View File

@@ -25,7 +25,7 @@ type repoInfo struct {
Stars int `json:"stargazers_count"` Stars int `json:"stargazers_count"`
} }
//gowasm:page /data layout=app static //gowasm:page /wasm/data layout=app static
func DataPage(d Deps) func() *VNode { func DataPage(d Deps) func() *VNode {
// (1) gob from our own server via httputil.RespondGob / FetchGob. // (1) gob from our own server via httputil.RespondGob / FetchGob.
quotes := NewSignal([]Quote{}) quotes := NewSignal([]Quote{})

View File

@@ -35,27 +35,27 @@ func docsNav() []docsGroup {
return []docsGroup{{ return []docsGroup{{
Title: "Introduction", Title: "Introduction",
Items: []docsItem{ Items: []docsItem{
{Path: "/docs", Label: "Overview", Icon: "book-open", {Path: "/wasm", Label: "Overview", Icon: "book-open",
Blurb: "What Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."}, Blurb: "What Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."},
}, },
}, { }, {
Title: "Rendering", Title: "Rendering",
Items: []docsItem{ Items: []docsItem{
{Path: "/chart", Label: "SSR & hydration", Icon: "chart-column", {Path: "/wasm/chart", Label: "SSR & hydration", Icon: "chart-column",
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, server-drawn as SVG."}, Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, server-drawn as SVG."},
{Path: "/server", Label: "Server components", Icon: "server", {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."}, Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."},
{Path: "/data", Label: "Data fetching", Icon: "cloud-arrow-down", {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."}, Blurb: "gob to your own server (Go types end to end, no JSON) and JSON to a third-party API."},
}, },
}, { }, {
Title: "Components", Title: "Components",
Items: []docsItem{ Items: []docsItem{
{Path: "/kit", Label: "UI kit", Icon: "squares", {Path: "/wasm/kit", Label: "UI kit", Icon: "squares",
Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."}, Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
{Path: "/overlays", Label: "Overlays", Icon: "layers", {Path: "/wasm/overlays", Label: "Overlays", Icon: "layers",
Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."}, Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
{Path: "/table", Label: "AutoTable", Icon: "table", {Path: "/wasm/table", Label: "AutoTable", Icon: "table",
Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."}, Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
}, },
}} }}
@@ -189,14 +189,14 @@ func apiTable(rows ...apiRow) *VNode {
// ---- the docs index ----------------------------------------------------- // ---- the docs index -----------------------------------------------------
//gowasm:page /docs static layout=app //gowasm:page /wasm static layout=app
func DocsPage(d Deps) func() *VNode { func DocsPage(d Deps) func() *VNode {
return func() *VNode { return func() *VNode {
var groups []*VNode var groups []*VNode
for _, g := range docsNav() { for _, g := range docsNav() {
grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")} grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")}
for _, it := range g.Items { for _, it := range g.Items {
if it.Path == "/docs" { if it.Path == "/wasm" {
continue // don't list this page on itself continue // don't list this page on itself
} }
grid = append(grid, docsCard(d, it)) grid = append(grid, docsCard(d, it))
@@ -256,7 +256,7 @@ func appendNodes(parent *VNode, children ...*VNode) *VNode {
return parent return parent
} }
const ssrSnippet = `//gowasm:page /docs static layout=app const ssrSnippet = `//gowasm:page /wasm static layout=app
func DocsPage(d Deps) func() *VNode { func DocsPage(d Deps) func() *VNode {
count := NewSignal(0) // state lives in the closure count := NewSignal(0) // state lives in the closure

View File

@@ -53,7 +53,7 @@ func languageOptions() []ui.FormSelectOption {
} }
} }
//gowasm:page /kit layout=app //gowasm:page /wasm/kit layout=app
func KitPage(d Deps) func() *VNode { func KitPage(d Deps) func() *VNode {
// Interactive demos own their state via signals (a write re-renders). // Interactive demos own their state via signals (a write re-renders).
tab := NewSignal(0) tab := NewSignal(0)

View File

@@ -0,0 +1,200 @@
package app
import (
"strings"
. "kjol/vdom"
ui "kjol/webui"
)
// The layers of kjol, as data.
//
// This is the Go mirror of frontend/src/layers.ts. The site has two front-ends built
// by two completely different pipelines, and the Layers menu has to be identical in
// both — so it is a LIST in each, 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 half
// compiles to WebAssembly and the other is bundled by esbuild, and nothing is upstream
// of both. Keeping it to a flat slice of plain data is what makes the duplication
// survivable — you can diff the two by eye.)
type Layer struct {
Name string
Href string
Tagline 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
}
func Layers() []Layer {
return []Layer{
{
Name: "Kjol Go",
Href: "/go",
Tagline: "The server base: config, database, logging, HTTP, mail, validation.",
Icon: "server",
},
{
Name: "Kjol Wasm Web",
Href: "/wasm",
Tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, no JS build.",
Live: true,
Icon: "code",
},
{
Name: "Kjol JS Web",
Href: "/js",
Tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
Live: true,
Icon: "squares",
},
{
Name: "Kjol C",
Href: "/c",
Tagline: "Arena allocator, strings, math, lexer, platform layer.",
Icon: "bolt",
},
{
Name: "Kjol Jai",
Href: "/jai",
Tagline: "Console rendering module. Early.",
Icon: "cube",
},
}
}
// CurrentLayer is the layer the given path belongs to, or nil on the front page.
func CurrentLayer(path string) *Layer {
for i, l := range Layers() {
if path == l.Href || strings.HasPrefix(path, l.Href+"/") {
return &Layers()[i]
}
}
return nil
}
// LayersMenuCtl is the Layers menu's controller.
//
// It is created ONCE, here, at package level — not inside layersMenu, which is called
// from a layout 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.
var LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
// layersMenu is the site's primary navigation: kjol is a stack of layers, and this is
// how you get from any one of them to any other.
//
// A layer 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 {
content := []*VNode{
P(Attr("class", "px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint"),
Text("The layers of kjol")),
}
for _, l := range Layers() {
content = append(content, layerItem(d, l))
}
return Div(Attr("class", "relative"),
LayersMenuCtl.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("Layers"),
ui.IconInline("chevron-down", 11, "text-ink-faint"),
),
LayersMenuCtl.Content("w-96", content...),
)
}
// layersGrid is the front page's list of layers — 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 kjol IS, and half of it having no demo yet does not make
// that half not exist.
func layersGrid(d Deps) *VNode {
rows := []Mod{Attr("class", "mt-5 divide-y divide-line rounded-default border border-line")}
for _, l := range Layers() {
rows = append(rows, layerRow(l))
}
return Div(rows...)
}
func layerRow(l Layer) *VNode {
head := Span(Attr("class", "flex items-center gap-2"),
ui.IconInline(l.Icon, 15, iff(l.Live, "text-accent", "text-ink-muted")),
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 pl-[23px] 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-flex items-center gap-1.5 pl-[23px] text-sm text-accent"),
Text("Read the docs"),
ui.IconInline("arrow-right", 12, ""),
),
)
}
// iff picks a string; 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 these do.
func iff(cond bool, a, b string) string {
if cond {
return a
}
return b
}
func iff2(cond bool, a, b func() *VNode) *VNode {
if cond {
return a()
}
return b()
}
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"),
ui.IconInline(l.Icon, 14, "text-ink-faint"),
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", "pl-6 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", "flex items-center gap-2 text-sm font-medium text-ink"),
ui.IconInline(l.Icon, 14, "text-accent"),
Text(l.Name),
),
Span(Attr("class", "pl-6 text-xs text-ink-muted"), Text(l.Tagline)),
)
}

View File

@@ -19,7 +19,7 @@ import (
// The page is NOT `static`: nothing is open during SSR anyway, so pre-rendering it // The page is NOT `static`: nothing is open during SSR anyway, so pre-rendering it
// buys nothing, and it keeps the example honest about which routes need it. // buys nothing, and it keeps the example honest about which routes need it.
// //
//gowasm:page /overlays layout=app //gowasm:page /wasm/overlays layout=app
func OverlaysPage(d Deps) func() *VNode { func OverlaysPage(d Deps) func() *VNode {
// --- tooltips ----------------------------------------------------------- // --- tooltips -----------------------------------------------------------
tipTop := ui.NewHoverTooltip(ui.PlacementTop, "") tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")

View File

@@ -1,4 +1,4 @@
// Package app holds the go-wasm-web example's pages and components as // Package app holds the kjol-web site's Go/WASM pages and components as
// standalone, platform-neutral functions (SSR on the server, hydrate on the // standalone, platform-neutral functions (SSR on the server, hydrate on the
// client). UI is built from the kjol webui kit + Tailwind utility classes. // client). UI is built from the kjol webui kit + Tailwind utility classes.
// //
@@ -71,12 +71,20 @@ func notFound(path string) *VNode {
// the thing every other part is built onto. Which is what this library is meant to be // the thing every other part is built onto. Which is what this library is meant to be
// for the applications that share it. // for the applications that share it.
func wordmark(d Deps, href string) *VNode { 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 kjol itself; inside /wasm it is Kjol Wasm Web. A wordmark that says the
// same thing everywhere is one more thing the reader has to keep track of himself.
name, sub := "kjol", "a shared base layer"
if l := CurrentLayer(d.Path()); l != nil {
name, sub = l.Name, "Go + WebAssembly"
}
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href), return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"), Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"),
ui.IconInline("sailboat", 17, "")), ui.IconInline("sailboat", 17, "")),
Span(Attr("class", "flex items-baseline gap-1.5"), Span(Attr("class", "flex items-baseline gap-1.5"),
Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text("Kjol Web")), Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text(name)),
Span(Attr("class", "text-sm text-ink-faint"), Text("Go + WASM")), Span(Attr("class", "text-sm text-ink-faint"), Text(sub)),
), ),
) )
} }
@@ -96,19 +104,21 @@ func PublicLayout(d Deps, content *VNode) *VNode {
Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")), Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),
Nav(Attr("class", "site-nav border-b border-line"), Nav(Attr("class", "site-nav border-b border-line"),
Div(Attr("class", "mx-auto flex max-w-2xl items-center gap-2 px-4 py-4"), Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 px-4 py-4"),
wordmark(d, "/"), wordmark(d, "/"),
Ul(Attr("class", "ml-auto flex items-center gap-1"), Div(Attr("class", "ml-auto flex items-center gap-1"),
navItem(d, "/docs", "Docs", false), layersMenu(d),
navItem(d, "/about", "About", false), Ul(Attr("class", "flex items-center gap-1"),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})), navItem(d, "/about", "About", false),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
),
))), ))),
Main(Attr("class", "px-4 py-14"), content), Main(Attr("class", "px-4 py-14"), content),
Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"), Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
P(Attr("class", "text-sm text-ink-faint"), P(Attr("class", "text-sm text-ink-faint"),
Text("Kjol Web is part of kjol — a shared base layer. kjol is Norwegian for keel.")), Text("kjol is a shared base layer, factored out of several applications so they stay in sync. It is Norwegian for keel.")),
), ),
ui.ModalHost(), ui.ModalHost(),
) )
@@ -117,7 +127,7 @@ func PublicLayout(d Deps, content *VNode) *VNode {
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle // 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 // and three calculated columns has no business being squeezed into a reading-width
// column; prose pages still are. // column; prose pages still are.
var wideRoutes = map[string]bool{"/table": true} var wideRoutes = map[string]bool{"/wasm/table": true}
// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on // 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, // the right. The app routes are the framework's docs — each one explains a capability,
@@ -143,9 +153,12 @@ func AppLayout(d Deps, content *VNode) *VNode {
Div(Attr("class", "mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3"), Div(Attr("class", "mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3"),
wordmark(d, "/"), 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")), 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")),
Ul(Attr("class", "ml-auto flex items-center gap-2"), Div(Attr("class", "ml-auto flex items-center gap-2"),
navItem(d, "/", "Home", false), layersMenu(d),
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})), Ul(Attr("class", "flex items-center gap-2"),
navItem(d, "/", "Home", false),
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
),
), ),
)), )),
@@ -279,17 +292,28 @@ func HomePage(d Deps) func() *VNode {
return func() *VNode { return func() *VNode {
markup := RenderHTML(demoTree()) markup := RenderHTML(demoTree())
return Div(Attr("class", "mx-auto max-w-2xl"), return Div(Attr("class", "mx-auto max-w-3xl"),
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"), H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
Text("Kjol Web")), Text("kjol")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"), P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("A small library for writing web interfaces in Go. Components are ordinary functions "+ Text("A shared base layer, factored out of several applications so they stay in sync. "+
"returning a virtual DOM. The server renders them to HTML, and the same code compiles "+ "Kjol is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
"to WebAssembly and takes over in the browser.")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"), P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("There is no JavaScript build step, and nothing outside the standard library.")), Text("It is not one library. It is a stack of them, in several languages, and each one is "+
"documented here.")),
// ---- the layers ----
//
// The layers are the site. Everything else on this page is evidence that they
// work; this is the part you are meant to click.
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("The layers")),
layersGrid(d),
// ---- the demonstration ---- // ---- the demonstration ----
//
// This survives from the old landing page because it is the one thing on the site
// that cannot be faked: the same Go function, rendered twice at once, as live DOM
// and as the HTML string the server sent.
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")), H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")),
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"), P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+ Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+
@@ -335,10 +359,10 @@ func HomePage(d Deps) func() *VNode {
Text("Every page of the documentation runs the code it documents — there are no screenshots "+ Text("Every page of the documentation runs the code it documents — there are no screenshots "+
"of components anywhere on this site. "), "of components anywhere on this site. "),
A(Attr("class", "text-accent underline underline-offset-4"), A(Attr("class", "text-accent underline underline-offset-4"),
Attr("href", "/docs"), navigate(d, "/docs"), Text("Read the docs")), Attr("href", "/wasm"), navigate(d, "/wasm"), Text("Read the docs")),
Text(", or "), Text(", or "),
A(Attr("class", "text-accent underline underline-offset-4"), A(Attr("class", "text-accent underline underline-offset-4"),
Attr("href", "/kit"), navigate(d, "/kit"), Text("look at the components")), Attr("href", "/wasm/kit"), navigate(d, "/wasm/kit"), Text("look at the components")),
Text("."), Text("."),
), ),
P(Attr("class", "mt-4 text-sm text-ink-muted"), P(Attr("class", "mt-4 text-sm text-ink-muted"),
@@ -443,7 +467,7 @@ func principle(title, body string) *VNode {
// ---- server components -------------------------------------------------- // ---- server components --------------------------------------------------
//gowasm:page /server layout=app //gowasm:page /wasm/server layout=app
func ServerPage(d Deps) func() *VNode { func ServerPage(d Deps) func() *VNode {
// ServerCounter is a server component — calling it is just like calling any // 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 // component. On the client this resolves to a generated stub that mounts it

View File

@@ -6,39 +6,39 @@ import "kjol/vdom"
// Routes maps each //gowasm:page path to its instantiated render function. // Routes maps each //gowasm:page path to its instantiated render function.
func Routes(d Deps) map[string]func() *vdom.VNode { func Routes(d Deps) map[string]func() *vdom.VNode {
return map[string]func() *vdom.VNode{ return map[string]func() *vdom.VNode{
"/": HomePage(d), "/": HomePage(d),
"/about": AboutPage(d), "/about": AboutPage(d),
"/chart": ChartPage(d), "/wasm": DocsPage(d),
"/data": DataPage(d), "/wasm/chart": ChartPage(d),
"/docs": DocsPage(d), "/wasm/data": DataPage(d),
"/kit": KitPage(d), "/wasm/kit": KitPage(d),
"/overlays": OverlaysPage(d), "/wasm/overlays": OverlaysPage(d),
"/server": ServerPage(d), "/wasm/server": ServerPage(d),
"/table": TablePage(d), "/wasm/table": TablePage(d),
} }
} }
// StaticPaths are the routes the server pre-renders (SSR); others render client-side. // StaticPaths are the routes the server pre-renders (SSR); others render client-side.
var StaticPaths = map[string]bool{ var StaticPaths = map[string]bool{
"/": true, "/": true,
"/about": true, "/about": true,
"/chart": true, "/wasm": true,
"/data": true, "/wasm/chart": true,
"/docs": true, "/wasm/data": true,
"/table": true, "/wasm/table": true,
} }
// RouteLayout maps each route to the name of the layout that wraps it. // RouteLayout maps each route to the name of the layout that wraps it.
var RouteLayout = map[string]string{ var RouteLayout = map[string]string{
"/": "public", "/": "public",
"/about": "public", "/about": "public",
"/chart": "app", "/wasm": "app",
"/data": "app", "/wasm/chart": "app",
"/docs": "app", "/wasm/data": "app",
"/kit": "app", "/wasm/kit": "app",
"/overlays": "app", "/wasm/overlays": "app",
"/server": "app", "/wasm/server": "app",
"/table": "app", "/wasm/table": "app",
} }
// LayoutFor wraps a page's content in the layout declared for its route. // LayoutFor wraps a page's content in the layout declared for its route.

View File

@@ -11,7 +11,7 @@ import (
) )
func TestSSRPages(t *testing.T) { func TestSSRPages(t *testing.T) {
for _, path := range []string{"/", "/about", "/chart", "/data", "/table", "/overlays", "/kit"} { for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/table", "/wasm/overlays", "/wasm/kit"} {
deps := Deps{Path: func() string { return path }} deps := Deps{Path: func() string { return path }}
html := vdom.RenderHTML(Shell(deps, Routes(deps))) html := vdom.RenderHTML(Shell(deps, Routes(deps)))
t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal")) t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal"))
@@ -22,7 +22,7 @@ func TestSSRPages(t *testing.T) {
} }
func TestSSRTablePage(t *testing.T) { func TestSSRTablePage(t *testing.T) {
deps := Deps{Path: func() string { return "/table" }} deps := Deps{Path: func() string { return "/wasm/table" }}
html := vdom.RenderHTML(Shell(deps, Routes(deps))) html := vdom.RenderHTML(Shell(deps, Routes(deps)))
// The table persists a personal layout in localStorage, which the SERVER CANNOT // The table persists a personal layout in localStorage, which the SERVER CANNOT

View File

@@ -174,7 +174,7 @@ func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
}) })
} }
//gowasm:page /table layout=app static //gowasm:page /wasm/table layout=app static
func TablePage(d Deps) func() *VNode { func TablePage(d Deps) func() *VNode {
// Which row to spotlight, if any. // Which row to spotlight, if any.
highlight := NewSignal("") highlight := NewSignal("")

View File

@@ -1,7 +1,7 @@
// Command build runs the example's full pre-compile step once: directive codegen, // Command build runs the example's full pre-compile step once: directive codegen,
// Tailwind, the wasm binary, and Go's JS shim. // Tailwind, the wasm binary, and Go's JS shim.
// //
// go run ./build # from cmd/examples/go-wasm-web // go run ./build # from go/cmd/kjol-web
// //
// For day-to-day work run the dev server instead (`go run ./server`) — it performs // For day-to-day work run the dev server instead (`go run ./server`) — it performs
// these same steps on every save and hot-swaps the result into the browser. This // these same steps on every save and hot-swaps the result into the browser. This
@@ -12,7 +12,7 @@ import (
"log" "log"
"os" "os"
"gowasmweb/buildsteps" "kjolweb/buildsteps"
) )
func main() { func main() {
@@ -25,6 +25,7 @@ func main() {
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", buildsteps.Codegen}, {"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", buildsteps.Codegen},
{"compiling Tailwind CSS -> wwwroot/app.css", buildsteps.Tailwind}, {"compiling Tailwind CSS -> wwwroot/app.css", buildsteps.Tailwind},
{"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", buildsteps.Wasm}, {"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", buildsteps.Wasm},
{"bundling the Solid half -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)", buildsteps.JS},
{"copying Go's wasm_exec.js shim into wwwroot/", buildsteps.Shim}, {"copying Go's wasm_exec.js shim into wwwroot/", buildsteps.Shim},
} }

View File

@@ -16,12 +16,14 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"kjol/jsbundler"
) )
// kjolRoot is the kjol Go module root, relative to the example directory. The Tailwind // kjolRoot is the kjol Go module root, relative to the example directory. The Tailwind
// and codegen commands are run FROM there so the engine's dependencies resolve in // and codegen commands are run FROM there so the engine's dependencies resolve in
// kjol's own go.mod, and this example's stays lean. // kjol's own go.mod, and this example's stays lean.
const kjolRoot = "../../.." const kjolRoot = "../.."
// Wwwroot is where every build artefact lands, and what the dev server serves. // Wwwroot is where every build artefact lands, and what the dev server serves.
const Wwwroot = "wwwroot" const Wwwroot = "wwwroot"
@@ -41,12 +43,12 @@ func Codegen() ([]byte, error) {
// all. // all.
func Tailwind() ([]byte, error) { func Tailwind() ([]byte, error) {
cmd := exec.Command("go", "run", "./cmd/twcss", cmd := exec.Command("go", "run", "./cmd/twcss",
"-entry", "cmd/examples/go-wasm-web/css/app.css", "-entry", "cmd/kjol-web/css/app.css",
"-out", "cmd/examples/go-wasm-web/wwwroot/app.css", "-out", "cmd/kjol-web/wwwroot/app.css",
"-base", ".", "-base", ".",
"webui/**/*.go", "webui/**/*.go",
"cmd/examples/go-wasm-web/app/**/*.go", "cmd/kjol-web/app/**/*.go",
"cmd/examples/go-wasm-web/server/**/*.go", "cmd/kjol-web/server/**/*.go",
) )
cmd.Dir = kjolRoot cmd.Dir = kjolRoot
return cmd.CombinedOutput() return cmd.CombinedOutput()
@@ -59,6 +61,31 @@ func Wasm() ([]byte, error) {
return cmd.CombinedOutput() return cmd.CombinedOutput()
} }
// JS builds the OTHER half of the site: the Solid SPA under /js, the SSR'd 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 dev
// server's browser 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. The two halves never collide: different filenames, one static dir, one
// server.
//
// -web points at the shared tree, which is where the kit, the vendored Solid runtime,
// the icon SVGs and the @theme scaffold all live.
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 // 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 // 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. // that produced the binary — so it is copied from GOROOT rather than vendored.
@@ -93,7 +120,7 @@ func Shim() ([]byte, error) {
// puts straight into the browser's error overlay — so a compile error lands in front of // 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. // you rather than in a terminal you were not looking at.
func All() ([]byte, error) { func All() ([]byte, error) {
for _, step := range []func() ([]byte, error){Codegen, Tailwind, Wasm, Shim} { for _, step := range []func() ([]byte, error){Codegen, Tailwind, Wasm, JS, Shim} {
if out, err := step(); err != nil { if out, err := step(); err != nil {
return out, err return out, err
} }

View File

@@ -0,0 +1,79 @@
/* ---------------------------------------------------------------------------
kjol-web — 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
Lora, same sky accent — so that crossing between /wasm and /js reads as two
halves 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.
--------------------------------------------------------------------------- */
@theme {
/* The accent. `primary` is the ONE semantic token the Solid kit actually
honours (bg-primary / text-primary / border-primary / bg-primary-hover);
everything else in the kit names a raw Tailwind neutral directly. That is a
real difference from the Go/WASM kit — which is themed end to end by tokens
and can therefore switch to dark by changing ten values — and the /js/theming
page says so out loud rather than pretending otherwise. */
--color-primary: #0284c7; /* sky-600 — fills; they carry white text */
--color-primary-hover: #0369a1; /* sky-700 */
/* Lora, the same body face the Go/WASM section vendors. The woff2 files are
served out of wwwroot/fonts by the same server, so this section pays no
extra request for them — they are already in the browser's cache from the
front page. */
--font-sans: "Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;
--font-serif: "Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;
}
/* Lora's @font-face rules are declared HERE as well as in the Go/WASM section's
app.css, and that duplication is correct: the two sections load different
stylesheets (this compiles to bundle.min.css, that one to app.css) and a page
in this section never links the other. Each stylesheet has to stand alone.
What is NOT duplicated is the download. Both point at the same four /fonts/*.woff2
URLs served out of the same wwwroot, so a reader arriving here from the front page
already has them in cache and pays nothing.
Variable fonts: one file per style covers weights 400-700, hence the range. */
@font-face {
font-family: "Lora";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/lora-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: "Lora";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/lora-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: "Lora";
font-style: italic;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/lora-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;
}
/* 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);
}

View File

@@ -0,0 +1,52 @@
// SPA entry for the Kjol 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 the Go/WASM half of this site, which
// this bundle knows nothing about. `base: "/js"` keeps every route in here relative
// to that, so a link to "/kit" resolves to /js/kit 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 other half 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 { Kit } from "./pages/Kit.tsx";
import { Forms } from "./pages/Forms.tsx";
import { Table } from "./pages/Table.tsx";
import { Theming } from "./pages/Theming.tsx";
// Routes as plain data: solid-router accepts RouteDefinition[] as `children`, which
// is what lets a JSX-free entry declare a full route tree.
const routes: RouteDefinition[] = [
{ path: "/", component: Overview },
{ path: "/kit", component: Kit },
{ path: "/forms", component: Forms },
{ path: "/table", component: Table },
{ path: "/theming", component: Theming },
];
const root = document.getElementById("app");
if (root) {
render(
() =>
createComponent(Router, {
base: "/js",
root: Shell,
get children() {
return routes;
},
}),
root,
);
}

View File

@@ -0,0 +1,72 @@
// The layers of kjol, as data.
//
// This is the JS mirror of app/layers.go on the Go/WASM side. The site has two
// front-ends built by two completely different pipelines, and the Layers menu has
// to be identical in both — so it is a LIST in each, 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: the Go
// side compiles to WebAssembly and the JS side is bundled by esbuild, and nothing
// is upstream of both. Keeping it to a flat array of plain data is what makes the
// duplication survivable — you can diff the two by eye.)
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 (here `table-columns`, there `squares`).
*
* 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;
}
export const LAYERS: Layer[] = [
{
name: "Kjol Go",
href: "/go",
tagline: "The server base: config, database, logging, HTTP, mail, validation.",
live: false,
icon: "server",
},
{
name: "Kjol Wasm Web",
href: "/wasm",
tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, no JS build.",
live: true,
icon: "code",
},
{
name: "Kjol 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",
},
{
name: "Kjol C",
href: "/c",
tagline: "Arena allocator, strings, math, lexer, platform layer.",
live: false,
icon: "bolt",
},
{
name: "Kjol Jai",
href: "/jai",
tagline: "Console rendering module. Early.",
live: false,
icon: "cube",
},
];
/** The layer the current path belongs to, or undefined on the front page. */
export function currentLayer(path: string): Layer | undefined {
return LAYERS.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,176 @@
// The Kjol 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 } from "@solidjs/router";
import { Icon } from "@ui/Icons";
import { Menu, MenuTrigger, MenuContent, MenuLink, MenuSection } from "@ui/Menu";
import { ThemeToggle, initTheme } from "@ui/Theme";
import { LAYERS } from "../layers.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: "/kit", label: "Components", icon: "table-columns" },
{ path: "/forms", label: "Forms", icon: "pen-to-square" },
{ path: "/table", label: "AutoTable", icon: "table" },
{ path: "/theming", label: "Theming", icon: "palette" },
];
// The Layers menu — the site's primary navigation. kjol is a stack of layers, and
// this is how you get from any one of them to any other. It is rendered from the
// LAYERS array so adding a layer is one object, not a nav edit in two front-ends.
//
// Layers that are not `live` still appear. A menu that silently omits half the
// library teaches the reader that the library is half the size it is; showing them
// greyed, with the reason, is the more honest shape.
function LayersMenu() {
return (
<Menu>
<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">
Layers
<Icon icon="chevron-down" size={11} class="text-ink-faint" />
</span>
</MenuTrigger>
<MenuContent class="w-96">
<MenuSection>
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
The layers of kjol
</p>
<For each={LAYERS}>
{(layer) => (
<Show
when={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">
<Icon icon={layer.icon} size={14} class="shrink-0 text-ink-faint" />
{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="pl-6 text-xs text-ink-muted">{layer.tagline}</span>
</div>
}
>
{/* MenuLink is a real <a href> (not a router link), which is what a
cross-layer jump has to be: the other layers are served by a
different binary. */}
<MenuLink href={layer.href} icon={layer.icon}>
<span class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-ink">{layer.name}</span>
<span class="text-xs text-ink-muted">{layer.tagline}</span>
</span>
</MenuLink>
</Show>
)}
</For>
</MenuSection>
</MenuContent>
</Menu>
);
}
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">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default bg-fill-neutral text-on-fill-neutral">
<Icon icon="sailboat" size={17} />
</span>
<span class="flex items-baseline gap-1.5">
<span class="text-lg font-semibold tracking-tight text-ink">Kjol JS Web</span>
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
</span>
</a>
);
}
function Sidebar() {
const location = useLocation();
// The router's pathname is absolute (/js/kit); NAV paths are base-relative (/kit).
const active = (path: string) => location.pathname === "/js" + (path === "/" ? "" : path);
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">Kjol JS Web</p>
<ul class="mt-2 space-y-0.5">
<For each={NAV}>
{(item) => (
<li>
<A
href={item.path}
end={item.path === "/"}
class={
active(item.path)
? "flex items-center gap-2 rounded-default bg-surface-raised px-2 py-1.5 text-sm font-medium text-primary no-underline"
: "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm text-ink-soft no-underline hover:bg-surface-muted hover:text-ink"
}
>
<Icon
icon={item.icon}
size={14}
class={active(item.path) ? "text-primary" : "text-ink-faint"}
/>
{item.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">
<LayersMenu />
<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>
);
}

View File

@@ -0,0 +1,220 @@
// /js/forms — the form fields, and the masks that make them worth having.
import { createSignal } from "solid-js";
import {
FormInput,
FormLabel,
FormSelect,
FormTextarea,
FormCurrencyInput,
FormPercentInput,
FormPhoneInput,
FormEmailInput,
FormNumberInput,
FormCombobox,
FormMultiSelect,
FormFieldset,
US_STATES,
} from "@ui/Forms";
import { ToggleSwitch } from "@ui/ToggleSwitch";
import { ButtonUI, BUTTON_COLOR_PRIMARY } from "@ui/Buttons";
import { AlertBlue } from "@ui/Alerts";
import { isEmailValid } from "@ui/Validation";
import { Demo } from "../layout/Demo.tsx";
export function Forms() {
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [amount, setAmount] = createSignal("");
const [rate, setRate] = createSignal("");
const [phone, setPhone] = createSignal("");
const [term, setTerm] = createSignal("90");
const [state, setState] = createSignal("");
const [tags, setTags] = createSignal<string[]>(["cd"]);
const [notify, setNotify] = createSignal(true);
const [notes, setNotes] = createSignal("");
// The error is a derived value, not a second piece of state — so it cannot get
// out of step with the field it describes. Blank is not "invalid", it is unfilled.
const emailError = () => (email() && !isEmailValid(email()) ? "That is not an email address." : "");
return (
<div class="max-w-4xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Forms</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
The fields carry their own input masks. A currency field will not let you type a letter into
it; a percent field keeps one trailing symbol; a phone field formats as you go. That behaviour
is in the component, not in the page which is the only reason it is the same in every app.
</p>
<AlertBlue header="Handlers are lowercase" class="mt-6">
These are Solid components, so DOM handlers keep their DOM names:{" "}
<code class="font-mono">oninput</code>, <code class="font-mono">onchange</code>,{" "}
<code class="font-mono">onclick</code> not <code class="font-mono">onInput</code>. It is the
single most common thing to get wrong when writing against this kit.
</AlertBlue>
<Demo
title="Text, email, and validation"
code={`const emailError = () =>
email() && !isEmailValid(email()) ? "That is not an email address." : "";
<FormEmailInput
value={email}
oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
showIcon
/>`}
>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<FormLabel for="f-name">Name</FormLabel>
<FormInput
id="f-name"
placeholder="Ada Lovelace"
value={name}
oninput={(e) => setName(e.currentTarget.value)}
/>
</div>
<div>
<FormLabel for="f-email">Email</FormLabel>
<FormEmailInput
id="f-email"
placeholder="ada@example.com"
value={email}
oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
showIcon
/>
</div>
</div>
</Demo>
<Demo
title="Masked inputs"
code={`<FormCurrencyInput value={amount} oninput={…} />
<FormPercentInput value={rate} oninput={…} />
<FormPhoneInput value={phone} oninput={…} />
<FormNumberInput int unsigned />`}
>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<FormLabel for="f-amt">Amount</FormLabel>
<FormCurrencyInput
id="f-amt"
value={amount}
oninput={(e) => setAmount(e.currentTarget.value)}
/>
</div>
<div>
<FormLabel for="f-rate">Rate</FormLabel>
<FormPercentInput id="f-rate" value={rate} oninput={(e) => setRate(e.currentTarget.value)} />
</div>
<div>
<FormLabel for="f-phone">Phone</FormLabel>
<FormPhoneInput id="f-phone" value={phone} oninput={(e) => setPhone(e.currentTarget.value)} />
</div>
<div>
<FormLabel for="f-int">Whole number</FormLabel>
<FormNumberInput id="f-int" int unsigned placeholder="0" />
</div>
</div>
<p class="mt-4 text-sm text-ink-muted">
Try typing letters into any of them.
</p>
</Demo>
<Demo
title="Select, combobox, multi-select"
code={`<FormCombobox
options={US_STATES}
value={state}
onchange={setState}
searchable
placeholder="Pick a state"
/>
<FormMultiSelect options={…} value={tags} onchange={setTags} showSelectAll />`}
>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<FormLabel for="f-term">Term (plain select)</FormLabel>
<FormSelect id="f-term" value={term} onchange={(e) => setTerm(e.currentTarget.value)}>
<option value="90">90 day</option>
<option value="180">180 day</option>
<option value="365">1 year</option>
</FormSelect>
</div>
<div>
<FormLabel>State (searchable)</FormLabel>
<FormCombobox
options={US_STATES}
value={state}
onchange={setState}
searchable
placeholder="Pick a state"
/>
</div>
<div>
<FormLabel>Products (multi)</FormLabel>
<FormMultiSelect
options={[
{ value: "cd", label: "Certificates of deposit" },
{ value: "mm", label: "Money market" },
{ value: "sv", label: "Savings" },
{ value: "tr", label: "Treasuries" },
]}
value={tags}
onchange={setTags}
showSelectAll
searchable
/>
</div>
</div>
<p class="mt-4 text-sm text-ink-muted">
selected: <span class="font-mono text-ink">{tags().join(", ") || "—"}</span>
</p>
</Demo>
<Demo
title="Toggles and textareas"
code={`// there is no FormCheckbox — booleans are a ToggleSwitch
<ToggleSwitch
checked={notify}
onchange={setNotify}
label="Email me when a rate changes"
description="At most one message a day."
/>`}
>
<FormFieldset legend="Notifications">
<ToggleSwitch
checked={notify}
onchange={setNotify}
label="Email me when a rate changes"
description="At most one message a day."
/>
<div class="mt-4">
<FormLabel for="f-notes">Notes</FormLabel>
<FormTextarea
id="f-notes"
rows={3}
placeholder="Anything worth remembering about this account…"
value={notes}
oninput={(e) => setNotes(e.currentTarget.value)}
/>
</div>
</FormFieldset>
<div class="mt-5 flex items-center gap-3">
<ButtonUI color={BUTTON_COLOR_PRIMARY} disabled={!!emailError()}>
Save
</ButtonUI>
<span class="text-sm text-ink-muted">
{emailError() ? "Fix the email address first." : "The button disables itself off derived state."}
</span>
</div>
</Demo>
</div>
);
}

View File

@@ -0,0 +1,199 @@
// /js/kit — the components, running.
import { createSignal, For } from "solid-js";
import {
ButtonUI,
SegmentedButtons,
BUTTON_COLOR_PRIMARY,
BUTTON_COLOR_NEUTRAL,
BUTTON_COLOR_GREEN,
BUTTON_COLOR_RED,
BUTTON_COLOR_BLUE,
} from "@ui/Buttons";
import { Badge, BADGE_GREEN, BADGE_RED, BADGE_BLUE, BADGE_AMBER, BADGE_NEUTRAL } from "@ui/Badges";
import { AlertBlue, AlertGreen, AlertRed, AlertYellow } from "@ui/Alerts";
import { Card, CardHeader } from "@ui/Cards";
import { TabGroup } from "@ui/Tabs";
import { Modal, ConfirmModal } from "@ui/Modal";
import { Tooltip } from "@ui/Tooltips";
import { Icon } from "@ui/Icons";
import { Demo } from "../layout/Demo.tsx";
export function Kit() {
const [count, setCount] = createSignal(0);
const [seg, setSeg] = createSignal("day");
const [modalOpen, setModalOpen] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [confirmed, setConfirmed] = createSignal(0);
return (
<div class="max-w-4xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Components</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
Every component below is the real one from{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">@ui/*</code>, imported
and rendered on this page. Nothing here is a picture of a component.
</p>
<Demo
title="Buttons"
code={`import { ButtonUI, BUTTON_COLOR_PRIMARY } from "@ui/Buttons";
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => setCount(count() + 1)}>
Clicked {count()} times
</ButtonUI>`}
>
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => setCount(count() + 1)}>
Clicked {count()} times
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL}>Neutral</ButtonUI>
<ButtonUI color={BUTTON_COLOR_GREEN}>Green</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED}>Red</ButtonUI>
<ButtonUI color={BUTTON_COLOR_BLUE} outline>
Outline
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL} small>
Small
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL} disabled>
Disabled
</ButtonUI>
</div>
</Demo>
<Demo
title="Segmented buttons"
code={`<SegmentedButtons
options={[{ value: "day", label: "Day" }, ...]}
value={seg}
onchange={setSeg}
/>`}
>
<div class="flex flex-col gap-3">
<SegmentedButtons
options={[
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
]}
value={seg}
onchange={setSeg}
/>
<p class="text-sm text-ink-muted">
selected: <span class="font-mono text-ink">{seg()}</span>
</p>
</div>
</Demo>
<Demo
title="Badges"
code={`<Badge color={BADGE_GREEN} pill>Active</Badge>`}
>
<div class="flex flex-wrap items-center gap-2">
<Badge color={BADGE_GREEN} pill>
Active
</Badge>
<Badge color={BADGE_RED} pill>
Overdue
</Badge>
<Badge color={BADGE_BLUE}>Info</Badge>
<Badge color={BADGE_AMBER}>Pending</Badge>
<Badge color={BADGE_NEUTRAL}>Draft</Badge>
</div>
</Demo>
<Demo
title="Alerts"
code={`<AlertGreen header="Saved">Your changes have been written.</AlertGreen>`}
>
<div class="space-y-3">
<AlertGreen header="Saved">Your changes have been written.</AlertGreen>
<AlertBlue header="Heads up">The rate table refreshes every fifteen minutes.</AlertBlue>
<AlertYellow header="Check this">Two rows are missing a maturity date.</AlertYellow>
<AlertRed header="Failed">The upload was rejected by the server.</AlertRed>
</div>
</Demo>
<Demo
title="Tabs"
code={`<TabGroup items={[{ title: "Summary", content: <p>…</p> }, …]} />`}
>
<TabGroup
items={[
{ title: "Summary", content: <p class="text-sm text-ink-soft">Three accounts, two of them funded.</p> },
{ title: "Activity", badge: 3, content: <p class="text-sm text-ink-soft">Three events since Tuesday.</p> },
{ title: "Settings", content: <p class="text-sm text-ink-soft">Nothing configurable yet.</p> },
]}
/>
</Demo>
<Demo
title="Modals"
code={`<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} header="A modal">
</Modal>
// no provider needed — it portals itself to document.body`}
>
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_NEUTRAL} onclick={() => setModalOpen(true)}>
Open modal
</ButtonUI>
<ButtonUI color={BUTTON_COLOR_RED} outline onclick={() => setConfirmOpen(true)}>
Delete something
</ButtonUI>
<span class="text-sm text-ink-muted">confirmed {confirmed()} times</span>
</div>
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} header={<h3 class="text-lg font-semibold">A modal</h3>}>
<p class="text-sm leading-relaxed text-ink-soft">
It portals itself to <code class="font-mono">document.body</code>, so it escapes any
ancestor with <code class="font-mono">overflow: hidden</code> or a transform the two
things that silently clip a floating panel.
</p>
</Modal>
<ConfirmModal
isOpen={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmed(confirmed() + 1)}
title="Delete this?"
message="This cannot be undone. (Nothing is actually deleted — this is a docs page.)"
confirmText="Delete"
/>
</Demo>
<Demo
title="Tooltips and icons"
code={`<Tooltip content="…"><Icon icon="circle-info" /></Tooltip>`}
>
<div class="flex flex-wrap items-center gap-5">
<For each={["circle-info", "calendar", "download", "print", "trash-can", "pen-to-square", "globe"]}>
{(name) => (
<Tooltip content={name}>
<span class="inline-flex cursor-help items-center gap-2 text-ink-soft">
<Icon icon={name} size={18} />
</span>
</Tooltip>
)}
</For>
</div>
<p class="mt-3 text-sm text-ink-muted">
Only the icons actually referenced in the source are bundled. The registry for this whole
site is a few dozen paths, not FontAwesome's 41.5 MB kit.
</p>
</Demo>
<Card class="mt-8">
<CardHeader>Not shown here</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
The kit also carries a calendar, a date picker, popovers, an accordion, a signature pad, a
chart wrapper, a toast system, a guided-tour overlay and a fuzzy matcher. They are in{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">go/jsruntime/uikit</code>.
</p>
</Card>
</div>
);
}

View File

@@ -0,0 +1,97 @@
// /js — what the JS layer is, and how it is built.
import { Card, CardHeader } from "@ui/Cards";
import { AlertBlue } from "@ui/Alerts";
import { CodeBox } from "@ui/General";
export function Overview() {
return (
<div class="max-w-3xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol 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 ./build\nGenerating FA icon subset...\nGenerating public routes...\nBundling JS + CSS...\n\nBundle Files Size Time\n-------------------------------------------------------\nbundle.min.js 84 241.3 KB 412ms\nbundle.min.css 1418 68.1 KB 31ms"} />
<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">What is on the other pages</h2>
<div class="mt-4 grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader>Components</CardHeader>
<p class="text-sm text-ink-soft">
Buttons, badges, alerts, cards, tabs and menus rendered live, not screenshotted.
</p>
</Card>
<Card>
<CardHeader>Forms</CardHeader>
<p class="text-sm text-ink-soft">
Masked inputs, comboboxes, multi-select, toggles, and the validation helpers.
</p>
</Card>
<Card>
<CardHeader>AutoTable</CardHeader>
<p class="text-sm text-ink-soft">
Sorting, search, column management, CSV export from one array of column defs.
</p>
</Card>
</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,167 @@
// /js/table — AutoTable, driven by an array of column definitions.
import AutoTable, {
AutoTableColumn,
AutoTableSearch,
AutoTableFilterFields,
TdLeft,
TdRight,
TdCenter,
COL_POS_LEFT,
COL_POS_RIGHT,
COL_POS_CENTER,
AUTOTABLE_SIZE_COMPACT,
} from "@ui/AutoTable";
import { Badge, BADGE_GREEN, BADGE_RED, BADGE_NEUTRAL } from "@ui/Badges";
import { AlertBlue } from "@ui/Alerts";
interface Institution {
name: string;
state: string;
term: string;
rate: number;
minimum: number;
status: "open" | "closed" | "waitlist";
}
// Static rows: the point of the page is the table, not where the rows came from.
// Swapping `data` for `url` is the only change needed to make it fetch, sort and
// paginate against a server instead.
const ROWS: Institution[] = [
{ name: "First Meridian Bank", state: "CA", term: "90 day", rate: 4.85, minimum: 1000, status: "open" },
{ name: "Harborline Credit Union", state: "WA", term: "180 day", rate: 5.1, minimum: 2500, status: "open" },
{ name: "Cascade Federal", state: "OR", term: "1 year", rate: 5.35, minimum: 500, status: "waitlist" },
{ name: "Ironwood Savings", state: "IL", term: "90 day", rate: 4.6, minimum: 10000, status: "closed" },
{ name: "Great Lakes Trust", state: "MI", term: "2 year", rate: 5.55, minimum: 1000, status: "open" },
{ name: "Sunbelt National", state: "TX", term: "180 day", rate: 4.95, minimum: 5000, status: "open" },
{ name: "Granite State Bank", state: "NH", term: "1 year", rate: 5.2, minimum: 2000, status: "waitlist" },
{ name: "Pacific Crest", state: "CA", term: "5 year", rate: 5.75, minimum: 25000, status: "open" },
{ name: "Copper Ridge Bank", state: "AZ", term: "90 day", rate: 4.4, minimum: 1000, status: "closed" },
{ name: "Bayou Community", state: "LA", term: "1 year", rate: 5.05, minimum: 1500, status: "open" },
{ name: "Northern Pine FCU", state: "MN", term: "2 year", rate: 5.45, minimum: 500, status: "open" },
{ name: "Chesapeake First", state: "MD", term: "180 day", rate: 4.75, minimum: 3000, status: "waitlist" },
];
// The whole table is this list. Sorting, column ordering, hiding, resizing and CSV
// export are all driven from it — there is no per-column wiring anywhere else.
const COLUMNS: AutoTableColumn[] = [
{ displayName: "Institution", sortable: true, sortIdentifier: "name", displayPosition: COL_POS_LEFT },
{ displayName: "State", sortable: true, sortIdentifier: "state", displayPosition: COL_POS_CENTER, toggleable: true },
{ displayName: "Term", sortable: true, sortIdentifier: "term", displayPosition: COL_POS_LEFT },
{
displayName: "Rate",
sortable: true,
sortIdentifier: "rate",
sortType: "numeric",
displayPosition: COL_POS_RIGHT,
csvValue: (i: Institution) => i.rate,
},
{
displayName: "Minimum",
sortable: true,
sortIdentifier: "minimum",
sortType: "money",
displayPosition: COL_POS_RIGHT,
toggleable: true,
csvValue: (i: Institution) => i.minimum,
},
{ displayName: "Status", displayPosition: COL_POS_CENTER, sortable: true, sortIdentifier: "status" },
];
const money = (n: number) => "$" + n.toLocaleString("en-US");
function StatusBadge(props: { status: Institution["status"] }) {
if (props.status === "open") return <Badge color={BADGE_GREEN} pill>open</Badge>;
if (props.status === "closed") return <Badge color={BADGE_RED} pill>closed</Badge>;
return <Badge color={BADGE_NEUTRAL} pill>waitlist</Badge>;
}
export function Table() {
return (
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">AutoTable</h1>
<p class="mt-4 max-w-3xl leading-relaxed text-ink-soft">
One array of column definitions produces sorting, per-column search, column reordering by
drag, column show/hide, column resizing, pagination and CSV export. The page below writes no
table markup only a <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">rowRenderer</code>{" "}
to say what a cell looks like.
</p>
<AlertBlue header="Try it" class="mt-6 max-w-3xl">
Sort by clicking a header. Drag a header to reorder. Use the toolbar to hide a column or
export what you are looking at. The column layout persists it is keyed to localStorage, so
it survives a reload.
</AlertBlue>
<div class="mt-8">
<AutoTable
data={ROWS}
columns={COLUMNS}
emptyMessage="No institutions match those filters."
options={{
size: AUTOTABLE_SIZE_COMPACT,
hover: true,
alternate: true,
surroundingBorder: true,
headerBorderY: true,
draggableColumns: true,
toggleColumns: true,
resizableColumns: true,
resetButton: true,
exportCSV: true,
exportFilename: "kjol-rates",
inlineToolbar: true,
columnOrderStorageKey: "kjolweb.table.order",
columnVisibilityStorageKey: "kjolweb.table.visible",
columnWidthStorageKey: "kjolweb.table.widths",
}}
searchFields={(ctx) => (
<AutoTableFilterFields>
<AutoTableSearch
label="Institution"
placeholder="Search by name…"
value={ctx.getSearchValue("name")}
onchange={(v) => ctx.setSearchValue("name", v)}
/>
<AutoTableSearch
label="State"
placeholder="CA"
value={ctx.getSearchValue("state")}
onchange={(v) => ctx.setSearchValue("state", v)}
/>
</AutoTableFilterFields>
)}
rowRenderer={(item: Institution) => (
<>
<TdLeft class="font-medium text-ink">{item.name}</TdLeft>
<TdCenter>{item.state}</TdCenter>
<TdLeft>{item.term}</TdLeft>
<TdRight class="font-mono">{item.rate.toFixed(2)}%</TdRight>
<TdRight class="font-mono">{money(item.minimum)}</TdRight>
<TdCenter>
<StatusBadge status={item.status} />
</TdCenter>
</>
)}
/>
</div>
<div class="mt-10 max-w-3xl">
<h2 class="text-lg font-semibold text-ink">Local rows, or a server</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
This table is passed <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">data</code>.
Give it <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">url</code> instead and
the same column list drives a server-side query the sort identifier becomes the sort key,
the search fields become query parameters, and pagination is handled for you. Nothing else
on the page changes.
</p>
<p class="mt-3 leading-relaxed text-ink-soft">
The Go/WASM layer has this same table, rewritten as Go returning a virtual DOM. Same
behaviour, no JavaScript which is the whole argument the other half of this site is
making.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,168 @@
// /js/theming — how the kit is themed, and the switch that proves it.
import { AlertBlue, AlertGreen } from "@ui/Alerts";
import { Card, CardHeader } from "@ui/Cards";
import { ButtonUI, BUTTON_COLOR_PRIMARY, BUTTON_COLOR_NEUTRAL, BUTTON_COLOR_WHITE } from "@ui/Buttons";
import { Badge, BADGE_GREEN, BADGE_NEUTRAL } from "@ui/Badges";
import { CodeBox } from "@ui/General";
import { ThemeToggle, useTheme } from "@ui/Theme";
import { Demo } from "../layout/Demo.tsx";
// The swatch class is written out in full, not built as "bg-" + name. Tailwind finds
// the classes it must compile by SCANNING THE SOURCE for literal strings — a
// concatenation is invisible to it, and every swatch here would come out colourless.
// It is the one thing about a utility CSS engine you cannot forget.
const TOKENS: { swatch: string; name: string; role: string }[] = [
{ swatch: "bg-surface", name: "surface", role: "the page" },
{ swatch: "bg-surface-muted", name: "surface-muted", role: "a recessed strip" },
{ swatch: "bg-surface-raised", name: "surface-raised", role: "a panel, a hover" },
{ swatch: "bg-surface-strong", name: "surface-strong", role: "a track, a divider fill" },
{ swatch: "bg-line", name: "line", role: "an ordinary border" },
{ swatch: "bg-line-strong", name: "line-strong", role: "a border that has to be seen" },
{ swatch: "bg-ink", name: "ink", role: "body text, headings" },
{ swatch: "bg-ink-soft", name: "ink-soft", role: "secondary text" },
{ swatch: "bg-ink-muted", name: "ink-muted", role: "captions, labels" },
{ swatch: "bg-ink-faint", name: "ink-faint", role: "placeholders, disabled" },
];
export function Theming() {
const { isDark, mode } = useTheme();
return (
<div class="max-w-3xl">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjol JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Theming</h1>
<p class="mt-4 leading-relaxed text-ink-soft">
No component in this kit names a colour. They say{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">bg-surface</code>,{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">text-ink</code>,{" "}
<code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">border-line</code> and
what those mean is decided in one place. That is the whole of the theme system, and it is why
dark mode is a rule that re-points ten variables rather than a{" "}
<code class="font-mono">dark:</code> variant on four hundred class strings.
</p>
<Demo
title="The switch"
code={`// styles/theme.css
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-surface: #ffffff;
--color-ink: #171717;
--color-line: #e5e5e5;
}
.dark {
--color-surface: #101013; /* not black: black makes every border vanish */
--color-ink: #f2f2f3;
--color-line: #2a2a30;
}`}
>
<div class="flex flex-wrap items-center gap-4">
<ThemeToggle />
<div class="text-sm text-ink-soft">
currently <span class="font-mono text-ink">{isDark() ? "dark" : "light"}</span>, because
you asked for <span class="font-mono text-ink">{mode()}</span>
</div>
</div>
<p class="mt-4 text-sm leading-relaxed text-ink-muted">
Press it. Every component on every page of this section moves none of them were told.
Your choice is remembered, and it is the <em>same</em> choice the Go/WASM section reads:
both halves of this site share one localStorage key, so the theme survives crossing between
two entirely different front-ends.
</p>
</Demo>
<h2 class="mt-12 text-lg font-semibold text-ink">The contract</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
These are the tokens a component is allowed to name. Each swatch below is drawn with the token
itself, so this table is not a picture of the theme it <em>is</em> the theme, and it repaints
when you press the switch.
</p>
<div class="mt-5 overflow-hidden rounded-default border border-line">
{TOKENS.map((t, i) => (
<div
class={
"flex items-center gap-4 px-4 py-2.5 " +
(i > 0 ? "border-t border-line" : "")
}
>
<span class={"h-7 w-7 shrink-0 rounded border border-line-strong " + t.swatch} />
<code class="w-40 shrink-0 font-mono text-[13px] text-ink">{t.name}</code>
<span class="text-sm text-ink-muted">{t.role}</span>
</div>
))}
</div>
<AlertBlue header="Two kits, one vocabulary" class="mt-8">
The Go/WASM kit uses these exact token names. A designer changes{" "}
<code class="font-mono">surface</code> once and both halves of the site move together even
though one is Solid compiled by esbuild and the other is Go compiled to WebAssembly.
</AlertBlue>
<h2 class="mt-12 text-lg font-semibold text-ink">Where a variant is still needed</h2>
<p class="mt-2 leading-relaxed text-ink-soft">
Two things a re-pointed token cannot fix, so they are the only places the kit still carries a{" "}
<code class="font-mono">dark:</code> variant.
</p>
<div class="mt-5 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>Coloured tints</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
A <code class="font-mono">red-50</code> wash is invisible on a near-black surface. An
alert's tint has to become a deep, transparent one a different colour, not a
different value of the same one.
</p>
</Card>
<Card>
<CardHeader>Fills that invert</CardHeader>
<p class="text-sm leading-relaxed text-ink-soft">
The neutral button is dark on a light page and light on a dark one so its label must
invert with it. <code class="font-mono">text-white</code> would disappear the moment
the fill went pale. Hence three tokens, not one.
</p>
</Card>
</div>
<Demo
title="The buttons that had to think about it"
code={`// the fill and its text move together, or the label vanishes
"neutral": "bg-fill-neutral text-on-fill-neutral hover:bg-fill-neutral-hover",
// a chromatic fill is dark enough for white text in BOTH themes — leave it
"red": "bg-red-700 text-white hover:bg-red-800",`}
>
<div class="flex flex-wrap items-center gap-2">
<ButtonUI color={BUTTON_COLOR_NEUTRAL}>Neutral (inverts)</ButtonUI>
<ButtonUI color={BUTTON_COLOR_WHITE}>White (a surface)</ButtonUI>
<ButtonUI color={BUTTON_COLOR_PRIMARY}>Primary (a fill)</ButtonUI>
<Badge color={BADGE_GREEN} pill>solid</Badge>
<Badge color={BADGE_NEUTRAL} pill>fills stay put</Badge>
</div>
</Demo>
<AlertGreen header="No flash" class="mt-8">
The theme class is applied by a ten-line script in the document head, before the stylesheet and
before any markup. The server cannot read localStorage, so it cannot know which theme to send;
if the class waited for the bundle, every dark-mode reader would get a white page and then have
it snatched away. It is the only hand-written JavaScript on the Go/WASM side of this site.
</AlertGreen>
<CodeBox
class="mt-5"
code={`<head>
<script>(function(){try{
var m = localStorage.getItem("kjol-theme");
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
}catch(e){}})();</script>
<link rel="stylesheet" href="/bundle.min.css" />
</head>`}
/>
</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">Kjol 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">
Kjol 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">Kjol 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 Kjol 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 — Kjol 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 — Kjol 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);
}

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

28
go/cmd/kjol-web/go.mod Normal file
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 kjolweb
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

@@ -1,6 +1,26 @@
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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 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/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 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ= 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= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -24,6 +44,8 @@ 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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 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.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-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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -34,12 +56,15 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.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.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.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.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/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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -57,6 +82,8 @@ 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.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-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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@@ -0,0 +1,117 @@
// Package handlers serves the server-rendered public pages of the Kjol 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

@@ -1,4 +1,4 @@
// Command server runs the go-wasm-web example on kjol's reusable wasmdevserver: // Command server runs the kjol-web site on kjol's reusable wasmdevserver:
// it SSRs the app's static routes, hosts the /rsc server-component endpoint, and // 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 // hot-swaps the wasm into the browser on change. It shows the coupling
// inversion — the framework (wasmdevserver) imports no app code; the app injects // inversion — the framework (wasmdevserver) imports no app code; the app injects
@@ -6,11 +6,12 @@
// //
// Run it from THIS directory (the relative paths below are resolved against it): // Run it from THIS directory (the relative paths below are resolved against it):
// //
// go run ./server # from cmd/examples/go-wasm-web // go run ./server # from go/cmd/kjol-web
package main package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"net/http" "net/http"
@@ -19,8 +20,9 @@ import (
"kjol/wasmdevserver" "kjol/wasmdevserver"
"kjol/webui" "kjol/webui"
"gowasmweb/app" "kjolweb/app"
"gowasmweb/buildsteps" "kjolweb/buildsteps"
"kjolweb/internal/handlers"
) )
func main() { func main() {
@@ -29,27 +31,69 @@ func main() {
flag.Parse() flag.Parse()
log.Fatal(wasmdevserver.Serve(wasmdevserver.Config{ log.Fatal(wasmdevserver.Serve(wasmdevserver.Config{
Addr: *addr, Addr: *addr,
Dir: "./wwwroot", Dir: "./wwwroot",
Watch: *watch, Watch: *watch,
WatchDirs: []string{"app", "wasm", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit WatchDirs: []string{
Build: buildsteps.All, "app", "wasm", "css", // this app's Go/WASM half
BuildCSS: buildsteps.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet "frontend", // its Solid half — a .tsx save rebuilds the JS bundle
Render: render, "../../webui", "../../vdom", "../../wasmruntime", "../../rsc", // the wasm engine
Document: document, "../../jsruntime/uikit", "../../jsruntime/styles", // the Solid kit + the shared theme
Handle: apiRoutes, },
Build: buildsteps.All,
BuildCSS: buildsteps.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
Render: render,
Document: document,
Handle: routes,
})) }))
} }
// apiRoutes registers the example's API endpoints. /api/quotes responds with a // routes registers everything the WASM app does not own.
// gob-encoded []app.Quote (via httputil.RespondGob) — the /data page fetches and //
// decodes it on the client with encoding/gob (Go types end to end, no JSON). // Order does not matter here — Go's ServeMux picks the most specific pattern, not the
func apiRoutes(mux *http.ServeMux) { // 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) { mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
httputil.RespondGob(w, http.StatusOK, sampleQuotes()) 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 the Go/WASM
// half rather than an oversight: this section 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>Kjol 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 { func sampleQuotes() []app.Quote {
return []app.Quote{ return []app.Quote{
{Author: "Rob Pike", Text: "A little copying is better than a little dependency."}, {Author: "Rob Pike", Text: "A little copying is better than a little dependency."},
@@ -86,7 +130,7 @@ func document(inner string) string {
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kjol Web Go + WASM</title> <title>kjol a shared base layer</title>
` + webui.ThemeBootScript + ` ` + webui.ThemeBootScript + `
<link rel="stylesheet" href="/app.css" /> <link rel="stylesheet" href="/app.css" />
</head> </head>

View File

@@ -5,7 +5,7 @@
package main package main
import ( import (
"gowasmweb/app" "kjolweb/app"
"kjol/vdom" "kjol/vdom"
"kjol/wasmruntime" "kjol/wasmruntime"
) )

View File

@@ -2,7 +2,7 @@
// scanning explicit content globs for utility candidates. Unlike the app bundler // scanning explicit content globs for utility candidates. Unlike the app bundler
// (which is wired to the frontend tree) it takes the entry, output, and content // (which is wired to the frontend tree) it takes the entry, output, and content
// globs as flags/args, so it works for markup authored in any language — used by // globs as flags/args, so it works for markup authored in any language — used by
// the go-wasm-web example, whose UI is written in Go. // the kjol-web site, whose Go/WASM half writes its UI in Go.
// //
// Usage (globs are relative to -base; pass "**" for a recursive walk): // Usage (globs are relative to -base; pass "**" for a recursive walk):
// //

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"fmt" "fmt"
@@ -11,8 +11,8 @@ import (
// aliasRoots maps the framework import prefixes to absolute filesystem roots: // aliasRoots maps the framework import prefixes to absolute filesystem roots:
// //
// @ui/* -> kjol web kit (shared Solid components) // @ui/* -> kjol JS kit (shared Solid components)
// @kjol/* -> kjol web root (auth, utils, hooks, ssr, env.ts, ...) // @kjol/* -> kjol JS root (auth, utils, hooks, ssr, env.ts, ...)
// @appgen/* -> the app's generated dir (faIcons registry etc. — app-owned) // @appgen/* -> the app's generated dir (faIcons registry etc. — app-owned)
// //
// In single-tree mode @ui/@kjol resolve back under the app frontend. // In single-tree mode @ui/@kjol resolve back under the app frontend.
@@ -49,7 +49,7 @@ func resolveAlias(root, rest string) (string, bool) {
} }
// aliasPlugin resolves @ui / @kjol / @appgen imports to absolute paths across the // aliasPlugin resolves @ui / @kjol / @appgen imports to absolute paths across the
// app and kjol web trees. It sits before the vendor resolvers; the Solid // app and kjol JS trees. It sits before the vendor resolvers; the Solid
// compiler's OnLoad then compiles any .tsx/.jsx it points at. // compiler's OnLoad then compiles any .tsx/.jsx it points at.
func aliasPlugin() esbuild.Plugin { func aliasPlugin() esbuild.Plugin {
roots := aliasRoots() roots := aliasRoots()

View File

@@ -1,4 +1,4 @@
// Package bundler is the frontend build system: it drives esbuild's Go API for // Package jsbundler is the frontend build system: it drives esbuild's Go API for
// JS bundling, compiles Solid JSX/TSX and Tailwind v4 CSS with Go-native // JS bundling, compiles Solid JSX/TSX and Tailwind v4 CSS with Go-native
// compilers (no Node, no Babel, no goja on the build path), and bakes the // compilers (no Node, no Babel, no goja on the build path), and bakes the
// public-page SSR (which does still use goja to execute components). The // public-page SSR (which does still use goja to execute components). The
@@ -18,7 +18,7 @@
// genssr.go public-page SSR bake + Go registry generation // genssr.go public-page SSR bake + Go registry generation
// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime) // ssr.go/renderer.go the goja SSR engine (also used by the server at runtime)
// watch.go poll-and-rebuild watch loop // watch.go poll-and-rebuild watch loop
package webbundler package jsbundler
import ( import (
"fmt" "fmt"
@@ -34,7 +34,7 @@ type bundleStats struct {
// Build runs the full one-shot build: FA icon subset, public-route generation, // Build runs the full one-shot build: FA icon subset, public-route generation,
// the JS/CSS bundles, and the SSR bake, printing a stats summary. c selects the // the JS/CSS bundles, and the SSR bake, printing a stats summary. c selects the
// app + kjol web trees (see Config); zero-value fields fall back to the // app + kjol JS trees (see Config); zero-value fields fall back to the
// single-tree defaults. // single-tree defaults.
func Build(c Config) error { func Build(c Config) error {
Configure(c) Configure(c)

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
// Go-native Solid JSX compiler (replaces babel-preset-solid running in goja). // Go-native Solid JSX compiler (replaces babel-preset-solid running in goja).
// //

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
// Go-native Solid codegen: JSX tree -> dom-expressions runtime output. // Go-native Solid codegen: JSX tree -> dom-expressions runtime output.
// //
@@ -950,25 +950,61 @@ func isDOMChild(ch *jsxNode) bool {
// collapseText applies JSX whitespace normalization: lines are trimmed and // collapseText applies JSX whitespace normalization: lines are trimmed and
// joined by a single space; text that is only whitespace-with-newline vanishes. // joined by a single space; text that is only whitespace-with-newline vanishes.
// collapseText applies JSX's whitespace rules to one text child. It is a port of
// Babel's cleanJSXElementLiteralChild, and it has to be, because the rules are not
// what you would guess:
//
// leading whitespace is stripped from every line EXCEPT the first,
// trailing whitespace is stripped from every line EXCEPT the last.
//
// That asymmetry is the whole point. In
//
// <button>
// Clicked {count()} times
// </button>
//
// the text before the expression is "\n Clicked " and the text after it is
// " times\n". The indentation must go; the single space before `{` and after `}`
// must NOT — they are the spaces between the words. Trimming both ends of every
// line (as this did) renders "Clicked0times", and it does it silently, in every
// multi-line component in the codebase.
//
// Blank lines vanish, and the surviving lines are joined with a single space, so a
// paragraph broken across source lines still reads as a sentence.
func collapseText(s string) string { func collapseText(s string) string {
if strings.TrimSpace(s) == "" { s = strings.ReplaceAll(s, "\r\n", "\n")
if strings.ContainsAny(s, "\n") { s = strings.ReplaceAll(s, "\r", "\n")
return ""
}
return s // significant single-line whitespace (e.g. "a {x} b")
}
if !strings.ContainsAny(s, "\n") {
return s
}
lines := strings.Split(s, "\n") lines := strings.Split(s, "\n")
var kept []string
for _, l := range lines { // The last line with any non-whitespace on it. Every kept line before this one
l = strings.Trim(l, " \t\r") // gets a separating space; this one does not, or every text child would end in a
if l != "" { // trailing space. Starts at 0 (not -1) so an all-whitespace chunk is left exactly
kept = append(kept, l) // as it is — " " between two elements is a real, significant space.
lastNonEmpty := 0
for i, l := range lines {
if strings.Trim(l, " \t") != "" {
lastNonEmpty = i
} }
} }
return strings.Join(kept, " ")
var b strings.Builder
for i, l := range lines {
line := strings.ReplaceAll(l, "\t", " ")
if i != 0 {
line = strings.TrimLeft(line, " ")
}
if i != len(lines)-1 {
line = strings.TrimRight(line, " ")
}
if line == "" {
continue
}
if i != lastNonEmpty {
line += " "
}
b.WriteString(line)
}
return b.String()
} }
func escapeTemplateText(s string) string { func escapeTemplateText(s string) string {

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"os" "os"
@@ -42,6 +42,23 @@ func assertRenderEquivalent(t *testing.T, name, src string) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Point the bundler at kjol's own vendored Solid before chdir'ing — the path is
// relative to the package dir, and Configure stores it for vendorDirs().
//
// Without this the oracle silently never ran: webDir defaults to "", so
// vendorDirs() resolved to <root>/frontend/vendor — a directory kjol does not
// have and never had (it is a framework; there is no app frontend here). Every
// render then failed to resolve solid-js/web. It only looked green if a test
// that calls Configure happened to run first, and none does: the sole callers
// are in crosstree_test.go, which sorts after this file.
webAbs, err := filepath.Abs(filepath.Join("..", "jsruntime"))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(webAbs, "runtime", "vendor.json")); err != nil {
t.Skipf("kjol vendored solid runtime not present (%v)", err)
}
Configure(Config{WebDir: webAbs})
t.Chdir(root) t.Chdir(root)
babelJS, err := Compile(src, name+".tsx") babelJS, err := Compile(src, name+".tsx")
@@ -77,6 +94,33 @@ func TestGoCompilerRenderCore(t *testing.T) {
{"list", `export const A = () => { const items = ["a", "b", "c"]; return <ul>{items.map((i) => <li>{i}</li>)}</ul>; };`}, {"list", `export const A = () => { const items = ["a", "b", "c"]; return <ul>{items.map((i) => <li>{i}</li>)}</ul>; };`},
{"deep-static", `export const A = () => <section><header><h1>Title</h1></header><p>body text</p></section>;`}, {"deep-static", `export const A = () => <section><header><h1>Title</h1></header><p>body text</p></section>;`},
{"multi-attr", `export const A = () => <input type="text" name="q" disabled />;`}, {"multi-attr", `export const A = () => <input type="text" name="q" disabled />;`},
// Multi-line children. Every case above is written on ONE line, which is why
// they all passed while the compiler was eating the spaces around an expression
// the moment the JSX was indented — i.e. in essentially all real code. These
// pin JSX's asymmetric whitespace rule: indentation goes, the space between the
// words stays.
{"ml-text-around-expr", `export const A = () => { const c = () => 0; return (
<button>
Clicked {c()} times
</button>
); };`},
{"ml-text-before-el", `export const A = () => (
<p>
selected: <span>day</span>
</p>
);`},
{"ml-wrapped-prose", `export const A = () => (
<p>
one two
three four
</p>
);`},
{"ml-expr-both-sides", `export const A = () => { const x = () => "X"; const y = () => "Y"; return (
<div>
a {x()} b {y()} c
</div>
); };`},
} }
for _, c := range cases { for _, c := range cases {
c := c c := c

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import "testing" import "testing"

View File

@@ -1,18 +1,18 @@
package webbundler package jsbundler
import "path/filepath" import "path/filepath"
// Config tells the bundler where the application tree and the shared kjol web // Config tells the bundler where the application tree and the shared kjol JS
// tree live. The bundler always runs from the app root, so relative paths are // tree live. The bundler always runs from the app root, so relative paths are
// resolved against that working directory (absolute paths also work). // resolved against that working directory (absolute paths also work).
// //
// When WebDir is empty the bundler runs in single-tree mode: the kit, vendored // When WebDir is empty the bundler runs in single-tree mode: the kit, vendored
// runtime, icons, and styles are expected under AppFrontend (the pre-extraction // runtime, icons, and styles are expected under AppFrontend (the pre-extraction
// layout). When WebDir points at kjol/web, those come from the shared tree and // layout). When WebDir points at kjol/go/jsruntime, those come from the shared
// the app supplies only its pages/routes/brand. // tree and the app supplies only its pages/routes/brand.
type Config struct { type Config struct {
AppFrontend string // app frontend source root (default "frontend") AppFrontend string // app frontend source root (default "frontend")
WebDir string // path to kjol/web; "" => single-tree under AppFrontend WebDir string // path to kjol/go/jsruntime; "" => single-tree under AppFrontend
Output string // build output dir (default "wwwroot") Output string // build output dir (default "wwwroot")
GenGoDir string // dir for generated Go, e.g. public_pages.gen.go (default "internal/handlers") GenGoDir string // dir for generated Go, e.g. public_pages.gen.go (default "internal/handlers")
GenTSDir string // dir for generated TS, e.g. faIcons.ts (default AppFrontend/src/ui/generated) GenTSDir string // dir for generated TS, e.g. faIcons.ts (default AppFrontend/src/ui/generated)
@@ -24,7 +24,7 @@ type Config struct {
var ( var (
frontendDir = "frontend" frontendDir = "frontend"
outputDir = "wwwroot" outputDir = "wwwroot"
webDir = "" // kjol/web; empty => single-tree webDir = "" // kjol/go/jsruntime; empty => single-tree
genGoDir = "internal/handlers" genGoDir = "internal/handlers"
genTSDir = filepath.Join("frontend", "src", "ui", "generated") genTSDir = filepath.Join("frontend", "src", "ui", "generated")
) )
@@ -48,7 +48,7 @@ func Configure(c Config) {
} }
} }
// webRoot is the kjol web tree (@kjol/* root), falling back to the app frontend // webRoot is the kjol JS tree (@kjol/* root), falling back to the app frontend
// in single-tree mode. // in single-tree mode.
func webRoot() string { func webRoot() string {
if webDir != "" { if webDir != "" {
@@ -65,12 +65,23 @@ func uikitDir() string {
return filepath.Join(frontendDir, "src", "ui") return filepath.Join(frontendDir, "src", "ui")
} }
// iconsDir is the FontAwesome SVG source kit. // iconsDirs lists the FontAwesome SVG source roots in resolution precedence
func iconsDir() string { // order: the app's own kit first, kjol's second.
//
// The app has to win. kjol ships a SUBSET — the icons its own kit and example
// reference — while an app carries the full FontAwesome kit it licensed. If kjol
// were searched first (or searched alone, as this used to be when WebDir was
// set), an app adopting the shared tree would silently regenerate its registry
// from kjol's few dozen icons and lose every other icon it uses, with no error:
// the registry would simply come back smaller and the icons would render blank.
//
// Searching both, app-first, means an app-side icon of the same name overrides
// kjol's, and an icon kjol has never heard of still resolves.
func iconsDirs() []string {
if webDir != "" { if webDir != "" {
return filepath.Join(webDir, "icons") return []string{filepath.Join(frontendDir, "icons"), filepath.Join(webDir, "icons")}
} }
return filepath.Join(frontendDir, "icons") return []string{filepath.Join(frontendDir, "icons")}
} }
// vendorDirs lists the vendored-runtime roots in resolution precedence order. // vendorDirs lists the vendored-runtime roots in resolution precedence order.

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"os" "os"
@@ -9,16 +9,16 @@ import (
// TestCrossTreeJSBundle proves the bundler resolves the shared kit across the // TestCrossTreeJSBundle proves the bundler resolves the shared kit across the
// app/kjol tree boundary: a temp app entry imports @ui/Buttons, which pulls the // app/kjol tree boundary: a temp app entry imports @ui/Buttons, which pulls the
// real kjol web/uikit (Buttons -> ./Icons -> @appgen/faIcons stub) and the solid // real kjol jsruntime/uikit (Buttons -> ./Icons -> @appgen/faIcons stub) and the solid
// runtime from web/runtime. If the alias plugin, merged vendor manifest, and // runtime from jsruntime/runtime. If the alias plugin, merged vendor manifest, and
// multi-dir NodePaths all work, esbuild produces a non-empty bundle. // multi-dir NodePaths all work, esbuild produces a non-empty bundle.
func TestCrossTreeJSBundle(t *testing.T) { func TestCrossTreeJSBundle(t *testing.T) {
webAbs, err := filepath.Abs(filepath.Join("..", "..", "web")) webAbs, err := filepath.Abs(filepath.Join("..", "jsruntime"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := os.Stat(filepath.Join(webAbs, "uikit", "Buttons.tsx")); err != nil { if _, err := os.Stat(filepath.Join(webAbs, "uikit", "Buttons.tsx")); err != nil {
t.Skipf("kjol web kit not present (%v)", err) t.Skipf("kjol JS kit not present (%v)", err)
} }
app := t.TempDir() app := t.TempDir()
@@ -58,12 +58,12 @@ func TestCrossTreeJSBundle(t *testing.T) {
// for utility candidates across the boundary (WebDir set) without error, and // for utility candidates across the boundary (WebDir set) without error, and
// that an app-side utility class makes it into the output. // that an app-side utility class makes it into the output.
func TestCrossTreeCSS(t *testing.T) { func TestCrossTreeCSS(t *testing.T) {
webAbs, err := filepath.Abs(filepath.Join("..", "..", "web")) webAbs, err := filepath.Abs(filepath.Join("..", "jsruntime"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := os.Stat(filepath.Join(webAbs, "uikit")); err != nil { if _, err := os.Stat(filepath.Join(webAbs, "uikit")); err != nil {
t.Skipf("kjol web kit not present (%v)", err) t.Skipf("kjol JS kit not present (%v)", err)
} }
app := t.TempDir() app := t.TempDir()

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
// CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css) // CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css)
// with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no // with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no
@@ -78,7 +78,7 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS
kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"}) kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
candidates = dedupStrings(append(candidates, kitCands...)) candidates = dedupStrings(append(candidates, kitCands...))
// Prepend the shared @theme scaffold (kjol web/styles/theme.css) ahead of the // Prepend the shared @theme scaffold (kjol jsruntime/styles/theme.css) ahead of the
// app's brand style.css so its tokens/vars are in scope. Absent in single-tree // app's brand style.css so its tokens/vars are in scope. Absent in single-tree
// mode (the app's style.css is already complete). // mode (the app's style.css is already complete).
input := string(src) input := string(src)

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
// defaultExportShimPlugin bridges two ESM-strictness mismatches that // defaultExportShimPlugin bridges two ESM-strictness mismatches that
// existed in the previous custom bundler: // existed in the previous custom bundler:

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"fmt" "fmt"
@@ -13,19 +13,20 @@ import (
// Tree-shaken FontAwesome. Instead of shipping the 41.5 MB `all.min.js` kit and // Tree-shaken FontAwesome. Instead of shipping the 41.5 MB `all.min.js` kit and
// looking icons up by runtime string, we scan the app for the icon names it // looking icons up by runtime string, we scan the app for the icon names it
// actually references and emit a registry of just those icons' SVG data, pulled // actually references and emit a registry of just those icons' SVG data, pulled
// from the FontAwesome SVGs under frontend/icons/. Icons.tsx looks up that // from the FontAwesome SVGs under the icon roots. Icons.tsx looks up that
// registry exactly like it used to call FontAwesome.findIconDefinition. // registry exactly like it used to call FontAwesome.findIconDefinition.
// FA prefix -> frontend/icons/<dir>. cdrateline renders classic far/fas; a Sharp // FA prefix -> <iconRoot>/<dir>. cdrateline renders classic far/fas; a Sharp
// project (fasr/fass) would add "fasr": "sharp-regular", "fass": "sharp-solid". // project (fasr/fass) would add "fasr": "sharp-regular", "fass": "sharp-solid".
var faStyleDirs = map[string]string{ var faStyleDirs = map[string]string{
"far": "regular", "far": "regular",
"fas": "solid", "fas": "solid",
} }
// The FA SVG source dir (iconsDir) and the generated registry output path // The FA SVG source roots (iconsDirs — the app's kit, then kjol's) and the
// (faOutPath) are resolved from Config — see config.go. Only the styles in // generated registry output path (faOutPath) are resolved from Config — see
// faStyleDirs are read; the rest of the kit is unused. // config.go. Only the styles in faStyleDirs are read; the rest of the kit is
// unused.
var ( var (
// icon="name" / icon: "name" // icon="name" / icon: "name"
@@ -39,10 +40,34 @@ var (
reRegisterIcon = regexp.MustCompile(`registerIcon\(\s*"([a-z0-9][a-z0-9-]*)"`) reRegisterIcon = regexp.MustCompile(`registerIcon\(\s*"([a-z0-9][a-z0-9-]*)"`)
) )
// presentIconsDirs is iconsDirs filtered to the roots that actually exist, so a
// missing kit is skipped rather than failing every icon lookup against it.
func presentIconsDirs() []string {
var roots []string
for _, d := range iconsDirs() {
if _, err := os.Stat(d); err == nil {
roots = append(roots, d)
}
}
return roots
}
// readIconSVG returns the first <root>/<style>/<name>.svg that exists, walking
// roots in precedence order (app kit before kjol's — see iconsDirs).
func readIconSVG(roots []string, style, name string) (string, bool) {
for _, root := range roots {
if svg, err := os.ReadFile(filepath.Join(root, style, name+".svg")); err == nil {
return string(svg), true
}
}
return "", false
}
// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a // generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a
// no-op when the kit isn't present (CI builds use the committed registry). // no-op when no kit is present (CI builds use the committed registry).
func generateFAIcons() error { func generateFAIcons() error {
if _, err := os.Stat(iconsDir()); err != nil { roots := presentIconsDirs()
if len(roots) == 0 {
return nil // SVGs absent — keep the committed registry return nil // SVGs absent — keep the committed registry
} }
@@ -61,11 +86,11 @@ func generateFAIcons() error {
for _, name := range names { for _, name := range names {
found := false found := false
for prefix, dir := range faStyleDirs { for prefix, dir := range faStyleDirs {
svg, err := os.ReadFile(filepath.Join(iconsDir(), dir, name+".svg")) svg, ok := readIconSVG(roots, dir, name)
if err != nil { if !ok {
continue continue
} }
x, y, w, h, d, ok := parseFASvg(string(svg)) x, y, w, h, d, ok := parseFASvg(svg)
if !ok { if !ok {
continue continue
} }
@@ -83,7 +108,7 @@ func generateFAIcons() error {
b.WriteString("// A tree-shaken subset of FontAwesome: only the icons this app references,\n") b.WriteString("// A tree-shaken subset of FontAwesome: only the icons this app references,\n")
b.WriteString("// as [x, y, width, height, svgPath] keyed by \"prefix:name\" (viewBox inset to FA's\n") b.WriteString("// as [x, y, width, height, svgPath] keyed by \"prefix:name\" (viewBox inset to FA's\n")
b.WriteString("// 512 design box within the 640 kit canvas). Regenerated each build while\n") b.WriteString("// 512 design box within the 640 kit canvas). Regenerated each build while\n")
b.WriteString("// frontend/icons/ is present; committed so CI needs no SVGs.\n") b.WriteString("// an icon kit is present; committed so CI needs no SVGs.\n")
b.WriteString("export const FA_ICONS: Record<string, readonly [number, number, number, number, string]> = {\n") b.WriteString("export const FA_ICONS: Record<string, readonly [number, number, number, number, string]> = {\n")
for _, e := range entries { for _, e := range entries {
b.WriteString(fmt.Sprintf(" %q: [%s, %s, %s, %s, %q],\n", e.key, e.x, e.y, e.w, e.h, e.path)) b.WriteString(fmt.Sprintf(" %q: [%s, %s, %s, %s, %q],\n", e.key, e.x, e.y, e.w, e.h, e.path))

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
// Public-route code generation. The single source of truth is the TypeScript // Public-route code generation. The single source of truth is the TypeScript
// manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild + // manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild +

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
// Public-page SSR code generation. For each page in the manifest // Public-page SSR code generation. For each page in the manifest
// (frontend/src/pages/public/pages.ts) this renders the solid-js/html component // (frontend/src/pages/public/pages.ts) this renders the solid-js/html component
@@ -94,7 +94,11 @@ func writeGoRegistry(defs []pageDef) error {
b.WriteString("//\n") b.WriteString("//\n")
b.WriteString("// Each html field is the page's data-free SSR skeleton, rendered from its\n") b.WriteString("// Each html field is the page's data-free SSR skeleton, rendered from its\n")
b.WriteString("// Solid component at bundle time. The browser bundle re-renders it on load.\n\n") b.WriteString("// Solid component at bundle time. The browser bundle re-renders it on load.\n\n")
b.WriteString("package handlers\n\n") // The package name is the output directory's, so Config.GenGoDir can point anywhere
// and the file still compiles. It used to be hardcoded to `handlers`, alongside a
// hardcoded internal/handlers path — which meant GenGoDir was documented, settable,
// and read by nothing at all.
b.WriteString("package " + filepath.Base(genGoDir) + "\n\n")
b.WriteString("var publicPages = []publicPage{\n") b.WriteString("var publicPages = []publicPage{\n")
for i, d := range defs { for i, d := range defs {
fmt.Fprintf(&b, "\t{route: %q, title: %q, module: %q, component: %q, html: %q, renderJS: %q},\n", goRoute(d.Path), d.Title, d.Module, d.Component, bodies[i], renderJSs[i]) fmt.Fprintf(&b, "\t{route: %q, title: %q, module: %q, component: %q, html: %q, renderJS: %q},\n", goRoute(d.Path), d.Title, d.Module, d.Component, bodies[i], renderJSs[i])
@@ -106,7 +110,10 @@ func writeGoRegistry(defs []pageDef) error {
} }
b.WriteString("}\n") b.WriteString("}\n")
if err := os.WriteFile(filepath.Join("internal", "handlers", "public_pages.gen.go"), []byte(b.String()), 0644); err != nil { if err := os.MkdirAll(genGoDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(genGoDir, "public_pages.gen.go"), []byte(b.String()), 0644); err != nil {
return err return err
} }

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
import ( import (
"context" "context"

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
import "net/http" import "net/http"

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
import ( import (
"strings" "strings"

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
import ( import (
"encoding/json" "encoding/json"

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
import ( import (
"errors" "errors"

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
// The development HMR server: it serves the SPA source tree as unbundled native // The development HMR server: it serves the SPA source tree as unbundled native
// ES modules (transformed on the fly), tracks the module import graph, watches // ES modules (transformed on the fly), tracks the module import graph, watches
@@ -8,7 +8,7 @@ package webbundler
// WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing // WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing
// a non-boundary module bubbles up to a full page reload. // a non-boundary module bubbles up to a full page reload.
// //
// This is the reason webbundler grew a `dev` build tag: the whole HMR // This is the reason jsbundler grew a `dev` build tag: the whole HMR
// subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go, // subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go,
// and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the // and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the
// production server and the plain `cmd/bundle` build carry none of it. // production server and the plain `cmd/bundle` build carry none of it.
@@ -29,7 +29,7 @@ import (
// transformed module frontend/src/<rel>. // transformed module frontend/src/<rel>.
const ( const (
srcURLPrefix = "/@src/" // transformed app source modules srcURLPrefix = "/@src/" // transformed app source modules
kitURLPrefix = "/@kit/" // transformed shared-kit modules (kjol web tree) kitURLPrefix = "/@kit/" // transformed shared-kit modules (kjol JS tree)
assetURLPrefix = "/@url/" // `?url` shim: a module whose default export is the file URL assetURLPrefix = "/@url/" // `?url` shim: a module whose default export is the file URL
fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at) fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at)
) )
@@ -38,7 +38,7 @@ type devServer struct {
hub *hub hub *hub
frontend string // abs app frontend/ frontend string // abs app frontend/
srcRoot string // abs app frontend/src srcRoot string // abs app frontend/src
kitRoot string // abs kjol web (@ui/@kjol root); == frontend in single-tree kitRoot string // abs kjol JS tree (@ui/@kjol root); == frontend in single-tree
genRoot string // abs app generated dir (@appgen) genRoot string // abs app generated dir (@appgen)
vendorDirs []string // abs vendor roots (kjol runtime first, then app vendor) vendorDirs []string // abs vendor roots (kjol runtime first, then app vendor)
output string // abs wwwroot/ output string // abs wwwroot/
@@ -152,7 +152,7 @@ func (d *devServer) serveModule(w http.ResponseWriter, r *http.Request) {
w.Write(code) w.Write(code)
} }
// serveKit transforms and serves one shared-kit module (kjol web tree) as ESM. // serveKit transforms and serves one shared-kit module (kjol JS tree) as ESM.
func (d *devServer) serveKit(w http.ResponseWriter, r *http.Request) { func (d *devServer) serveKit(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, kitURLPrefix) rel := strings.TrimPrefix(r.URL.Path, kitURLPrefix)
abs := filepath.Join(d.kitRoot, filepath.FromSlash(rel)) abs := filepath.Join(d.kitRoot, filepath.FromSlash(rel))
@@ -176,7 +176,7 @@ func (d *devServer) serveKit(w http.ResponseWriter, r *http.Request) {
} }
// moduleURLBase maps an absolute module file to its dev URL (no version query): // moduleURLBase maps an absolute module file to its dev URL (no version query):
// app sources → /@src/, shared-kit modules (kjol web) → /@kit/. "" if outside both. // app sources → /@src/, shared-kit modules (kjol JS tree) → /@kit/. "" if outside both.
func (d *devServer) moduleURLBase(abs string) string { func (d *devServer) moduleURLBase(abs string) string {
if within(d.srcRoot, abs) { if within(d.srcRoot, abs) {
return srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, abs)) return srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, abs))
@@ -312,7 +312,7 @@ func (d *devServer) externalRewritePlugin() esbuild.Plugin {
} }
} }
// @ui/@kjol/@appgen → resolve into the kjol web tree (or the app's // @ui/@kjol/@appgen → resolve into the kjol JS tree (or the app's
// generated dir) and rewrite to the /@src/ or /@kit/ URL that serves it. // generated dir) and rewrite to the /@src/ or /@kit/ URL that serves it.
if strings.HasPrefix(spec, "@") { if strings.HasPrefix(spec, "@") {
if resolved := d.resolveAliasSpec(spec); resolved != "" { if resolved := d.resolveAliasSpec(spec); resolved != "" {

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router, // Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/, // solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css // The dev watcher: a lightweight mtime poll over frontend/src and frontend/css
// (no external dependency). A source-module change is turned into an HMR update // (no external dependency). A source-module change is turned into an HMR update
@@ -26,7 +26,7 @@ func (d *devServer) watch() {
go d.cssLoop() go d.cssLoop()
roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")} roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")}
if webDir != "" { // cross-tree: also watch the shared kjol web kit + styles if webDir != "" { // cross-tree: also watch the shared kjol JS kit + styles
roots = append(roots, d.kitRoot) roots = append(roots, d.kitRoot)
} }
mtimes := map[string]time.Time{} mtimes := map[string]time.Time{}

View File

@@ -1,12 +1,12 @@
//go:build dev //go:build dev
package webbundler package jsbundler
// A minimal RFC 6455 WebSocket server — just enough for one-way server→browser // A minimal RFC 6455 WebSocket server — just enough for one-way server→browser
// push of HMR messages. We hand-roll it (rather than add a dependency) because // push of HMR messages. We hand-roll it (rather than add a dependency) because
// the surface we need is tiny: the handshake, unmasked server text frames, and a // the surface we need is tiny: the handshake, unmasked server text frames, and a
// read loop that answers pings and notices close. No per-message compression, no // read loop that answers pings and notices close. No per-message compression, no
// fragmentation, no client→server application data. All of webbundler's // fragmentation, no client→server application data. All of jsbundler's
// HMR support is behind `//go:build dev`, so prod builds compile none of it. // HMR support is behind `//go:build dev`, so prod builds compile none of it.
import ( import (

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
import ( import (
"bufio" "bufio"

View File

@@ -1,6 +1,6 @@
//go:build dev //go:build dev
package webbundler package jsbundler
import ( import (
"bufio" "bufio"

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`, // esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
// which lets misnamed specifiers slip through. This check catches them up // which lets misnamed specifiers slip through. This check catches them up

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"encoding/json" "encoding/json"
@@ -72,7 +72,7 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err) return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
} }
// aliasPlugin resolves @ui/@kjol/@appgen into the kjol web tree (and the app's // aliasPlugin resolves @ui/@kjol/@appgen into the kjol JS tree (and the app's
// generated dir) first. The Go-native Solid compiler (Plugin) sits between the // generated dir) first. The Go-native Solid compiler (Plugin) sits between the
// export shim and the vendor resolvers so it compiles .tsx/.jsx before they're // export shim and the vendor resolvers so it compiles .tsx/.jsx before they're
// resolved. // resolved.

View File

@@ -1,4 +1,4 @@
// Solid JSX/TSX compilation (part of package webbundler): compiles Solid JSX/TSX to // Solid JSX/TSX compilation (part of package jsbundler): compiles Solid JSX/TSX to
// optimized Solid dom-expressions output (template cloning + fine-grained // optimized Solid dom-expressions output (template cloning + fine-grained
// updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler // updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler
// lives in compile_solid.go (parser) and compile_solid_gen.go (codegen); // lives in compile_solid.go (parser) and compile_solid_gen.go (codegen);
@@ -10,7 +10,7 @@
// per-declaration incremental caching is gone. A small in-memory cache dedups // per-declaration incremental caching is gone. A small in-memory cache dedups
// identical transforms within a process (e.g. a module served to several page // identical transforms within a process (e.g. a module served to several page
// loads under HMR); there is no on-disk cache and no compiler bootstrap. // loads under HMR); there is no on-disk cache and no compiler bootstrap.
package webbundler package jsbundler
import ( import (
"crypto/sha256" "crypto/sha256"

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"strings" "strings"

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"strings" "strings"

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"encoding/json" "encoding/json"

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
// Top-level source segmentation. segmentTopLevel splits a module into contiguous // Top-level source segmentation. segmentTopLevel splits a module into contiguous
// chunks at top-level declaration boundaries; the Go Solid compiler uses it to // chunks at top-level declaration boundaries; the Go Solid compiler uses it to

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"os" "os"
@@ -8,7 +8,7 @@ import (
) )
// walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the // walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the
// package dir, which is webbundler). // package dir, which is jsbundler).
func walkRepoTSX(t *testing.T) []string { func walkRepoTSX(t *testing.T) []string {
t.Helper() t.Helper()
root := filepath.Join("..", "..", "frontend", "src") root := filepath.Join("..", "..", "frontend", "src")

View File

@@ -1,4 +1,4 @@
// SSR (part of package webbundler): server-renders the public-page Solid components // SSR (part of package jsbundler): server-renders the public-page Solid components
// to HTML strings by running the (unmodified) client Solid runtime inside a goja // to HTML strings by running the (unmodified) client Solid runtime inside a goja
// JS engine against a minimal Go-backed DOM (dom.js). Components are authored in // JS engine against a minimal Go-backed DOM (dom.js). Components are authored in
// JSX/TSX and compiled to optimized Solid output at build time by the Go-native // JSX/TSX and compiled to optimized Solid output at build time by the Go-native
@@ -11,7 +11,7 @@
// is imported by the server; the build-time entries are used by the bundler. // is imported by the server; the build-time entries are used by the bundler.
// //
// -mta // -mta
package webbundler package jsbundler
import ( import (
"crypto/sha256" "crypto/sha256"
@@ -224,7 +224,7 @@ func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile s
}) })
}, },
} }
// aliasPlugin resolves @ui/@kjol/@appgen into the kjol web tree first; the Go // aliasPlugin resolves @ui/@kjol/@appgen into the kjol JS tree first; the Go
// Solid compiler (Plugin) compiles .tsx before the catch-all stub sees any of // Solid compiler (Plugin) compiles .tsx before the catch-all stub sees any of
// its imports. // its imports.
plugins := []esbuild.Plugin{aliasPlugin(), Plugin(), stubPlugin} plugins := []esbuild.Plugin{aliasPlugin(), Plugin(), stubPlugin}

203
go/jsbundler/ssr_test.go Normal file
View File

@@ -0,0 +1,203 @@
package jsbundler
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// entryHome renders the fixture page the way ssrEntrySolid does: plain JS
// (createComponent, no JSX in the entry) importing the .tsx page, which the Go
// Solid compiler compiles. The layout is omitted to target the body.
const entryHome = `
import { render, createComponent } from "solid-js/web";
import { Home } from "./frontend/src/pages/public/Home.tsx";
globalThis.__render = function () {
const root = document.createElement("div");
const dispose = render(function () { return createComponent(Home, {}); }, root);
const out = globalThis.__serialize(root);
dispose();
return out;
};
`
// homePage is the fixture entryHome imports: the smallest page that exercises the
// thing under test — serverData() read inside the reactive body, a skeleton when
// it is null, the data when it is not.
//
// It used to be cdrateline's real frontend/src/pages/public/Home.tsx, reached by
// chdir'ing to "../..". That worked when the bundler lived inside cdrateline and
// "../.." was the app root. Since the extraction, "../.." is the kjol repo root —
// which has no frontend/ and, by the first golden rule, never will: the framework
// does not import application code, and a test that does is the same coupling
// wearing a different hat. Both tests here have been failing ever since.
// It is deliberately not just a <div>: it carries the specific things the Solid
// compiler has to get right when it bakes a template and goja serializes it — an
// inline SVG (attribute CASE must survive: viewBox, not viewbox), a static string
// style containing a url() with slashes and a comma-free but parenthesised value,
// and an HTML entity.
const homePage = `
import { serverData } from "@kjol/ssr/serverData.ts";
interface Rate { term: string; low: string; high: string; avg: string }
const TERMS = ["90 Day", "180 Day", "1 Year"];
export const Home = () => {
const data = () => serverData<{ rates: Rate[] }>();
const rates = () => data()?.rates ?? [];
return (
<div class="page-home">
<header style="background-image: url(/images/hero-bg.jpg)">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h16" /></svg>
<h1>Rates &amp; terms</h1>
</header>
{rates().length === 0
? <table class="animate-pulse">
<tbody>{TERMS.map((t) => <tr><td>{t}</td><td>&mdash;</td></tr>)}</tbody>
</table>
: <table>
<tbody>{rates().map((r) => (
<tr><td>{r.term}</td><td>{r.low}</td><td>{r.high}</td><td>{r.avg}</td></tr>
))}</tbody>
</table>}
</div>
);
};
`
// ssrFixtureRoot writes the fixture app tree to a temp dir, points the bundler at
// it plus kjol's vendored Solid, and chdirs there — reproducing the server's setup
// (cwd at the app root, relative "." project root) without needing a real app.
func ssrFixtureRoot(t *testing.T) string {
t.Helper()
webAbs, err := filepath.Abs(filepath.Join("..", "jsruntime"))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(webAbs, "runtime", "vendor.json")); err != nil {
t.Skipf("kjol vendored solid runtime not present (%v)", err)
}
root := t.TempDir()
pageDir := filepath.Join(root, "frontend", "src", "pages", "public")
if err := os.MkdirAll(pageDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pageDir, "Home.tsx"), []byte(homePage), 0o644); err != nil {
t.Fatal(err)
}
Configure(Config{AppFrontend: filepath.Join(root, "frontend"), WebDir: webAbs})
t.Chdir(root)
return root
}
// Proves the ISR data-injection path the server uses: a pre-bundled render entry
// run in goja with server data injected (RenderBundleWithData) makes Home render
// the injected rates (via serverData()) instead of the loading skeleton.
func TestRenderHomeWithServerData(t *testing.T) {
ssrFixtureRoot(t)
data := `{"rates":[{"term":"90 Day","low":"1.111%","high":"2.222%","avg":"1.500%"}]}`
js, err := BundleEntry(entryHome, ".")
if err != nil {
t.Fatalf("bundle: %v", err)
}
out, err := RenderBundleWithData(js, data)
if err != nil {
t.Fatalf("render with data: %v", err)
}
for _, want := range []string{"1.111%", "2.222%", "1.500%"} {
if !strings.Contains(out, want) {
t.Errorf("output missing injected rate %q", want)
}
}
// With data present the table shows it, not the loading skeleton.
if strings.Contains(out, "animate-pulse") {
t.Errorf("skeleton rendered despite injected data")
}
}
// Reproduces the server's setup (cwd at the app root, relative "." project root).
// Guards the esbuild alias-resolution bug where a non-absolute root yields
// bare-specifier alias targets that fail to resolve.
func TestRenderWithRelativeRoot(t *testing.T) {
ssrFixtureRoot(t)
r := NewRenderer(".", entryHome, true)
out, err := r.HTML()
if err != nil {
t.Fatalf("render with relative root: %v", err)
}
if !strings.Contains(out, `class="page-home"`) {
t.Errorf("output missing page-home")
}
}
// Confirms the cache: the first HTML() pays bundle+render, the second (no source
// change) returns the cached string near-instantly.
func TestRendererCaching(t *testing.T) {
root := ssrFixtureRoot(t)
r := NewRenderer(root, entryHome, false) // prod: warm HTML() returns the cached string directly
t0 := time.Now()
first, err := r.HTML()
if err != nil {
t.Fatalf("cold render: %v", err)
}
cold := time.Since(t0)
t1 := time.Now()
second, err := r.HTML()
if err != nil {
t.Fatalf("warm render: %v", err)
}
warm := time.Since(t1)
t.Logf("cold=%v warm=%v", cold, warm)
if first != second {
t.Errorf("cached output differs from first render")
}
if warm > cold/4 {
t.Errorf("cache hit too slow: cold=%v warm=%v", cold, warm)
}
}
// No data → the page renders its loading skeleton, exercising compiled Solid
// against inline SVGs (viewBox case), string style attributes, entities, and the
// table. SVG attribute case and the style string must survive serialization.
func TestRenderHomeSkeleton(t *testing.T) {
root := ssrFixtureRoot(t)
bundle, err := BundleEntry(entryHome, root)
if err != nil {
t.Fatalf("bundle: %v", err)
}
eng, err := New()
if err != nil {
t.Fatalf("engine: %v", err)
}
if err := eng.LoadBundle(bundle); err != nil {
t.Fatalf("load bundle: %v", err)
}
out, err := eng.Render()
if err != nil {
t.Fatalf("render: %v", err)
}
t.Logf("HOME SKELETON (%d bytes):\n%s", len(out), out)
for _, want := range []string{
`class="page-home"`,
`viewBox="0 0 24 24"`, // SVG attribute case preserved
`background-image: url(/images/hero-bg.jpg)`, // static string style baked into the template
`animate-pulse`, // skeleton bars (no server data => skeleton branch)
`90 Day`, // term labels shown in the skeleton
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q", want)
}
}
}

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
// Build-time caching + parallelism for public-page SSR (see genssr.go). // Build-time caching + parallelism for public-page SSR (see genssr.go).
// //

View File

@@ -1,4 +1,4 @@
package webbundler package jsbundler
import ( import (
"encoding/json" "encoding/json"

Some files were not shown because too many files have changed in this diff Show More