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

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

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):
//

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,308 @@
import { createContext, useContext, createSignal, createMemo, onMount, JSXElement } from "solid-js";
import html from "solid-js/html";
import { getToken, setToken, clearToken, isTokenExpired } from "./useAuthFetch.js";
import { apiFetch } from "./checkBundleVersion.js";
export interface Identity {
id: string;
username: string;
email: string;
first_name: string;
last_name: string;
permissions: string[];
org_id: string | null;
org_name: string | null;
org_cert_num: string | null;
}
export interface MemberOrg {
id: string;
name: string;
certnum: string | null;
}
export interface SuperAdminState {
enabled: boolean;
orgName: string | null;
firstMemberOrgId: string | null;
}
export interface LoginResult {
success: boolean;
error?: string;
}
export interface SwitchOrgResult {
success: boolean;
org_id?: string;
org_name?: string;
org_cert_num?: string | null;
error?: string;
}
export interface AuthContextValue {
identity: () => Identity | null;
loading: () => boolean;
error: () => string | null;
isAuthenticated: () => boolean;
superAdminMode: () => SuperAdminState;
memberOrganizations: () => MemberOrg[];
login: (username: string, password: string, rememberMe?: boolean) => Promise<LoginResult>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
switchOrganization: (orgId: string) => Promise<SwitchOrgResult>;
disableSuperAdminMode: () => Promise<void>;
}
interface AuthProviderProps {
children?: JSXElement;
}
const AuthContext = createContext<AuthContextValue>();
export function AuthProvider(props: AuthProviderProps) {
const [identity, setIdentity] = createSignal<Identity | null>(null);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [memberOrganizations, setMemberOrganizations] = createSignal<MemberOrg[]>([]);
const [isAdmin, setIsAdmin] = createSignal(false);
const isAuthenticated = createMemo(() => !!identity());
const checkAuth = async (): Promise<void> => {
try {
setLoading(true);
setError(null);
const token = getToken();
if (!token) {
setIdentity(null);
setLoading(false);
return;
}
if (isTokenExpired()) {
clearToken();
setIdentity(null);
setLoading(false);
return;
}
// Retry transient network failures a few times so a brief blip
// self-heals instead of dropping the user to an error screen. A
// 401 is a real response and is handled below, not retried. -mta 6/3/26
let response;
for (let attempt = 0; ; attempt++) {
try {
response = await apiFetch("/api/auth/me", {
headers: {
"Authorization": `Bearer ${token}`,
},
});
break;
} catch (err) {
if (attempt >= 2) throw err;
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
}
}
// Only a definitive 401 means the session is actually invalid. A
// network error, an aborted request (e.g. the user refreshed
// mid-flight), or a 5xx must NOT clear the token -- otherwise a
// quick refresh aborts this request and silently logs the user
// out. -mta 6/3/26
if (response.status === 401) {
clearToken();
setIdentity(null);
setMemberOrganizations([]);
setIsAdmin(false);
return;
}
if (!response.ok) {
throw new Error(`Auth check failed: ${response.status}`);
}
const data = await response.json();
setIdentity(data);
const orgsResponse = await apiFetch("/api/auth/my-organizations", {
headers: {
"Authorization": `Bearer ${token}`,
},
});
if (orgsResponse.ok) {
const orgsData = await orgsResponse.json();
setMemberOrganizations(orgsData.member_organizations || []);
setIsAdmin(orgsData.is_admin || false);
}
} catch (err) {
// Transient failure (network/abort/5xx). Preserve the token so the
// next load can retry instead of forcing a logout. -mta 6/3/26
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
};
onMount(() => {
checkAuth();
});
const login = async (username: string, password: string, rememberMe = false): Promise<LoginResult> => {
try {
setLoading(true);
setError(null);
const response = await apiFetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
password,
remember_me: rememberMe,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Login failed");
}
setToken(data.token, data.expires_at);
setIdentity(data.user);
const orgsResponse = await apiFetch("/api/auth/my-organizations", {
headers: {
"Authorization": `Bearer ${data.token}`,
},
});
if (orgsResponse.ok) {
const orgsData = await orgsResponse.json();
setMemberOrganizations(orgsData.member_organizations || []);
setIsAdmin(orgsData.is_admin || false);
}
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
return { success: false, error: message };
} finally {
setLoading(false);
}
};
const logout = async (): Promise<void> => {
const token = getToken();
if (token) {
try {
await apiFetch("/api/auth/logout", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
},
});
} catch {
// Continue with logout even if API call fails
}
}
clearToken();
setIdentity(null);
window.location.href = "/login";
};
const switchOrganization = async (orgId: string): Promise<SwitchOrgResult> => {
try {
setError(null);
const token = getToken();
if (!token) {
throw new Error("Not authenticated");
}
const response = await apiFetch("/api/auth/switch-org", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ org_id: orgId }),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Failed to switch organization");
}
setIdentity((prev) => prev ? {
...prev,
org_id: data.org_id,
org_name: data.org_name,
org_cert_num: data.org_cert_num,
} : null);
return {
success: true,
org_id: data.org_id,
org_name: data.org_name,
org_cert_num: data.org_cert_num,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
return { success: false, error: message };
}
};
const disableSuperAdminMode = async (): Promise<void> => {
const orgs = memberOrganizations();
if (orgs.length > 0) {
await switchOrganization(orgs[0].id);
}
};
const superAdminMode = (): SuperAdminState => {
const id = identity();
const orgs = memberOrganizations();
return {
enabled: isAdmin() && id?.org_id != null && !orgs.some((org) => org.id === id.org_id),
orgName: id?.org_name || null,
firstMemberOrgId: orgs.length > 0 ? orgs[0].id : null,
};
};
const value: AuthContextValue = {
identity,
loading,
error,
isAuthenticated,
superAdminMode,
memberOrganizations,
login,
logout,
checkAuth,
switchOrganization,
disableSuperAdminMode,
};
// CRITICAL: Use lazy children evaluation
return html`<${AuthContext.Provider} value=${value}>${() => props.children}<//>`;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,61 @@
import { createEffect, createSignal, createMemo, JSXElement } from "solid-js";
import html from "solid-js/html";
import { useAuth } from "./AuthContext.ts";
import { getToken } from "./useAuthFetch.js";
import { hasPermission } from "./permissions.ts";
import { Loader } from "../uikit/General.tsx";
interface ProtectedRouteProps {
permissions?: string[];
children?: JSXElement;
}
export function ProtectedRoute(props: ProtectedRouteProps) {
const auth = useAuth();
const [isRedirecting, setIsRedirecting] = createSignal(false);
createEffect(() => {
// Redirect to login ONLY when there is genuinely no session. A real
// 401 clears the token (so getToken() is null here), but a transient
// check failure preserves it -- in that case we keep the user here and
// show a retry screen instead of logging them out. -mta 6/3/26
if (!auth.loading() && !auth.isAuthenticated() && !getToken() && !isRedirecting()) {
setIsRedirecting(true);
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.replace(`/login?redirect=${returnUrl}`);
}
});
const hasRequiredPermissions = createMemo(() => {
const permissions = props.permissions || [];
if (permissions.length === 0) return true;
const userPermissions = auth.identity()?.permissions || [];
return permissions.every((perm) => hasPermission(userPermissions, perm));
});
const state = createMemo(() => {
if (isRedirecting() || auth.loading()) return 'loading';
if (!auth.isAuthenticated()) {
// Token still present => the check failed transiently => offer a
// retry. No token => the redirect effect is taking over.
return getToken() ? 'error' : 'loading';
}
if (!hasRequiredPermissions()) return 'unauthorized';
return 'ready';
});
return html`<div>
${() => state() === 'loading' && html`<${Loader} />`}
${() => state() === 'error' && html`<div class="flex flex-col items-center justify-center min-h-[50vh] text-center p-8">
<h1 class="text-2xl font-semibold text-neutral-800 mb-2">Connection problem</h1>
<p class="text-neutral-600 mb-4">We couldn't verify your session. You're still signed in -- please try again.</p>
<button onclick=${() => auth.checkAuth()} class="text-sky-600 hover:text-sky-800 hover:underline">Retry</button>
</div>`}
${() => state() === 'unauthorized' && html`<div class="flex flex-col items-center justify-center min-h-[50vh] text-center p-8">
<h1 class="text-2xl font-semibold text-neutral-800 mb-2">Access Denied</h1>
<p class="text-neutral-600 mb-4">You don't have permission to access this page.</p>
<a href="/app/dashboard" class="text-sky-600 hover:text-sky-800 hover:underline">Return to Dashboard</a>
</div>`}
${() => state() === 'ready' && props.children}
</div>`;
}

View File

@@ -0,0 +1,51 @@
let bundleStaleNotified = false;
export function checkBundleVersion(response) {
const serverVersion = response.headers.get("X-Bundle-Version");
const clientVersion = window.__BUNDLE_VERSION__;
if (!serverVersion || !clientVersion || serverVersion === clientVersion) {
return;
}
if (bundleStaleNotified) {
return;
}
bundleStaleNotified = true;
const bar = document.createElement("div");
bar.setAttribute("role", "status");
bar.style.cssText = [
"position:fixed",
"top:0",
"left:0",
"right:0",
"z-index:99999",
"padding:12px 16px",
"background:#141620",
"color:#fff",
"text-align:center",
"font:14px system-ui,sans-serif",
"box-shadow:0 2px 8px rgba(0,0,0,0.2)",
].join(";");
const message = document.createElement("span");
message.textContent = "A new version of National CD Rateline is available.";
const button = document.createElement("button");
button.type = "button";
button.textContent = "Refresh";
button.style.cssText = "margin-left:12px;padding:4px 12px;cursor:pointer;border:0;border-radius:4px;background:#fff;color:#141620;font:inherit";
button.addEventListener("click", () => {
window.location.reload();
});
bar.append(message, button);
document.body.prepend(bar);
}
export async function apiFetch(url, options) {
const response = await fetch(url, options);
checkBundleVersion(response);
return response;
}

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