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

View File

@@ -1,32 +1,32 @@
package main
// Thin CLI wrapper around kjol/webbundler. The bundler wires its own Go-native
// Solid JSX compiler (see webbundler.Build), so this wrapper carries no build logic.
// Thin CLI wrapper around kjol/jsbundler. The bundler wires its own Go-native
// Solid JSX compiler (see jsbundler.Build), so this wrapper carries no build logic.
import (
"flag"
"fmt"
"os"
"kjol/webbundler"
"kjol/jsbundler"
)
func main() {
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.")
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).")
flag.Parse()
cfg := webbundler.Config{
cfg := jsbundler.Config{
AppFrontend: *app,
WebDir: *web,
Output: *out,
GenGoDir: *genGo,
GenTSDir: *genTS,
}
if err := webbundler.Build(cfg); err != nil {
if err := jsbundler.Build(cfg); err != nil {
fmt.Fprintln(os.Stderr, err)
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 {
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 {
data := NewSignal(fixedChartData())

View File

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

View File

@@ -35,27 +35,27 @@ func docsNav() []docsGroup {
return []docsGroup{{
Title: "Introduction",
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."},
},
}, {
Title: "Rendering",
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."},
{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."},
{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."},
},
}, {
Title: "Components",
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."},
{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."},
{Path: "/table", Label: "AutoTable", Icon: "table",
{Path: "/wasm/table", Label: "AutoTable", Icon: "table",
Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
},
}}
@@ -189,14 +189,14 @@ func apiTable(rows ...apiRow) *VNode {
// ---- the docs index -----------------------------------------------------
//gowasm:page /docs static layout=app
//gowasm:page /wasm static layout=app
func DocsPage(d Deps) func() *VNode {
return func() *VNode {
var groups []*VNode
for _, g := range docsNav() {
grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")}
for _, it := range g.Items {
if it.Path == "/docs" {
if it.Path == "/wasm" {
continue // don't list this page on itself
}
grid = append(grid, docsCard(d, it))
@@ -256,7 +256,7 @@ func appendNodes(parent *VNode, children ...*VNode) *VNode {
return parent
}
const ssrSnippet = `//gowasm:page /docs static layout=app
const ssrSnippet = `//gowasm:page /wasm static layout=app
func DocsPage(d Deps) func() *VNode {
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 {
// Interactive demos own their state via signals (a write re-renders).
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
// 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 {
// --- tooltips -----------------------------------------------------------
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
// 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
// for the applications that share it.
func wordmark(d Deps, href string) *VNode {
// The lockup names the LAYER you are standing in, not the site. On the front page
// that is 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),
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"),
ui.IconInline("sailboat", 17, "")),
Span(Attr("class", "flex items-baseline gap-1.5"),
Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text("Kjol Web")),
Span(Attr("class", "text-sm text-ink-faint"), Text("Go + WASM")),
Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text(name)),
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")),
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, "/"),
Ul(Attr("class", "ml-auto flex items-center gap-1"),
navItem(d, "/docs", "Docs", false),
navItem(d, "/about", "About", false),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
Div(Attr("class", "ml-auto flex items-center gap-1"),
layersMenu(d),
Ul(Attr("class", "flex items-center gap-1"),
navItem(d, "/about", "About", false),
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
),
))),
Main(Attr("class", "px-4 py-14"), content),
Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
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(),
)
@@ -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
// and three calculated columns has no business being squeezed into a reading-width
// 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
// 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"),
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")),
Ul(Attr("class", "ml-auto flex items-center gap-2"),
navItem(d, "/", "Home", false),
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
Div(Attr("class", "ml-auto flex items-center gap-2"),
layersMenu(d),
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 {
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"),
Text("Kjol Web")),
Text("kjol")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("A small library for writing web interfaces in Go. Components are ordinary functions "+
"returning a virtual DOM. The server renders them to HTML, and the same code compiles "+
"to WebAssembly and takes over in the browser.")),
Text("A shared base layer, factored out of several applications so they stay in sync. "+
"Kjol is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
Text("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 ----
//
// 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")),
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 "+
@@ -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 "+
"of components anywhere on this site. "),
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 "),
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("."),
),
P(Attr("class", "mt-4 text-sm text-ink-muted"),
@@ -443,7 +467,7 @@ func principle(title, body string) *VNode {
// ---- server components --------------------------------------------------
//gowasm:page /server layout=app
//gowasm:page /wasm/server layout=app
func ServerPage(d Deps) func() *VNode {
// ServerCounter is a server component — calling it is just like calling any
// component. On the client this resolves to a generated stub that mounts it

View File

@@ -6,39 +6,39 @@ import "kjol/vdom"
// Routes maps each //gowasm:page path to its instantiated render function.
func Routes(d Deps) map[string]func() *vdom.VNode {
return map[string]func() *vdom.VNode{
"/": HomePage(d),
"/about": AboutPage(d),
"/chart": ChartPage(d),
"/data": DataPage(d),
"/docs": DocsPage(d),
"/kit": KitPage(d),
"/overlays": OverlaysPage(d),
"/server": ServerPage(d),
"/table": TablePage(d),
"/": HomePage(d),
"/about": AboutPage(d),
"/wasm": DocsPage(d),
"/wasm/chart": ChartPage(d),
"/wasm/data": DataPage(d),
"/wasm/kit": KitPage(d),
"/wasm/overlays": OverlaysPage(d),
"/wasm/server": ServerPage(d),
"/wasm/table": TablePage(d),
}
}
// StaticPaths are the routes the server pre-renders (SSR); others render client-side.
var StaticPaths = map[string]bool{
"/": true,
"/about": true,
"/chart": true,
"/data": true,
"/docs": true,
"/table": true,
"/": true,
"/about": true,
"/wasm": true,
"/wasm/chart": true,
"/wasm/data": true,
"/wasm/table": true,
}
// RouteLayout maps each route to the name of the layout that wraps it.
var RouteLayout = map[string]string{
"/": "public",
"/about": "public",
"/chart": "app",
"/data": "app",
"/docs": "app",
"/kit": "app",
"/overlays": "app",
"/server": "app",
"/table": "app",
"/": "public",
"/about": "public",
"/wasm": "app",
"/wasm/chart": "app",
"/wasm/data": "app",
"/wasm/kit": "app",
"/wasm/overlays": "app",
"/wasm/server": "app",
"/wasm/table": "app",
}
// 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) {
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 }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
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) {
deps := Deps{Path: func() string { return "/table" }}
deps := Deps{Path: func() string { return "/wasm/table" }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
// 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 {
// Which row to spotlight, if any.
highlight := NewSignal("")

View File

@@ -1,7 +1,7 @@
// Command build runs the example's full pre-compile step once: directive codegen,
// 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
// these same steps on every save and hot-swaps the result into the browser. This
@@ -12,7 +12,7 @@ import (
"log"
"os"
"gowasmweb/buildsteps"
"kjolweb/buildsteps"
)
func main() {
@@ -25,6 +25,7 @@ func main() {
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", buildsteps.Codegen},
{"compiling Tailwind CSS -> wwwroot/app.css", buildsteps.Tailwind},
{"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},
}

View File

@@ -16,12 +16,14 @@ import (
"os/exec"
"path/filepath"
"strings"
"kjol/jsbundler"
)
// 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
// 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.
const Wwwroot = "wwwroot"
@@ -41,12 +43,12 @@ func Codegen() ([]byte, error) {
// all.
func Tailwind() ([]byte, error) {
cmd := exec.Command("go", "run", "./cmd/twcss",
"-entry", "cmd/examples/go-wasm-web/css/app.css",
"-out", "cmd/examples/go-wasm-web/wwwroot/app.css",
"-entry", "cmd/kjol-web/css/app.css",
"-out", "cmd/kjol-web/wwwroot/app.css",
"-base", ".",
"webui/**/*.go",
"cmd/examples/go-wasm-web/app/**/*.go",
"cmd/examples/go-wasm-web/server/**/*.go",
"cmd/kjol-web/app/**/*.go",
"cmd/kjol-web/server/**/*.go",
)
cmd.Dir = kjolRoot
return cmd.CombinedOutput()
@@ -59,6 +61,31 @@ func Wasm() ([]byte, error) {
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
// 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.
@@ -93,7 +120,7 @@ func Shim() ([]byte, error) {
// puts straight into the browser's error overlay — so a compile error lands in front of
// you rather than in a terminal you were not looking at.
func All() ([]byte, error) {
for _, 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 {
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/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

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
// hot-swaps the wasm into the browser on change. It shows the coupling
// 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):
//
// go run ./server # from cmd/examples/go-wasm-web
// go run ./server # from go/cmd/kjol-web
package main
import (
"flag"
"fmt"
"log"
"net/http"
@@ -19,8 +20,9 @@ import (
"kjol/wasmdevserver"
"kjol/webui"
"gowasmweb/app"
"gowasmweb/buildsteps"
"kjolweb/app"
"kjolweb/buildsteps"
"kjolweb/internal/handlers"
)
func main() {
@@ -29,27 +31,69 @@ func main() {
flag.Parse()
log.Fatal(wasmdevserver.Serve(wasmdevserver.Config{
Addr: *addr,
Dir: "./wwwroot",
Watch: *watch,
WatchDirs: []string{"app", "wasm", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit
Build: buildsteps.All,
BuildCSS: buildsteps.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
Render: render,
Document: document,
Handle: apiRoutes,
Addr: *addr,
Dir: "./wwwroot",
Watch: *watch,
WatchDirs: []string{
"app", "wasm", "css", // this app's Go/WASM half
"frontend", // its Solid half — a .tsx save rebuilds the JS bundle
"../../webui", "../../vdom", "../../wasmruntime", "../../rsc", // the wasm engine
"../../jsruntime/uikit", "../../jsruntime/styles", // the Solid kit + the shared theme
},
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
// 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).
func apiRoutes(mux *http.ServeMux) {
// routes registers everything the WASM app does not own.
//
// Order does not matter here — Go's ServeMux picks the most specific pattern, not the
// first — but the shape does: /js/* belongs to a completely different front-end, and it
// is claimed BEFORE the wasm app's "/" catch-all ever sees it. Two SPAs, one server, no
// argument about who owns a URL.
func routes(mux *http.ServeMux) {
handlers.RegisterPublicPages(mux) // the SSR'd public pages (/js/ssr)
mux.HandleFunc("GET /js/", serveJSApp)
mux.HandleFunc("GET /js", serveJSApp)
// /api/quotes responds with a gob-encoded []app.Quote (via httputil.RespondGob) —
// the /wasm/data page fetches and decodes it on the client with encoding/gob (Go
// types end to end, no JSON).
mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
httputil.RespondGob(w, http.StatusOK, sampleQuotes())
})
}
// serveJSApp ships the shell for the Solid SPA. Every /js/* route gets the SAME empty
// document — the client router reads the URL and decides what to render, which is what
// makes it a single-page app.
//
// It carries no server-rendered markup, and that is a real difference from 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 {
return []app.Quote{
{Author: "Rob Pike", Text: "A little copying is better than a little dependency."},
@@ -86,7 +130,7 @@ func document(inner string) string {
<head>
<meta charset="utf-8" />
<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 + `
<link rel="stylesheet" href="/app.css" />
</head>

View File

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

View File

@@ -2,7 +2,7 @@
// 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
// 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):
//