Add js web stuff to landing page + documentation
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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); ... }
|
||||
```
|
||||
@@ -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 => ../../..
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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
120
go/cmd/kjol-web/README.md
Normal 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.
|
||||
@@ -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())
|
||||
|
||||
@@ -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{})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
200
go/cmd/kjol-web/app/layers.go
Normal file
200
go/cmd/kjol-web/app/layers.go
Normal 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)),
|
||||
)
|
||||
}
|
||||
@@ -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, "")
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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("")
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
79
go/cmd/kjol-web/frontend/css/style.css
Normal file
79
go/cmd/kjol-web/frontend/css/style.css
Normal 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);
|
||||
}
|
||||
52
go/cmd/kjol-web/frontend/src/app.ts
Normal file
52
go/cmd/kjol-web/frontend/src/app.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
72
go/cmd/kjol-web/frontend/src/layers.ts
Normal file
72
go/cmd/kjol-web/frontend/src/layers.ts
Normal 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 + "/"));
|
||||
}
|
||||
33
go/cmd/kjol-web/frontend/src/layout/Demo.tsx
Normal file
33
go/cmd/kjol-web/frontend/src/layout/Demo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
go/cmd/kjol-web/frontend/src/layout/Shell.tsx
Normal file
176
go/cmd/kjol-web/frontend/src/layout/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
go/cmd/kjol-web/frontend/src/pages/Forms.tsx
Normal file
220
go/cmd/kjol-web/frontend/src/pages/Forms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
go/cmd/kjol-web/frontend/src/pages/Kit.tsx
Normal file
199
go/cmd/kjol-web/frontend/src/pages/Kit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
go/cmd/kjol-web/frontend/src/pages/Overview.tsx
Normal file
97
go/cmd/kjol-web/frontend/src/pages/Overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
go/cmd/kjol-web/frontend/src/pages/Table.tsx
Normal file
167
go/cmd/kjol-web/frontend/src/pages/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
go/cmd/kjol-web/frontend/src/pages/Theming.tsx
Normal file
168
go/cmd/kjol-web/frontend/src/pages/Theming.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
go/cmd/kjol-web/frontend/src/pages/public/PublicLayout.tsx
Normal file
73
go/cmd/kjol-web/frontend/src/pages/public/PublicLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
go/cmd/kjol-web/frontend/src/pages/public/Ssr.tsx
Normal file
77
go/cmd/kjol-web/frontend/src/pages/public/Ssr.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
go/cmd/kjol-web/frontend/src/pages/public/pages.ts
Normal file
30
go/cmd/kjol-web/frontend/src/pages/public/pages.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
17
go/cmd/kjol-web/frontend/src/pages/public/routes.gen.ts
Normal file
17
go/cmd/kjol-web/frontend/src/pages/public/routes.gen.ts
Normal 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",
|
||||
};
|
||||
35
go/cmd/kjol-web/frontend/src/public.tsx
Normal file
35
go/cmd/kjol-web/frontend/src/public.tsx
Normal 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);
|
||||
}
|
||||
39404
go/cmd/kjol-web/frontend/vendor/pdf-lib/dist/pdf-lib.esm.js
vendored
Normal file
39404
go/cmd/kjol-web/frontend/vendor/pdf-lib/dist/pdf-lib.esm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
141
go/cmd/kjol-web/frontend/vendor/pdf-lib/package.json
vendored
Normal file
141
go/cmd/kjol-web/frontend/vendor/pdf-lib/package.json
vendored
Normal 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"
|
||||
]
|
||||
}
|
||||
26465
go/cmd/kjol-web/frontend/vendor/pdfjs-dist/build/pdf.mjs
vendored
Normal file
26465
go/cmd/kjol-web/frontend/vendor/pdfjs-dist/build/pdf.mjs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
28
go/cmd/kjol-web/frontend/vendor/pdfjs-dist/build/pdf.worker.min.mjs
vendored
Normal file
28
go/cmd/kjol-web/frontend/vendor/pdfjs-dist/build/pdf.worker.min.mjs
vendored
Normal file
File diff suppressed because one or more lines are too long
34
go/cmd/kjol-web/frontend/vendor/pdfjs-dist/package.json
vendored
Normal file
34
go/cmd/kjol-web/frontend/vendor/pdfjs-dist/package.json
vendored
Normal 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": {}
|
||||
}
|
||||
12
go/cmd/kjol-web/frontend/vendor/vendor.json
vendored
Normal file
12
go/cmd/kjol-web/frontend/vendor/vendor.json
vendored
Normal 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
28
go/cmd/kjol-web/go.mod
Normal 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 => ../..
|
||||
@@ -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=
|
||||
117
go/cmd/kjol-web/internal/handlers/public.go
Normal file
117
go/cmd/kjol-web/internal/handlers/public.go
Normal 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>`
|
||||
}
|
||||
@@ -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>
|
||||
@@ -5,7 +5,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gowasmweb/app"
|
||||
"kjolweb/app"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
@@ -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):
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user