Update 3d chart mode, add US heatmap, move kjol-web -> kjol-website
This commit is contained in:
59
go/cmd/kjol-website/frontend/src/app.ts
Normal file
59
go/cmd/kjol-website/frontend/src/app.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// SPA entry for the Kjøl 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 Kjøl Wasm Web, a different binary, which
|
||||
// this bundle knows nothing about. `base: "/js"` keeps every route in here relative
|
||||
// to that, so a link to "/components" resolves to /js/components 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 rest 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 { Components } from "./pages/Components.tsx";
|
||||
import { NotFound } from "./pages/NotFound.tsx";
|
||||
|
||||
// Routes as plain data: solid-router accepts RouteDefinition[] as `children`, which
|
||||
// is what lets a JSX-free entry declare a full route tree.
|
||||
//
|
||||
// There are only two. The kit used to be spread across /kit, /forms, /table and
|
||||
// /theming — a split along the lines of the SOURCE FILES rather than along anything a
|
||||
// reader wants: a person looking for a date picker does not know, and should not have
|
||||
// to guess, whether it was filed under forms or under overlays. It is one page now,
|
||||
// and the sidebar jumps you down it.
|
||||
const routes: RouteDefinition[] = [
|
||||
{ path: "/", component: Overview },
|
||||
{ path: "/components", component: Components },
|
||||
|
||||
// The catch-all, and it is not optional. The SERVER answers every /js/* URL with this
|
||||
// shell — it has no idea which paths the router knows about — so without a fallback an
|
||||
// unknown one renders the chrome around an empty <main>: a blank page, with a 200, and
|
||||
// nothing to tell you why. Kjøl Wasm Web has the same catch-all for the same reason.
|
||||
{ path: "*", component: NotFound },
|
||||
];
|
||||
|
||||
const root = document.getElementById("app");
|
||||
if (root) {
|
||||
render(
|
||||
() =>
|
||||
createComponent(Router, {
|
||||
base: "/js",
|
||||
root: Shell,
|
||||
get children() {
|
||||
return routes;
|
||||
},
|
||||
}),
|
||||
root,
|
||||
);
|
||||
}
|
||||
36
go/cmd/kjol-website/frontend/src/componentGroups.ts
Normal file
36
go/cmd/kjol-website/frontend/src/componentGroups.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// The component groups: one entry per section of /js/components, AND one line in the
|
||||
// sidebar that jumps to it.
|
||||
//
|
||||
// Declaring them once, as data, is what keeps those two in step — the sidebar cannot
|
||||
// offer a jump to a section that does not exist, and a section cannot go missing from
|
||||
// the sidebar.
|
||||
//
|
||||
// This is the JS mirror of app/components.go's componentGroups(). The ids match, so the
|
||||
// two sections of the site have the same shape and a reader crossing between them lands
|
||||
// in the same place. The ICONS differ, and have to: this side names FontAwesome, the Go
|
||||
// side names webui's own hand-drawn registry, and where the two have no glyph in common
|
||||
// the names diverge.
|
||||
|
||||
export interface ComponentGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const COMPONENT_GROUPS: ComponentGroup[] = [
|
||||
{ id: "buttons", label: "Buttons", icon: "check" },
|
||||
{ id: "badges", label: "Badges & alerts", icon: "circle-info" },
|
||||
{ id: "cards", label: "Cards & layout", icon: "table-columns" },
|
||||
{ id: "icons", label: "Icons", icon: "star" },
|
||||
{ id: "forms", label: "Forms & inputs", icon: "pen-to-square" },
|
||||
{ id: "selects", label: "Selects & comboboxes", icon: "sliders" },
|
||||
{ id: "toggles", label: "Toggles & signature", icon: "check" },
|
||||
{ id: "dates", label: "Dates", icon: "calendar" },
|
||||
{ id: "tables", label: "Tables", icon: "table" },
|
||||
{ id: "overlays", label: "Overlays", icon: "copy" },
|
||||
{ id: "feedback", label: "Toasts & tours", icon: "bell" },
|
||||
{ id: "navigation", label: "Tabs & navigation", icon: "bars" },
|
||||
{ id: "search", label: "Fuzzy search", icon: "magnifying-glass" },
|
||||
{ id: "charts", label: "Charts", icon: "chart-column" },
|
||||
{ id: "theming", label: "Theming", icon: "palette" },
|
||||
];
|
||||
94
go/cmd/kjol-website/frontend/src/layers.ts
Normal file
94
go/cmd/kjol-website/frontend/src/layers.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// What kjøl is made of, as data.
|
||||
//
|
||||
// There are two kinds of thing here, and conflating them was the mistake this file used
|
||||
// to make — one flat list called "the layers", holding both.
|
||||
//
|
||||
// LAYERS are LANGUAGES. What kjøl is written in, and what it gives you in each:
|
||||
// the Go base, the TypeScript kit, the C base, the Jai modules. A layer is
|
||||
// a directory of code you can use on its own.
|
||||
//
|
||||
// COMPOSITIONS are FRAMEWORKS. What you get when the layers are assembled into
|
||||
// something that does a job — the two web engines. A composition is not
|
||||
// another language; it is a use of them.
|
||||
//
|
||||
// Kjøl Wasm Web is Go, all the way down. Kjøl JS Web is TypeScript compiled by a Go
|
||||
// toolchain — two layers, one framework. Listing that beside "C" as though they were the
|
||||
// same kind of noun told the reader nothing about either.
|
||||
//
|
||||
// This is the JS mirror of app/layers.go. The ids, the order and the taglines match; only
|
||||
// the ICONS diverge, and they have to — see below.
|
||||
|
||||
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.
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Languages: what kjøl is written in. */
|
||||
export const LANGUAGES: Layer[] = [
|
||||
{
|
||||
name: "Go",
|
||||
href: "/go",
|
||||
tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
|
||||
live: false,
|
||||
icon: "server",
|
||||
},
|
||||
{
|
||||
name: "TypeScript",
|
||||
href: "/ts",
|
||||
tagline: "The Solid component kit, the vendored runtime, and the generic scaffolding apps import as @kjol/*.",
|
||||
live: false,
|
||||
icon: "code",
|
||||
},
|
||||
{
|
||||
name: "C",
|
||||
href: "/c",
|
||||
tagline: "Arena allocator, counted strings, math, a lexer, a platform layer — and a build system that is a C file.",
|
||||
live: true,
|
||||
icon: "bolt",
|
||||
},
|
||||
{
|
||||
name: "Jai",
|
||||
href: "/jai",
|
||||
tagline: "Console rendering. Early.",
|
||||
live: false,
|
||||
icon: "cube",
|
||||
},
|
||||
];
|
||||
|
||||
/** Compositions: what the languages are assembled into. */
|
||||
export const COMPOSITIONS: Layer[] = [
|
||||
{
|
||||
name: "Kjøl Wasm Web",
|
||||
href: "/wasm",
|
||||
tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, and no JavaScript build at all.",
|
||||
live: true,
|
||||
icon: "code",
|
||||
},
|
||||
{
|
||||
name: "Kjøl 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",
|
||||
},
|
||||
];
|
||||
|
||||
/** The layer or composition the current path belongs to, or undefined on the front page. */
|
||||
export function currentLayer(path: string): Layer | undefined {
|
||||
return [...COMPOSITIONS, ...LANGUAGES].find(
|
||||
(l) => path === l.href || path.startsWith(l.href + "/"),
|
||||
);
|
||||
}
|
||||
33
go/cmd/kjol-website/frontend/src/layout/Demo.tsx
Normal file
33
go/cmd/kjol-website/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>
|
||||
);
|
||||
}
|
||||
245
go/cmd/kjol-website/frontend/src/layout/Shell.tsx
Normal file
245
go/cmd/kjol-website/frontend/src/layout/Shell.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// The Kjøl 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, useNavigate } from "@solidjs/router";
|
||||
import { Icon } from "@ui/Icons";
|
||||
import { Menu, MenuTrigger, MenuContent } from "@ui/Menu";
|
||||
import { ThemeToggle, initTheme } from "@ui/Theme";
|
||||
import { LANGUAGES, COMPOSITIONS, currentLayer, Layer } from "../layers.ts";
|
||||
import { COMPONENT_GROUPS } from "../componentGroups.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: "/components", label: "Components", icon: "table-columns" },
|
||||
];
|
||||
|
||||
// jumpTo scrolls a section into view, routing there first if we are somewhere else.
|
||||
//
|
||||
// A plain <a href="#forms"> would work if the reader were already on the components
|
||||
// page, and would do nothing useful from anywhere else. The router's <A> is no good
|
||||
// either — it would try to navigate to a route called "#forms".
|
||||
//
|
||||
// The queueMicrotask is not superstition: after navigate() the target section does not
|
||||
// exist yet, because the page it lives on has not rendered. Scrolling on the next tick
|
||||
// is the earliest moment the element is actually there to scroll to.
|
||||
function jumpTo(navigate: (to: string) => void, onComponentsPage: boolean, id: string) {
|
||||
const scroll = () => document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
|
||||
if (onComponentsPage) {
|
||||
scroll();
|
||||
return;
|
||||
}
|
||||
navigate("/components");
|
||||
queueMicrotask(scroll);
|
||||
}
|
||||
|
||||
// The site's primary navigation, as TWO dropdowns: Layers (the languages) and
|
||||
// Compositions (the frameworks assembled out of them — see layers.ts).
|
||||
//
|
||||
// Two menus, not one with two headings inside it. They answer different questions —
|
||||
// "what is this written in" and "what can I read" — and a reader who wants the second
|
||||
// should not have to scroll past the first to find it. The kit's single-open manager
|
||||
// means opening one closes the other, so they behave like one control with two halves.
|
||||
//
|
||||
// Anything not `live` still appears, greyed, with the reason. A menu that silently omits
|
||||
// half the library teaches the reader that the library is half the size it is.
|
||||
//
|
||||
// Same shape as the Go side (app/layers.go: layersMenu / compositionsMenu).
|
||||
function Dropdown(props: { label: string; rows: Layer[] }) {
|
||||
const location = useLocation();
|
||||
const current = () => currentLayer(location.pathname);
|
||||
|
||||
return (
|
||||
// bottom-end, because the triggers sit at the right-hand end of the bar and a 24rem
|
||||
// panel hanging off the left edge of one would run past the window.
|
||||
<Menu placement="bottom-end">
|
||||
<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">
|
||||
{props.label}
|
||||
<Icon icon="chevron-down" size={11} class="text-ink-faint" />
|
||||
</span>
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuContent class="w-96">
|
||||
<For each={props.rows}>{(layer) => <LayerItem layer={layer} current={current()} />}</For>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
// LayerItem is one row of the menu.
|
||||
//
|
||||
// The markup is deliberately the same shape and the same classes as app/layers.go's
|
||||
// layerItem. Two front-ends, one menu: if they drifted, this is where it would show,
|
||||
// because it is the one component a reader sees on both sides within seconds of each
|
||||
// other.
|
||||
function LayerItem(props: { layer: Layer; current?: Layer }) {
|
||||
return (
|
||||
<Show
|
||||
when={props.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">
|
||||
{props.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="text-xs text-ink-muted">{props.layer.tagline}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* A plain <a href>, not the router's <A>: the other side is served by a
|
||||
different binary, so crossing to it has to be a real navigation, not a
|
||||
client-side route the router would try to handle itself. */}
|
||||
<a
|
||||
href={props.layer.href}
|
||||
class={
|
||||
"flex flex-col gap-0.5 px-3 py-2 no-underline hover:bg-surface-raised " +
|
||||
(props.current?.href === props.layer.href ? "bg-primary-subtle" : "")
|
||||
}
|
||||
>
|
||||
<span class="text-sm font-medium text-ink">{props.layer.name}</span>
|
||||
<span class="text-xs text-ink-muted">{props.layer.tagline}</span>
|
||||
</a>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{/* text-white, not a theme token: the flag tile is the same in both themes, so
|
||||
the boat on top of it has to be too. The flag carries a dark scrim (.flag-no)
|
||||
so the plain white boat reads without a shadow of its own. */}
|
||||
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white">
|
||||
<Icon icon="sailboat" size={17} />
|
||||
</span>
|
||||
<span class="flex items-baseline gap-1.5">
|
||||
<span class="text-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
|
||||
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// The router's pathname is absolute (/js/components); NAV paths are base-relative.
|
||||
const active = (path: string) => location.pathname === "/js" + (path === "/" ? "" : path);
|
||||
const onComponents = () => location.pathname === "/js/components";
|
||||
|
||||
// The same active treatment the Go sidebar uses (app/pages.go: sidebarLink) — a
|
||||
// tinted panel and accent text, not a grey fill. Now that the Solid theme carries the
|
||||
// primary-subtle / accent tokens, the two sidebars are the same sidebar.
|
||||
//
|
||||
// No icons, also matching the Go sidebar: the sidebar is a list of words, and a glyph on
|
||||
// every row is noise to read past. So `block`, not `flex items-center gap-2`.
|
||||
const linkCls = (on: boolean) =>
|
||||
on
|
||||
? "block rounded-default bg-primary-subtle px-2 py-1.5 text-sm font-medium text-accent no-underline"
|
||||
: "block rounded-default px-2 py-1.5 text-sm text-ink-soft no-underline hover:bg-surface-raised hover:text-ink";
|
||||
|
||||
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">Kjøl JS Web</p>
|
||||
<ul class="mt-2 space-y-0.5">
|
||||
<For each={NAV}>
|
||||
{(item) => (
|
||||
<li>
|
||||
<A href={item.path} end={item.path === "/"} class={linkCls(active(item.path))}>
|
||||
{item.label}
|
||||
</A>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
|
||||
{/* The component groups are not pages — they are anchors into the one components
|
||||
page, and clicking one scrolls you there.
|
||||
|
||||
They are not highlighted by which section you have scrolled to. Finding that
|
||||
out means measuring all fifteen of them on every scroll frame, and the only
|
||||
way to act on the answer is a state write, which re-renders. Sixty times a
|
||||
second, to move a highlight. The highlight is not worth the page. */}
|
||||
<p class="mt-8 px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
|
||||
Components
|
||||
</p>
|
||||
<ul class="mt-2 space-y-0.5">
|
||||
<For each={COMPONENT_GROUPS}>
|
||||
{(g) => (
|
||||
<li>
|
||||
<a
|
||||
href={"/js/components#" + g.id}
|
||||
class={linkCls(false)}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
jumpTo(navigate, onComponents(), g.id);
|
||||
}}
|
||||
>
|
||||
{g.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">
|
||||
<Dropdown label="Layers" rows={LANGUAGES} />
|
||||
<Dropdown label="Compositions" rows={COMPOSITIONS} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
1585
go/cmd/kjol-website/frontend/src/pages/Components.tsx
Normal file
1585
go/cmd/kjol-website/frontend/src/pages/Components.tsx
Normal file
File diff suppressed because it is too large
Load Diff
41
go/cmd/kjol-website/frontend/src/pages/NotFound.tsx
Normal file
41
go/cmd/kjol-website/frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// The fallback for any /js/* URL the router does not know.
|
||||
//
|
||||
// The server cannot 404 these: it answers every /js/* path with the same SPA shell,
|
||||
// because it has no idea which routes the bundle contains. So the router has to be the
|
||||
// one to say so — and if it does not, an unknown URL renders the chrome around an empty
|
||||
// <main>, which is a blank page with a 200 and no explanation.
|
||||
//
|
||||
// It names the paths that MOVED, because that is what a stale bookmark most likely wants:
|
||||
// /js/kit, /js/forms, /js/table and /js/theming were four pages, and are now four
|
||||
// sections of one.
|
||||
|
||||
import { useLocation, useNavigate } from "@solidjs/router";
|
||||
import { ButtonUI, BUTTON_COLOR_PRIMARY, BUTTON_COLOR_LIGHT_NEUTRAL } from "@ui/Buttons";
|
||||
|
||||
export function NotFound() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div class="max-w-2xl py-10">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">Page not found</h1>
|
||||
<p class="mt-2 leading-relaxed text-ink-soft">
|
||||
No route matches <code class="rounded bg-surface-raised px-1 py-0.5 font-mono text-[13px]">{location.pathname}</code>.
|
||||
</p>
|
||||
<p class="mt-3 leading-relaxed text-ink-soft">
|
||||
The kit used to be spread across several pages — <code class="font-mono">/js/kit</code>,{" "}
|
||||
<code class="font-mono">/js/forms</code>, <code class="font-mono">/js/table</code>,{" "}
|
||||
<code class="font-mono">/js/theming</code>. It is one page now, and they are sections of it.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2">
|
||||
<ButtonUI color={BUTTON_COLOR_PRIMARY} onclick={() => navigate("/components")}>
|
||||
Go to Components
|
||||
</ButtonUI>
|
||||
<ButtonUI color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={() => navigate("/")}>
|
||||
Overview
|
||||
</ButtonUI>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
go/cmd/kjol-website/frontend/src/pages/Overview.tsx
Normal file
115
go/cmd/kjol-website/frontend/src/pages/Overview.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// /js — what the JS layer is, and how it is built.
|
||||
|
||||
import { For } from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Card, CardHeader } from "@ui/Cards";
|
||||
import { AlertBlue } from "@ui/Alerts";
|
||||
import { CodeBox } from "@ui/General";
|
||||
import { Icon } from "@ui/Icons";
|
||||
import { COMPONENT_GROUPS } from "../componentGroups.ts";
|
||||
|
||||
export function Overview() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl 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 ./server -build\nGenerating FA icon subset...\n FA icons: 94 defs for 47 names\nGenerating public routes...\n /js/ssr Ssr (rendered, 2498 bytes)\nBundling JS + CSS...\n\nBundle Files Size Time\n-------------------------------------------------------\nbundle.min.js 1 1.4 MB 198ms\nbundle.min.css 2500 74.6 KB 30ms"} />
|
||||
|
||||
<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">The kit</h2>
|
||||
<p class="mt-2 leading-relaxed text-ink-soft">
|
||||
All of it is on one page. It used to be three — Components, Forms, AutoTable — which is a
|
||||
split along the lines of the source files rather than along anything a reader wants: a person
|
||||
looking for a date picker does not know, and should not have to guess, whether it was filed
|
||||
under forms or under overlays.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
<For each={COMPONENT_GROUPS}>
|
||||
{(g) => (
|
||||
<a
|
||||
href={"/js/components#" + g.id}
|
||||
class="group flex items-center gap-2 rounded-default border border-line bg-surface px-3 py-2.5 no-underline shadow-xs transition hover:border-primary hover:shadow-sm"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/components");
|
||||
queueMicrotask(() =>
|
||||
document.getElementById(g.id)?.scrollIntoView({ behavior: "smooth", block: "start" }),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon icon={g.icon} size={14} class="shrink-0 text-primary" />
|
||||
<span class="text-sm font-medium text-ink">{g.label}</span>
|
||||
<Icon
|
||||
icon="arrow-right"
|
||||
size={11}
|
||||
class="ml-auto text-ink-faint transition group-hover:text-primary"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">Kjøl 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">
|
||||
Kjøl JS Web is one layer of kjol — a shared base layer. kjol is Norwegian for keel.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
go/cmd/kjol-website/frontend/src/pages/public/Ssr.tsx
Normal file
77
go/cmd/kjol-website/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">Kjøl 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 Kjøl JS Web
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
go/cmd/kjol-website/frontend/src/pages/public/pages.ts
Normal file
30
go/cmd/kjol-website/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 — Kjøl 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-website/frontend/src/pages/public/routes.gen.ts
Normal file
17
go/cmd/kjol-website/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 — Kjøl JS Web",
|
||||
};
|
||||
35
go/cmd/kjol-website/frontend/src/public.tsx
Normal file
35
go/cmd/kjol-website/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);
|
||||
}
|
||||
8
go/cmd/kjol-website/frontend/src/vendor.d.ts
vendored
Normal file
8
go/cmd/kjol-website/frontend/src/vendor.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// Ambient shims for tsserver only. The vendored pdf-lib / pdfjs-dist here are trimmed to
|
||||
// the runtime files the bundler pins (frontend/vendor/vendor.json), so their `.d.ts` type
|
||||
// trees are absent and each package.json `types` field points at a file that was not
|
||||
// vendored. @ui/AutoTable imports both; the bundler resolves them at build time, but the
|
||||
// editor needs a declaration or it reports "cannot find module". These make them `any`,
|
||||
// which is all this example needs — it does not exercise the PDF export path itself.
|
||||
declare module "pdf-lib";
|
||||
declare module "pdfjs-dist";
|
||||
Reference in New Issue
Block a user