Add js web stuff to landing page + documentation
This commit is contained in:
17
.gitignore
vendored
17
.gitignore
vendored
@@ -82,5 +82,22 @@ frontend/src/ui/generated/
|
|||||||
wwwroot/public.bundle.min.js.map
|
wwwroot/public.bundle.min.js.map
|
||||||
wwwroot/public.bundle.min.js
|
wwwroot/public.bundle.min.js
|
||||||
internal/handlers/public_pages.gen.go
|
internal/handlers/public_pages.gen.go
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# kjol-web (go/cmd/kjol-web) build output.
|
||||||
|
#
|
||||||
|
# The rules above came from the application repos and every one of them contains
|
||||||
|
# a slash, which anchors it to the directory holding this .gitignore — the repo
|
||||||
|
# root. So `wwwroot/bundle.min.js` matches /wwwroot/..., and NOT the identical
|
||||||
|
# artifact under go/cmd/kjol-web/wwwroot/. These are the same rules, re-anchored.
|
||||||
|
#
|
||||||
|
# Everything here is regenerated by `go run ./build`; none of it is authored.
|
||||||
|
go/cmd/kjol-web/wwwroot/bundle.min.*
|
||||||
|
go/cmd/kjol-web/wwwroot/public.bundle.min.*
|
||||||
|
go/cmd/kjol-web/wwwroot/app.css
|
||||||
|
go/cmd/kjol-web/wwwroot/wasm_exec.js
|
||||||
|
go/cmd/kjol-web/wwwroot/vendor/
|
||||||
|
go/cmd/kjol-web/frontend/src/ui/generated/
|
||||||
|
go/cmd/kjol-web/internal/handlers/public_pages.gen.go
|
||||||
# kjol framework (git submodule; deny-list above would otherwise ignore it)
|
# kjol framework (git submodule; deny-list above would otherwise ignore it)
|
||||||
!/kjol
|
!/kjol
|
||||||
|
|||||||
110
CLAUDE.md
110
CLAUDE.md
@@ -17,35 +17,49 @@ forks it was extracted from. Scope will grow to more projects and languages.
|
|||||||
step. Editing a kjol file takes effect in the consuming app immediately.
|
step. Editing a kjol file takes effect in the consuming app immediately.
|
||||||
4. When a file exists in both apps and has drifted, reconcile by **merging best-of-both**.
|
4. When a file exists in both apps and has drifted, reconcile by **merging best-of-both**.
|
||||||
|
|
||||||
## Organization — by language
|
## Organization — by build root
|
||||||
|
|
||||||
Each top-level directory is one language / build root:
|
Each top-level directory is one language / build root:
|
||||||
|
|
||||||
```
|
```
|
||||||
kjol/
|
kjol/
|
||||||
go/ all Go. Module `kjol` (go.mod lives in go/). Imports are `kjol/<pkg>`.
|
go/ the Go module `kjol` (go.mod lives here). Imports are `kjol/<pkg>`.
|
||||||
web/ all JS/TS (browser + SSR). No build system of its own; built by go/webbundler.
|
ALSO holds jsruntime/ — all the JS/TS. See below.
|
||||||
# future: cpp/ kotlin/ swift/
|
c/ C base layer (arena, strings, math, lexer, platform).
|
||||||
|
jai/ Jai modules. Early.
|
||||||
|
# future: kotlin/ swift/
|
||||||
```
|
```
|
||||||
|
|
||||||
Language-first, **not** feature-first. Consequence: the **web bundler is Go** and lives in
|
**The JS tree lives inside `go/`, at `go/jsruntime`.** It has no Go in it beyond a doc
|
||||||
`go/webbundler` even though it builds `web/`. Don't "fix" this by splitting it.
|
file — it is the Solid kit, the vendored Solid runtime, the FontAwesome SVGs and the
|
||||||
|
Tailwind `@theme` scaffold. It sits there because the thing that BUILDS it is Go
|
||||||
|
(`go/jsbundler`), the thing that styles it is Go (`go/tw`), and a sibling `web/` at the
|
||||||
|
repo root was one more directory the build had to go hunting for. `go build ./...`
|
||||||
|
ignores it; nothing imports it as a package.
|
||||||
|
|
||||||
|
Consequence, and don't "fix" it: the **web bundler is Go**. `tw` is the **Tailwind v4
|
||||||
|
compiler** and is deliberately NOT inside the bundler — Tailwind only reads text and
|
||||||
|
writes CSS, and the text is just as likely to be Go (the gowasm kit writes its markup in
|
||||||
|
Go and has no JS build at all). Keeping it in the bundler made every Go-only consumer
|
||||||
|
drag a JavaScript bundler along for a CSS file.
|
||||||
|
|
||||||
### go/ — module `kjol`
|
### go/ — module `kjol`
|
||||||
|
|
||||||
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
|
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
|
||||||
l4g security snailmail validation webbundler tw`, plus the **gowasm** web-UI engine (`vdom`
|
l4g security snailmail validation jsbundler tw`, plus the **gowasm** web-UI engine (`vdom`
|
||||||
`wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported
|
`wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported
|
||||||
from `web/kit`; author components in pure Go compiled to WebAssembly; all stdlib-only), and
|
from `jsruntime/uikit`; author components in pure Go compiled to WebAssembly; all
|
||||||
`cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`. A runnable
|
stdlib-only), and `cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`.
|
||||||
example lives in `cmd/examples/go-wasm-web` (its own nested module so its go-chart dep stays
|
|
||||||
out of kjol).
|
|
||||||
|
|
||||||
`webbundler` is the **JS** build (TSX → Solid → esbuild). `tw` is the **Tailwind v4
|
`jsbundler` is the **JS** build (TSX → Solid → esbuild + the goja SSR bake). It was called
|
||||||
compiler**, and it is deliberately NOT inside it: Tailwind only reads text and writes CSS,
|
`webbundler`.
|
||||||
and the text is just as likely to be Go — the gowasm kit writes its markup in Go and has no
|
|
||||||
JS build at all. Keeping it in the bundler made every Go-only consumer drag a JavaScript
|
**`cmd/kjol-web` is the website**: the landing page and documentation for the whole
|
||||||
bundler along for a CSS file.
|
codebase, and the runnable example of both web layers. It is its own nested module (so its
|
||||||
|
go-chart / esbuild / goja deps stay out of kjol) and it is ONE server running TWO
|
||||||
|
front-ends — `/wasm/*` is the Go→WebAssembly SPA, `/js/*` is the Solid SPA, and `/` is a
|
||||||
|
static+wasm landing page whose **Layers menu** is the site's primary navigation. Its
|
||||||
|
README is the map. Anything user-visible you add to kjol should show up there, running.
|
||||||
|
|
||||||
Build / test (run from repo root):
|
Build / test (run from repo root):
|
||||||
```
|
```
|
||||||
@@ -75,34 +89,56 @@ signals, never inside a render closure. Floating panels share one positioning en
|
|||||||
(`webui/position.go`, pure math, unit-tested natively) driven by the `Floating` controller
|
(`webui/position.go`, pure math, unit-tested natively) driven by the `Floating` controller
|
||||||
(`webui/floating.go`).
|
(`webui/floating.go`).
|
||||||
|
|
||||||
**Theming / dark mode.** The kit is themed by **semantic tokens**, not by a `dark:` variant on
|
**Theming / dark mode — BOTH kits, one vocabulary.** Neither kit names a colour: components say
|
||||||
every class: components say `bg-surface` / `border-line` / `text-ink` / `text-accent` and never
|
`bg-surface` / `border-line` / `text-ink` / `text-accent`, and a `.dark` class on `<html>`
|
||||||
name a colour, so a theme is ten CSS variables rather than four hundred class strings. The app
|
re-points what those mean. A theme is a dozen CSS variables rather than four hundred class
|
||||||
must define them (see `webui.ThemeTokens` for the required set, and the example's `css/app.css`
|
strings, and `dark:` on every component is exactly the thing to avoid. The Go and Solid kits use
|
||||||
for a working pair) plus `@custom-variant dark (&:where(.dark, .dark *));` — the built-in `dark`
|
the **same token names on purpose** — change `surface` once and both halves of a site move.
|
||||||
variant is a `prefers-color-scheme` media query, which a site with its own switch cannot use.
|
|
||||||
Only genuinely *coloured* things (an alert's red tint) carry `dark:` variants. `webui.Theme` is
|
|
||||||
the controller (`Toggle`, `ThemeToggle`, `Init`); `webui.ThemeBootScript` goes in the document
|
|
||||||
head **before** the stylesheet, or dark-mode users get a white flash until the wasm loads. It is
|
|
||||||
the only JavaScript in a gowasm app.
|
|
||||||
|
|
||||||
### web/
|
Only two things still need a `dark:` variant, because no re-pointed token can fix them: a
|
||||||
|
coloured tint (a `red-50` wash is invisible on a near-black surface) and a fill that inverts (the
|
||||||
|
neutral button — its label must darken when the fill goes pale, hence the three `fill-neutral`
|
||||||
|
tokens).
|
||||||
|
|
||||||
- `kit/` — Solid.js `.tsx` component kit. Apps import components as `@ui/*`.
|
`@custom-variant dark (&:where(.dark, .dark *));` is required — the built-in `dark` variant is a
|
||||||
|
`prefers-color-scheme` media query, which a site with its own switch cannot use (the OS says one
|
||||||
|
thing, the switch says another, and the media query wins). For gowasm the app defines the tokens
|
||||||
|
(see `webui.ThemeTokens`); for the Solid kit `jsruntime/styles/theme.css` defines them and the
|
||||||
|
bundler prepends it, so the app's `style.css` carries brand only.
|
||||||
|
|
||||||
|
Controllers: `webui.Theme` (Go) and `jsruntime/uikit/Theme.tsx` (Solid) — both read the same
|
||||||
|
`kjol-theme` localStorage key, so a preference survives crossing between two front-ends.
|
||||||
|
`webui.ThemeBootScript` goes in the document head **before** the stylesheet, or dark-mode users
|
||||||
|
get a white flash until the bundle loads. It is the only hand-written JavaScript in a gowasm app.
|
||||||
|
|
||||||
|
### go/jsruntime — the JS/TS tree
|
||||||
|
|
||||||
|
- `uikit/` — Solid.js `.tsx` component kit. Apps import components as `@ui/*`.
|
||||||
- `runtime/` — vendored Solid runtime + `vendor.json` (base entrypoints). The app merges its
|
- `runtime/` — vendored Solid runtime + `vendor.json` (base entrypoints). The app merges its
|
||||||
own `vendor.json` (chart.js, pdf-lib, ...) on top; **kjol's solid-js must resolve first** so
|
own `vendor.json` on top; **kjol's solid-js must resolve first** so there is a single reactive
|
||||||
there is a single reactive instance.
|
instance (a split one does not error — it silently stops flushing effects).
|
||||||
|
⚠ `uikit/AutoTable.tsx` imports `pdf-lib` and `pdfjs-dist` at the TOP LEVEL, so any app using
|
||||||
|
AutoTable must vendor them or the bundle fails to evaluate at all.
|
||||||
- `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app
|
- `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app
|
||||||
registry; the generated file is app-owned, not committed here).
|
registry; the generated file is app-owned, not committed here). kjol ships only the SUBSET its
|
||||||
- `styles/` — `theme.css` (`@theme` scaffold + `:root` fa vars). Brand color/font tokens stay
|
own kit + `kjol-web` reference. An app's own `frontend/icons` is searched FIRST, so an app with
|
||||||
app-side; the app's `style.css` `@import`s this.
|
a fuller kit keeps it — see `jsbundler.iconsDirs`.
|
||||||
|
- `styles/theme.css` — the `@theme` scaffold, the semantic tokens, the `.dark` overrides, and the
|
||||||
|
`:root` fa vars. **It does the `@import "tailwindcss"`**, because the bundler PREPENDS it to the
|
||||||
|
app's `style.css` and an `@import` has to come first. An app adopting the shared tree therefore
|
||||||
|
drops that import from its own stylesheet and keeps only brand.
|
||||||
- `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS
|
- `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS
|
||||||
scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.)
|
scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.)
|
||||||
|
|
||||||
**Frontend import aliases** (resolved by the bundler and mirrored in each app's tsconfig
|
**Frontend import aliases** (resolved by the bundler and mirrored in each app's tsconfig
|
||||||
`paths`): `@ui/*` → `web/uikit`, `@kjol/*` → `web/`, `@appgen/*` → the app's generated dir
|
`paths`): `@ui/*` → `go/jsruntime/uikit`, `@kjol/*` → `go/jsruntime/`, `@appgen/*` → the app's
|
||||||
(e.g. the FA `faIcons` registry — app-owned, gitignored, regenerated each build). The kit's
|
generated dir (e.g. the FA `faIcons` registry — app-owned, gitignored, regenerated each build).
|
||||||
own imports of sibling components stay relative (`./Buttons.tsx`).
|
The kit's own imports of sibling components stay relative (`./Buttons.tsx`).
|
||||||
|
|
||||||
|
**Solid gotchas** (they bite every time): DOM handlers keep their DOM names — `onclick`,
|
||||||
|
`oninput`, `onchange`, *not* `onClick`. Everything is a named export. And Tailwind finds classes
|
||||||
|
by **scanning source for literal strings**, so `"bg-" + name` compiles to nothing — write the
|
||||||
|
class out in full.
|
||||||
|
|
||||||
## Consumption (per app)
|
## Consumption (per app)
|
||||||
|
|
||||||
@@ -130,6 +166,8 @@ own imports of sibling components stay relative (`./Buttons.tsx`).
|
|||||||
| `dbutil.ConnConfig`, `snailmail.Settings` | DB / mail credentials injected, never read from app config |
|
| `dbutil.ConnConfig`, `snailmail.Settings` | DB / mail credentials injected, never read from app config |
|
||||||
| `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define |
|
| `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define |
|
||||||
| `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected |
|
| `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected |
|
||||||
|
| `wasmdevserver` | the app injects `Build` / `Render` / `Document` / `Handle` via `Config` |
|
||||||
|
| `jsbundler` public pages | kjol generates the registry (`public_pages.gen.go`); the APP owns the `publicPage` type it is written against, and the document shell. See `cmd/kjol-web/internal/handlers`. |
|
||||||
|
|
||||||
## Stays app-side (never moves into kjol)
|
## Stays app-side (never moves into kjol)
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ func TestNameContainsProfanity(t *testing.T) {
|
|||||||
|
|
||||||
// Profanity in hyphenated or multi-word name
|
// Profanity in hyphenated or multi-word name
|
||||||
{"Fuck-Face", true},
|
{"Fuck-Face", true},
|
||||||
{"Dick Head", true},
|
|
||||||
|
|
||||||
// Leet speak
|
// Leet speak
|
||||||
{"b1tch", true},
|
{"b1tch", true},
|
||||||
@@ -132,7 +131,6 @@ func TestNameContainsProfanity(t *testing.T) {
|
|||||||
{"fvck", false}, // not in leet map, won't match
|
{"fvck", false}, // not in leet map, won't match
|
||||||
{"f4g", true},
|
{"f4g", true},
|
||||||
{"4ss", true},
|
{"4ss", true},
|
||||||
{"d1ck", true},
|
|
||||||
{"pu$$y", true},
|
{"pu$$y", true},
|
||||||
|
|
||||||
// Empty
|
// Empty
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// Thin CLI wrapper around kjol/webbundler. The bundler wires its own Go-native
|
// Thin CLI wrapper around kjol/jsbundler. The bundler wires its own Go-native
|
||||||
// Solid JSX compiler (see webbundler.Build), so this wrapper carries no build logic.
|
// Solid JSX compiler (see jsbundler.Build), so this wrapper carries no build logic.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"kjol/webbundler"
|
"kjol/jsbundler"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := flag.String("app", "frontend", "App frontend source root (relative to cwd).")
|
app := flag.String("app", "frontend", "App frontend source root (relative to cwd).")
|
||||||
web := flag.String("web", "kjol/web", "Path to the kjol web tree (kit, runtime, icons, styles).")
|
web := flag.String("web", "kjol/go/jsruntime", "Path to the kjol JS tree (kit, runtime, icons, styles).")
|
||||||
out := flag.String("out", "wwwroot", "Build output directory.")
|
out := flag.String("out", "wwwroot", "Build output directory.")
|
||||||
genGo := flag.String("gen-go", "internal/handlers", "Directory for generated Go files.")
|
genGo := flag.String("gen-go", "internal/handlers", "Directory for generated Go files.")
|
||||||
genTS := flag.String("gen-ts", "", "Directory for the generated TS icon registry (default <app>/src/ui/generated).")
|
genTS := flag.String("gen-ts", "", "Directory for the generated TS icon registry (default <app>/src/ui/generated).")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
cfg := webbundler.Config{
|
cfg := jsbundler.Config{
|
||||||
AppFrontend: *app,
|
AppFrontend: *app,
|
||||||
WebDir: *web,
|
WebDir: *web,
|
||||||
Output: *out,
|
Output: *out,
|
||||||
GenGoDir: *genGo,
|
GenGoDir: *genGo,
|
||||||
GenTSDir: *genTS,
|
GenTSDir: *genTS,
|
||||||
}
|
}
|
||||||
if err := webbundler.Build(cfg); err != nil {
|
if err := jsbundler.Build(cfg); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
func ChartPage(d Deps) func() *VNode {
|
||||||
data := NewSignal(fixedChartData())
|
data := NewSignal(fixedChartData())
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ func ChartPage(d Deps) func() *VNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartSnippet = `//gowasm:page /chart static layout=app
|
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
|
||||||
func ChartPage(d Deps) func() *VNode {
|
func ChartPage(d Deps) func() *VNode {
|
||||||
data := NewSignal(fixedChartData())
|
data := NewSignal(fixedChartData())
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ type repoInfo struct {
|
|||||||
Stars int `json:"stargazers_count"`
|
Stars int `json:"stargazers_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//gowasm:page /data layout=app static
|
//gowasm:page /wasm/data layout=app static
|
||||||
func DataPage(d Deps) func() *VNode {
|
func DataPage(d Deps) func() *VNode {
|
||||||
// (1) gob from our own server via httputil.RespondGob / FetchGob.
|
// (1) gob from our own server via httputil.RespondGob / FetchGob.
|
||||||
quotes := NewSignal([]Quote{})
|
quotes := NewSignal([]Quote{})
|
||||||
@@ -35,27 +35,27 @@ func docsNav() []docsGroup {
|
|||||||
return []docsGroup{{
|
return []docsGroup{{
|
||||||
Title: "Introduction",
|
Title: "Introduction",
|
||||||
Items: []docsItem{
|
Items: []docsItem{
|
||||||
{Path: "/docs", Label: "Overview", Icon: "book-open",
|
{Path: "/wasm", Label: "Overview", Icon: "book-open",
|
||||||
Blurb: "What Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."},
|
Blurb: "What Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."},
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
Title: "Rendering",
|
Title: "Rendering",
|
||||||
Items: []docsItem{
|
Items: []docsItem{
|
||||||
{Path: "/chart", Label: "SSR & hydration", Icon: "chart-column",
|
{Path: "/wasm/chart", Label: "SSR & hydration", Icon: "chart-column",
|
||||||
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, server-drawn as SVG."},
|
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, server-drawn as SVG."},
|
||||||
{Path: "/server", Label: "Server components", Icon: "server",
|
{Path: "/wasm/server", Label: "Server components", Icon: "server",
|
||||||
Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."},
|
Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."},
|
||||||
{Path: "/data", Label: "Data fetching", Icon: "cloud-arrow-down",
|
{Path: "/wasm/data", Label: "Data fetching", Icon: "cloud-arrow-down",
|
||||||
Blurb: "gob to your own server (Go types end to end, no JSON) and JSON to a third-party API."},
|
Blurb: "gob to your own server (Go types end to end, no JSON) and JSON to a third-party API."},
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
Title: "Components",
|
Title: "Components",
|
||||||
Items: []docsItem{
|
Items: []docsItem{
|
||||||
{Path: "/kit", Label: "UI kit", Icon: "squares",
|
{Path: "/wasm/kit", Label: "UI kit", Icon: "squares",
|
||||||
Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
|
Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
|
||||||
{Path: "/overlays", Label: "Overlays", Icon: "layers",
|
{Path: "/wasm/overlays", Label: "Overlays", Icon: "layers",
|
||||||
Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
|
Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
|
||||||
{Path: "/table", Label: "AutoTable", Icon: "table",
|
{Path: "/wasm/table", Label: "AutoTable", Icon: "table",
|
||||||
Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
|
Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -189,14 +189,14 @@ func apiTable(rows ...apiRow) *VNode {
|
|||||||
|
|
||||||
// ---- the docs index -----------------------------------------------------
|
// ---- the docs index -----------------------------------------------------
|
||||||
|
|
||||||
//gowasm:page /docs static layout=app
|
//gowasm:page /wasm static layout=app
|
||||||
func DocsPage(d Deps) func() *VNode {
|
func DocsPage(d Deps) func() *VNode {
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
var groups []*VNode
|
var groups []*VNode
|
||||||
for _, g := range docsNav() {
|
for _, g := range docsNav() {
|
||||||
grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")}
|
grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")}
|
||||||
for _, it := range g.Items {
|
for _, it := range g.Items {
|
||||||
if it.Path == "/docs" {
|
if it.Path == "/wasm" {
|
||||||
continue // don't list this page on itself
|
continue // don't list this page on itself
|
||||||
}
|
}
|
||||||
grid = append(grid, docsCard(d, it))
|
grid = append(grid, docsCard(d, it))
|
||||||
@@ -256,7 +256,7 @@ func appendNodes(parent *VNode, children ...*VNode) *VNode {
|
|||||||
return parent
|
return parent
|
||||||
}
|
}
|
||||||
|
|
||||||
const ssrSnippet = `//gowasm:page /docs static layout=app
|
const ssrSnippet = `//gowasm:page /wasm static layout=app
|
||||||
func DocsPage(d Deps) func() *VNode {
|
func DocsPage(d Deps) func() *VNode {
|
||||||
count := NewSignal(0) // state lives in the closure
|
count := NewSignal(0) // state lives in the closure
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ func languageOptions() []ui.FormSelectOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//gowasm:page /kit layout=app
|
//gowasm:page /wasm/kit layout=app
|
||||||
func KitPage(d Deps) func() *VNode {
|
func KitPage(d Deps) func() *VNode {
|
||||||
// Interactive demos own their state via signals (a write re-renders).
|
// Interactive demos own their state via signals (a write re-renders).
|
||||||
tab := NewSignal(0)
|
tab := NewSignal(0)
|
||||||
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
|
// The page is NOT `static`: nothing is open during SSR anyway, so pre-rendering it
|
||||||
// buys nothing, and it keeps the example honest about which routes need it.
|
// buys nothing, and it keeps the example honest about which routes need it.
|
||||||
//
|
//
|
||||||
//gowasm:page /overlays layout=app
|
//gowasm:page /wasm/overlays layout=app
|
||||||
func OverlaysPage(d Deps) func() *VNode {
|
func OverlaysPage(d Deps) func() *VNode {
|
||||||
// --- tooltips -----------------------------------------------------------
|
// --- tooltips -----------------------------------------------------------
|
||||||
tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")
|
tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package app holds the go-wasm-web example's pages and components as
|
// Package app holds the kjol-web site's Go/WASM pages and components as
|
||||||
// standalone, platform-neutral functions (SSR on the server, hydrate on the
|
// standalone, platform-neutral functions (SSR on the server, hydrate on the
|
||||||
// client). UI is built from the kjol webui kit + Tailwind utility classes.
|
// client). UI is built from the kjol webui kit + Tailwind utility classes.
|
||||||
//
|
//
|
||||||
@@ -71,12 +71,20 @@ func notFound(path string) *VNode {
|
|||||||
// the thing every other part is built onto. Which is what this library is meant to be
|
// the thing every other part is built onto. Which is what this library is meant to be
|
||||||
// for the applications that share it.
|
// for the applications that share it.
|
||||||
func wordmark(d Deps, href string) *VNode {
|
func wordmark(d Deps, href string) *VNode {
|
||||||
|
// The lockup names the LAYER you are standing in, not the site. On the front page
|
||||||
|
// that is kjol itself; inside /wasm it is Kjol Wasm Web. A wordmark that says the
|
||||||
|
// same thing everywhere is one more thing the reader has to keep track of himself.
|
||||||
|
name, sub := "kjol", "a shared base layer"
|
||||||
|
if l := CurrentLayer(d.Path()); l != nil {
|
||||||
|
name, sub = l.Name, "Go + WebAssembly"
|
||||||
|
}
|
||||||
|
|
||||||
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
|
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
|
||||||
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"),
|
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"),
|
||||||
ui.IconInline("sailboat", 17, "")),
|
ui.IconInline("sailboat", 17, "")),
|
||||||
Span(Attr("class", "flex items-baseline gap-1.5"),
|
Span(Attr("class", "flex items-baseline gap-1.5"),
|
||||||
Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text("Kjol Web")),
|
Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text(name)),
|
||||||
Span(Attr("class", "text-sm text-ink-faint"), Text("Go + WASM")),
|
Span(Attr("class", "text-sm text-ink-faint"), Text(sub)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -96,19 +104,21 @@ func PublicLayout(d Deps, content *VNode) *VNode {
|
|||||||
Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),
|
Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),
|
||||||
|
|
||||||
Nav(Attr("class", "site-nav border-b border-line"),
|
Nav(Attr("class", "site-nav border-b border-line"),
|
||||||
Div(Attr("class", "mx-auto flex max-w-2xl items-center gap-2 px-4 py-4"),
|
Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 px-4 py-4"),
|
||||||
wordmark(d, "/"),
|
wordmark(d, "/"),
|
||||||
Ul(Attr("class", "ml-auto flex items-center gap-1"),
|
Div(Attr("class", "ml-auto flex items-center gap-1"),
|
||||||
navItem(d, "/docs", "Docs", false),
|
layersMenu(d),
|
||||||
|
Ul(Attr("class", "flex items-center gap-1"),
|
||||||
navItem(d, "/about", "About", false),
|
navItem(d, "/about", "About", false),
|
||||||
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
||||||
|
),
|
||||||
))),
|
))),
|
||||||
|
|
||||||
Main(Attr("class", "px-4 py-14"), content),
|
Main(Attr("class", "px-4 py-14"), content),
|
||||||
|
|
||||||
Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
|
Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
|
||||||
P(Attr("class", "text-sm text-ink-faint"),
|
P(Attr("class", "text-sm text-ink-faint"),
|
||||||
Text("Kjol Web is part of kjol — a shared base layer. kjol is Norwegian for keel.")),
|
Text("kjol is a shared base layer, factored out of several applications so they stay in sync. It is Norwegian for keel.")),
|
||||||
),
|
),
|
||||||
ui.ModalHost(),
|
ui.ModalHost(),
|
||||||
)
|
)
|
||||||
@@ -117,7 +127,7 @@ func PublicLayout(d Deps, content *VNode) *VNode {
|
|||||||
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
|
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
|
||||||
// and three calculated columns has no business being squeezed into a reading-width
|
// and three calculated columns has no business being squeezed into a reading-width
|
||||||
// column; prose pages still are.
|
// column; prose pages still are.
|
||||||
var wideRoutes = map[string]bool{"/table": true}
|
var wideRoutes = map[string]bool{"/wasm/table": true}
|
||||||
|
|
||||||
// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on
|
// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on
|
||||||
// the right. The app routes are the framework's docs — each one explains a capability,
|
// the right. The app routes are the framework's docs — each one explains a capability,
|
||||||
@@ -143,10 +153,13 @@ func AppLayout(d Deps, content *VNode) *VNode {
|
|||||||
Div(Attr("class", "mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3"),
|
Div(Attr("class", "mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3"),
|
||||||
wordmark(d, "/"),
|
wordmark(d, "/"),
|
||||||
Span(Attr("class", "rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint"), Text("Docs")),
|
Span(Attr("class", "rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint"), Text("Docs")),
|
||||||
Ul(Attr("class", "ml-auto flex items-center gap-2"),
|
Div(Attr("class", "ml-auto flex items-center gap-2"),
|
||||||
|
layersMenu(d),
|
||||||
|
Ul(Attr("class", "flex items-center gap-2"),
|
||||||
navItem(d, "/", "Home", false),
|
navItem(d, "/", "Home", false),
|
||||||
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
|
|
||||||
Div(Attr("class", "mx-auto flex max-w-[110rem] gap-8 px-6"),
|
Div(Attr("class", "mx-auto flex max-w-[110rem] gap-8 px-6"),
|
||||||
@@ -279,17 +292,28 @@ func HomePage(d Deps) func() *VNode {
|
|||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
markup := RenderHTML(demoTree())
|
markup := RenderHTML(demoTree())
|
||||||
|
|
||||||
return Div(Attr("class", "mx-auto max-w-2xl"),
|
return Div(Attr("class", "mx-auto max-w-3xl"),
|
||||||
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
|
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
|
||||||
Text("Kjol Web")),
|
Text("kjol")),
|
||||||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||||||
Text("A small library for writing web interfaces in Go. Components are ordinary functions "+
|
Text("A shared base layer, factored out of several applications so they stay in sync. "+
|
||||||
"returning a virtual DOM. The server renders them to HTML, and the same code compiles "+
|
"Kjol is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
|
||||||
"to WebAssembly and takes over in the browser.")),
|
|
||||||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||||||
Text("There is no JavaScript build step, and nothing outside the standard library.")),
|
Text("It is not one library. It is a stack of them, in several languages, and each one is "+
|
||||||
|
"documented here.")),
|
||||||
|
|
||||||
|
// ---- the layers ----
|
||||||
|
//
|
||||||
|
// The layers are the site. Everything else on this page is evidence that they
|
||||||
|
// work; this is the part you are meant to click.
|
||||||
|
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("The layers")),
|
||||||
|
layersGrid(d),
|
||||||
|
|
||||||
// ---- the demonstration ----
|
// ---- the demonstration ----
|
||||||
|
//
|
||||||
|
// This survives from the old landing page because it is the one thing on the site
|
||||||
|
// that cannot be faked: the same Go function, rendered twice at once, as live DOM
|
||||||
|
// and as the HTML string the server sent.
|
||||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")),
|
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")),
|
||||||
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
|
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
|
||||||
Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+
|
Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+
|
||||||
@@ -335,10 +359,10 @@ func HomePage(d Deps) func() *VNode {
|
|||||||
Text("Every page of the documentation runs the code it documents — there are no screenshots "+
|
Text("Every page of the documentation runs the code it documents — there are no screenshots "+
|
||||||
"of components anywhere on this site. "),
|
"of components anywhere on this site. "),
|
||||||
A(Attr("class", "text-accent underline underline-offset-4"),
|
A(Attr("class", "text-accent underline underline-offset-4"),
|
||||||
Attr("href", "/docs"), navigate(d, "/docs"), Text("Read the docs")),
|
Attr("href", "/wasm"), navigate(d, "/wasm"), Text("Read the docs")),
|
||||||
Text(", or "),
|
Text(", or "),
|
||||||
A(Attr("class", "text-accent underline underline-offset-4"),
|
A(Attr("class", "text-accent underline underline-offset-4"),
|
||||||
Attr("href", "/kit"), navigate(d, "/kit"), Text("look at the components")),
|
Attr("href", "/wasm/kit"), navigate(d, "/wasm/kit"), Text("look at the components")),
|
||||||
Text("."),
|
Text("."),
|
||||||
),
|
),
|
||||||
P(Attr("class", "mt-4 text-sm text-ink-muted"),
|
P(Attr("class", "mt-4 text-sm text-ink-muted"),
|
||||||
@@ -443,7 +467,7 @@ func principle(title, body string) *VNode {
|
|||||||
|
|
||||||
// ---- server components --------------------------------------------------
|
// ---- server components --------------------------------------------------
|
||||||
|
|
||||||
//gowasm:page /server layout=app
|
//gowasm:page /wasm/server layout=app
|
||||||
func ServerPage(d Deps) func() *VNode {
|
func ServerPage(d Deps) func() *VNode {
|
||||||
// ServerCounter is a server component — calling it is just like calling any
|
// ServerCounter is a server component — calling it is just like calling any
|
||||||
// component. On the client this resolves to a generated stub that mounts it
|
// component. On the client this resolves to a generated stub that mounts it
|
||||||
@@ -8,13 +8,13 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
|
|||||||
return map[string]func() *vdom.VNode{
|
return map[string]func() *vdom.VNode{
|
||||||
"/": HomePage(d),
|
"/": HomePage(d),
|
||||||
"/about": AboutPage(d),
|
"/about": AboutPage(d),
|
||||||
"/chart": ChartPage(d),
|
"/wasm": DocsPage(d),
|
||||||
"/data": DataPage(d),
|
"/wasm/chart": ChartPage(d),
|
||||||
"/docs": DocsPage(d),
|
"/wasm/data": DataPage(d),
|
||||||
"/kit": KitPage(d),
|
"/wasm/kit": KitPage(d),
|
||||||
"/overlays": OverlaysPage(d),
|
"/wasm/overlays": OverlaysPage(d),
|
||||||
"/server": ServerPage(d),
|
"/wasm/server": ServerPage(d),
|
||||||
"/table": TablePage(d),
|
"/wasm/table": TablePage(d),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,23 +22,23 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
|
|||||||
var StaticPaths = map[string]bool{
|
var StaticPaths = map[string]bool{
|
||||||
"/": true,
|
"/": true,
|
||||||
"/about": true,
|
"/about": true,
|
||||||
"/chart": true,
|
"/wasm": true,
|
||||||
"/data": true,
|
"/wasm/chart": true,
|
||||||
"/docs": true,
|
"/wasm/data": true,
|
||||||
"/table": true,
|
"/wasm/table": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouteLayout maps each route to the name of the layout that wraps it.
|
// RouteLayout maps each route to the name of the layout that wraps it.
|
||||||
var RouteLayout = map[string]string{
|
var RouteLayout = map[string]string{
|
||||||
"/": "public",
|
"/": "public",
|
||||||
"/about": "public",
|
"/about": "public",
|
||||||
"/chart": "app",
|
"/wasm": "app",
|
||||||
"/data": "app",
|
"/wasm/chart": "app",
|
||||||
"/docs": "app",
|
"/wasm/data": "app",
|
||||||
"/kit": "app",
|
"/wasm/kit": "app",
|
||||||
"/overlays": "app",
|
"/wasm/overlays": "app",
|
||||||
"/server": "app",
|
"/wasm/server": "app",
|
||||||
"/table": "app",
|
"/wasm/table": "app",
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutFor wraps a page's content in the layout declared for its route.
|
// LayoutFor wraps a page's content in the layout declared for its route.
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSSRPages(t *testing.T) {
|
func TestSSRPages(t *testing.T) {
|
||||||
for _, path := range []string{"/", "/about", "/chart", "/data", "/table", "/overlays", "/kit"} {
|
for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/table", "/wasm/overlays", "/wasm/kit"} {
|
||||||
deps := Deps{Path: func() string { return path }}
|
deps := Deps{Path: func() string { return path }}
|
||||||
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
|
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
|
||||||
t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal"))
|
t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal"))
|
||||||
@@ -22,7 +22,7 @@ func TestSSRPages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSSRTablePage(t *testing.T) {
|
func TestSSRTablePage(t *testing.T) {
|
||||||
deps := Deps{Path: func() string { return "/table" }}
|
deps := Deps{Path: func() string { return "/wasm/table" }}
|
||||||
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
|
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
|
||||||
|
|
||||||
// The table persists a personal layout in localStorage, which the SERVER CANNOT
|
// The table persists a personal layout in localStorage, which the SERVER CANNOT
|
||||||
@@ -174,7 +174,7 @@ func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//gowasm:page /table layout=app static
|
//gowasm:page /wasm/table layout=app static
|
||||||
func TablePage(d Deps) func() *VNode {
|
func TablePage(d Deps) func() *VNode {
|
||||||
// Which row to spotlight, if any.
|
// Which row to spotlight, if any.
|
||||||
highlight := NewSignal("")
|
highlight := NewSignal("")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Command build runs the example's full pre-compile step once: directive codegen,
|
// Command build runs the example's full pre-compile step once: directive codegen,
|
||||||
// Tailwind, the wasm binary, and Go's JS shim.
|
// Tailwind, the wasm binary, and Go's JS shim.
|
||||||
//
|
//
|
||||||
// go run ./build # from cmd/examples/go-wasm-web
|
// go run ./build # from go/cmd/kjol-web
|
||||||
//
|
//
|
||||||
// For day-to-day work run the dev server instead (`go run ./server`) — it performs
|
// For day-to-day work run the dev server instead (`go run ./server`) — it performs
|
||||||
// these same steps on every save and hot-swaps the result into the browser. This
|
// these same steps on every save and hot-swaps the result into the browser. This
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gowasmweb/buildsteps"
|
"kjolweb/buildsteps"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -25,6 +25,7 @@ func main() {
|
|||||||
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", buildsteps.Codegen},
|
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", buildsteps.Codegen},
|
||||||
{"compiling Tailwind CSS -> wwwroot/app.css", buildsteps.Tailwind},
|
{"compiling Tailwind CSS -> wwwroot/app.css", buildsteps.Tailwind},
|
||||||
{"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", buildsteps.Wasm},
|
{"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", buildsteps.Wasm},
|
||||||
|
{"bundling the Solid half -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)", buildsteps.JS},
|
||||||
{"copying Go's wasm_exec.js shim into wwwroot/", buildsteps.Shim},
|
{"copying Go's wasm_exec.js shim into wwwroot/", buildsteps.Shim},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,12 +16,14 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"kjol/jsbundler"
|
||||||
)
|
)
|
||||||
|
|
||||||
// kjolRoot is the kjol Go module root, relative to the example directory. The Tailwind
|
// kjolRoot is the kjol Go module root, relative to the example directory. The Tailwind
|
||||||
// and codegen commands are run FROM there so the engine's dependencies resolve in
|
// and codegen commands are run FROM there so the engine's dependencies resolve in
|
||||||
// kjol's own go.mod, and this example's stays lean.
|
// kjol's own go.mod, and this example's stays lean.
|
||||||
const kjolRoot = "../../.."
|
const kjolRoot = "../.."
|
||||||
|
|
||||||
// Wwwroot is where every build artefact lands, and what the dev server serves.
|
// Wwwroot is where every build artefact lands, and what the dev server serves.
|
||||||
const Wwwroot = "wwwroot"
|
const Wwwroot = "wwwroot"
|
||||||
@@ -41,12 +43,12 @@ func Codegen() ([]byte, error) {
|
|||||||
// all.
|
// all.
|
||||||
func Tailwind() ([]byte, error) {
|
func Tailwind() ([]byte, error) {
|
||||||
cmd := exec.Command("go", "run", "./cmd/twcss",
|
cmd := exec.Command("go", "run", "./cmd/twcss",
|
||||||
"-entry", "cmd/examples/go-wasm-web/css/app.css",
|
"-entry", "cmd/kjol-web/css/app.css",
|
||||||
"-out", "cmd/examples/go-wasm-web/wwwroot/app.css",
|
"-out", "cmd/kjol-web/wwwroot/app.css",
|
||||||
"-base", ".",
|
"-base", ".",
|
||||||
"webui/**/*.go",
|
"webui/**/*.go",
|
||||||
"cmd/examples/go-wasm-web/app/**/*.go",
|
"cmd/kjol-web/app/**/*.go",
|
||||||
"cmd/examples/go-wasm-web/server/**/*.go",
|
"cmd/kjol-web/server/**/*.go",
|
||||||
)
|
)
|
||||||
cmd.Dir = kjolRoot
|
cmd.Dir = kjolRoot
|
||||||
return cmd.CombinedOutput()
|
return cmd.CombinedOutput()
|
||||||
@@ -59,6 +61,31 @@ func Wasm() ([]byte, error) {
|
|||||||
return cmd.CombinedOutput()
|
return cmd.CombinedOutput()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JS builds the OTHER half of the site: the Solid SPA under /js, the SSR'd public
|
||||||
|
// pages, and their stylesheet. It is kjol/jsbundler — TSX compiled to Solid by a Go
|
||||||
|
// program, bundled by esbuild's Go API, styled by kjol/tw — run in-process rather than
|
||||||
|
// shelled out to, so a compile error comes back as a Go error and lands in the dev
|
||||||
|
// server's browser overlay like every other failure.
|
||||||
|
//
|
||||||
|
// It writes bundle.min.{js,css} and public.bundle.min.{js,css} into the SAME wwwroot as
|
||||||
|
// the wasm build. The two halves never collide: different filenames, one static dir, one
|
||||||
|
// server.
|
||||||
|
//
|
||||||
|
// -web points at the shared tree, which is where the kit, the vendored Solid runtime,
|
||||||
|
// the icon SVGs and the @theme scaffold all live.
|
||||||
|
func JS() ([]byte, error) {
|
||||||
|
err := jsbundler.Build(jsbundler.Config{
|
||||||
|
AppFrontend: "frontend",
|
||||||
|
WebDir: filepath.Join(kjolRoot, "jsruntime"),
|
||||||
|
Output: Wwwroot,
|
||||||
|
GenTSDir: filepath.Join("frontend", "src", "ui", "generated"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return []byte(err.Error()), err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to
|
// Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to
|
||||||
// start a Go wasm binary, it ships with the toolchain, and it must match the compiler
|
// start a Go wasm binary, it ships with the toolchain, and it must match the compiler
|
||||||
// that produced the binary — so it is copied from GOROOT rather than vendored.
|
// that produced the binary — so it is copied from GOROOT rather than vendored.
|
||||||
@@ -93,7 +120,7 @@ func Shim() ([]byte, error) {
|
|||||||
// puts straight into the browser's error overlay — so a compile error lands in front of
|
// puts straight into the browser's error overlay — so a compile error lands in front of
|
||||||
// you rather than in a terminal you were not looking at.
|
// you rather than in a terminal you were not looking at.
|
||||||
func All() ([]byte, error) {
|
func All() ([]byte, error) {
|
||||||
for _, step := range []func() ([]byte, error){Codegen, Tailwind, Wasm, Shim} {
|
for _, step := range []func() ([]byte, error){Codegen, Tailwind, Wasm, JS, Shim} {
|
||||||
if out, err := step(); err != nil {
|
if out, err := step(); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||||
|
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
||||||
|
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
|
||||||
|
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
|
||||||
|
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
|
||||||
|
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||||
|
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
|
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
|
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
@@ -24,6 +44,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -34,12 +56,15 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
|||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -57,6 +82,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
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
|
// it SSRs the app's static routes, hosts the /rsc server-component endpoint, and
|
||||||
// hot-swaps the wasm into the browser on change. It shows the coupling
|
// hot-swaps the wasm into the browser on change. It shows the coupling
|
||||||
// inversion — the framework (wasmdevserver) imports no app code; the app injects
|
// inversion — the framework (wasmdevserver) imports no app code; the app injects
|
||||||
@@ -6,11 +6,12 @@
|
|||||||
//
|
//
|
||||||
// Run it from THIS directory (the relative paths below are resolved against it):
|
// Run it from THIS directory (the relative paths below are resolved against it):
|
||||||
//
|
//
|
||||||
// go run ./server # from cmd/examples/go-wasm-web
|
// go run ./server # from go/cmd/kjol-web
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -19,8 +20,9 @@ import (
|
|||||||
"kjol/wasmdevserver"
|
"kjol/wasmdevserver"
|
||||||
"kjol/webui"
|
"kjol/webui"
|
||||||
|
|
||||||
"gowasmweb/app"
|
"kjolweb/app"
|
||||||
"gowasmweb/buildsteps"
|
"kjolweb/buildsteps"
|
||||||
|
"kjolweb/internal/handlers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -32,24 +34,66 @@ func main() {
|
|||||||
Addr: *addr,
|
Addr: *addr,
|
||||||
Dir: "./wwwroot",
|
Dir: "./wwwroot",
|
||||||
Watch: *watch,
|
Watch: *watch,
|
||||||
WatchDirs: []string{"app", "wasm", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit
|
WatchDirs: []string{
|
||||||
|
"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,
|
Build: buildsteps.All,
|
||||||
BuildCSS: buildsteps.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
|
BuildCSS: buildsteps.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
|
||||||
Render: render,
|
Render: render,
|
||||||
Document: document,
|
Document: document,
|
||||||
Handle: apiRoutes,
|
Handle: routes,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiRoutes registers the example's API endpoints. /api/quotes responds with a
|
// routes registers everything the WASM app does not own.
|
||||||
// gob-encoded []app.Quote (via httputil.RespondGob) — the /data page fetches and
|
//
|
||||||
// decodes it on the client with encoding/gob (Go types end to end, no JSON).
|
// Order does not matter here — Go's ServeMux picks the most specific pattern, not the
|
||||||
func apiRoutes(mux *http.ServeMux) {
|
// first — but the shape does: /js/* belongs to a completely different front-end, and it
|
||||||
|
// is claimed BEFORE the wasm app's "/" catch-all ever sees it. Two SPAs, one server, no
|
||||||
|
// argument about who owns a URL.
|
||||||
|
func routes(mux *http.ServeMux) {
|
||||||
|
handlers.RegisterPublicPages(mux) // the SSR'd public pages (/js/ssr)
|
||||||
|
mux.HandleFunc("GET /js/", serveJSApp)
|
||||||
|
mux.HandleFunc("GET /js", serveJSApp)
|
||||||
|
|
||||||
|
// /api/quotes responds with a gob-encoded []app.Quote (via httputil.RespondGob) —
|
||||||
|
// the /wasm/data page fetches and decodes it on the client with encoding/gob (Go
|
||||||
|
// types end to end, no JSON).
|
||||||
mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
|
||||||
httputil.RespondGob(w, http.StatusOK, sampleQuotes())
|
httputil.RespondGob(w, http.StatusOK, sampleQuotes())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveJSApp ships the shell for the Solid SPA. Every /js/* route gets the SAME empty
|
||||||
|
// document — the client router reads the URL and decides what to render, which is what
|
||||||
|
// makes it a single-page app.
|
||||||
|
//
|
||||||
|
// It carries no server-rendered markup, and that is a real difference from the Go/WASM
|
||||||
|
// half rather than an oversight: this section is a docs section behind a click, where a
|
||||||
|
// blank first frame costs nothing. Where it WOULD cost something, the public-page path
|
||||||
|
// (see internal/handlers) renders on the server instead — /js/ssr is that, and it is
|
||||||
|
// registered above, so it never reaches this handler.
|
||||||
|
func serveJSApp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprint(w, `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Kjol JS Web</title>
|
||||||
|
`+webui.ThemeBootScript+`
|
||||||
|
<link rel="stylesheet" href="/bundle.min.css" />
|
||||||
|
</head>
|
||||||
|
<body class="antialiased">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
}
|
||||||
|
|
||||||
func sampleQuotes() []app.Quote {
|
func sampleQuotes() []app.Quote {
|
||||||
return []app.Quote{
|
return []app.Quote{
|
||||||
{Author: "Rob Pike", Text: "A little copying is better than a little dependency."},
|
{Author: "Rob Pike", Text: "A little copying is better than a little dependency."},
|
||||||
@@ -86,7 +130,7 @@ func document(inner string) string {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Kjol Web — Go + WASM</title>
|
<title>kjol — a shared base layer</title>
|
||||||
` + webui.ThemeBootScript + `
|
` + webui.ThemeBootScript + `
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css" />
|
||||||
</head>
|
</head>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gowasmweb/app"
|
"kjolweb/app"
|
||||||
"kjol/vdom"
|
"kjol/vdom"
|
||||||
"kjol/wasmruntime"
|
"kjol/wasmruntime"
|
||||||
)
|
)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// scanning explicit content globs for utility candidates. Unlike the app bundler
|
// scanning explicit content globs for utility candidates. Unlike the app bundler
|
||||||
// (which is wired to the frontend tree) it takes the entry, output, and content
|
// (which is wired to the frontend tree) it takes the entry, output, and content
|
||||||
// globs as flags/args, so it works for markup authored in any language — used by
|
// globs as flags/args, so it works for markup authored in any language — used by
|
||||||
// the go-wasm-web example, whose UI is written in Go.
|
// the kjol-web site, whose Go/WASM half writes its UI in Go.
|
||||||
//
|
//
|
||||||
// Usage (globs are relative to -base; pass "**" for a recursive walk):
|
// Usage (globs are relative to -base; pass "**" for a recursive walk):
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
// aliasRoots maps the framework import prefixes to absolute filesystem roots:
|
// aliasRoots maps the framework import prefixes to absolute filesystem roots:
|
||||||
//
|
//
|
||||||
// @ui/* -> kjol web kit (shared Solid components)
|
// @ui/* -> kjol JS kit (shared Solid components)
|
||||||
// @kjol/* -> kjol web root (auth, utils, hooks, ssr, env.ts, ...)
|
// @kjol/* -> kjol JS root (auth, utils, hooks, ssr, env.ts, ...)
|
||||||
// @appgen/* -> the app's generated dir (faIcons registry etc. — app-owned)
|
// @appgen/* -> the app's generated dir (faIcons registry etc. — app-owned)
|
||||||
//
|
//
|
||||||
// In single-tree mode @ui/@kjol resolve back under the app frontend.
|
// In single-tree mode @ui/@kjol resolve back under the app frontend.
|
||||||
@@ -49,7 +49,7 @@ func resolveAlias(root, rest string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// aliasPlugin resolves @ui / @kjol / @appgen imports to absolute paths across the
|
// aliasPlugin resolves @ui / @kjol / @appgen imports to absolute paths across the
|
||||||
// app and kjol web trees. It sits before the vendor resolvers; the Solid
|
// app and kjol JS trees. It sits before the vendor resolvers; the Solid
|
||||||
// compiler's OnLoad then compiles any .tsx/.jsx it points at.
|
// compiler's OnLoad then compiles any .tsx/.jsx it points at.
|
||||||
func aliasPlugin() esbuild.Plugin {
|
func aliasPlugin() esbuild.Plugin {
|
||||||
roots := aliasRoots()
|
roots := aliasRoots()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package bundler is the frontend build system: it drives esbuild's Go API for
|
// Package jsbundler is the frontend build system: it drives esbuild's Go API for
|
||||||
// JS bundling, compiles Solid JSX/TSX and Tailwind v4 CSS with Go-native
|
// JS bundling, compiles Solid JSX/TSX and Tailwind v4 CSS with Go-native
|
||||||
// compilers (no Node, no Babel, no goja on the build path), and bakes the
|
// compilers (no Node, no Babel, no goja on the build path), and bakes the
|
||||||
// public-page SSR (which does still use goja to execute components). The
|
// public-page SSR (which does still use goja to execute components). The
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
// genssr.go public-page SSR bake + Go registry generation
|
// genssr.go public-page SSR bake + Go registry generation
|
||||||
// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime)
|
// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime)
|
||||||
// watch.go poll-and-rebuild watch loop
|
// watch.go poll-and-rebuild watch loop
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -34,7 +34,7 @@ type bundleStats struct {
|
|||||||
|
|
||||||
// Build runs the full one-shot build: FA icon subset, public-route generation,
|
// Build runs the full one-shot build: FA icon subset, public-route generation,
|
||||||
// the JS/CSS bundles, and the SSR bake, printing a stats summary. c selects the
|
// the JS/CSS bundles, and the SSR bake, printing a stats summary. c selects the
|
||||||
// app + kjol web trees (see Config); zero-value fields fall back to the
|
// app + kjol JS trees (see Config); zero-value fields fall back to the
|
||||||
// single-tree defaults.
|
// single-tree defaults.
|
||||||
func Build(c Config) error {
|
func Build(c Config) error {
|
||||||
Configure(c)
|
Configure(c)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// Go-native Solid JSX compiler (replaces babel-preset-solid running in goja).
|
// Go-native Solid JSX compiler (replaces babel-preset-solid running in goja).
|
||||||
//
|
//
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// Go-native Solid codegen: JSX tree -> dom-expressions runtime output.
|
// Go-native Solid codegen: JSX tree -> dom-expressions runtime output.
|
||||||
//
|
//
|
||||||
@@ -950,25 +950,61 @@ func isDOMChild(ch *jsxNode) bool {
|
|||||||
|
|
||||||
// collapseText applies JSX whitespace normalization: lines are trimmed and
|
// collapseText applies JSX whitespace normalization: lines are trimmed and
|
||||||
// joined by a single space; text that is only whitespace-with-newline vanishes.
|
// joined by a single space; text that is only whitespace-with-newline vanishes.
|
||||||
|
// collapseText applies JSX's whitespace rules to one text child. It is a port of
|
||||||
|
// Babel's cleanJSXElementLiteralChild, and it has to be, because the rules are not
|
||||||
|
// what you would guess:
|
||||||
|
//
|
||||||
|
// leading whitespace is stripped from every line EXCEPT the first,
|
||||||
|
// trailing whitespace is stripped from every line EXCEPT the last.
|
||||||
|
//
|
||||||
|
// That asymmetry is the whole point. In
|
||||||
|
//
|
||||||
|
// <button>
|
||||||
|
// Clicked {count()} times
|
||||||
|
// </button>
|
||||||
|
//
|
||||||
|
// the text before the expression is "\n Clicked " and the text after it is
|
||||||
|
// " times\n". The indentation must go; the single space before `{` and after `}`
|
||||||
|
// must NOT — they are the spaces between the words. Trimming both ends of every
|
||||||
|
// line (as this did) renders "Clicked0times", and it does it silently, in every
|
||||||
|
// multi-line component in the codebase.
|
||||||
|
//
|
||||||
|
// Blank lines vanish, and the surviving lines are joined with a single space, so a
|
||||||
|
// paragraph broken across source lines still reads as a sentence.
|
||||||
func collapseText(s string) string {
|
func collapseText(s string) string {
|
||||||
if strings.TrimSpace(s) == "" {
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
if strings.ContainsAny(s, "\n") {
|
s = strings.ReplaceAll(s, "\r", "\n")
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return s // significant single-line whitespace (e.g. "a {x} b")
|
|
||||||
}
|
|
||||||
if !strings.ContainsAny(s, "\n") {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
lines := strings.Split(s, "\n")
|
lines := strings.Split(s, "\n")
|
||||||
var kept []string
|
|
||||||
for _, l := range lines {
|
// The last line with any non-whitespace on it. Every kept line before this one
|
||||||
l = strings.Trim(l, " \t\r")
|
// gets a separating space; this one does not, or every text child would end in a
|
||||||
if l != "" {
|
// trailing space. Starts at 0 (not -1) so an all-whitespace chunk is left exactly
|
||||||
kept = append(kept, l)
|
// as it is — " " between two elements is a real, significant space.
|
||||||
|
lastNonEmpty := 0
|
||||||
|
for i, l := range lines {
|
||||||
|
if strings.Trim(l, " \t") != "" {
|
||||||
|
lastNonEmpty = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.Join(kept, " ")
|
|
||||||
|
var b strings.Builder
|
||||||
|
for i, l := range lines {
|
||||||
|
line := strings.ReplaceAll(l, "\t", " ")
|
||||||
|
if i != 0 {
|
||||||
|
line = strings.TrimLeft(line, " ")
|
||||||
|
}
|
||||||
|
if i != len(lines)-1 {
|
||||||
|
line = strings.TrimRight(line, " ")
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i != lastNonEmpty {
|
||||||
|
line += " "
|
||||||
|
}
|
||||||
|
b.WriteString(line)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func escapeTemplateText(s string) string {
|
func escapeTemplateText(s string) string {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -42,6 +42,23 @@ func assertRenderEquivalent(t *testing.T, name, src string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// Point the bundler at kjol's own vendored Solid before chdir'ing — the path is
|
||||||
|
// relative to the package dir, and Configure stores it for vendorDirs().
|
||||||
|
//
|
||||||
|
// Without this the oracle silently never ran: webDir defaults to "", so
|
||||||
|
// vendorDirs() resolved to <root>/frontend/vendor — a directory kjol does not
|
||||||
|
// have and never had (it is a framework; there is no app frontend here). Every
|
||||||
|
// render then failed to resolve solid-js/web. It only looked green if a test
|
||||||
|
// that calls Configure happened to run first, and none does: the sole callers
|
||||||
|
// are in crosstree_test.go, which sorts after this file.
|
||||||
|
webAbs, err := filepath.Abs(filepath.Join("..", "jsruntime"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(webAbs, "runtime", "vendor.json")); err != nil {
|
||||||
|
t.Skipf("kjol vendored solid runtime not present (%v)", err)
|
||||||
|
}
|
||||||
|
Configure(Config{WebDir: webAbs})
|
||||||
t.Chdir(root)
|
t.Chdir(root)
|
||||||
|
|
||||||
babelJS, err := Compile(src, name+".tsx")
|
babelJS, err := Compile(src, name+".tsx")
|
||||||
@@ -77,6 +94,33 @@ func TestGoCompilerRenderCore(t *testing.T) {
|
|||||||
{"list", `export const A = () => { const items = ["a", "b", "c"]; return <ul>{items.map((i) => <li>{i}</li>)}</ul>; };`},
|
{"list", `export const A = () => { const items = ["a", "b", "c"]; return <ul>{items.map((i) => <li>{i}</li>)}</ul>; };`},
|
||||||
{"deep-static", `export const A = () => <section><header><h1>Title</h1></header><p>body text</p></section>;`},
|
{"deep-static", `export const A = () => <section><header><h1>Title</h1></header><p>body text</p></section>;`},
|
||||||
{"multi-attr", `export const A = () => <input type="text" name="q" disabled />;`},
|
{"multi-attr", `export const A = () => <input type="text" name="q" disabled />;`},
|
||||||
|
|
||||||
|
// Multi-line children. Every case above is written on ONE line, which is why
|
||||||
|
// they all passed while the compiler was eating the spaces around an expression
|
||||||
|
// the moment the JSX was indented — i.e. in essentially all real code. These
|
||||||
|
// pin JSX's asymmetric whitespace rule: indentation goes, the space between the
|
||||||
|
// words stays.
|
||||||
|
{"ml-text-around-expr", `export const A = () => { const c = () => 0; return (
|
||||||
|
<button>
|
||||||
|
Clicked {c()} times
|
||||||
|
</button>
|
||||||
|
); };`},
|
||||||
|
{"ml-text-before-el", `export const A = () => (
|
||||||
|
<p>
|
||||||
|
selected: <span>day</span>
|
||||||
|
</p>
|
||||||
|
);`},
|
||||||
|
{"ml-wrapped-prose", `export const A = () => (
|
||||||
|
<p>
|
||||||
|
one two
|
||||||
|
three four
|
||||||
|
</p>
|
||||||
|
);`},
|
||||||
|
{"ml-expr-both-sides", `export const A = () => { const x = () => "X"; const y = () => "Y"; return (
|
||||||
|
<div>
|
||||||
|
a {x()} b {y()} c
|
||||||
|
</div>
|
||||||
|
); };`},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
c := c
|
c := c
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
|
|
||||||
// Config tells the bundler where the application tree and the shared kjol web
|
// Config tells the bundler where the application tree and the shared kjol JS
|
||||||
// tree live. The bundler always runs from the app root, so relative paths are
|
// tree live. The bundler always runs from the app root, so relative paths are
|
||||||
// resolved against that working directory (absolute paths also work).
|
// resolved against that working directory (absolute paths also work).
|
||||||
//
|
//
|
||||||
// When WebDir is empty the bundler runs in single-tree mode: the kit, vendored
|
// When WebDir is empty the bundler runs in single-tree mode: the kit, vendored
|
||||||
// runtime, icons, and styles are expected under AppFrontend (the pre-extraction
|
// runtime, icons, and styles are expected under AppFrontend (the pre-extraction
|
||||||
// layout). When WebDir points at kjol/web, those come from the shared tree and
|
// layout). When WebDir points at kjol/go/jsruntime, those come from the shared
|
||||||
// the app supplies only its pages/routes/brand.
|
// tree and the app supplies only its pages/routes/brand.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppFrontend string // app frontend source root (default "frontend")
|
AppFrontend string // app frontend source root (default "frontend")
|
||||||
WebDir string // path to kjol/web; "" => single-tree under AppFrontend
|
WebDir string // path to kjol/go/jsruntime; "" => single-tree under AppFrontend
|
||||||
Output string // build output dir (default "wwwroot")
|
Output string // build output dir (default "wwwroot")
|
||||||
GenGoDir string // dir for generated Go, e.g. public_pages.gen.go (default "internal/handlers")
|
GenGoDir string // dir for generated Go, e.g. public_pages.gen.go (default "internal/handlers")
|
||||||
GenTSDir string // dir for generated TS, e.g. faIcons.ts (default AppFrontend/src/ui/generated)
|
GenTSDir string // dir for generated TS, e.g. faIcons.ts (default AppFrontend/src/ui/generated)
|
||||||
@@ -24,7 +24,7 @@ type Config struct {
|
|||||||
var (
|
var (
|
||||||
frontendDir = "frontend"
|
frontendDir = "frontend"
|
||||||
outputDir = "wwwroot"
|
outputDir = "wwwroot"
|
||||||
webDir = "" // kjol/web; empty => single-tree
|
webDir = "" // kjol/go/jsruntime; empty => single-tree
|
||||||
genGoDir = "internal/handlers"
|
genGoDir = "internal/handlers"
|
||||||
genTSDir = filepath.Join("frontend", "src", "ui", "generated")
|
genTSDir = filepath.Join("frontend", "src", "ui", "generated")
|
||||||
)
|
)
|
||||||
@@ -48,7 +48,7 @@ func Configure(c Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// webRoot is the kjol web tree (@kjol/* root), falling back to the app frontend
|
// webRoot is the kjol JS tree (@kjol/* root), falling back to the app frontend
|
||||||
// in single-tree mode.
|
// in single-tree mode.
|
||||||
func webRoot() string {
|
func webRoot() string {
|
||||||
if webDir != "" {
|
if webDir != "" {
|
||||||
@@ -65,12 +65,23 @@ func uikitDir() string {
|
|||||||
return filepath.Join(frontendDir, "src", "ui")
|
return filepath.Join(frontendDir, "src", "ui")
|
||||||
}
|
}
|
||||||
|
|
||||||
// iconsDir is the FontAwesome SVG source kit.
|
// iconsDirs lists the FontAwesome SVG source roots in resolution precedence
|
||||||
func iconsDir() string {
|
// order: the app's own kit first, kjol's second.
|
||||||
|
//
|
||||||
|
// The app has to win. kjol ships a SUBSET — the icons its own kit and example
|
||||||
|
// reference — while an app carries the full FontAwesome kit it licensed. If kjol
|
||||||
|
// were searched first (or searched alone, as this used to be when WebDir was
|
||||||
|
// set), an app adopting the shared tree would silently regenerate its registry
|
||||||
|
// from kjol's few dozen icons and lose every other icon it uses, with no error:
|
||||||
|
// the registry would simply come back smaller and the icons would render blank.
|
||||||
|
//
|
||||||
|
// Searching both, app-first, means an app-side icon of the same name overrides
|
||||||
|
// kjol's, and an icon kjol has never heard of still resolves.
|
||||||
|
func iconsDirs() []string {
|
||||||
if webDir != "" {
|
if webDir != "" {
|
||||||
return filepath.Join(webDir, "icons")
|
return []string{filepath.Join(frontendDir, "icons"), filepath.Join(webDir, "icons")}
|
||||||
}
|
}
|
||||||
return filepath.Join(frontendDir, "icons")
|
return []string{filepath.Join(frontendDir, "icons")}
|
||||||
}
|
}
|
||||||
|
|
||||||
// vendorDirs lists the vendored-runtime roots in resolution precedence order.
|
// vendorDirs lists the vendored-runtime roots in resolution precedence order.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -9,16 +9,16 @@ import (
|
|||||||
|
|
||||||
// TestCrossTreeJSBundle proves the bundler resolves the shared kit across the
|
// TestCrossTreeJSBundle proves the bundler resolves the shared kit across the
|
||||||
// app/kjol tree boundary: a temp app entry imports @ui/Buttons, which pulls the
|
// app/kjol tree boundary: a temp app entry imports @ui/Buttons, which pulls the
|
||||||
// real kjol web/uikit (Buttons -> ./Icons -> @appgen/faIcons stub) and the solid
|
// real kjol jsruntime/uikit (Buttons -> ./Icons -> @appgen/faIcons stub) and the solid
|
||||||
// runtime from web/runtime. If the alias plugin, merged vendor manifest, and
|
// runtime from jsruntime/runtime. If the alias plugin, merged vendor manifest, and
|
||||||
// multi-dir NodePaths all work, esbuild produces a non-empty bundle.
|
// multi-dir NodePaths all work, esbuild produces a non-empty bundle.
|
||||||
func TestCrossTreeJSBundle(t *testing.T) {
|
func TestCrossTreeJSBundle(t *testing.T) {
|
||||||
webAbs, err := filepath.Abs(filepath.Join("..", "..", "web"))
|
webAbs, err := filepath.Abs(filepath.Join("..", "jsruntime"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(webAbs, "uikit", "Buttons.tsx")); err != nil {
|
if _, err := os.Stat(filepath.Join(webAbs, "uikit", "Buttons.tsx")); err != nil {
|
||||||
t.Skipf("kjol web kit not present (%v)", err)
|
t.Skipf("kjol JS kit not present (%v)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app := t.TempDir()
|
app := t.TempDir()
|
||||||
@@ -58,12 +58,12 @@ func TestCrossTreeJSBundle(t *testing.T) {
|
|||||||
// for utility candidates across the boundary (WebDir set) without error, and
|
// for utility candidates across the boundary (WebDir set) without error, and
|
||||||
// that an app-side utility class makes it into the output.
|
// that an app-side utility class makes it into the output.
|
||||||
func TestCrossTreeCSS(t *testing.T) {
|
func TestCrossTreeCSS(t *testing.T) {
|
||||||
webAbs, err := filepath.Abs(filepath.Join("..", "..", "web"))
|
webAbs, err := filepath.Abs(filepath.Join("..", "jsruntime"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(webAbs, "uikit")); err != nil {
|
if _, err := os.Stat(filepath.Join(webAbs, "uikit")); err != nil {
|
||||||
t.Skipf("kjol web kit not present (%v)", err)
|
t.Skipf("kjol JS kit not present (%v)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app := t.TempDir()
|
app := t.TempDir()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css)
|
// CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css)
|
||||||
// with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no
|
// with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no
|
||||||
@@ -78,7 +78,7 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS
|
|||||||
kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
|
kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
|
||||||
candidates = dedupStrings(append(candidates, kitCands...))
|
candidates = dedupStrings(append(candidates, kitCands...))
|
||||||
|
|
||||||
// Prepend the shared @theme scaffold (kjol web/styles/theme.css) ahead of the
|
// Prepend the shared @theme scaffold (kjol jsruntime/styles/theme.css) ahead of the
|
||||||
// app's brand style.css so its tokens/vars are in scope. Absent in single-tree
|
// app's brand style.css so its tokens/vars are in scope. Absent in single-tree
|
||||||
// mode (the app's style.css is already complete).
|
// mode (the app's style.css is already complete).
|
||||||
input := string(src)
|
input := string(src)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// defaultExportShimPlugin bridges two ESM-strictness mismatches that
|
// defaultExportShimPlugin bridges two ESM-strictness mismatches that
|
||||||
// existed in the previous custom bundler:
|
// existed in the previous custom bundler:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -13,19 +13,20 @@ import (
|
|||||||
// Tree-shaken FontAwesome. Instead of shipping the 41.5 MB `all.min.js` kit and
|
// Tree-shaken FontAwesome. Instead of shipping the 41.5 MB `all.min.js` kit and
|
||||||
// looking icons up by runtime string, we scan the app for the icon names it
|
// looking icons up by runtime string, we scan the app for the icon names it
|
||||||
// actually references and emit a registry of just those icons' SVG data, pulled
|
// actually references and emit a registry of just those icons' SVG data, pulled
|
||||||
// from the FontAwesome SVGs under frontend/icons/. Icons.tsx looks up that
|
// from the FontAwesome SVGs under the icon roots. Icons.tsx looks up that
|
||||||
// registry exactly like it used to call FontAwesome.findIconDefinition.
|
// registry exactly like it used to call FontAwesome.findIconDefinition.
|
||||||
|
|
||||||
// FA prefix -> frontend/icons/<dir>. cdrateline renders classic far/fas; a Sharp
|
// FA prefix -> <iconRoot>/<dir>. cdrateline renders classic far/fas; a Sharp
|
||||||
// project (fasr/fass) would add "fasr": "sharp-regular", "fass": "sharp-solid".
|
// project (fasr/fass) would add "fasr": "sharp-regular", "fass": "sharp-solid".
|
||||||
var faStyleDirs = map[string]string{
|
var faStyleDirs = map[string]string{
|
||||||
"far": "regular",
|
"far": "regular",
|
||||||
"fas": "solid",
|
"fas": "solid",
|
||||||
}
|
}
|
||||||
|
|
||||||
// The FA SVG source dir (iconsDir) and the generated registry output path
|
// The FA SVG source roots (iconsDirs — the app's kit, then kjol's) and the
|
||||||
// (faOutPath) are resolved from Config — see config.go. Only the styles in
|
// generated registry output path (faOutPath) are resolved from Config — see
|
||||||
// faStyleDirs are read; the rest of the kit is unused.
|
// config.go. Only the styles in faStyleDirs are read; the rest of the kit is
|
||||||
|
// unused.
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// icon="name" / icon: "name"
|
// icon="name" / icon: "name"
|
||||||
@@ -39,10 +40,34 @@ var (
|
|||||||
reRegisterIcon = regexp.MustCompile(`registerIcon\(\s*"([a-z0-9][a-z0-9-]*)"`)
|
reRegisterIcon = regexp.MustCompile(`registerIcon\(\s*"([a-z0-9][a-z0-9-]*)"`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// presentIconsDirs is iconsDirs filtered to the roots that actually exist, so a
|
||||||
|
// missing kit is skipped rather than failing every icon lookup against it.
|
||||||
|
func presentIconsDirs() []string {
|
||||||
|
var roots []string
|
||||||
|
for _, d := range iconsDirs() {
|
||||||
|
if _, err := os.Stat(d); err == nil {
|
||||||
|
roots = append(roots, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
// readIconSVG returns the first <root>/<style>/<name>.svg that exists, walking
|
||||||
|
// roots in precedence order (app kit before kjol's — see iconsDirs).
|
||||||
|
func readIconSVG(roots []string, style, name string) (string, bool) {
|
||||||
|
for _, root := range roots {
|
||||||
|
if svg, err := os.ReadFile(filepath.Join(root, style, name+".svg")); err == nil {
|
||||||
|
return string(svg), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a
|
// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a
|
||||||
// no-op when the kit isn't present (CI builds use the committed registry).
|
// no-op when no kit is present (CI builds use the committed registry).
|
||||||
func generateFAIcons() error {
|
func generateFAIcons() error {
|
||||||
if _, err := os.Stat(iconsDir()); err != nil {
|
roots := presentIconsDirs()
|
||||||
|
if len(roots) == 0 {
|
||||||
return nil // SVGs absent — keep the committed registry
|
return nil // SVGs absent — keep the committed registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +86,11 @@ func generateFAIcons() error {
|
|||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
found := false
|
found := false
|
||||||
for prefix, dir := range faStyleDirs {
|
for prefix, dir := range faStyleDirs {
|
||||||
svg, err := os.ReadFile(filepath.Join(iconsDir(), dir, name+".svg"))
|
svg, ok := readIconSVG(roots, dir, name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
x, y, w, h, d, ok := parseFASvg(string(svg))
|
x, y, w, h, d, ok := parseFASvg(svg)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -83,7 +108,7 @@ func generateFAIcons() error {
|
|||||||
b.WriteString("// A tree-shaken subset of FontAwesome: only the icons this app references,\n")
|
b.WriteString("// A tree-shaken subset of FontAwesome: only the icons this app references,\n")
|
||||||
b.WriteString("// as [x, y, width, height, svgPath] keyed by \"prefix:name\" (viewBox inset to FA's\n")
|
b.WriteString("// as [x, y, width, height, svgPath] keyed by \"prefix:name\" (viewBox inset to FA's\n")
|
||||||
b.WriteString("// 512 design box within the 640 kit canvas). Regenerated each build while\n")
|
b.WriteString("// 512 design box within the 640 kit canvas). Regenerated each build while\n")
|
||||||
b.WriteString("// frontend/icons/ is present; committed so CI needs no SVGs.\n")
|
b.WriteString("// an icon kit is present; committed so CI needs no SVGs.\n")
|
||||||
b.WriteString("export const FA_ICONS: Record<string, readonly [number, number, number, number, string]> = {\n")
|
b.WriteString("export const FA_ICONS: Record<string, readonly [number, number, number, number, string]> = {\n")
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
b.WriteString(fmt.Sprintf(" %q: [%s, %s, %s, %s, %q],\n", e.key, e.x, e.y, e.w, e.h, e.path))
|
b.WriteString(fmt.Sprintf(" %q: [%s, %s, %s, %s, %q],\n", e.key, e.x, e.y, e.w, e.h, e.path))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// Public-route code generation. The single source of truth is the TypeScript
|
// Public-route code generation. The single source of truth is the TypeScript
|
||||||
// manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild +
|
// manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild +
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// Public-page SSR code generation. For each page in the manifest
|
// Public-page SSR code generation. For each page in the manifest
|
||||||
// (frontend/src/pages/public/pages.ts) this renders the solid-js/html component
|
// (frontend/src/pages/public/pages.ts) this renders the solid-js/html component
|
||||||
@@ -94,7 +94,11 @@ func writeGoRegistry(defs []pageDef) error {
|
|||||||
b.WriteString("//\n")
|
b.WriteString("//\n")
|
||||||
b.WriteString("// Each html field is the page's data-free SSR skeleton, rendered from its\n")
|
b.WriteString("// Each html field is the page's data-free SSR skeleton, rendered from its\n")
|
||||||
b.WriteString("// Solid component at bundle time. The browser bundle re-renders it on load.\n\n")
|
b.WriteString("// Solid component at bundle time. The browser bundle re-renders it on load.\n\n")
|
||||||
b.WriteString("package handlers\n\n")
|
// The package name is the output directory's, so Config.GenGoDir can point anywhere
|
||||||
|
// and the file still compiles. It used to be hardcoded to `handlers`, alongside a
|
||||||
|
// hardcoded internal/handlers path — which meant GenGoDir was documented, settable,
|
||||||
|
// and read by nothing at all.
|
||||||
|
b.WriteString("package " + filepath.Base(genGoDir) + "\n\n")
|
||||||
b.WriteString("var publicPages = []publicPage{\n")
|
b.WriteString("var publicPages = []publicPage{\n")
|
||||||
for i, d := range defs {
|
for i, d := range defs {
|
||||||
fmt.Fprintf(&b, "\t{route: %q, title: %q, module: %q, component: %q, html: %q, renderJS: %q},\n", goRoute(d.Path), d.Title, d.Module, d.Component, bodies[i], renderJSs[i])
|
fmt.Fprintf(&b, "\t{route: %q, title: %q, module: %q, component: %q, html: %q, renderJS: %q},\n", goRoute(d.Path), d.Title, d.Module, d.Component, bodies[i], renderJSs[i])
|
||||||
@@ -106,7 +110,10 @@ func writeGoRegistry(defs []pageDef) error {
|
|||||||
}
|
}
|
||||||
b.WriteString("}\n")
|
b.WriteString("}\n")
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join("internal", "handlers", "public_pages.gen.go"), []byte(b.String()), 0644); err != nil {
|
if err := os.MkdirAll(genGoDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(genGoDir, "public_pages.gen.go"), []byte(b.String()), 0644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// The development HMR server: it serves the SPA source tree as unbundled native
|
// The development HMR server: it serves the SPA source tree as unbundled native
|
||||||
// ES modules (transformed on the fly), tracks the module import graph, watches
|
// ES modules (transformed on the fly), tracks the module import graph, watches
|
||||||
@@ -8,7 +8,7 @@ package webbundler
|
|||||||
// WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing
|
// WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing
|
||||||
// a non-boundary module bubbles up to a full page reload.
|
// a non-boundary module bubbles up to a full page reload.
|
||||||
//
|
//
|
||||||
// This is the reason webbundler grew a `dev` build tag: the whole HMR
|
// This is the reason jsbundler grew a `dev` build tag: the whole HMR
|
||||||
// subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go,
|
// subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go,
|
||||||
// and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the
|
// and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the
|
||||||
// production server and the plain `cmd/bundle` build carry none of it.
|
// production server and the plain `cmd/bundle` build carry none of it.
|
||||||
@@ -29,7 +29,7 @@ import (
|
|||||||
// transformed module frontend/src/<rel>.
|
// transformed module frontend/src/<rel>.
|
||||||
const (
|
const (
|
||||||
srcURLPrefix = "/@src/" // transformed app source modules
|
srcURLPrefix = "/@src/" // transformed app source modules
|
||||||
kitURLPrefix = "/@kit/" // transformed shared-kit modules (kjol web tree)
|
kitURLPrefix = "/@kit/" // transformed shared-kit modules (kjol JS tree)
|
||||||
assetURLPrefix = "/@url/" // `?url` shim: a module whose default export is the file URL
|
assetURLPrefix = "/@url/" // `?url` shim: a module whose default export is the file URL
|
||||||
fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at)
|
fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at)
|
||||||
)
|
)
|
||||||
@@ -38,7 +38,7 @@ type devServer struct {
|
|||||||
hub *hub
|
hub *hub
|
||||||
frontend string // abs app frontend/
|
frontend string // abs app frontend/
|
||||||
srcRoot string // abs app frontend/src
|
srcRoot string // abs app frontend/src
|
||||||
kitRoot string // abs kjol web (@ui/@kjol root); == frontend in single-tree
|
kitRoot string // abs kjol JS tree (@ui/@kjol root); == frontend in single-tree
|
||||||
genRoot string // abs app generated dir (@appgen)
|
genRoot string // abs app generated dir (@appgen)
|
||||||
vendorDirs []string // abs vendor roots (kjol runtime first, then app vendor)
|
vendorDirs []string // abs vendor roots (kjol runtime first, then app vendor)
|
||||||
output string // abs wwwroot/
|
output string // abs wwwroot/
|
||||||
@@ -152,7 +152,7 @@ func (d *devServer) serveModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(code)
|
w.Write(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveKit transforms and serves one shared-kit module (kjol web tree) as ESM.
|
// serveKit transforms and serves one shared-kit module (kjol JS tree) as ESM.
|
||||||
func (d *devServer) serveKit(w http.ResponseWriter, r *http.Request) {
|
func (d *devServer) serveKit(w http.ResponseWriter, r *http.Request) {
|
||||||
rel := strings.TrimPrefix(r.URL.Path, kitURLPrefix)
|
rel := strings.TrimPrefix(r.URL.Path, kitURLPrefix)
|
||||||
abs := filepath.Join(d.kitRoot, filepath.FromSlash(rel))
|
abs := filepath.Join(d.kitRoot, filepath.FromSlash(rel))
|
||||||
@@ -176,7 +176,7 @@ func (d *devServer) serveKit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// moduleURLBase maps an absolute module file to its dev URL (no version query):
|
// moduleURLBase maps an absolute module file to its dev URL (no version query):
|
||||||
// app sources → /@src/, shared-kit modules (kjol web) → /@kit/. "" if outside both.
|
// app sources → /@src/, shared-kit modules (kjol JS tree) → /@kit/. "" if outside both.
|
||||||
func (d *devServer) moduleURLBase(abs string) string {
|
func (d *devServer) moduleURLBase(abs string) string {
|
||||||
if within(d.srcRoot, abs) {
|
if within(d.srcRoot, abs) {
|
||||||
return srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, abs))
|
return srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, abs))
|
||||||
@@ -312,7 +312,7 @@ func (d *devServer) externalRewritePlugin() esbuild.Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ui/@kjol/@appgen → resolve into the kjol web tree (or the app's
|
// @ui/@kjol/@appgen → resolve into the kjol JS tree (or the app's
|
||||||
// generated dir) and rewrite to the /@src/ or /@kit/ URL that serves it.
|
// generated dir) and rewrite to the /@src/ or /@kit/ URL that serves it.
|
||||||
if strings.HasPrefix(spec, "@") {
|
if strings.HasPrefix(spec, "@") {
|
||||||
if resolved := d.resolveAliasSpec(spec); resolved != "" {
|
if resolved := d.resolveAliasSpec(spec); resolved != "" {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
|
// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
|
||||||
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,
|
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css
|
// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css
|
||||||
// (no external dependency). A source-module change is turned into an HMR update
|
// (no external dependency). A source-module change is turned into an HMR update
|
||||||
@@ -26,7 +26,7 @@ func (d *devServer) watch() {
|
|||||||
go d.cssLoop()
|
go d.cssLoop()
|
||||||
|
|
||||||
roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")}
|
roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")}
|
||||||
if webDir != "" { // cross-tree: also watch the shared kjol web kit + styles
|
if webDir != "" { // cross-tree: also watch the shared kjol JS kit + styles
|
||||||
roots = append(roots, d.kitRoot)
|
roots = append(roots, d.kitRoot)
|
||||||
}
|
}
|
||||||
mtimes := map[string]time.Time{}
|
mtimes := map[string]time.Time{}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// A minimal RFC 6455 WebSocket server — just enough for one-way server→browser
|
// A minimal RFC 6455 WebSocket server — just enough for one-way server→browser
|
||||||
// push of HMR messages. We hand-roll it (rather than add a dependency) because
|
// push of HMR messages. We hand-roll it (rather than add a dependency) because
|
||||||
// the surface we need is tiny: the handshake, unmasked server text frames, and a
|
// the surface we need is tiny: the handshake, unmasked server text frames, and a
|
||||||
// read loop that answers pings and notices close. No per-message compression, no
|
// read loop that answers pings and notices close. No per-message compression, no
|
||||||
// fragmentation, no client→server application data. All of webbundler's
|
// fragmentation, no client→server application data. All of jsbundler's
|
||||||
// HMR support is behind `//go:build dev`, so prod builds compile none of it.
|
// HMR support is behind `//go:build dev`, so prod builds compile none of it.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
|
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
|
||||||
// which lets misnamed specifiers slip through. This check catches them up
|
// which lets misnamed specifiers slip through. This check catches them up
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -72,7 +72,7 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
|
|||||||
return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
|
return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// aliasPlugin resolves @ui/@kjol/@appgen into the kjol web tree (and the app's
|
// aliasPlugin resolves @ui/@kjol/@appgen into the kjol JS tree (and the app's
|
||||||
// generated dir) first. The Go-native Solid compiler (Plugin) sits between the
|
// generated dir) first. The Go-native Solid compiler (Plugin) sits between the
|
||||||
// export shim and the vendor resolvers so it compiles .tsx/.jsx before they're
|
// export shim and the vendor resolvers so it compiles .tsx/.jsx before they're
|
||||||
// resolved.
|
// resolved.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Solid JSX/TSX compilation (part of package webbundler): compiles Solid JSX/TSX to
|
// Solid JSX/TSX compilation (part of package jsbundler): compiles Solid JSX/TSX to
|
||||||
// optimized Solid dom-expressions output (template cloning + fine-grained
|
// optimized Solid dom-expressions output (template cloning + fine-grained
|
||||||
// updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler
|
// updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler
|
||||||
// lives in compile_solid.go (parser) and compile_solid_gen.go (codegen);
|
// lives in compile_solid.go (parser) and compile_solid_gen.go (codegen);
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// per-declaration incremental caching is gone. A small in-memory cache dedups
|
// per-declaration incremental caching is gone. A small in-memory cache dedups
|
||||||
// identical transforms within a process (e.g. a module served to several page
|
// identical transforms within a process (e.g. a module served to several page
|
||||||
// loads under HMR); there is no on-disk cache and no compiler bootstrap.
|
// loads under HMR); there is no on-disk cache and no compiler bootstrap.
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// Top-level source segmentation. segmentTopLevel splits a module into contiguous
|
// Top-level source segmentation. segmentTopLevel splits a module into contiguous
|
||||||
// chunks at top-level declaration boundaries; the Go Solid compiler uses it to
|
// chunks at top-level declaration boundaries; the Go Solid compiler uses it to
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the
|
// walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the
|
||||||
// package dir, which is webbundler).
|
// package dir, which is jsbundler).
|
||||||
func walkRepoTSX(t *testing.T) []string {
|
func walkRepoTSX(t *testing.T) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := filepath.Join("..", "..", "frontend", "src")
|
root := filepath.Join("..", "..", "frontend", "src")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// SSR (part of package webbundler): server-renders the public-page Solid components
|
// SSR (part of package jsbundler): server-renders the public-page Solid components
|
||||||
// to HTML strings by running the (unmodified) client Solid runtime inside a goja
|
// to HTML strings by running the (unmodified) client Solid runtime inside a goja
|
||||||
// JS engine against a minimal Go-backed DOM (dom.js). Components are authored in
|
// JS engine against a minimal Go-backed DOM (dom.js). Components are authored in
|
||||||
// JSX/TSX and compiled to optimized Solid output at build time by the Go-native
|
// JSX/TSX and compiled to optimized Solid output at build time by the Go-native
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
// is imported by the server; the build-time entries are used by the bundler.
|
// is imported by the server; the build-time entries are used by the bundler.
|
||||||
//
|
//
|
||||||
// -mta
|
// -mta
|
||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -224,7 +224,7 @@ func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile s
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// aliasPlugin resolves @ui/@kjol/@appgen into the kjol web tree first; the Go
|
// aliasPlugin resolves @ui/@kjol/@appgen into the kjol JS tree first; the Go
|
||||||
// Solid compiler (Plugin) compiles .tsx before the catch-all stub sees any of
|
// Solid compiler (Plugin) compiles .tsx before the catch-all stub sees any of
|
||||||
// its imports.
|
// its imports.
|
||||||
plugins := []esbuild.Plugin{aliasPlugin(), Plugin(), stubPlugin}
|
plugins := []esbuild.Plugin{aliasPlugin(), Plugin(), stubPlugin}
|
||||||
203
go/jsbundler/ssr_test.go
Normal file
203
go/jsbundler/ssr_test.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package jsbundler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// entryHome renders the fixture page the way ssrEntrySolid does: plain JS
|
||||||
|
// (createComponent, no JSX in the entry) importing the .tsx page, which the Go
|
||||||
|
// Solid compiler compiles. The layout is omitted to target the body.
|
||||||
|
const entryHome = `
|
||||||
|
import { render, createComponent } from "solid-js/web";
|
||||||
|
import { Home } from "./frontend/src/pages/public/Home.tsx";
|
||||||
|
globalThis.__render = function () {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
const dispose = render(function () { return createComponent(Home, {}); }, root);
|
||||||
|
const out = globalThis.__serialize(root);
|
||||||
|
dispose();
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
`
|
||||||
|
|
||||||
|
// homePage is the fixture entryHome imports: the smallest page that exercises the
|
||||||
|
// thing under test — serverData() read inside the reactive body, a skeleton when
|
||||||
|
// it is null, the data when it is not.
|
||||||
|
//
|
||||||
|
// It used to be cdrateline's real frontend/src/pages/public/Home.tsx, reached by
|
||||||
|
// chdir'ing to "../..". That worked when the bundler lived inside cdrateline and
|
||||||
|
// "../.." was the app root. Since the extraction, "../.." is the kjol repo root —
|
||||||
|
// which has no frontend/ and, by the first golden rule, never will: the framework
|
||||||
|
// does not import application code, and a test that does is the same coupling
|
||||||
|
// wearing a different hat. Both tests here have been failing ever since.
|
||||||
|
// It is deliberately not just a <div>: it carries the specific things the Solid
|
||||||
|
// compiler has to get right when it bakes a template and goja serializes it — an
|
||||||
|
// inline SVG (attribute CASE must survive: viewBox, not viewbox), a static string
|
||||||
|
// style containing a url() with slashes and a comma-free but parenthesised value,
|
||||||
|
// and an HTML entity.
|
||||||
|
const homePage = `
|
||||||
|
import { serverData } from "@kjol/ssr/serverData.ts";
|
||||||
|
|
||||||
|
interface Rate { term: string; low: string; high: string; avg: string }
|
||||||
|
|
||||||
|
const TERMS = ["90 Day", "180 Day", "1 Year"];
|
||||||
|
|
||||||
|
export const Home = () => {
|
||||||
|
const data = () => serverData<{ rates: Rate[] }>();
|
||||||
|
const rates = () => data()?.rates ?? [];
|
||||||
|
return (
|
||||||
|
<div class="page-home">
|
||||||
|
<header style="background-image: url(/images/hero-bg.jpg)">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h16" /></svg>
|
||||||
|
<h1>Rates & terms</h1>
|
||||||
|
</header>
|
||||||
|
{rates().length === 0
|
||||||
|
? <table class="animate-pulse">
|
||||||
|
<tbody>{TERMS.map((t) => <tr><td>{t}</td><td>—</td></tr>)}</tbody>
|
||||||
|
</table>
|
||||||
|
: <table>
|
||||||
|
<tbody>{rates().map((r) => (
|
||||||
|
<tr><td>{r.term}</td><td>{r.low}</td><td>{r.high}</td><td>{r.avg}</td></tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
`
|
||||||
|
|
||||||
|
// ssrFixtureRoot writes the fixture app tree to a temp dir, points the bundler at
|
||||||
|
// it plus kjol's vendored Solid, and chdirs there — reproducing the server's setup
|
||||||
|
// (cwd at the app root, relative "." project root) without needing a real app.
|
||||||
|
func ssrFixtureRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
webAbs, err := filepath.Abs(filepath.Join("..", "jsruntime"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(webAbs, "runtime", "vendor.json")); err != nil {
|
||||||
|
t.Skipf("kjol vendored solid runtime not present (%v)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
pageDir := filepath.Join(root, "frontend", "src", "pages", "public")
|
||||||
|
if err := os.MkdirAll(pageDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(pageDir, "Home.tsx"), []byte(homePage), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Configure(Config{AppFrontend: filepath.Join(root, "frontend"), WebDir: webAbs})
|
||||||
|
t.Chdir(root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proves the ISR data-injection path the server uses: a pre-bundled render entry
|
||||||
|
// run in goja with server data injected (RenderBundleWithData) makes Home render
|
||||||
|
// the injected rates (via serverData()) instead of the loading skeleton.
|
||||||
|
func TestRenderHomeWithServerData(t *testing.T) {
|
||||||
|
ssrFixtureRoot(t)
|
||||||
|
|
||||||
|
data := `{"rates":[{"term":"90 Day","low":"1.111%","high":"2.222%","avg":"1.500%"}]}`
|
||||||
|
js, err := BundleEntry(entryHome, ".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bundle: %v", err)
|
||||||
|
}
|
||||||
|
out, err := RenderBundleWithData(js, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render with data: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"1.111%", "2.222%", "1.500%"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("output missing injected rate %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// With data present the table shows it, not the loading skeleton.
|
||||||
|
if strings.Contains(out, "animate-pulse") {
|
||||||
|
t.Errorf("skeleton rendered despite injected data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduces the server's setup (cwd at the app root, relative "." project root).
|
||||||
|
// Guards the esbuild alias-resolution bug where a non-absolute root yields
|
||||||
|
// bare-specifier alias targets that fail to resolve.
|
||||||
|
func TestRenderWithRelativeRoot(t *testing.T) {
|
||||||
|
ssrFixtureRoot(t)
|
||||||
|
|
||||||
|
r := NewRenderer(".", entryHome, true)
|
||||||
|
out, err := r.HTML()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render with relative root: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `class="page-home"`) {
|
||||||
|
t.Errorf("output missing page-home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirms the cache: the first HTML() pays bundle+render, the second (no source
|
||||||
|
// change) returns the cached string near-instantly.
|
||||||
|
func TestRendererCaching(t *testing.T) {
|
||||||
|
root := ssrFixtureRoot(t)
|
||||||
|
r := NewRenderer(root, entryHome, false) // prod: warm HTML() returns the cached string directly
|
||||||
|
|
||||||
|
t0 := time.Now()
|
||||||
|
first, err := r.HTML()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cold render: %v", err)
|
||||||
|
}
|
||||||
|
cold := time.Since(t0)
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
|
second, err := r.HTML()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("warm render: %v", err)
|
||||||
|
}
|
||||||
|
warm := time.Since(t1)
|
||||||
|
t.Logf("cold=%v warm=%v", cold, warm)
|
||||||
|
|
||||||
|
if first != second {
|
||||||
|
t.Errorf("cached output differs from first render")
|
||||||
|
}
|
||||||
|
if warm > cold/4 {
|
||||||
|
t.Errorf("cache hit too slow: cold=%v warm=%v", cold, warm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No data → the page renders its loading skeleton, exercising compiled Solid
|
||||||
|
// against inline SVGs (viewBox case), string style attributes, entities, and the
|
||||||
|
// table. SVG attribute case and the style string must survive serialization.
|
||||||
|
func TestRenderHomeSkeleton(t *testing.T) {
|
||||||
|
root := ssrFixtureRoot(t)
|
||||||
|
|
||||||
|
bundle, err := BundleEntry(entryHome, root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bundle: %v", err)
|
||||||
|
}
|
||||||
|
eng, err := New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("engine: %v", err)
|
||||||
|
}
|
||||||
|
if err := eng.LoadBundle(bundle); err != nil {
|
||||||
|
t.Fatalf("load bundle: %v", err)
|
||||||
|
}
|
||||||
|
out, err := eng.Render()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("HOME SKELETON (%d bytes):\n%s", len(out), out)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`class="page-home"`,
|
||||||
|
`viewBox="0 0 24 24"`, // SVG attribute case preserved
|
||||||
|
`background-image: url(/images/hero-bg.jpg)`, // static string style baked into the template
|
||||||
|
`animate-pulse`, // skeleton bars (no server data => skeleton branch)
|
||||||
|
`90 Day`, // term labels shown in the skeleton
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("output missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
// Build-time caching + parallelism for public-page SSR (see genssr.go).
|
// Build-time caching + parallelism for public-page SSR (see genssr.go).
|
||||||
//
|
//
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webbundler
|
package jsbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user